Files
bilin/src/renderer/components/ai/WizardPanel.tsx
T
2026-07-06 20:57:31 +08:00

237 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} />
</>
)
}