6a949b54ba
Co-authored-by: Cursor <cursoragent@cursor.com>
237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import type { AutoRhythm, AutoWritingConfig, SettingEntry, WizardWritingConfig } from '@shared/types'
|
||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||
|
||
interface WizardPanelProps {
|
||
bookId: string
|
||
sessionId: string | null
|
||
disabled: boolean
|
||
}
|
||
|
||
const RHYTHMS: AutoRhythm[] = ['relaxed', 'tense', 'mixed']
|
||
|
||
export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps): React.JSX.Element {
|
||
const { t } = useTranslation()
|
||
const {
|
||
flow,
|
||
previewHtml,
|
||
streaming,
|
||
streamBuffer,
|
||
loadFlow,
|
||
start,
|
||
setGoal,
|
||
setRhythm,
|
||
setPov,
|
||
confirmContext,
|
||
buildPreview,
|
||
startGenerate,
|
||
mount,
|
||
unmount
|
||
} = useWizardStore()
|
||
|
||
const [goal, setGoalText] = useState('')
|
||
const [wordMin, setWordMin] = useState(2000)
|
||
const [wordMax, setWordMax] = useState(4000)
|
||
const [rhythm, setRhythmVal] = useState<AutoRhythm>('mixed')
|
||
const [styleNote, setStyleNote] = useState('')
|
||
const [characters, setCharacters] = useState<SettingEntry[]>([])
|
||
const [povId, setPovId] = useState('')
|
||
const [contextOpen, setContextOpen] = useState(false)
|
||
|
||
useEffect(() => {
|
||
mount()
|
||
return () => unmount()
|
||
}, [mount, unmount])
|
||
|
||
useEffect(() => {
|
||
if (bookId && sessionId) void loadFlow(bookId, sessionId)
|
||
}, [bookId, sessionId, loadFlow])
|
||
|
||
useEffect(() => {
|
||
if (!bookId) return
|
||
void ipcCall(() => window.electronAPI.setting.list(bookId)).then((list) => {
|
||
setCharacters(list.filter((s) => s.type === 'character'))
|
||
})
|
||
}, [bookId])
|
||
|
||
if (!sessionId || !flow) {
|
||
return (
|
||
<div className="wizard-panel" data-testid="wizard-panel">
|
||
<p className="placeholder-box">{t('ai.noSession')}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const step = flow.step
|
||
const config = flow.modeConfig as WizardWritingConfig
|
||
|
||
const handleContextClose = (): void => {
|
||
setContextOpen(false)
|
||
const session = useAiStore.getState().sessions.find(
|
||
(s) => s.id === useAiStore.getState().activeSessionId
|
||
)
|
||
if (session) void confirmContext(bookId, session.context)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="wizard-panel interactive-panel" data-testid="wizard-panel">
|
||
<div className="wizard-step-indicator" data-testid="wizard-step-indicator">
|
||
{t('wizard.step.goal')} → {t('wizard.step.rhythm')} → {t('wizard.step.pov')} →{' '}
|
||
{t('wizard.step.context')} → {t('wizard.step.preview')}
|
||
</div>
|
||
|
||
{step === 'wizard_goal' && (
|
||
<div className="interactive-section">
|
||
<textarea
|
||
className="form-control"
|
||
data-testid="wizard-goal-input"
|
||
placeholder={t('auto.goalPlaceholder')}
|
||
value={goal}
|
||
onChange={(e) => setGoalText(e.target.value)}
|
||
rows={3}
|
||
/>
|
||
<div className="auto-word-range">
|
||
<input type="number" value={wordMin} onChange={(e) => setWordMin(Number(e.target.value))} />
|
||
<span>–</span>
|
||
<input type="number" value={wordMax} onChange={(e) => setWordMax(Number(e.target.value))} />
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
disabled={disabled || !goal.trim()}
|
||
onClick={() =>
|
||
void setGoal(bookId, {
|
||
chapterGoal: goal.trim(),
|
||
wordMin,
|
||
wordMax
|
||
} as Partial<AutoWritingConfig>)
|
||
}
|
||
>
|
||
{t('wizard.next')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'wizard_rhythm' && (
|
||
<div className="interactive-section">
|
||
{RHYTHMS.map((r) => (
|
||
<button
|
||
key={r}
|
||
type="button"
|
||
className={`btn ${rhythm === r ? 'primary' : ''}`}
|
||
data-testid={`wizard-rhythm-${r}`}
|
||
disabled={disabled}
|
||
onClick={() => setRhythmVal(r)}
|
||
>
|
||
{t(`wizard.rhythm.${r}`)}
|
||
</button>
|
||
))}
|
||
<input
|
||
className="form-control"
|
||
data-testid="wizard-style-note"
|
||
placeholder={t('wizard.styleNotePlaceholder')}
|
||
value={styleNote}
|
||
onChange={(e) => setStyleNote(e.target.value)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
disabled={disabled}
|
||
onClick={() => void setRhythm(bookId, rhythm, styleNote)}
|
||
>
|
||
{t('wizard.next')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'wizard_pov' && (
|
||
<div className="interactive-section">
|
||
<select
|
||
className="form-control"
|
||
data-testid="wizard-pov-select"
|
||
value={povId}
|
||
onChange={(e) => setPovId(e.target.value)}
|
||
>
|
||
<option value="">{t('wizard.povDefault')}</option>
|
||
{characters.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
disabled={disabled}
|
||
onClick={() => void setPov(bookId, povId || characters[0]?.id || '')}
|
||
>
|
||
{t('wizard.next')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'wizard_context' && (
|
||
<div className="interactive-section">
|
||
<p>{t('wizard.contextHint')}</p>
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
data-testid="wizard-context-edit"
|
||
disabled={disabled}
|
||
onClick={() => setContextOpen(true)}
|
||
>
|
||
{t('ai.contextEditorTitle')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'wizard_preview' && (
|
||
<div className="interactive-section">
|
||
<h4>{t('wizard.previewTitle')}</h4>
|
||
<div
|
||
className="wizard-preview"
|
||
data-testid="wizard-preview"
|
||
dangerouslySetInnerHTML={{
|
||
__html: previewHtml || config.strategyPreview || '<p>…</p>'
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
disabled={disabled || streaming}
|
||
onClick={() => void buildPreview(bookId)}
|
||
>
|
||
{t('wizard.refreshPreview')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
data-testid="wizard-start-generate"
|
||
disabled={disabled || streaming}
|
||
onClick={() => void startGenerate(bookId)}
|
||
>
|
||
{t('wizard.startGenerate')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'wizard_generating' && (
|
||
<div className="interactive-section">
|
||
<p>{streaming ? t('ai.sending') : t('wizard.generating')}</p>
|
||
<div
|
||
className="interactive-scene-preview"
|
||
dangerouslySetInnerHTML={{ __html: streamBuffer || '<p>…</p>' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<ContextEditorModal open={contextOpen} onClose={handleContextClose} />
|
||
</>
|
||
)
|
||
}
|