Add components for Act management and integrate Electron setup
This commit is contained in:
426
components/ghostwriter/GhostWriter.tsx
Normal file
426
components/ghostwriter/GhostWriter.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, {ChangeEvent, useContext, useState} from 'react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBookOpen,
|
||||
faFileImport,
|
||||
faFloppyDisk,
|
||||
faGear,
|
||||
faGhost,
|
||||
faHashtag,
|
||||
faMagicWandSparkles,
|
||||
faPalette,
|
||||
faTags,
|
||||
faX
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {EditorContext} from "@/context/EditorContext";
|
||||
import System from "@/lib/models/System";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {ChapterContext} from "@/context/ChapterContext";
|
||||
import QSTextGeneratedPreview from "@/components/QSTextGeneratedPreview";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import RadioBox from "@/components/form/RadioBox";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading";
|
||||
import NumberInput from "@/components/form/NumberInput";
|
||||
import PanelHeader from "@/components/PanelHeader";
|
||||
import GhostWriterTags from "@/components/ghostwriter/GhostWriterTags";
|
||||
import Chapter, {TiptapNode} from "@/lib/models/Chapter";
|
||||
import GhostWriterSettings from "@/components/ghostwriter/GhostWriterSettings";
|
||||
import {useTranslations} from "next-intl";
|
||||
import QuillSense, {AIGeneratedText} from "@/lib/models/QuillSense";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
import {LangContext} from "@/context/LangContext";
|
||||
import {configs} from "@/lib/configs";
|
||||
|
||||
export default function GhostWriter() {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext)
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage, successMessage, infoMessage} = useContext(AlertContext);
|
||||
const {editor} = useContext(EditorContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {chapter} = useContext(ChapterContext);
|
||||
const {setTotalPrice, setTotalCredits} = useContext<AIUsageContextProps>(AIUsageContext);
|
||||
|
||||
const [minWords, setMinWords] = useState<number>(500);
|
||||
const [maxWords, setMaxWords] = useState<number>(1000);
|
||||
const [toneAtmosphere, setToneAtmosphere] = useState<string>('');
|
||||
const [directive, setDirective] = useState<string>('');
|
||||
const [type, setType] = useState<number>(0);
|
||||
const [isGenerating, setIsGenerating] = useState<boolean>(false);
|
||||
const [textGenerated, setTextGenerated] = useState<string>('');
|
||||
const [isTextGenerated, setIsTextGenerated] = useState<boolean>(false);
|
||||
const [advanceSettings, setAdvanceSettings] = useState<boolean>(false);
|
||||
const [advancedPrompt, setAdvancedPrompt] = useState<string>('');
|
||||
const [showTags, setShowTags] = useState<boolean>(false);
|
||||
const [taguedCharacters, setTaguedCharacters] = useState<string[]>([]);
|
||||
const [taguedLocations, setTaguedLocations] = useState<string[]>([]);
|
||||
const [taguedObjects, setTaguedObjects] = useState<string[]>([]);
|
||||
const [taguedWorldElements, setTaguedWorldElements] = useState<string[]>([]);
|
||||
const [abortController, setAbortController] = useState<ReadableStreamDefaultReader<Uint8Array> | null>(null);
|
||||
|
||||
const isGPTEnabled: boolean = QuillSense.isOpenAIEnabled(session);
|
||||
const isSubTierTree: boolean = QuillSense.getSubLevel(session) === 3;
|
||||
const hasAccess: boolean = isGPTEnabled || isSubTierTree;
|
||||
|
||||
async function showAdvanceSetting(): Promise<void> {
|
||||
if (advanceSettings) {
|
||||
await handleSaveAdvancedSettings();
|
||||
setAdvanceSettings(false);
|
||||
} else {
|
||||
setAdvanceSettings(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAdvancedSettings(): Promise<void> {
|
||||
try {
|
||||
if (advancedPrompt.trim() === '') {
|
||||
errorMessage(t('ghostWriter.promptEmpty'));
|
||||
return;
|
||||
}
|
||||
const response: boolean = await System.authPostToServer<boolean>(`quillsense/ghostwriter/advanced-settings`, {
|
||||
bookId: book?.bookId,
|
||||
advancedPrompt: advancedPrompt
|
||||
}, session.accessToken, lang);
|
||||
if (!response) {
|
||||
errorMessage(t('ghostWriter.errorSaveAdvanced'));
|
||||
return;
|
||||
}
|
||||
successMessage(t('ghostWriter.successSaveAdvanced'));
|
||||
setAdvanceSettings(false);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t('ghostWriter.errorSave'));
|
||||
} else {
|
||||
errorMessage(t('ghostWriter.errorUnknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopGeneration(): Promise<void> {
|
||||
if (abortController) {
|
||||
await abortController.cancel();
|
||||
setAbortController(null);
|
||||
infoMessage(t("ghostWriter.abortSuccess"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateGhostWriter(): Promise<void> {
|
||||
setIsGenerating(true);
|
||||
setIsTextGenerated(false);
|
||||
setTextGenerated('');
|
||||
|
||||
try {
|
||||
let content: string = '';
|
||||
if (editor?.getText()) {
|
||||
try {
|
||||
content = editor?.getText();
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t('ghostWriter.errorRetrieveContent'));
|
||||
} else {
|
||||
errorMessage(t('ghostWriter.errorUnknownRetrieveContent'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response: Response = await fetch(`${configs.apiUrl}quillsense/ghostwriter/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${session.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
bookId: book?.bookId,
|
||||
minWords: minWords,
|
||||
maxWords: maxWords,
|
||||
toneAtmosphere: toneAtmosphere,
|
||||
directive: directive,
|
||||
positionType: type,
|
||||
content: content,
|
||||
tags: {
|
||||
characters: taguedCharacters,
|
||||
locations: taguedLocations,
|
||||
objects: taguedObjects,
|
||||
worldElements: taguedWorldElements,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: { message?: string } = await response.json();
|
||||
errorMessage(error.message || t('ghostWriter.errorGenerate'));
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader: ReadableStreamDefaultReader<Uint8Array> | undefined = response.body?.getReader();
|
||||
const decoder: TextDecoder = new TextDecoder();
|
||||
let accumulatedText: string = '';
|
||||
|
||||
if (!reader) {
|
||||
errorMessage(t('ghostWriter.errorGenerate'));
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAbortController(reader);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const {done, value}: ReadableStreamReadResult<Uint8Array> = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
const chunk: string = decoder.decode(value, {stream: true});
|
||||
const lines: string[] = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const dataStr: string = line.slice(6);
|
||||
const data: {
|
||||
content?: string;
|
||||
totalCost?: number;
|
||||
totalPrice?: number;
|
||||
useYourKey?: boolean;
|
||||
aborted?: boolean;
|
||||
} = JSON.parse(dataStr);
|
||||
|
||||
// Si c'est le message final avec les totaux
|
||||
if ('totalCost' in data && 'useYourKey' in data && 'totalPrice' in data) {
|
||||
console.log(data)
|
||||
if (data.useYourKey) {
|
||||
setTotalPrice((prev: number): number => prev + data.totalPrice!);
|
||||
} else {
|
||||
setTotalCredits(data.totalPrice!);
|
||||
}
|
||||
} else if ('content' in data && data.content && data.content !== 'starting') {
|
||||
accumulatedText += data.content;
|
||||
setTextGenerated(accumulatedText);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error('Error parsing SSE data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setIsGenerating(false);
|
||||
setIsTextGenerated(true);
|
||||
setAbortController(null);
|
||||
} catch (e: unknown) {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('ghostWriter.errorUnknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importPrompt(): Promise<void> {
|
||||
try {
|
||||
const response: TiptapNode = await System.authGetQueryToServer<TiptapNode>(
|
||||
`chapter/content`,
|
||||
session.accessToken,
|
||||
lang,
|
||||
{
|
||||
chapterid: chapter?.chapterId,
|
||||
version: 1
|
||||
},
|
||||
)
|
||||
if (!response) {
|
||||
errorMessage(t('ghostWriter.noContentFound'));
|
||||
return;
|
||||
}
|
||||
const content: string = System.htmlToText(Chapter.convertTiptapToHTML(response));
|
||||
setDirective(content);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('ghostWriter.errorUnknownImport'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertText(): void {
|
||||
if (editor && textGenerated) {
|
||||
editor.commands.focus('end');
|
||||
if (editor.getText().length > 0) {
|
||||
editor.commands.insertContent('\n\n');
|
||||
}
|
||||
editor.commands.insertContent(System.textContentToHtml(textGenerated));
|
||||
setIsTextGenerated(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm p-10 rounded-2xl shadow-2xl text-center border border-secondary/50 max-w-md">
|
||||
<h2 className="text-2xl font-['ADLaM_Display'] text-text-primary mb-4">{t("ghostWriter.title")}</h2>
|
||||
<p className="text-muted mb-6 text-lg leading-relaxed">{t("ghostWriter.subscriptionRequired")}</p>
|
||||
<button
|
||||
onClick={(): string => window.location.href = '/pricing'}
|
||||
className="px-6 py-3 bg-primary text-text-primary rounded-xl hover:bg-primary-dark transition-all duration-200 hover:scale-105 shadow-md hover:shadow-lg font-semibold"
|
||||
>
|
||||
{t("ghostWriter.subscribe")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-secondary/20 backdrop-blur-sm flex flex-col h-full overflow-hidden">
|
||||
<PanelHeader
|
||||
title={t("ghostWriter.title")}
|
||||
description={t("ghostWriter.description")}
|
||||
badge="AI"
|
||||
icon={faGhost}
|
||||
/>
|
||||
|
||||
{
|
||||
showTags ? (
|
||||
<GhostWriterTags taguedCharacters={taguedCharacters} setTaguedCharacters={setTaguedCharacters}
|
||||
taguedLocations={taguedLocations} setTaguedLocations={setTaguedLocations}
|
||||
taguedObjects={taguedObjects} setTaguedObjects={setTaguedObjects}
|
||||
taguedWorldElements={taguedWorldElements}
|
||||
setTaguedWorldElements={setTaguedWorldElements}/>
|
||||
) : !showTags && !advanceSettings ? (
|
||||
<div className="p-4 lg:p-5 space-y-5 overflow-y-auto flex-grow custom-scrollbar">
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<h3 className="text-text-primary text-lg font-medium mb-4 flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faHashtag} className="text-primary w-5 h-5"/>
|
||||
{t("ghostWriter.length")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
fieldName={t("ghostWriter.minimum")}
|
||||
input={
|
||||
<NumberInput
|
||||
value={minWords}
|
||||
setValue={setMinWords}
|
||||
placeholder={t("ghostWriter.words")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
fieldName={t("ghostWriter.maximum")}
|
||||
input={
|
||||
<NumberInput
|
||||
value={maxWords}
|
||||
setValue={setMaxWords}
|
||||
placeholder={t("ghostWriter.words")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<div className="mb-1">
|
||||
<InputField
|
||||
icon={faBookOpen}
|
||||
fieldName={t("ghostWriter.type")}
|
||||
input={<RadioBox selected={type} setSelected={setType} name={'sectionType'}/>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField
|
||||
icon={faPalette}
|
||||
fieldName={t("ghostWriter.toneAtmosphere")}
|
||||
input={
|
||||
<TextInput
|
||||
value={toneAtmosphere}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setToneAtmosphere(e.target.value)}
|
||||
placeholder={t("ghostWriter.tonePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-lg p-5 shadow-inner flex-1">
|
||||
<InputField
|
||||
icon={faMagicWandSparkles}
|
||||
fieldName={t("ghostWriter.directive")}
|
||||
action={importPrompt}
|
||||
actionIcon={faFileImport}
|
||||
actionLabel={t("ghostWriter.importPrompt")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={directive}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setDirective(e.target.value)}
|
||||
placeholder={t("ghostWriter.directivePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : advanceSettings && (
|
||||
<GhostWriterSettings advancedPrompt={advancedPrompt} setAdvancedPrompt={setAdvancedPrompt}/>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="p-5 border-t border-secondary/50 bg-secondary/30 backdrop-blur-sm shrink-0 shadow-inner">
|
||||
<div className="flex justify-center gap-6">
|
||||
<button
|
||||
onClick={showAdvanceSetting}
|
||||
className="px-6 py-3 rounded-xl font-medium text-base bg-secondary/50 hover:bg-secondary text-primary transition-all duration-200 flex items-center gap-2 shadow-md hover:shadow-lg hover:scale-105 border border-secondary/50"
|
||||
>
|
||||
<FontAwesomeIcon icon={advanceSettings ? faFloppyDisk : faGear} className={'w-5 h-5'}/>
|
||||
<span>{advanceSettings ? t("ghostWriter.save") : t("ghostWriter.advanced")}</span>
|
||||
</button>
|
||||
{
|
||||
advanceSettings && (
|
||||
<button
|
||||
onClick={(): void => setAdvanceSettings(false)}
|
||||
className="px-6 py-3 rounded-xl font-medium text-base bg-secondary/50 hover:bg-secondary text-error transition-all duration-200 flex items-center gap-2 shadow-md hover:shadow-lg hover:scale-105 border border-secondary/50"
|
||||
>
|
||||
<FontAwesomeIcon icon={faX} className={'w-5 h-5'}/>
|
||||
<span>{t("ghostWriter.cancel")}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
<button
|
||||
onClick={(): void => setShowTags(!showTags)}
|
||||
className="px-6 py-3 rounded-xl font-medium text-base bg-secondary/50 hover:bg-secondary text-primary transition-all duration-200 flex items-center gap-2 shadow-md hover:shadow-lg hover:scale-105 border border-secondary/50"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTags} className={'w-5 h-5'}/>
|
||||
<span>{t("ghostWriter.tags.addTagPlaceholder")}</span>
|
||||
</button>
|
||||
|
||||
<SubmitButtonWLoading
|
||||
callBackAction={handleGenerateGhostWriter}
|
||||
isLoading={isGenerating}
|
||||
text={t("ghostWriter.generate")}
|
||||
loadingText={t("ghostWriter.generating")}
|
||||
icon={faMagicWandSparkles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(isTextGenerated || isGenerating) && (
|
||||
<QSTextGeneratedPreview
|
||||
onClose={(): void => setIsTextGenerated(false)}
|
||||
onRefresh={(): Promise<void> => handleGenerateGhostWriter()}
|
||||
value={textGenerated}
|
||||
onInsert={insertText}
|
||||
isGenerating={isGenerating}
|
||||
onStop={handleStopGeneration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user