Files
ERitors-Scribe-Desktop/components/editor/DraftCompanion.tsx
natreex e1d87c6997 Refactor subscription logic and replace quill-sense references with ritor equivalents
- Updated `getCurrentSubscription` and related functions to reflect terminology changes.
- Adjusted subscription levels and access checks to streamline logic.
- Removed unused cases in `getSubLevel` and optimized conditionals for clarity.
2026-03-30 21:13:55 -04:00

591 lines
28 KiB
TypeScript

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, isAnthropicEnabled} 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<AIUsageContextProps>(AIUsageContext)
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
const mainEditor: Editor | null = useEditor({
extensions: [
StarterKit,
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
],
injectCSS: false,
editable: false,
immediatelyRender: false,
});
const {editor}: EditorContextProps = useContext<EditorContextProps>(EditorContext);
const {chapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage, infoMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const [draftVersion, setDraftVersion] = useState<number>(0);
const [draftWordCount, setDraftWordCount] = useState<number>(0);
const [refinedText, setRefinedText] = useState<string>('');
const [isRefining, setIsRefining] = useState<boolean>(false);
const [showRefinedText, setShowRefinedText] = useState<boolean>(false);
const [showEnhancer, setShowEnhancer] = useState<boolean>(false);
const [abortController, setAbortController] = useState<ReadableStreamDefaultReader<Uint8Array> | null>(null);
const [toneAtmosphere, setToneAtmosphere] = useState<string>('');
const [specifications, setSpecifications] = useState<string>('');
const [characters, setCharacters] = useState<SelectBoxProps[]>([]);
const [locations, setLocations] = useState<SelectBoxProps[]>([]);
const [objects, setObjects] = useState<SelectBoxProps[]>([]);
const [worldElements, setWorldElements] = useState<SelectBoxProps[]>([]);
const [taguedCharacters, setTaguedCharacters] = useState<string[]>([]);
const [taguedLocations, setTaguedLocations] = useState<string[]>([]);
const [taguedObjects, setTaguedObjects] = useState<string[]>([]);
const [taguedWorldElements, setTaguedWorldElements] = useState<string[]>([]);
const [searchCharacters, setSearchCharacters] = useState<string>('');
const [searchLocations, setSearchLocations] = useState<string>('');
const [searchObjects, setSearchObjects] = useState<string>('');
const [searchWorldElements, setSearchWorldElements] = useState<string>('');
const [showCharacterSuggestions, setShowCharacterSuggestions] = useState<boolean>(false);
const [showLocationSuggestions, setShowLocationSuggestions] = useState<boolean>(false);
const [showObjectSuggestions, setShowObjectSuggestions] = useState<boolean>(false);
const [showWorldElementSuggestions, setShowWorldElementSuggestions] = useState<boolean>(false);
const [useExplicit, setUseExplicit] = useState<boolean>(false);
const [useSmart, setUseSmart] = useState<boolean>(false);
const isAnthropicKey: boolean = isAnthropicEnabled(session);
const isSubTierTwo: boolean = getSubLevel(session) >= 2;
const hasAccess: boolean = (isAnthropicKey || isSubTierTwo) && !isCurrentlyOffline() && !book?.localBook;
useEffect((): void => {
getDraftContent().then();
if (showEnhancer) {
fetchTags().then();
}
}, [mainEditor, chapter, showEnhancer]);
async function getDraftContent(): Promise<void> {
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<CompanionContent>(`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<void> {
try {
let responseTags: BookTags;
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
responseTags = await tauri.getBookTags(book?.bookId ?? '') as BookTags;
} else {
responseTags = await apiGet<BookTags>(`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<void> {
if (abortController) {
await abortController.cancel();
setAbortController(null);
infoMessage(t("draftCompanion.abortSuccess"));
}
}
async function handleQuillSenseRefined(): Promise<void> {
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<Uint8Array> | 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<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;
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 (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<div
className="flex items-center justify-between p-4 flex-shrink-0">
<h2 className="text-text-primary font-['ADLaM_Display'] text-xl">Amélioration de texte</h2>
<Button variant="secondary" onClick={(): void => setShowEnhancer(false)}>
Retour
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
<Collapse
variant="card"
title="Style d'écriture"
content={
<div className="space-y-4">
<InputField
icon={Palette}
fieldName={t("ghostWriter.toneAtmosphere")}
input={
<TextInput
value={toneAtmosphere}
setValue={(e: ChangeEvent<HTMLInputElement>) => setToneAtmosphere(e.target.value)}
placeholder={t("ghostWriter.tonePlaceholder")}
/>
}
/>
</div>
}
/>
<Collapse
variant="card"
title="Tags contextuels"
content={
<div className="space-y-4">
<SuggestFieldInput inputFieldName={`Personnages`}
inputFieldIcon={User}
searchTags={searchCharacters}
tagued={taguedCharacters}
handleTagSearch={(e) => handleCharacterSearch(e.target.value)}
handleAddTag={handleAddCharacter}
handleRemoveTag={handleRemoveCharacter}
filteredTags={filteredCharacters}
showTagSuggestions={showCharacterSuggestions}
setShowTagSuggestions={setShowCharacterSuggestions}
getTagLabel={getCharacterLabel}
/>
<SuggestFieldInput inputFieldName={`Lieux`}
inputFieldIcon={MapPin}
searchTags={searchLocations}
tagued={taguedLocations}
handleTagSearch={(e) => handleLocationSearch(e.target.value)}
handleAddTag={handleAddLocation}
handleRemoveTag={handleRemoveLocation}
filteredTags={filteredLocations}
showTagSuggestions={showLocationSuggestions}
setShowTagSuggestions={setShowLocationSuggestions}
getTagLabel={getLocationLabel}
/>
<SuggestFieldInput inputFieldName={`Objets`}
inputFieldIcon={Boxes}
searchTags={searchObjects}
tagued={taguedObjects}
handleTagSearch={(e) => handleObjectSearch(e.target.value)}
handleAddTag={handleAddObject}
handleRemoveTag={handleRemoveObject}
filteredTags={filteredObjects}
showTagSuggestions={showObjectSuggestions}
setShowTagSuggestions={setShowObjectSuggestions}
getTagLabel={getObjectLabel}
/>
<SuggestFieldInput inputFieldName={`Éléments mondiaux`}
inputFieldIcon={Globe}
searchTags={searchWorldElements}
tagued={taguedWorldElements}
handleTagSearch={(e) => handleWorldElementSearch(e.target.value)}
handleAddTag={handleAddWorldElement}
handleRemoveTag={handleRemoveWorldElement}
filteredTags={filteredWorldElements}
showTagSuggestions={showWorldElementSuggestions}
setShowTagSuggestions={setShowWorldElementSuggestions}
getTagLabel={getWorldElementLabel}
/>
</div>
}
/>
<div className="bg-tertiary rounded-xl p-4">
<InputField
icon={Wand2}
fieldName="Spécifications"
input={
<TextAreaInput
value={specifications}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setSpecifications(e.target.value)}
placeholder="Spécifications particulières pour l'amélioration..."
maxLength={600}
/>
}
/>
</div>
<AdvancedGenerationOptions
useExplicit={useExplicit}
setUseExplicit={setUseExplicit}
useSmart={useSmart}
setUseSmart={setUseSmart}
/>
</div>
<div
className="p-5 shrink-0">
<div className="flex justify-center">
<Button
variant="primary"
onClick={handleQuillSenseRefined}
isLoading={isRefining}
loadingText={t("draftCompanion.refining")}
icon={Wand2}
>{t("draftCompanion.refine")}</Button>
</div>
</div>
{(showRefinedText || isRefining) && (
<QSTextGeneratedPreview
onClose={(): void => setShowRefinedText(false)}
onRefresh={handleQuillSenseRefined}
value={refinedText}
onInsert={insertText}
isGenerating={isRefining}
onStop={handleStopRefining}
/>
)}
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden font-['Lora']">
<div
className="flex items-center justify-between p-4 flex-shrink-0 font-['ADLaM_Display']">
<div className="mr-4 text-muted">
<span>{t("draftCompanion.words")}: </span>
<span className="text-text-primary">{draftWordCount}</span>
</div>
{
book?.quillsenseEnabled !== false && hasAccess && chapter?.chapterContent.version === 3 && (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={(): void => setShowEnhancer(true)}
isLoading={isRefining}
loadingText={t("draftCompanion.refining")}
icon={Feather}
>{t("draftCompanion.refine")}</Button>
</div>
)
}
</div>
<div className="flex-1 min-h-0 overflow-auto">
<EditorContent
className="w-full h-full tiptap-draft"
editor={mainEditor}
/>
</div>
</div>
);
}