348 lines
15 KiB
TypeScript
348 lines
15 KiB
TypeScript
import {fetch} from "@tauri-apps/plugin-http";
|
|
import React, {ChangeEvent, useContext, useState} from 'react';
|
|
import {BookOpen, FileInput, Hash, Palette, Wand2} from 'lucide-react';
|
|
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
|
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
|
import {EditorContext, EditorContextProps} from "@/context/EditorContext";
|
|
import {apiGet} from "@/lib/api/client";
|
|
import {htmlToText, textContentToHtml} from "@/lib/utils/html";
|
|
import {BookContext, BookContextProps} from "@/context/BookContext";
|
|
import {ChapterContext, ChapterContextProps} from "@/context/ChapterContext";
|
|
import QSTextGeneratedPreview from "@/components/ui/QSTextGeneratedPreview";
|
|
import TextInput from "@/components/form/TextInput";
|
|
import InputField from "@/components/form/InputField";
|
|
import RadioBox from "@/components/form/RadioBox";
|
|
import TextAreaInput from "@/components/form/TextAreaInput";
|
|
import Button from "@/components/ui/Button";
|
|
import NumberInput from "@/components/form/NumberInput";
|
|
import SectionHeader from "@/components/ui/SectionHeader";
|
|
import GhostWriterTags from "@/components/ghostwriter/GhostWriterTags";
|
|
import {TiptapNode} from "@/lib/types/chapter";
|
|
import {convertTiptapToHTML} from "@/lib/utils/tiptap";
|
|
import {useTranslations} from '@/lib/i18n';
|
|
import {getSubLevel, isAnthropicEnabled} from "@/lib/utils/quillsense";
|
|
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
|
import {LangContext, LangContextProps} from "@/context/LangContext";
|
|
import {configs} from "@/lib/configs";
|
|
import AdvancedGenerationOptions from "@/components/form/AdvancedGenerationOptions";
|
|
|
|
export default function GhostWriter() {
|
|
const t = useTranslations();
|
|
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
|
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
|
const {errorMessage, infoMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
|
const {editor}: EditorContextProps = useContext<EditorContextProps>(EditorContext);
|
|
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
|
const {chapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
|
|
const {setTotalPrice, setTotalCredits}: AIUsageContextProps = 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 [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 [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;
|
|
|
|
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(e.message);
|
|
} 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,
|
|
},
|
|
useExplicit: useExplicit,
|
|
useSmart: useSmart,
|
|
}),
|
|
});
|
|
|
|
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;
|
|
} = JSON.parse(dataStr);
|
|
if ('content' in data && data.content) {
|
|
accumulatedText += data.content;
|
|
setTextGenerated(accumulatedText);
|
|
}
|
|
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('ghostWriter.errorProcessingData'));
|
|
}
|
|
}
|
|
}
|
|
} 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 apiGet<TiptapNode>(
|
|
`chapter/content`,
|
|
session.accessToken,
|
|
lang,
|
|
{
|
|
chapterid: chapter?.chapterId,
|
|
version: 1
|
|
},
|
|
)
|
|
if (!response) {
|
|
errorMessage(t('ghostWriter.noContentFound'));
|
|
return;
|
|
}
|
|
const content: string = htmlToText(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(textContentToHtml(textGenerated));
|
|
setIsTextGenerated(false);
|
|
}
|
|
}
|
|
|
|
if (!hasAccess) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="bg-tertiary rounded-2xl p-10 text-center 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
|
|
variant="primary"
|
|
size="lg"
|
|
onClick={(): void => {
|
|
window.location.href = '/pricing';
|
|
}}
|
|
>
|
|
{t("ghostWriter.subscribe")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
<SectionHeader
|
|
title={t("ghostWriter.title")}
|
|
description={t("ghostWriter.description")}
|
|
badge="AI"
|
|
/>
|
|
|
|
<div className="p-4 lg:p-5 space-y-6 overflow-y-auto flex-grow custom-scrollbar">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<InputField
|
|
icon={Hash}
|
|
fieldName={t("ghostWriter.minimum")}
|
|
input={
|
|
<NumberInput
|
|
value={minWords}
|
|
setValue={setMinWords}
|
|
placeholder={t("ghostWriter.words")}
|
|
/>
|
|
}
|
|
/>
|
|
<InputField
|
|
icon={Hash}
|
|
fieldName={t("ghostWriter.maximum")}
|
|
input={
|
|
<NumberInput
|
|
value={maxWords}
|
|
setValue={setMaxWords}
|
|
placeholder={t("ghostWriter.words")}
|
|
/>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<InputField
|
|
icon={BookOpen}
|
|
fieldName={t("ghostWriter.type")}
|
|
input={<RadioBox selected={type} setSelected={setType} name={'sectionType'}/>}
|
|
/>
|
|
|
|
<InputField
|
|
icon={Palette}
|
|
fieldName={t("ghostWriter.toneAtmosphere")}
|
|
input={
|
|
<TextInput
|
|
value={toneAtmosphere}
|
|
setValue={(e: ChangeEvent<HTMLInputElement>) => setToneAtmosphere(e.target.value)}
|
|
placeholder={t("ghostWriter.tonePlaceholder")}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<InputField
|
|
icon={Wand2}
|
|
fieldName={t("ghostWriter.directive")}
|
|
action={importPrompt}
|
|
actionIcon={FileInput}
|
|
actionLabel={t("ghostWriter.importPrompt")}
|
|
input={
|
|
<TextAreaInput
|
|
value={directive}
|
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setDirective(e.target.value)}
|
|
placeholder={t("ghostWriter.directivePlaceholder")}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<GhostWriterTags
|
|
taguedCharacters={taguedCharacters} setTaguedCharacters={setTaguedCharacters}
|
|
taguedLocations={taguedLocations} setTaguedLocations={setTaguedLocations}
|
|
taguedObjects={taguedObjects} setTaguedObjects={setTaguedObjects}
|
|
taguedWorldElements={taguedWorldElements} setTaguedWorldElements={setTaguedWorldElements}
|
|
/>
|
|
|
|
<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={handleGenerateGhostWriter}
|
|
isLoading={isGenerating}
|
|
loadingText={t("ghostWriter.generating")}
|
|
icon={Wand2}
|
|
>{t("ghostWriter.generate")}</Button>
|
|
</div>
|
|
</div>
|
|
{(isTextGenerated || isGenerating) && (
|
|
<QSTextGeneratedPreview
|
|
onClose={(): void => setIsTextGenerated(false)}
|
|
onRefresh={(): Promise<void> => handleGenerateGhostWriter()}
|
|
value={textGenerated}
|
|
onInsert={insertText}
|
|
isGenerating={isGenerating}
|
|
onStop={handleStopGeneration}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|