feat: ship v0.5.0 with auto and wizard writing modes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 20:57:31 +08:00
parent 86b66a311a
commit 6a949b54ba
38 changed files with 2581 additions and 184 deletions
+236
View File
@@ -0,0 +1,236 @@
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} />
</>
)
}