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
+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 })
}
}))