import React, {ChangeEvent, useContext, useEffect, useState} from "react"; import {Editor, EditorContent, useEditor} from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Underline from "@tiptap/extension-underline"; import TextAlign from "@tiptap/extension-text-align"; import {apiGet} from "@/lib/api/client"; import {configs, isDesktop} from '@/lib/configs'; import * as tauri from '@/lib/tauri'; import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; import {textContentToHtml} from "@/lib/utils/html"; import {ChapterContext, ChapterContextProps} from "@/context/ChapterContext"; import {BookContext, BookContextProps} from "@/context/BookContext"; import {SelectBoxProps} from "@/components/form/SelectBox"; import {AlertContext, AlertContextProps} from "@/context/AlertContext"; import {SessionContext, SessionContextProps} from "@/context/SessionContext"; import {Boxes, Feather, Globe, MapPin, Palette, User, Wand2} from "lucide-react"; import Button from "@/components/ui/Button"; import QSTextGeneratedPreview from "@/components/ui/QSTextGeneratedPreview"; import {EditorContext, EditorContextProps} from "@/context/EditorContext"; import {useTranslations} from '@/lib/i18n'; import {getSubLevel, isOpenAIEnabled} from "@/lib/utils/quillsense"; import TextInput from "@/components/form/TextInput"; import InputField from "@/components/form/InputField"; import TextAreaInput from "@/components/form/TextAreaInput"; import SuggestFieldInput from "@/components/form/SuggestFieldInput"; import Collapse from "@/components/ui/Collapse"; import {LangContext, LangContextProps} from "@/context/LangContext"; import {BookTags} from "@/lib/types/book"; import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext"; import AdvancedGenerationOptions from "@/components/form/AdvancedGenerationOptions"; interface CompanionContent { version: number; content: string; wordsCount: number; } export default function DraftCompanion() { const t = useTranslations(); const {setTotalPrice, setTotalCredits}: AIUsageContextProps = useContext(AIUsageContext) const {lang}: LangContextProps = useContext(LangContext) const mainEditor: Editor | null = useEditor({ extensions: [ StarterKit, Underline, TextAlign.configure({ types: ['heading', 'paragraph'], }), ], injectCSS: false, editable: false, immediatelyRender: false, }); const {editor}: EditorContextProps = useContext(EditorContext); const {chapter}: ChapterContextProps = useContext(ChapterContext); const {book}: BookContextProps = useContext(BookContext); const {session}: SessionContextProps = useContext(SessionContext); const {errorMessage, infoMessage}: AlertContextProps = useContext(AlertContext); const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); const [draftVersion, setDraftVersion] = useState(0); const [draftWordCount, setDraftWordCount] = useState(0); const [refinedText, setRefinedText] = useState(''); const [isRefining, setIsRefining] = useState(false); const [showRefinedText, setShowRefinedText] = useState(false); const [showEnhancer, setShowEnhancer] = useState(false); const [abortController, setAbortController] = useState | null>(null); const [toneAtmosphere, setToneAtmosphere] = useState(''); const [specifications, setSpecifications] = useState(''); const [characters, setCharacters] = useState([]); const [locations, setLocations] = useState([]); const [objects, setObjects] = useState([]); const [worldElements, setWorldElements] = useState([]); const [taguedCharacters, setTaguedCharacters] = useState([]); const [taguedLocations, setTaguedLocations] = useState([]); const [taguedObjects, setTaguedObjects] = useState([]); const [taguedWorldElements, setTaguedWorldElements] = useState([]); const [searchCharacters, setSearchCharacters] = useState(''); const [searchLocations, setSearchLocations] = useState(''); const [searchObjects, setSearchObjects] = useState(''); const [searchWorldElements, setSearchWorldElements] = useState(''); const [showCharacterSuggestions, setShowCharacterSuggestions] = useState(false); const [showLocationSuggestions, setShowLocationSuggestions] = useState(false); const [showObjectSuggestions, setShowObjectSuggestions] = useState(false); const [showWorldElementSuggestions, setShowWorldElementSuggestions] = useState(false); const [useExplicit, setUseExplicit] = useState(false); const [useSmart, setUseSmart] = useState(false); const isGPTEnabled: boolean = isOpenAIEnabled(session); const isSubTierTree: boolean = getSubLevel(session) === 3; const hasAccess: boolean = (isGPTEnabled || isSubTierTree) && !isCurrentlyOffline() && !book?.localBook; useEffect((): void => { getDraftContent().then(); if (showEnhancer) { fetchTags().then(); } }, [mainEditor, chapter, showEnhancer]); async function getDraftContent(): Promise { try { let response: CompanionContent; if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { response = await tauri.getCompanionContent(chapter?.chapterId ?? '', chapter?.chapterContent.version ?? 0) as CompanionContent; } else { response = await apiGet(`chapter/content/companion`, session.accessToken, lang, { bookid: book?.bookId, chapterid: chapter?.chapterId, version: chapter?.chapterContent.version, }); } if (response && mainEditor) { mainEditor.commands.setContent(JSON.parse(response.content)); setDraftVersion(response.version); setDraftWordCount(response.wordsCount); } else if (response && response.content.length === 0 && mainEditor) { mainEditor.commands.setContent({ "type": "doc", "content": [ { "type": "heading", "attrs": { "level": 1 }, "content": [ { "type": "text", "text": t("draftCompanion.noPreviousVersion") } ] } ] }); } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("draftCompanion.unknownError")); } } } async function fetchTags(): Promise { try { let responseTags: BookTags; if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { responseTags = await tauri.getBookTags(book?.bookId ?? '') as BookTags; } else { responseTags = await apiGet(`book/tags`, session.accessToken, lang, { bookId: book?.bookId }); } if (responseTags) { setCharacters(responseTags.characters); setLocations(responseTags.locations); setObjects(responseTags.objects); setWorldElements(responseTags.worldElements); } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("draftCompanion.unknownError")); } } } async function handleStopRefining(): Promise { if (abortController) { await abortController.cancel(); setAbortController(null); infoMessage(t("draftCompanion.abortSuccess")); } } async function handleQuillSenseRefined(): Promise { if (chapter && session?.accessToken) { setIsRefining(true); setShowRefinedText(false); setRefinedText(''); try { const response: Response = await fetch(`${configs.apiUrl}quillsense/refine`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.accessToken}`, }, body: JSON.stringify({ chapterId: chapter?.chapterId, bookId: book?.bookId, toneAndAtmosphere: toneAtmosphere, advancedPrompt: specifications, tags: { characters: taguedCharacters, locations: taguedLocations, objects: taguedObjects, worldElements: taguedWorldElements, }, useExplicit: useExplicit, useSmart: useSmart, }), }); if (!response.ok) { const error: { message?: string } = await response.json(); errorMessage(error.message || t('draftCompanion.errorRefineDraft')); setIsRefining(false); return; } const reader: ReadableStreamDefaultReader | undefined = response.body?.getReader(); const decoder: TextDecoder = new TextDecoder(); let accumulatedText: string = ''; if (!reader) { errorMessage(t('draftCompanion.errorRefineDraft')); setIsRefining(false); return; } setAbortController(reader); while (true) { try { const {done, value}: ReadableStreamReadResult = 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; totalPrice?: number; useYourKey?: boolean; } = JSON.parse(dataStr); // Si c'est un chunk de contenu if ('content' in data && data.content) { accumulatedText += data.content; setRefinedText(accumulatedText); } // Si c'est le message final avec les totaux else if ('useYourKey' in data && 'totalPrice' in data) { if (data.useYourKey) { setTotalPrice((prev: number): number => prev + data.totalPrice!); } else { setTotalCredits(data.totalPrice!); } } } catch (e: unknown) { errorMessage(t("draftCompanion.sseParsingError")); } } } } catch (e: unknown) { break; } } setIsRefining(false); setShowRefinedText(true); setAbortController(null); } catch (e: unknown) { setIsRefining(false); setAbortController(null); if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('draftCompanion.unknownErrorRefineDraft')); } } } } function insertText(): void { if (editor && refinedText) { editor.commands.focus('end'); if (editor.getText().length > 0) { editor.commands.insertContent('\n\n'); } editor.commands.insertContent(textContentToHtml(refinedText)); setShowRefinedText(false); } } function filteredCharacters(): SelectBoxProps[] { if (searchCharacters.trim().length === 0) return []; return characters .filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchCharacters.toLowerCase()) && !taguedCharacters.includes(item.value)) .slice(0, 3); } function filteredLocations(): SelectBoxProps[] { if (searchLocations.trim().length === 0) return []; return locations .filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchLocations.toLowerCase()) && !taguedLocations.includes(item.value)) .slice(0, 3); } function filteredObjects(): SelectBoxProps[] { if (searchObjects.trim().length === 0) return []; return objects .filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchObjects.toLowerCase()) && !taguedObjects.includes(item.value)) .slice(0, 3); } function filteredWorldElements(): SelectBoxProps[] { if (searchWorldElements.trim().length === 0) return []; return worldElements .filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchWorldElements.toLowerCase()) && !taguedWorldElements.includes(item.value)) .slice(0, 3); } function handleAddCharacter(value: string): void { if (!taguedCharacters.includes(value)) { const newCharacters: string[] = [...taguedCharacters, value]; setTaguedCharacters(newCharacters); } setSearchCharacters(''); setShowCharacterSuggestions(false); } function handleAddLocation(value: string): void { if (!taguedLocations.includes(value)) { const newLocations: string[] = [...taguedLocations, value]; setTaguedLocations(newLocations); } setSearchLocations(''); setShowLocationSuggestions(false); } function handleAddObject(value: string): void { if (!taguedObjects.includes(value)) { const newObjects: string[] = [...taguedObjects, value]; setTaguedObjects(newObjects); } setSearchObjects(''); setShowObjectSuggestions(false); } function handleAddWorldElement(value: string): void { if (!taguedWorldElements.includes(value)) { const newWorldElements: string[] = [...taguedWorldElements, value]; setTaguedWorldElements(newWorldElements); } setSearchWorldElements(''); setShowWorldElementSuggestions(false); } function handleRemoveCharacter(value: string): void { setTaguedCharacters(taguedCharacters.filter((tag: string): boolean => tag !== value)); } function handleRemoveLocation(value: string): void { setTaguedLocations(taguedLocations.filter((tag: string): boolean => tag !== value)); } function handleRemoveObject(value: string): void { setTaguedObjects(taguedObjects.filter((tag: string): boolean => tag !== value)); } function handleRemoveWorldElement(value: string): void { setTaguedWorldElements(taguedWorldElements.filter((tag: string): boolean => tag !== value)); } function handleCharacterSearch(text: string): void { setSearchCharacters(text); setShowCharacterSuggestions(text.trim().length > 0); } function handleLocationSearch(text: string): void { setSearchLocations(text); setShowLocationSuggestions(text.trim().length > 0); } function handleObjectSearch(text: string): void { setSearchObjects(text); setShowObjectSuggestions(text.trim().length > 0); } function handleWorldElementSearch(text: string): void { setSearchWorldElements(text); setShowWorldElementSuggestions(text.trim().length > 0); } function getCharacterLabel(value: string): string { const character: SelectBoxProps | undefined = characters.find((item: SelectBoxProps): boolean => item.value === value); return character ? character.label : value; } function getLocationLabel(value: string): string { const location: SelectBoxProps | undefined = locations.find((item: SelectBoxProps): boolean => item.value === value); return location ? location.label : value; } function getObjectLabel(value: string): string { const object: SelectBoxProps | undefined = objects.find((item: SelectBoxProps): boolean => item.value === value); return object ? object.label : value; } function getWorldElementLabel(value: string): string { const element: SelectBoxProps | undefined = worldElements.find((item: SelectBoxProps): boolean => item.value === value); return element ? element.label : value; } if (showEnhancer && book?.quillsenseEnabled !== false) { return (

