feat: ship v0.5.0 with auto and wizard writing modes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user