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
@@ -0,0 +1,212 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AiContextBinding, PlotSelection } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings'
import { AiClientService } from '../../services/ai-client.service'
import { aiContextBuilder } from '../../services/ai-context-builder.service'
import { checkInteractiveGate } from '../../services/interactive-gate.service'
import { InteractiveWritingService } from '../../services/interactive-writing.service'
import { wrap } from '../result'
const activeInteractive = new Map<string, AbortController>()
function serviceFor(registry: BookRegistryService, bookId: string, aiClient: AiClientService) {
return new InteractiveWritingService(registry.getDb(bookId), aiClient)
}
function enrich(registry: BookRegistryService, bookId: string, aiClient: AiClientService, sessionId: string) {
const svc = serviceFor(registry, bookId, aiClient)
const flow = svc.getFlow(sessionId)
return flow ? svc.enrichFlow(flow) : null
}
export function registerInteractiveHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService
): void {
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
)
ipcMain.handle(
IPC.INTERACTIVE_GET_FLOW,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => enrich(registry, bookId, aiClient, sessionId))
)
ipcMain.handle(
IPC.INTERACTIVE_START,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).start(sessionId)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
})
)
ipcMain.handle(
IPC.INTERACTIVE_CONFIRM_CONTEXT,
(
_e,
{
bookId,
flowId,
binding
}: { bookId: string; flowId: string; binding: AiContextBinding }
) =>
wrap(() => {
const built = aiContextBuilder.build(binding, registry, bookId, settings.get().penName)
const summary = built.preview.map((p) => p.title).join('、') || '(空)'
const flow = serviceFor(registry, bookId, aiClient).confirmContext(
flowId,
binding,
summary
)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
})
)
ipcMain.handle(
IPC.INTERACTIVE_SUGGEST_PLOTS,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => serviceFor(registry, bookId, aiClient).suggestPlots(flowId))
)
ipcMain.handle(
IPC.INTERACTIVE_SELECT_PLOT,
(
_e,
{ bookId, flowId, selection }: { bookId: string; flowId: string; selection: PlotSelection }
) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).selectPlot(flowId, selection)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
})
)
ipcMain.handle(
IPC.INTERACTIVE_GENERATE_SCENE,
(event, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(async () => {
const controller = new AbortController()
activeInteractive.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
try {
const flow = await serviceFor(registry, bookId, aiClient).generateScene(
flowId,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
phase: 'scene',
delta,
done
})
},
controller.signal
)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
} finally {
activeInteractive.delete(flowId)
}
})
)
ipcMain.handle(
IPC.INTERACTIVE_RESOLVE_NAMING,
(
event,
{ bookId, flowId, chosenName }: { bookId: string; flowId: string; chosenName: string }
) =>
wrap(async () => {
const controller = new AbortController()
activeInteractive.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
try {
const flow = await serviceFor(registry, bookId, aiClient).resolveNaming(
flowId,
chosenName,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
phase: 'naming_resume',
delta,
done
})
},
controller.signal
)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
} finally {
activeInteractive.delete(flowId)
}
})
)
ipcMain.handle(
IPC.INTERACTIVE_REFINE_SCENE,
(
event,
{ bookId, flowId, instruction }: { bookId: string; flowId: string; instruction: string }
) =>
wrap(async () => {
const controller = new AbortController()
activeInteractive.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
try {
const flow = await serviceFor(registry, bookId, aiClient).refineScene(
flowId,
instruction,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
phase: 'refine',
delta,
done
})
},
controller.signal
)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
} finally {
activeInteractive.delete(flowId)
}
})
)
ipcMain.handle(
IPC.INTERACTIVE_ACCEPT_SCENE,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).acceptScene(flowId)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
})
)
ipcMain.handle(
IPC.INTERACTIVE_FINISH_CHAPTER,
(
_e,
{
bookId,
flowId,
volumeId,
title
}: { bookId: string; flowId: string; volumeId: string; title?: string }
) =>
wrap(() => serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title))
)
ipcMain.handle(IPC.INTERACTIVE_ABORT, (_e, { flowId }: { flowId: string }) =>
wrap(() => {
activeInteractive.get(flowId)?.abort()
activeInteractive.delete(flowId)
})
)
ipcMain.handle(
IPC.INTERACTIVE_GET_SCENE_DRAFT,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => serviceFor(registry, bookId, aiClient).getSceneDraft(flowId))
)
}