Amélioration de texte

) => setToneAtmosphere(e.target.value)} placeholder={t("ghostWriter.tonePlaceholder")} /> } />
} /> handleCharacterSearch(e.target.value)} handleAddTag={handleAddCharacter} handleRemoveTag={handleRemoveCharacter} filteredTags={filteredCharacters} showTagSuggestions={showCharacterSuggestions} setShowTagSuggestions={setShowCharacterSuggestions} getTagLabel={getCharacterLabel} /> handleLocationSearch(e.target.value)} handleAddTag={handleAddLocation} handleRemoveTag={handleRemoveLocation} filteredTags={filteredLocations} showTagSuggestions={showLocationSuggestions} setShowTagSuggestions={setShowLocationSuggestions} getTagLabel={getLocationLabel} /> handleObjectSearch(e.target.value)} handleAddTag={handleAddObject} handleRemoveTag={handleRemoveObject} filteredTags={filteredObjects} showTagSuggestions={showObjectSuggestions} setShowTagSuggestions={setShowObjectSuggestions} getTagLabel={getObjectLabel} /> handleWorldElementSearch(e.target.value)} handleAddTag={handleAddWorldElement} handleRemoveTag={handleRemoveWorldElement} filteredTags={filteredWorldElements} showTagSuggestions={showWorldElementSuggestions} setShowTagSuggestions={setShowWorldElementSuggestions} getTagLabel={getWorldElementLabel} />
} />
) => setSpecifications(e.target.value)} placeholder="Spécifications particulières pour l'amélioration..." maxLength={600} /> } />
{(showRefinedText || isRefining) && ( setShowRefinedText(false)} onRefresh={handleQuillSenseRefined} value={refinedText} onInsert={insertText} isGenerating={isRefining} onStop={handleStopRefining} /> )} ); } return (
{t("draftCompanion.words")}: {draftWordCount}
{ book?.quillsenseEnabled !== false && hasAccess && chapter?.chapterContent.version === 3 && (
) }
); }