feat: ship v0.4.0 with interactive writing mode

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 17:46:16 +08:00
parent 0a054606fe
commit a8e0ba9ac9
39 changed files with 2113 additions and 89 deletions
+198
View File
@@ -0,0 +1,198 @@
import { create } from 'zustand'
import type { InteractiveFlow, PlotOption, PlotSelection } 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 InteractiveState {
gateEligible: boolean
gateReason?: string
flow: InteractiveFlow | null
plots: PlotOption[]
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>
confirmContextAndStart: (bookId: string, flowId: string) => Promise<void>
suggestPlots: (bookId: string) => Promise<void>
selectPlot: (bookId: string, selection: PlotSelection) => Promise<void>
generateScene: (bookId: string) => Promise<void>
resolveNaming: (bookId: string, chosenName: string) => Promise<void>
refineScene: (bookId: string, instruction: string) => Promise<void>
acceptScene: (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 useInteractiveStore = create<InteractiveState>((set, get) => ({
gateEligible: false,
flow: null,
plots: [],
streamBuffer: '',
streaming: false,
sceneDraft: '',
listenersAttached: false,
checkGate: async (bookId) => {
const r = await ipcCall(() => window.electronAPI.interactive.checkGate(bookId))
set({ gateEligible: r.eligible, gateReason: r.reason })
},
loadFlow: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.interactive.getFlow(bookId, sessionId))
set({ flow })
if (flow) await get().refreshSceneDraft(bookId)
},
start: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.interactive.start(bookId, sessionId))
set({ flow, plots: [], streamBuffer: '', sceneDraft: '' })
},
confirmContextAndStart: async (bookId, flowId) => {
const session = useAiStore.getState().sessions.find(
(s) => s.id === useAiStore.getState().activeSessionId
)
const binding = session?.context ?? {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: []
}
const flow = await ipcCall(() =>
window.electronAPI.interactive.confirmContext(bookId, flowId, binding)
)
set({ flow })
await get().suggestPlots(bookId)
},
suggestPlots: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true })
try {
const plots = await ipcCall(() =>
window.electronAPI.interactive.suggestPlots(bookId, flowId)
)
const flow = await ipcCall(() => window.electronAPI.interactive.getFlow(bookId, get().flow!.sessionId))
set({ plots, flow })
} finally {
set({ streaming: false })
}
},
selectPlot: async (bookId, selection) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() =>
window.electronAPI.interactive.selectPlot(bookId, flowId, selection)
)
set({ flow, streamBuffer: '', sceneDraft: '' })
await get().generateScene(bookId)
},
generateScene: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true, streamBuffer: '' })
try {
const flow = await ipcCall(() =>
window.electronAPI.interactive.generateScene(bookId, flowId)
)
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 flow = await ipcCall(() =>
window.electronAPI.interactive.resolveNaming(bookId, flowId, chosenName)
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
refineScene: async (bookId, instruction) => {
const flowId = get().flow?.id
if (!flowId || !instruction.trim()) return
set({ streaming: true, streamBuffer: '' })
try {
const flow = await ipcCall(() =>
window.electronAPI.interactive.refineScene(bookId, flowId, instruction.trim())
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
acceptScene: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.interactive.acceptScene(bookId, flowId))
set({ flow, plots: [], sceneDraft: '', streamBuffer: '' })
await get().suggestPlots(bookId)
},
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.interactive.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('interactive.chapterCreated'))
},
refreshSceneDraft: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const draft = await ipcCall(() =>
window.electronAPI.interactive.getSceneDraft(bookId, flowId)
)
set({ sceneDraft: draft })
},
mount: () => {
if (get().listenersAttached) return
unsubStream = window.electronAPI.onInteractiveStreamChunk((payload) => {
if (payload.done) return
set((s) => ({
streaming: true,
streamBuffer: s.streamBuffer + payload.delta
}))
})
set({ listenersAttached: true })
},
unmount: () => {
unsubStream?.()
unsubStream = null
set({ listenersAttached: false })
}
}))