Files
ERitors-Scribe-Desktop/components/ShortStoryGenerator.tsx

692 lines
33 KiB
TypeScript

import React, {ChangeEvent, RefObject, useContext, useEffect, useRef, useState} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faBookBookmark,
faBookOpen,
faChartSimple,
faChevronRight,
faClock,
faCloudSun,
faComments,
faFileLines,
faGraduationCap,
faLanguage,
faMagicWandSparkles,
faMusic,
faPencilAlt,
faRotateRight,
faSpinner,
faStop,
faUserAstronaut,
faUserEdit,
faX
} from "@fortawesome/free-solid-svg-icons";
import {writingLevel} from "@/lib/models/User";
import Story, {
advancedDialogueTypes,
advancedNarrativePersons,
advancedPredefinedType,
beginnerDialogueTypes,
beginnerNarrativePersons,
beginnerPredefinedType,
intermediateDialogueTypes,
intermediateNarrativePersons,
intermediatePredefinedType,
langues,
verbalTime
} from '@/lib/models/Story';
import SelectBox from "@/components/form/SelectBox";
import TextInput from "@/components/form/TextInput";
import TexteAreaInput from "@/components/form/TexteAreaInput";
import {SessionContext} from "@/context/SessionContext";
import System from "@/lib/models/System";
import {AlertContext} from "@/context/AlertContext";
import {configs} from "@/lib/configs";
import InputField from "@/components/form/InputField";
import NumberInput from "@/components/form/NumberInput";
import {Editor as TipEditor, EditorContent, useEditor} from "@tiptap/react";
import Editor from "@/lib/models/Editor";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import QuillSense from "@/lib/models/QuillSense";
import {useTranslations} from "next-intl";
import {LangContext, LangContextProps} from "@/context/LangContext";
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
interface ShortStoryGeneratorProps {
onClose: () => void;
}
export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) {
const {session} = useContext(SessionContext);
const {errorMessage, infoMessage} = useContext(AlertContext);
const {lang} = useContext<LangContextProps>(LangContext)
const t = useTranslations();
const {setTotalPrice, setTotalCredits} = useContext<AIUsageContextProps>(AIUsageContext)
const [tone, setTone] = useState<string>('');
const [atmosphere, setAtmosphere] = useState<string>('');
const [verbTense, setVerbTense] = useState<string>('0');
const [person, setPerson] = useState<string>('0');
const [characters, setCharacters] = useState<string>('');
const [language, setLanguage] = useState<string>(
session.user?.writingLang.toString() ?? '0',
);
const [dialogueType, setDialogueType] = useState<string>('0');
const [wordsCount, setWordsCount] = useState<number>(500)
const [directives, setDirectives] = useState<string>('');
const [authorLevel, setAuthorLevel] = useState<string>(
session.user?.writingLevel.toString() ?? '0',
);
const [presetType, setPresetType] = useState<string>('0');
const [activeTab, setActiveTab] = useState<number>(1);
const [progress, setProgress] = useState<number>(25);
const modalRef: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
const [isGenerating, setIsGenerating] = useState<boolean>(false);
const [generatedText, setGeneratedText] = useState<string>('');
const [generatedStoryTitle, setGeneratedStoryTitle] = useState<string>('');
const [resume, setResume] = useState<string>('');
const [totalWordsCount, setTotalWordsCount] = useState<number>(0);
const [hasGenerated, setHasGenerated] = useState<boolean>(false);
const [abortController, setAbortController] = useState<ReadableStreamDefaultReader<Uint8Array> | null>(null);
const isAnthropicEnabled: boolean = QuillSense.isAnthropicEnabled(session);
const isSubTierTwo: boolean = QuillSense.getSubLevel(session) >= 2;
const hasAccess: boolean = isAnthropicEnabled || isSubTierTwo;
const editor: TipEditor | null = useEditor({
extensions: [
StarterKit,
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
],
injectCSS: false,
immediatelyRender: false,
});
useEffect((): () => void => {
document.body.style.overflow = 'hidden';
return (): void => {
document.body.style.overflow = 'auto';
};
}, []);
useEffect((): void => {
Story.presetStoryType(
presetType,
setTone,
setAtmosphere,
setVerbTense,
setPerson,
setDialogueType,
(): void => {
},
);
}, [presetType]);
useEffect((): void => {
setProgress(activeTab * 25);
}, [activeTab]);
useEffect((): void => {
if (editor)
editor.commands.setContent(Editor.convertToHtml(generatedText))
getWordCount();
}, [editor, generatedText]);
async function handleStopGeneration(): Promise<void> {
if (abortController) {
await abortController.cancel();
setAbortController(null);
infoMessage(t("shortStoryGenerator.result.abortSuccess"));
}
}
async function handleGeneration(): Promise<void> {
setIsGenerating(true);
setGeneratedText('');
try {
const response: Response = await fetch(`${configs.apiUrl}quillsense/generate/short`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.accessToken}`,
},
body: JSON.stringify({
authorLevel: authorLevel,
tone: tone,
atmosphere: atmosphere,
verbTense: verbTense,
person: person,
characters: characters,
language: language,
dialogueType: dialogueType,
directives: directives,
wordsCount: wordsCount
}),
});
if (!response.ok) {
const error: { message?: string } = await response.json();
errorMessage(error.message || t("shortStoryGenerator.result.unknownError"));
setIsGenerating(false);
return;
}
setActiveTab(4);
setProgress(100);
const reader: ReadableStreamDefaultReader<Uint8Array> | undefined = response.body?.getReader();
const decoder: TextDecoder = new TextDecoder();
let accumulatedText: string = '';
if (!reader) {
errorMessage(t("shortStoryGenerator.result.noResponse"));
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 data: {
content?: string;
title?: string;
useYourKey?: boolean;
totalPrice?: number;
totalCost?: number;
} = JSON.parse(line.slice(6));
if (data.content && data.content !== 'starting') {
accumulatedText += data.content;
setGeneratedText(accumulatedText);
}
if (data.title) {
setGeneratedStoryTitle(data.title);
}
// Le message final du endpoint avec title, totalPrice, useYourKey, totalCost
if (data.useYourKey !== undefined && data.totalPrice !== undefined) {
console.log(data);
if (data.useYourKey) {
setTotalPrice((prev: number): number => prev + data.totalPrice!);
} else {
setTotalCredits(data.totalPrice);
}
}
} catch (e: unknown) {
console.error('Error parsing SSE data:', e);
}
}
}
} catch (e: unknown) {
// Si le reader est annulé ou une erreur survient, sortir
break;
}
}
setIsGenerating(false);
setHasGenerated(true);
setAbortController(null);
} catch (e: unknown) {
if (e instanceof Error) {
if (e.name !== 'AbortError') {
errorMessage(e.message);
}
} else {
errorMessage(t("shortStoryGenerator.result.unknownError"));
}
setIsGenerating(false);
setAbortController(null);
}
}
function getWordCount(): void {
if (editor) {
try {
const content: string = editor?.state.doc.textContent;
const texteNormalise: string = content
.replace(/'/g, ' ')
.replace(/-/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const mots: string[] = texteNormalise.split(' ');
const wordCount: number = mots.filter(
(mot: string): boolean => mot.length > 0,
).length;
setTotalWordsCount(wordCount);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("shortStoryGenerator.result.unknownError"));
}
}
}
}
async function handleSave(): Promise<void> {
let content: string = '';
if (editor) content = editor?.state?.doc.toJSON();
try {
const bookId: string = await System.authPostToServer<string>(
`quillsense/generate/add`,
{
title: generatedStoryTitle,
resume: resume,
content: content,
wordCount: totalWordsCount,
tone: tone,
atmosphere: atmosphere,
verbTense: verbTense,
language: language,
dialogueType: dialogueType,
person: person,
authorLevel: authorLevel
? authorLevel
: session.user?.writingLevel,
},
session.accessToken,
lang
);
if (!bookId) {
errorMessage(t("shortStoryGenerator.result.saveError"));
return;
}
onClose();
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("shortStoryGenerator.result.unknownError"));
}
}
}
if (!hasAccess) {
return (
<div
className="fixed inset-0 flex items-center justify-center bg-darkest-background/80 z-50 backdrop-blur-sm">
<div
className="bg-dark-background text-text-primary rounded-lg border border-secondary shadow-xl w-full max-w-md p-6">
<h2 className="flex items-center font-['ADLaM_Display'] text-xl text-text-primary mb-4">
<FontAwesomeIcon icon={faMagicWandSparkles} className="mr-3 w-5 h-5"/>
{t("shortStoryGenerator.accessDenied.title")}
</h2>
<p className="text-text-secondary mb-6">
{t("shortStoryGenerator.accessDenied.message")}
</p>
<button
onClick={onClose}
className="w-full bg-primary text-text-primary px-4 py-2 rounded-lg hover:bg-primary-dark transition-colors"
>
{t("shortStoryGenerator.accessDenied.close")}
</button>
</div>
</div>
);
}
return (
<div className="fixed inset-0 flex items-center justify-center bg-overlay z-40 backdrop-blur-sm">
<div ref={modalRef}
className="bg-tertiary/90 backdrop-blur-sm text-text-primary rounded-2xl border border-secondary/50 shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex justify-between items-center bg-primary px-6 py-4 rounded-t-2xl shadow-md">
<h2 className="font-['ADLaM_Display'] text-xl text-text-primary flex items-center">
<FontAwesomeIcon icon={faMagicWandSparkles} className="mr-3 w-5 h-5"/>
{t("shortStoryGenerator.title")}
</h2>
<button
className="text-text-primary hover:bg-primary-dark p-2 rounded-xl transition-all duration-200 hover:scale-110"
onClick={onClose}
disabled={isGenerating}
>
<FontAwesomeIcon icon={faX} className="w-5 h-5"/>
</button>
</div>
<div className="px-6 py-4 border-b border-secondary/50">
<div className="w-full bg-secondary/50 rounded-full h-2.5 shadow-inner">
<div
className="bg-primary h-2.5 rounded-full transition-all duration-300 shadow-sm"
style={{width: `${progress}%`}}
/>
</div>
</div>
<div className="flex border-b border-secondary/50">
{[
{id: 1, label: t("shortStoryGenerator.tabs.basics"), icon: faBookOpen},
{id: 2, label: t("shortStoryGenerator.tabs.structure"), icon: faUserEdit},
{id: 3, label: t("shortStoryGenerator.tabs.atmosphere"), icon: faCloudSun},
...(hasGenerated || isGenerating ? [{
id: 4,
label: t("shortStoryGenerator.tabs.result"),
icon: faFileLines
}] : [])
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
disabled={isGenerating}
className={`flex items-center px-6 py-3 font-medium transition-colors ${
activeTab === tab.id
? 'text-primary border-b-2 border-primary bg-primary/5'
: 'text-text-secondary hover:text-text-primary'
}`}
>
<FontAwesomeIcon icon={tab.icon} className="mr-2 w-4 h-4"/>
{tab.label}
{tab.id === 4 && isGenerating && !generatedText && (
<FontAwesomeIcon icon={faSpinner} className="ml-2 animate-spin w-4 h-4"/>
)}
</button>
))}
</div>
<div className="flex-1">
{activeTab === 1 && (
<div className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
icon={faGraduationCap}
fieldName={t("shortStoryGenerator.fields.complexity")}
input={
<SelectBox
onChangeCallBack={(e) => setAuthorLevel(e.target.value)}
data={writingLevel}
defaultValue={authorLevel}
/>
}
/>
<InputField
icon={faBookOpen}
fieldName={t("shortStoryGenerator.fields.preset")}
input={
<SelectBox
onChangeCallBack={(e) => setPresetType(e.target.value)}
data={
authorLevel === '1'
? beginnerPredefinedType
: authorLevel === '2'
? intermediatePredefinedType
: advancedPredefinedType
}
defaultValue={presetType}
/>
}
/>
<InputField
icon={faLanguage}
fieldName={t("shortStoryGenerator.fields.language")}
input={
<SelectBox
onChangeCallBack={(e) => setLanguage(e.target.value)}
data={langues}
defaultValue={language}
/>
}
/>
<InputField
icon={faChartSimple}
fieldName={t("shortStoryGenerator.fields.wordCount")}
input={
<NumberInput
value={wordsCount}
setValue={setWordsCount}
placeholder="500"
/>
}
/>
</div>
</div>
)}
{activeTab === 2 && (
<div className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
icon={faClock}
fieldName={t("shortStoryGenerator.fields.tense")}
input={
<SelectBox
onChangeCallBack={(e) => setVerbTense(e.target.value)}
data={verbalTime}
defaultValue={verbTense}
/>
}
/>
<InputField
icon={faUserEdit}
fieldName={t("shortStoryGenerator.fields.narrative")}
input={
<SelectBox
onChangeCallBack={(e) => setPerson(e.target.value)}
data={
authorLevel === '1'
? beginnerNarrativePersons
: authorLevel === '2'
? intermediateNarrativePersons
: advancedNarrativePersons
}
defaultValue={person}
/>
}
/>
</div>
<InputField
icon={faComments}
fieldName={t("shortStoryGenerator.fields.dialogue")}
input={
<SelectBox
onChangeCallBack={(e) => setDialogueType(e.target.value)}
data={
authorLevel === '1'
? beginnerDialogueTypes
: authorLevel === '2'
? intermediateDialogueTypes
: advancedDialogueTypes
}
defaultValue={dialogueType}
/>
}
/>
<InputField
icon={faPencilAlt}
fieldName={t("shortStoryGenerator.fields.directives")}
input={
<TexteAreaInput
value={directives}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setDirectives(e.target.value)}
placeholder={t("shortStoryGenerator.placeholders.directives")}
/>
}
/>
</div>
)}
{activeTab === 3 && (
<div className="p-6 space-y-6">
<div className="space-y-4">
<InputField
icon={faMusic}
fieldName={t("shortStoryGenerator.fields.tone")}
input={
<TextInput
value={tone}
setValue={(e: ChangeEvent<HTMLInputElement>) => setTone(e.target.value)}
placeholder={t("shortStoryGenerator.placeholders.tone")}
/>
}
/>
</div>
<div className="space-y-4">
<InputField
icon={faCloudSun}
fieldName={t("shortStoryGenerator.fields.atmosphere")}
input={
<TextInput
value={atmosphere}
setValue={(e: ChangeEvent<HTMLInputElement>) => setAtmosphere(e.target.value)}
placeholder={t("shortStoryGenerator.placeholders.atmosphere")}
/>
}
/>
</div>
<InputField
icon={faUserAstronaut}
fieldName={t("shortStoryGenerator.fields.character")}
input={
<TextInput
value={characters}
setValue={(e: ChangeEvent<HTMLInputElement>) => setCharacters(e.target.value)}
placeholder={t("shortStoryGenerator.placeholders.character")}
/>
}
/>
</div>
)}
{activeTab === 4 && (
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-lg">
{generatedStoryTitle || t("shortStoryGenerator.result.title")}
</h3>
<div className="flex items-center space-x-2">
{isGenerating ? (
<button
onClick={handleStopGeneration}
className="p-2 rounded-xl bg-red-500 hover:bg-red-600 transition-all duration-200 hover:scale-110 shadow-md"
title={t("shortStoryGenerator.actions.stop")}
>
<FontAwesomeIcon icon={faStop} className="w-4 h-4"/>
</button>
) : generatedText && (
<>
<button
onClick={handleGeneration}
className="p-2 rounded-xl bg-secondary/50 hover:bg-secondary transition-all duration-200 hover:scale-110 shadow-sm border border-secondary/50"
title={t("shortStoryGenerator.actions.regenerate")}
>
<FontAwesomeIcon icon={faRotateRight} className="w-4 h-4"/>
</button>
<button
onClick={handleSave}
className="p-2 rounded-xl bg-primary hover:bg-primary-dark transition-all duration-200 hover:scale-110 shadow-md"
title={t("shortStoryGenerator.actions.save")}
>
<FontAwesomeIcon icon={faBookBookmark} className="w-4 h-4"/>
</button>
</>
)}
</div>
</div>
{isGenerating && !generatedText ? (
<div className="flex flex-col items-center justify-center py-20">
<FontAwesomeIcon icon={faSpinner}
className="animate-spin text-primary mb-4 w-8 h-8"/>
<p className="text-text-secondary">{t("shortStoryGenerator.result.generating")}</p>
</div>
) : (
<div
className="bg-darkest-background rounded-lg p-6 overflow-auto max-h-96 fade-in-text">
<EditorContent editor={editor} className="prose prose-invert max-w-none"/>
</div>
)}
{generatedText && (
<div className="flex justify-between items-center mt-4 pt-4 border-t border-secondary">
<div className="flex items-center text-sm text-text-secondary">
<FontAwesomeIcon icon={faChartSimple} className="mr-2 w-4 h-4"/>
{totalWordsCount} {t("shortStoryGenerator.result.words")}
</div>
</div>
)}
</div>
)}
</div>
<div
className="flex justify-between items-center p-6 border-t border-secondary/50 bg-secondary/30 backdrop-blur-sm shadow-inner">
<button
onClick={() => setActiveTab(Math.max(1, activeTab - 1))}
className={`px-4 py-2 rounded-xl transition-all duration-200 flex items-center ${
activeTab > 1 && !isGenerating
? 'text-text-secondary hover:text-text-primary hover:bg-secondary hover:scale-105 shadow-sm'
: 'text-muted cursor-not-allowed'
}`}
disabled={activeTab === 1 || isGenerating}
>
<FontAwesomeIcon icon={faChevronRight} className="mr-2 rotate-180 w-4 h-4"/>
{t("shortStoryGenerator.navigation.previous")}
</button>
<div className="flex items-center space-x-3">
<button
onClick={onClose}
className="px-6 py-2.5 rounded-xl bg-secondary/50 text-text-primary hover:bg-secondary transition-all duration-200 hover:scale-105 shadow-sm border border-secondary/50 font-medium"
disabled={isGenerating}
>
{activeTab === 4 && hasGenerated ? t("shortStoryGenerator.navigation.close") : t("shortStoryGenerator.navigation.cancel")}
</button>
{activeTab < 3 ? (
<button
onClick={() => setActiveTab(activeTab + 1)}
disabled={isGenerating}
className="px-6 py-2.5 rounded-xl bg-primary text-text-primary hover:bg-primary-dark transition-all duration-200 hover:scale-105 flex items-center disabled:opacity-50 shadow-md hover:shadow-lg font-medium"
>
{t("shortStoryGenerator.navigation.next")}
<FontAwesomeIcon icon={faChevronRight} className="ml-2 w-4 h-4"/>
</button>
) : activeTab === 3 && (
<button
onClick={handleGeneration}
disabled={isGenerating}
className="px-6 py-2.5 rounded-xl bg-primary text-text-primary hover:bg-primary-dark transition-all duration-200 hover:scale-105 flex items-center disabled:opacity-50 shadow-md hover:shadow-lg font-medium"
>
{isGenerating ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2 w-4 h-4"/>
{t("shortStoryGenerator.actions.generating")}
</>
) : (
<>
<FontAwesomeIcon icon={faMagicWandSparkles} className="mr-2 w-4 h-4"/>
{t("shortStoryGenerator.actions.generate")}
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
);
}