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
+84 -26
View File
@@ -10,7 +10,11 @@ import { insertToEditor } from '@renderer/lib/editor-commands'
import { ipcCall } from '@renderer/lib/ipc-client'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
import { AutoPanel } from '@renderer/components/ai/AutoPanel'
import { WizardPanel } from '@renderer/components/ai/WizardPanel'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { useAutoStore } from '@renderer/stores/useAutoStore'
import { useWizardStore } from '@renderer/stores/useWizardStore'
const MODES: { id: AiWritingMode; labelKey: string }[] = [
{ id: 'chat', labelKey: 'ai.mode.chat' },
@@ -42,8 +46,16 @@ export function AiPanel(): React.JSX.Element {
const sendMessage = useAiStore((s) => s.sendMessage)
const setWritingMode = useAiStore((s) => s.setWritingMode)
const gateEligible = useInteractiveStore((s) => s.gateEligible)
const autoGateEligible = useAutoStore((s) => s.gateEligible)
const wizardGateEligible = useWizardStore((s) => s.gateEligible)
const checkGate = useInteractiveStore((s) => s.checkGate)
const checkAutoGate = useAutoStore((s) => s.checkGate)
const checkWizardGate = useWizardStore((s) => s.checkGate)
const startInteractive = useInteractiveStore((s) => s.start)
const startAuto = useAutoStore((s) => s.start)
const startWizard = useWizardStore((s) => s.start)
const loadAutoFlow = useAutoStore((s) => s.loadFlow)
const loadWizardFlow = useWizardStore((s) => s.loadFlow)
const [input, setInput] = useState('')
const [contextOpen, setContextOpen] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -65,8 +77,10 @@ export function AiPanel(): React.JSX.Element {
if (bookId) {
void loadSessions(bookId)
void checkGate(bookId)
void checkAutoGate(bookId)
void checkWizardGate(bookId)
}
}, [bookId, loadSessions, checkGate])
}, [bookId, loadSessions, checkGate, checkAutoGate, checkWizardGate])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -93,31 +107,62 @@ export function AiPanel(): React.JSX.Element {
}
}
const enterWritingMode = (mode: 'interactive' | 'auto' | 'wizard'): void => {
void (async () => {
const gateCheck =
mode === 'interactive'
? checkGate
: mode === 'auto'
? checkAutoGate
: checkWizardGate
const gateKey =
mode === 'interactive'
? 'interactive'
: mode === 'auto'
? 'auto'
: 'wizard'
if (bookId) await gateCheck(bookId)
const eligible =
mode === 'interactive'
? useInteractiveStore.getState().gateEligible
: mode === 'auto'
? useAutoStore.getState().gateEligible
: useWizardStore.getState().gateEligible
if (!eligible) {
showToast(t(`${gateKey}.gateBlocked`))
return
}
setWritingMode(mode)
if (!bookId) return
if (!useAiStore.getState().activeSessionId) {
await createSession(bookId)
}
const sessionId = useAiStore.getState().activeSessionId
if (!sessionId) return
if (mode === 'interactive') {
await useInteractiveStore.getState().loadFlow(bookId, sessionId)
if (!useInteractiveStore.getState().flow) await startInteractive(bookId, sessionId)
} else if (mode === 'auto') {
await loadAutoFlow(bookId, sessionId)
if (!useAutoStore.getState().flow) await startAuto(bookId, sessionId)
} else {
await loadWizardFlow(bookId, sessionId)
if (!useWizardStore.getState().flow) await startWizard(bookId, sessionId)
}
})()
}
const handleModeClick = (mode: AiWritingMode): void => {
if (mode === 'auto' || mode === 'wizard') {
showToast(t('feature.comingSoon'))
if (mode === 'interactive') {
enterWritingMode('interactive')
return
}
if (mode === 'interactive') {
void (async () => {
if (bookId) await checkGate(bookId)
if (!useInteractiveStore.getState().gateEligible) {
showToast(t('interactive.gateBlocked'))
return
}
setWritingMode(mode)
const sid = activeSessionId ?? useAiStore.getState().activeSessionId
if (!bookId) return
if (!sid) {
await createSession(bookId)
}
const sessionId = useAiStore.getState().activeSessionId
if (!sessionId) return
await useInteractiveStore.getState().loadFlow(bookId, sessionId)
if (!useInteractiveStore.getState().flow) {
await startInteractive(bookId, sessionId)
}
})()
if (mode === 'auto') {
enterWritingMode('auto')
return
}
if (mode === 'wizard') {
enterWritingMode('wizard')
return
}
setWritingMode(mode)
@@ -159,15 +204,24 @@ export function AiPanel(): React.JSX.Element {
<div className="ai-mode-tabs" role="tablist">
{MODES.map((mode) => {
const interactiveDisabled = mode.id === 'interactive' && !gateEligible
const modeGate =
mode.id === 'interactive'
? gateEligible
: mode.id === 'auto'
? autoGateEligible
: mode.id === 'wizard'
? wizardGateEligible
: true
const writingDisabled =
(mode.id === 'interactive' || mode.id === 'auto' || mode.id === 'wizard') && !modeGate
return (
<button
key={mode.id}
type="button"
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
data-testid={`ai-mode-tab-${mode.id}`}
disabled={interactiveDisabled}
title={interactiveDisabled ? t('interactive.gateTooltip') : undefined}
disabled={writingDisabled}
title={writingDisabled ? t(`${mode.id}.gateTooltip`) : undefined}
onClick={() => handleModeClick(mode.id)}
>
{t(mode.labelKey)}
@@ -178,6 +232,10 @@ export function AiPanel(): React.JSX.Element {
{writingMode === 'interactive' ? (
<InteractivePanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
) : writingMode === 'auto' ? (
<AutoPanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
) : writingMode === 'wizard' ? (
<WizardPanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
) : (
<>
<button
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AutoWritingConfig, OutlineItem } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAutoStore } from '@renderer/stores/useAutoStore'
interface AutoPanelProps {
bookId: string
sessionId: string | null
disabled: boolean
}
export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): React.JSX.Element {
const { t } = useTranslation()
const {
flow,
streaming,
streamBuffer,
sceneDraft,
loadFlow,
start,
setGoal,
planScenes,
generateNext,
resolveNaming,
pause,
resume,
handoffInteractive,
finishChapter,
mount,
unmount
} = useAutoStore()
const [goal, setGoalText] = useState('')
const [wordMin, setWordMin] = useState(2000)
const [wordMax, setWordMax] = useState(4000)
const [outlineIds, setOutlineIds] = useState<string[]>([])
const [outlines, setOutlines] = useState<OutlineItem[]>([])
const [chapterTitle, setChapterTitle] = useState('')
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.outline.list(bookId)).then(setOutlines)
}, [bookId])
if (!sessionId || !flow) {
return (
<div className="auto-panel" data-testid="auto-panel">
<p className="placeholder-box">{t('ai.noSession')}</p>
</div>
)
}
const step = flow.step
const previewHtml = streaming ? streamBuffer : sceneDraft
const config = flow.modeConfig as AutoWritingConfig
const submitGoal = (): void => {
const payload: Partial<AutoWritingConfig> = {
chapterGoal: goal.trim(),
outlineItemIds: outlineIds,
wordMin,
wordMax
}
void setGoal(bookId, payload)
}
const toggleOutline = (id: string): void => {
setOutlineIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
}
return (
<div className="auto-panel interactive-panel" data-testid="auto-panel">
<div className="interactive-step-bar" data-testid="auto-step-indicator">
{t('auto.step.goal')} {t('auto.step.plan')} {t('auto.step.generate')} {t('auto.step.done')}
</div>
{(step === 'goal_setup' || step === 'scene_plan') && (
<div className="interactive-section">
<textarea
className="form-control"
data-testid="auto-goal-input"
placeholder={t('auto.goalPlaceholder')}
value={goal}
onChange={(e) => setGoalText(e.target.value)}
rows={3}
/>
<div className="auto-outline-picker" data-testid="auto-outline-picker">
{outlines.map((o) => (
<label key={o.id} className="auto-outline-option">
<input
type="checkbox"
checked={outlineIds.includes(o.id)}
onChange={() => toggleOutline(o.id)}
/>
{o.title}
</label>
))}
</div>
<div className="auto-word-range">
<input
type="number"
data-testid="auto-word-min"
value={wordMin}
onChange={(e) => setWordMin(Number(e.target.value))}
/>
<span></span>
<input
type="number"
data-testid="auto-word-max"
value={wordMax}
onChange={(e) => setWordMax(Number(e.target.value))}
/>
</div>
<button
type="button"
className="btn primary"
data-testid="auto-plan-scenes"
disabled={disabled || streaming || !goal.trim()}
onClick={() => {
void (async () => {
await setGoal(bookId, { chapterGoal: goal.trim(), outlineItemIds: outlineIds, wordMin, wordMax })
await planScenes(bookId)
})()
}}
>
{t('auto.planScenes')}
</button>
</div>
)}
{(step === 'auto_generate' || step === 'paused' || step === 'naming_pause') && (
<div className="interactive-section">
{config.scenePlan && (
<p className="auto-plan-summary">
{t('auto.sceneProgress', {
current: config.currentPlanIndex ?? 0,
total: config.scenePlan.length
})}
</p>
)}
<div
className="interactive-scene-preview"
data-testid="auto-scene-preview"
dangerouslySetInnerHTML={{ __html: previewHtml || '<p>…</p>' }}
/>
{step === 'naming_pause' && flow.namingPending && (
<div className="naming-options">
{flow.namingPending.options.map((name, i) => (
<button
key={name}
type="button"
className="btn"
data-testid={`naming-option-${i}`}
disabled={disabled || streaming}
onClick={() => void resolveNaming(bookId, name)}
>
{name}
</button>
))}
</div>
)}
<div className="interactive-actions">
{step === 'auto_generate' && (
<>
<button
type="button"
className="btn"
data-testid="auto-pause"
disabled={disabled || streaming}
onClick={() => void pause(bookId)}
>
{t('auto.pause')}
</button>
<button
type="button"
className="btn primary"
data-testid="auto-start-generate"
disabled={disabled || streaming}
onClick={() => void generateNext(bookId)}
>
{t('auto.startGenerate')}
</button>
</>
)}
{step === 'paused' && (
<>
<button
type="button"
className="btn"
disabled={disabled || streaming}
onClick={() => void resume(bookId)}
>
{t('auto.resume')}
</button>
<button
type="button"
className="btn"
data-testid="auto-handoff-interactive"
disabled={disabled}
onClick={() => void handoffInteractive(bookId)}
>
{t('auto.handoffInteractive')}
</button>
</>
)}
</div>
{flow.scenes.length > 0 && (
<div className="interactive-finish">
<input
className="form-control"
value={chapterTitle}
onChange={(e) => setChapterTitle(e.target.value)}
placeholder={t('interactive.chapterTitlePlaceholder')}
/>
<button
type="button"
className="btn primary"
data-testid="auto-finish-chapter"
disabled={disabled || streaming}
onClick={() => void finishChapter(bookId, chapterTitle.trim() || undefined)}
>
{t('auto.finishChapter')}
</button>
</div>
)}
</div>
)}
</div>
)
}
+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} />
</>
)
}
@@ -103,7 +103,7 @@ export function SettingsPage(): React.JSX.Element {
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v0.4.0
{t('app.name')} v0.5.0
<br />
{t('app.tagline')}
</p>
+175
View File
@@ -0,0 +1,175 @@
import { create } from 'zustand'
import type { AutoWritingConfig, InteractiveFlow } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useAiStore } from '@renderer/stores/useAiStore'
import i18n from '@renderer/i18n'
interface AutoState {
gateEligible: boolean
flow: InteractiveFlow | null
streamBuffer: string
streaming: boolean
sceneDraft: string
listenersAttached: boolean
checkGate: (bookId: string) => Promise<void>
loadFlow: (bookId: string, sessionId: string) => Promise<void>
start: (bookId: string, sessionId: string) => Promise<void>
setGoal: (bookId: string, config: Partial<AutoWritingConfig>) => Promise<void>
planScenes: (bookId: string) => Promise<void>
generateNext: (bookId: string) => Promise<void>
resolveNaming: (bookId: string, chosenName: string) => Promise<void>
pause: (bookId: string) => Promise<void>
resume: (bookId: string) => Promise<void>
handoffInteractive: (bookId: string) => Promise<void>
finishChapter: (bookId: string, title?: string) => Promise<void>
refreshSceneDraft: (bookId: string) => Promise<void>
mount: () => void
unmount: () => void
}
let unsubStream: (() => void) | null = null
export const useAutoStore = create<AutoState>((set, get) => ({
gateEligible: false,
flow: null,
streamBuffer: '',
streaming: false,
sceneDraft: '',
listenersAttached: false,
checkGate: async (bookId) => {
const r = await ipcCall(() => window.electronAPI.auto.checkGate(bookId))
set({ gateEligible: r.eligible })
},
loadFlow: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.auto.getFlow(bookId, sessionId))
set({ flow })
if (flow) await get().refreshSceneDraft(bookId)
},
start: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.auto.start(bookId, sessionId))
set({ flow, streamBuffer: '', sceneDraft: '' })
},
setGoal: async (bookId, config) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.auto.setGoal(bookId, flowId, config))
set({ flow })
},
planScenes: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true })
try {
const summary = useAiStore.getState().contextSummary
const flow = await ipcCall(() =>
window.electronAPI.auto.planScenes(bookId, flowId, summary)
)
set({ flow })
} finally {
set({ streaming: false })
}
},
generateNext: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true, streamBuffer: '' })
try {
const summary = useAiStore.getState().contextSummary
const flow = await ipcCall(() =>
window.electronAPI.auto.generate(bookId, flowId, summary)
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
resolveNaming: async (bookId, chosenName) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true, streamBuffer: '' })
try {
const summary = useAiStore.getState().contextSummary
const flow = await ipcCall(() =>
window.electronAPI.auto.resolveNaming(bookId, flowId, chosenName, summary)
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
pause: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.auto.pause(bookId, flowId))
set({ flow, streaming: false, streamBuffer: '' })
},
resume: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.auto.resume(bookId, flowId))
set({ flow })
},
handoffInteractive: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.auto.handoffInteractive(bookId, flowId))
set({ flow })
useAiStore.getState().setWritingMode('interactive')
useAppStore.getState().showToast(i18n.t('wizard.handoffToast'))
},
finishChapter: async (bookId, title) => {
const flowId = get().flow?.id
const volumeId = useBookStore.getState().activeVolumeId
if (!flowId || !volumeId) throw new Error('no volume')
const { chapterId } = await ipcCall(() =>
window.electronAPI.auto.finishChapter(bookId, flowId, volumeId, title)
)
await useBookStore.getState().refreshChapters()
await useBookStore.getState().openBook(bookId)
useBookStore.getState().setSelectedChapter(chapterId)
useEditStore.getState().setTarget({ kind: 'chapter', id: chapterId })
useAppStore.getState().setView('editor')
useAppStore.getState().showToast(i18n.t('auto.chapterCreated'))
},
refreshSceneDraft: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const draft = await ipcCall(() => window.electronAPI.auto.getSceneDraft(bookId, flowId))
set({ sceneDraft: draft })
},
mount: () => {
if (get().listenersAttached) return
unsubStream = window.electronAPI.onInteractiveStreamChunk((payload) => {
if (payload.flowMode !== 'auto' || payload.done) return
set((s) => ({
streaming: true,
streamBuffer: s.streamBuffer + payload.delta
}))
})
set({ listenersAttached: true })
},
unmount: () => {
unsubStream?.()
unsubStream = null
set({ listenersAttached: false })
}
}))
+137
View File
@@ -0,0 +1,137 @@
import { create } from 'zustand'
import type {
AiContextBinding,
AutoRhythm,
AutoWritingConfig,
InteractiveFlow,
WizardWritingConfig
} from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import i18n from '@renderer/i18n'
interface WizardState {
gateEligible: boolean
flow: InteractiveFlow | null
previewHtml: string
streaming: boolean
streamBuffer: string
listenersAttached: boolean
checkGate: (bookId: string) => Promise<void>
loadFlow: (bookId: string, sessionId: string) => Promise<void>
start: (bookId: string, sessionId: string) => Promise<void>
setGoal: (bookId: string, config: Partial<AutoWritingConfig>) => Promise<void>
setRhythm: (bookId: string, rhythm: AutoRhythm, styleNote?: string) => Promise<void>
setPov: (bookId: string, povSettingId: string) => Promise<void>
confirmContext: (bookId: string, binding: AiContextBinding) => Promise<void>
buildPreview: (bookId: string) => Promise<void>
startGenerate: (bookId: string) => Promise<void>
mount: () => void
unmount: () => void
}
let unsubStream: (() => void) | null = null
export const useWizardStore = create<WizardState>((set, get) => ({
gateEligible: false,
flow: null,
previewHtml: '',
streaming: false,
streamBuffer: '',
listenersAttached: false,
checkGate: async (bookId) => {
const r = await ipcCall(() => window.electronAPI.auto.checkGate(bookId))
set({ gateEligible: r.eligible })
},
loadFlow: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.wizard.getFlow(bookId, sessionId))
const config = flow?.modeConfig as WizardWritingConfig | undefined
set({
flow,
previewHtml: config?.strategyPreview ?? ''
})
},
start: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.wizard.start(bookId, sessionId))
set({ flow, previewHtml: '', streamBuffer: '' })
},
setGoal: async (bookId, config) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.wizard.setGoal(bookId, flowId, config))
set({ flow })
},
setRhythm: async (bookId, rhythm, styleNote) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() =>
window.electronAPI.wizard.setRhythm(bookId, flowId, rhythm, styleNote)
)
set({ flow })
},
setPov: async (bookId, povSettingId) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.wizard.setPov(bookId, flowId, povSettingId))
set({ flow })
},
confirmContext: async (bookId, binding) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() =>
window.electronAPI.wizard.confirmContext(bookId, flowId, binding)
)
set({ flow })
},
buildPreview: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true })
try {
const { preview, flow } = await ipcCall(() =>
window.electronAPI.wizard.buildPreview(bookId, flowId)
)
set({ previewHtml: preview, flow })
} finally {
set({ streaming: false })
}
},
startGenerate: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true, streamBuffer: '' })
try {
const flow = await ipcCall(() => window.electronAPI.wizard.startGenerate(bookId, flowId))
set({ flow })
useAiStore.getState().setWritingMode('interactive')
useAppStore.getState().showToast(i18n.t('wizard.handoffToast'))
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
mount: () => {
if (get().listenersAttached) return
unsubStream = window.electronAPI.onInteractiveStreamChunk((payload) => {
if (payload.flowMode !== 'wizard' || payload.done) return
set((s) => ({ streaming: true, streamBuffer: s.streamBuffer + payload.delta }))
})
set({ listenersAttached: true })
},
unmount: () => {
unsubStream?.()
unsubStream = null
set({ listenersAttached: false })
}
}))
+40
View File
@@ -1399,6 +1399,46 @@
gap: 8px;
}
.auto-outline-picker {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: auto;
font-size: 12px;
}
.auto-outline-option {
display: flex;
align-items: center;
gap: 6px;
}
.auto-word-range {
display: flex;
align-items: center;
gap: 8px;
}
.auto-word-range input {
width: 80px;
}
.wizard-step-indicator {
font-size: 11px;
color: var(--text-muted);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.wizard-preview {
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
}
.plot-options {
display: flex;
flex-direction: column;