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
+9 -1
View File
@@ -3,8 +3,9 @@ import schemaV1 from './schema-v1.sql?raw'
import schemaV2 from './schema-v2.sql?raw'
import schemaV3 from './schema-v3.sql?raw'
import schemaV4 from './schema-v4.sql?raw'
import schemaV5 from './schema-v5.sql?raw'
const CURRENT_VERSION = 4
const CURRENT_VERSION = 5
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -56,5 +57,12 @@ export function migrate(db: SqliteDb): void {
tryAlter(db, "ALTER TABLE chapters ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'")
db.exec(schemaV4)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(4, 'P4 interactive schema')
current = 4
}
if (current < 5) {
tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive'")
tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}'")
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(5, 'P4.1 auto/wizard schema')
}
}
+63 -9
View File
@@ -1,10 +1,13 @@
import { randomUUID } from 'crypto'
import type {
AutoWritingConfig,
InteractiveFlow,
InteractiveFlowStatus,
InteractiveScene,
InteractiveStep,
NamingPause
NamingPause,
WizardWritingConfig,
WritingFlowMode,
WritingStep
} from '../../../shared/types'
import type { SqliteDb } from '../types'
@@ -27,33 +30,47 @@ export class InteractiveRepository {
return row ? this.mapFlow(row) : null
}
create(sessionId: string): InteractiveFlow {
create(
sessionId: string,
flowMode: WritingFlowMode = 'interactive',
initialStep?: WritingStep
): InteractiveFlow {
const id = randomUUID()
const step =
initialStep ??
(flowMode === 'auto'
? 'goal_setup'
: flowMode === 'wizard'
? 'wizard_goal'
: 'context_confirm')
this.db
.prepare(
`INSERT INTO interactive_flows (id, session_id, step, status, context_json) VALUES (?, ?, 'context_confirm', 'in_progress', '{}')`
`INSERT INTO interactive_flows (id, session_id, step, status, context_json, flow_mode, mode_config_json)
VALUES (?, ?, ?, 'in_progress', '{}', ?, '{}')`
)
.run(id, sessionId)
.run(id, sessionId, step, flowMode)
return this.get(id)!
}
resetInProgress(sessionId: string): InteractiveFlow {
resetInProgress(sessionId: string, flowMode: WritingFlowMode = 'interactive'): InteractiveFlow {
this.db
.prepare(
`UPDATE interactive_flows SET status = 'abandoned', updated_at = datetime('now') WHERE session_id = ? AND status = 'in_progress'`
)
.run(sessionId)
return this.create(sessionId)
return this.create(sessionId, flowMode)
}
updateStep(
flowId: string,
step: InteractiveStep,
step: WritingStep,
patch: {
contextJson?: string
plotSelectionJson?: string | null
namingPendingJson?: string | null
sceneDraftHtml?: string | null
modeConfigJson?: string
flowMode?: WritingFlowMode
status?: InteractiveFlowStatus
} = {}
): InteractiveFlow {
@@ -75,6 +92,14 @@ export class InteractiveRepository {
sets.push('scene_draft_html = ?')
vals.push(patch.sceneDraftHtml)
}
if (patch.modeConfigJson !== undefined) {
sets.push('mode_config_json = ?')
vals.push(patch.modeConfigJson)
}
if (patch.flowMode !== undefined) {
sets.push('flow_mode = ?')
vals.push(patch.flowMode)
}
if (patch.status !== undefined) {
sets.push('status = ?')
vals.push(patch.status)
@@ -84,6 +109,27 @@ export class InteractiveRepository {
return this.get(flowId)!
}
getModeConfig<T extends AutoWritingConfig | WizardWritingConfig>(
flowId: string
): T {
const row = this.db
.prepare('SELECT mode_config_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { mode_config_json: string } | undefined
try {
return JSON.parse(row?.mode_config_json ?? '{}') as T
} catch {
return {} as T
}
}
setModeConfig(flowId: string, config: AutoWritingConfig | WizardWritingConfig): void {
this.db
.prepare(
`UPDATE interactive_flows SET mode_config_json = ?, updated_at = datetime('now') WHERE id = ?`
)
.run(JSON.stringify(config), flowId)
}
listScenes(flowId: string): InteractiveScene[] {
const rows = this.db
.prepare('SELECT * FROM interactive_scenes WHERE flow_id = ? ORDER BY sort_order')
@@ -156,11 +202,19 @@ export class InteractiveRepository {
namingPending = null
}
}
let modeConfig: AutoWritingConfig | WizardWritingConfig | Record<string, never> = {}
try {
modeConfig = JSON.parse((row.mode_config_json as string) ?? '{}')
} catch {
modeConfig = {}
}
return {
id: flowId,
sessionId: row.session_id as string,
step: row.step as InteractiveStep,
flowMode: (row.flow_mode as WritingFlowMode) ?? 'interactive',
step: row.step as WritingStep,
status: row.status as InteractiveFlowStatus,
modeConfig,
scenes: this.listScenes(flowId),
namingPending,
contextSummary: undefined
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive';
ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}';
+207
View File
@@ -0,0 +1,207 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AutoWritingConfig } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings'
import { AiClientService } from '../../services/ai-client.service'
import { checkInteractiveGate } from '../../services/interactive-gate.service'
import { AutoWritingService } from '../../services/auto-writing.service'
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result'
const activeAuto = new Map<string, AbortController>()
function serviceFor(registry: BookRegistryService, bookId: string, aiClient: AiClientService) {
return new AutoWritingService(registry.getDb(bookId), aiClient)
}
function enrich(registry: BookRegistryService, bookId: string, aiClient: AiClientService, flowId: string) {
const svc = serviceFor(registry, bookId, aiClient)
const flow = new InteractiveRepository(registry.getDb(bookId)).get(flowId)
return flow ? svc.enrichFlow(flow) : null
}
function readContextText(registry: BookRegistryService, bookId: string, flowId: string): string {
const row = registry
.getDb(bookId)
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { context_json: string } | undefined
try {
const parsed = JSON.parse(row?.context_json ?? '{}') as { contextSummary?: string }
return parsed.contextSummary ?? '(无上下文摘要)'
} catch {
return '(无上下文摘要)'
}
}
export function registerAutoHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService
): void {
void settings
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
)
ipcMain.handle(
IPC.AUTO_GET_FLOW,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).getFlow(sessionId)
return flow ? serviceFor(registry, bookId, aiClient).enrichFlow(flow) : null
})
)
ipcMain.handle(
IPC.AUTO_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.AUTO_SET_GOAL,
(
_e,
{ bookId, flowId, config }: { bookId: string; flowId: string; config: Partial<AutoWritingConfig> }
) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
const flow = svc.setGoal(flowId, config)
return svc.enrichFlow(flow)
})
)
ipcMain.handle(
IPC.AUTO_PLAN_SCENES,
(_e, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
wrap(async () => {
const svc = serviceFor(registry, bookId, aiClient)
if (contextSummary) svc.setContextSummary(flowId, contextSummary)
const text = contextSummary ?? readContextText(registry, bookId, flowId)
await svc.planScenes(flowId, text)
return enrich(registry, bookId, aiClient, flowId)
})
)
ipcMain.handle(
IPC.AUTO_GENERATE,
(event, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
wrap(async () => {
const controller = new AbortController()
activeAuto.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
const svc = serviceFor(registry, bookId, aiClient)
const text = contextSummary ?? readContextText(registry, bookId, flowId)
try {
const flow = await svc.generateNext(
flowId,
text,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
flowMode: 'auto',
phase: 'scene',
delta,
done
})
},
controller.signal
)
return svc.enrichFlow(flow)
} finally {
activeAuto.delete(flowId)
}
})
)
ipcMain.handle(
IPC.AUTO_RESOLVE_NAMING,
(
event,
{
bookId,
flowId,
chosenName,
contextSummary
}: { bookId: string; flowId: string; chosenName: string; contextSummary?: string }
) =>
wrap(async () => {
const controller = new AbortController()
activeAuto.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
const svc = serviceFor(registry, bookId, aiClient)
const text = contextSummary ?? readContextText(registry, bookId, flowId)
try {
const flow = await svc.resolveNaming(
flowId,
chosenName,
text,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
flowMode: 'auto',
phase: 'naming_resume',
delta,
done
})
},
controller.signal
)
return svc.enrichFlow(flow)
} finally {
activeAuto.delete(flowId)
}
})
)
ipcMain.handle(IPC.AUTO_PAUSE, (_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
activeAuto.get(flowId)?.abort()
activeAuto.delete(flowId)
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.pause(flowId))
})
)
ipcMain.handle(IPC.AUTO_RESUME, (_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.resume(flowId))
})
)
ipcMain.handle(
IPC.AUTO_HANDOFF_INTERACTIVE,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.handoffToInteractive(flowId))
})
)
ipcMain.handle(
IPC.AUTO_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.AUTO_ABORT, (_e, { flowId }: { flowId: string }) =>
wrap(() => {
activeAuto.get(flowId)?.abort()
activeAuto.delete(flowId)
})
)
ipcMain.handle(
IPC.AUTO_GET_SCENE_DRAFT,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => serviceFor(registry, bookId, aiClient).getSceneDraft(flowId))
)
}
+170
View File
@@ -0,0 +1,170 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AiContextBinding, AutoRhythm, AutoWritingConfig, WizardWritingConfig } 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 { WizardWritingService } from '../../services/wizard-writing.service'
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result'
const activeWizard = new Map<string, AbortController>()
function serviceFor(registry: BookRegistryService, bookId: string, aiClient: AiClientService) {
return new WizardWritingService(registry.getDb(bookId), aiClient)
}
function readContextText(registry: BookRegistryService, bookId: string, flowId: string): string {
const row = registry
.getDb(bookId)
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { context_json: string } | undefined
try {
const parsed = JSON.parse(row?.context_json ?? '{}') as { contextSummary?: string }
return parsed.contextSummary ?? '(无上下文摘要)'
} catch {
return '(无上下文摘要)'
}
}
export function registerWizardHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService
): void {
ipcMain.handle(
IPC.WIZARD_GET_FLOW,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
const flow = svc.getFlow(sessionId)
return flow ? svc.enrichFlow(flow) : null
})
)
ipcMain.handle(
IPC.WIZARD_START,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
const flow = svc.start(sessionId)
return svc.enrichFlow(flow)
})
)
ipcMain.handle(
IPC.WIZARD_SET_GOAL,
(
_e,
{ bookId, flowId, config }: { bookId: string; flowId: string; config: Partial<AutoWritingConfig> }
) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.setGoal(flowId, config))
})
)
ipcMain.handle(
IPC.WIZARD_SET_RHYTHM,
(
_e,
{
bookId,
flowId,
rhythm,
styleNote
}: { bookId: string; flowId: string; rhythm: AutoRhythm; styleNote?: string }
) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.setRhythm(flowId, rhythm, styleNote))
})
)
ipcMain.handle(
IPC.WIZARD_SET_POV,
(_e, { bookId, flowId, povSettingId }: { bookId: string; flowId: string; povSettingId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.setPov(flowId, povSettingId))
})
)
ipcMain.handle(
IPC.WIZARD_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 svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.confirmContext(flowId, binding, summary))
})
)
ipcMain.handle(
IPC.WIZARD_BUILD_PREVIEW,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(async () => {
const svc = serviceFor(registry, bookId, aiClient)
const config = new InteractiveRepository(registry.getDb(bookId)).getModeConfig<WizardWritingConfig>(
flowId
)
const text = readContextText(registry, bookId, flowId)
const povName = svc.getPovName(config.povSettingId)
const preview = await svc.buildPreview(flowId, text, povName)
const flow = new InteractiveRepository(registry.getDb(bookId)).get(flowId)!
return { preview, flow: svc.enrichFlow(flow) }
})
)
ipcMain.handle(
IPC.WIZARD_START_GENERATE,
(event, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(async () => {
const controller = new AbortController()
activeWizard.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
const svc = serviceFor(registry, bookId, aiClient)
const text = readContextText(registry, bookId, flowId)
try {
const flow = await svc.startGenerate(
flowId,
text,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
flowMode: 'wizard',
phase: 'scene',
delta,
done
})
},
controller.signal
)
return svc.enrichFlow(flow)
} finally {
activeWizard.delete(flowId)
}
})
)
ipcMain.handle(
IPC.WIZARD_HANDOFF_INTERACTIVE,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.handoffToInteractive(flowId))
})
)
ipcMain.handle(IPC.WIZARD_ABORT, (_e, { flowId }: { flowId: string }) =>
wrap(() => {
activeWizard.get(flowId)?.abort()
activeWizard.delete(flowId)
})
)
}
+7 -2
View File
@@ -16,6 +16,8 @@ import { registerBookmarkHandlers } from './handlers/bookmark.handler'
import { registerSearchHandlers } from './handlers/search.handler'
import { registerAiHandlers } from './handlers/ai.handler'
import { registerInteractiveHandlers } from './handlers/interactive.handler'
import { registerAutoHandlers } from './handlers/auto.handler'
import { registerWizardHandlers } from './handlers/wizard.handler'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -42,8 +44,11 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerSnapshotHandlers(books, snapshotService!)
registerBookmarkHandlers(books)
registerSearchHandlers(books, settings)
registerAiHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
const aiClient = new AiClientService(() => settings.get().aiConfig)
registerAiHandlers(books, settings, aiClient)
registerInteractiveHandlers(books, settings, aiClient)
registerAutoHandlers(books, settings, aiClient)
registerWizardHandlers(books, settings, aiClient)
return { settings, books, shortcuts: shortcutManager }
}
+31
View File
@@ -0,0 +1,31 @@
import type { ScenePlanItem } from '../../shared/types'
export interface ScenePlanResult {
scenes: ScenePlanItem[]
estimatedWords: number
}
export function parseScenePlanJson(response: string): ScenePlanResult {
const trimmed = response.trim()
const start = trimmed.indexOf('{')
const end = trimmed.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('scene plan JSON not found')
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as {
scenes?: unknown
estimatedWords?: unknown
}
if (!Array.isArray(parsed.scenes) || parsed.scenes.length < 2) {
throw new Error('scenes array invalid')
}
const scenes = parsed.scenes.map((s, i) => {
const row = s as Record<string, unknown>
return {
label: String(row.label ?? `场景${i + 1}`),
summary: String(row.summary ?? '')
}
})
return {
scenes,
estimatedWords: Number(parsed.estimatedWords ?? 3000)
}
}
@@ -0,0 +1,75 @@
import type { AutoWritingConfig, ScenePlanItem, WizardWritingConfig } from '../../shared/types'
import type { ChatMessage } from './ai-client.service'
export function buildScenePlanMessages(
chapterGoal: string,
outlineSummaries: string[],
contextText: string,
wordMin: number,
wordMax: number
): ChatMessage[] {
const outline =
outlineSummaries.length > 0
? `关联大纲:\n${outlineSummaries.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n`
: ''
return [
{
role: 'system',
content:
'你是长篇小说章节规划助手。根据本章目标规划 3-6 个连续场景。仅返回 JSON{"scenes":[{"label":"场景名","summary":"至少80字的场景要点"},...],"estimatedWords":数字}。estimatedWords 须在用户给定字数范围内。'
},
{
role: 'user',
content: `${outline}本章目标:${chapterGoal}\n\n上下文:\n${contextText}\n\n目标字数:${wordMin}-${wordMax} 字。请规划场景顺序。`
}
]
}
export function buildAutoSceneMessages(
config: AutoWritingConfig,
planItem: ScenePlanItem,
priorSceneSummaries: string[],
contextText: string
): ChatMessage[] {
const prior =
priorSceneSummaries.length > 0
? `已写场景摘要:\n${priorSceneSummaries.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n`
: ''
return [
{
role: 'system',
content:
'你是小说场景写作助手。若需为新元素命名且尚无定论,先仅返回 JSON{"type":"naming_required","elementType":"类型","context":"说明","options":["名1","名2","名3","名4","名5"]}。否则输出 600-1500 字场景 HTML <p> 段落。'
},
{
role: 'user',
content: `${prior}上下文:\n${contextText}\n\n本章目标:${config.chapterGoal}\n\n当前场景:${planItem.label}\n场景要点:${planItem.summary}\n\n请生成本场景。`
}
]
}
const RHYTHM_LABEL: Record<string, string> = {
relaxed: '舒缓',
tense: '紧张',
mixed: '混合'
}
export function buildWizardPreviewMessages(
config: WizardWritingConfig,
contextText: string,
povName: string
): ChatMessage[] {
const rhythm = RHYTHM_LABEL[config.rhythm] ?? config.rhythm
const style = config.styleNote ? `\n风格说明:${config.styleNote}` : ''
return [
{
role: 'system',
content:
'你是写作策略助手。根据用户配置生成一段策略预览,使用 HTML <p> 包裹,中文,150-300 字,说明视角、节奏、目标与重点上下文。'
},
{
role: 'user',
content: `视角人物:${povName}\n节奏:${rhythm}${style}\n本章目标:${config.chapterGoal}\n\n上下文摘要:\n${contextText}\n\n请生成策略预览。`
}
]
}
+208
View File
@@ -0,0 +1,208 @@
import type {
AutoWritingConfig,
InteractiveFlow,
ScenePlanItem
} from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { InteractiveRepository } from '../db/repositories/interactive.repo'
import { OutlineRepository } from '../db/repositories/outline.repo'
import type { AiClientService } from './ai-client.service'
import {
buildAutoSceneMessages,
buildScenePlanMessages
} from './auto-prompt-builder.service'
import { parseScenePlanJson, type ScenePlanResult } from './auto-parse.service'
import { ChapterFinishService } from './chapter-finish.service'
import { SceneGenerationCore } from './scene-generation-core.service'
import { handoffToInteractive } from './writing-flow-handoff.service'
import { stripHtml } from './word-count'
export class AutoWritingService {
private repo: InteractiveRepository
private core: SceneGenerationCore
constructor(
private db: SqliteDb,
private aiClient: AiClientService
) {
this.repo = new InteractiveRepository(db)
this.core = new SceneGenerationCore(this.repo, aiClient)
}
start(sessionId: string): InteractiveFlow {
return this.repo.resetInProgress(sessionId, 'auto')
}
getFlow(sessionId: string): InteractiveFlow | null {
return this.repo.getBySession(sessionId)
}
setGoal(flowId: string, partial: Partial<AutoWritingConfig>): InteractiveFlow {
const existing = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const config: AutoWritingConfig = {
chapterGoal: partial.chapterGoal ?? existing.chapterGoal ?? '',
outlineItemIds: partial.outlineItemIds ?? existing.outlineItemIds,
wordMin: partial.wordMin ?? existing.wordMin ?? 2000,
wordMax: partial.wordMax ?? existing.wordMax ?? 4000,
scenePlan: existing.scenePlan,
currentPlanIndex: existing.currentPlanIndex ?? 0
}
this.repo.setModeConfig(flowId, config)
return this.repo.updateStep(flowId, 'scene_plan')
}
async planScenes(flowId: string, contextText: string): Promise<ScenePlanResult> {
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const outlines = new OutlineRepository(this.db)
const outlineSummaries = (config.outlineItemIds ?? [])
.map((id) => outlines.get(id))
.filter(Boolean)
.map((o) => `${o!.title}${stripHtml(o!.description).slice(0, 120)}`)
const messages = buildScenePlanMessages(
config.chapterGoal,
outlineSummaries,
contextText,
config.wordMin,
config.wordMax
)
let response = await this.aiClient.chat(messages)
let plan: ScenePlanResult
try {
plan = parseScenePlanJson(response)
} catch {
response = await this.aiClient.chat([
...messages,
{ role: 'user', content: '仅返回 JSON,不要 markdown 或其它说明。' }
])
plan = parseScenePlanJson(response)
}
const updated: AutoWritingConfig = {
...config,
scenePlan: plan.scenes,
currentPlanIndex: 0
}
this.repo.setModeConfig(flowId, updated)
this.repo.updateStep(flowId, 'auto_generate')
return plan
}
async generateNext(
flowId: string,
contextText: string,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal
): Promise<InteractiveFlow> {
const flow = this.repo.get(flowId)
if (!flow) throw new Error('flow not found')
if (flow.step === 'naming_pause') throw new Error('naming pause active')
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const index = config.currentPlanIndex ?? 0
const plan = config.scenePlan ?? []
if (index >= plan.length) return flow
const planItem = plan[index]!
const prior = this.repo
.listScenes(flowId)
.map((s) => stripHtml(s.contentHtml).slice(0, 200))
const messages = buildAutoSceneMessages(config, planItem, prior, contextText)
const { naming } = await this.core.generateStream(
flowId,
messages,
onChunk,
abortSignal,
'auto_generate'
)
if (naming) return this.repo.get(flowId)!
await this.commitCurrentScene(flowId, planItem)
return this.repo.get(flowId)!
}
async resolveNaming(
flowId: string,
chosenName: string,
contextText: string,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal
): Promise<InteractiveFlow> {
const flow = this.repo.get(flowId)
if (!flow?.namingPending) throw new Error('no naming pause')
await this.core.resolveNaming(
flowId,
chosenName,
flow.namingPending,
onChunk,
abortSignal,
'auto_generate'
)
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const index = config.currentPlanIndex ?? 0
const planItem = config.scenePlan?.[index]
if (planItem) await this.commitCurrentScene(flowId, planItem)
return this.repo.get(flowId)!
}
pause(flowId: string): InteractiveFlow {
return this.repo.updateStep(flowId, 'paused')
}
resume(flowId: string): InteractiveFlow {
return this.repo.updateStep(flowId, 'auto_generate')
}
handoffToInteractive(flowId: string): InteractiveFlow {
return handoffToInteractive(this.repo, flowId)
}
finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: string } {
return new ChapterFinishService(this.db).finishFromFlow(
flowId,
volumeId,
title ?? '自动初稿',
'auto',
'自动写作成章'
)
}
getSceneDraft(flowId: string): string {
return this.repo.getSceneDraft(flowId)
}
totalWordCount(flowId: string): number {
return this.repo
.listScenes(flowId)
.reduce((sum, s) => sum + stripHtml(s.contentHtml).length, 0)
}
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
try {
const parsed = JSON.parse(this.repo.getContextJson(flow.id)) as { contextSummary?: string }
return { ...flow, contextSummary: parsed.contextSummary }
} catch {
return flow
}
}
setContextSummary(flowId: string, contextSummary: string): void {
this.repo.updateStep(flowId, this.repo.get(flowId)!.step, {
contextJson: JSON.stringify({ contextSummary })
})
}
private async commitCurrentScene(flowId: string, planItem: ScenePlanItem): Promise<void> {
const draft = this.repo.getSceneDraft(flowId).trim()
if (!draft) return
this.repo.addScene(flowId, draft, planItem.label)
this.repo.setSceneDraft(flowId, '')
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const nextIndex = (config.currentPlanIndex ?? 0) + 1
this.repo.setModeConfig(flowId, { ...config, currentPlanIndex: nextIndex })
}
}
@@ -0,0 +1,34 @@
import type { ChapterOrigin } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { InteractiveRepository } from '../db/repositories/interactive.repo'
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
export class ChapterFinishService {
constructor(private db: SqliteDb) {}
finishFromFlow(
flowId: string,
volumeId: string,
title: string,
origin: ChapterOrigin,
snapshotLabel: string
): { chapterId: string } {
const repo = new InteractiveRepository(this.db)
const draft = repo.getSceneDraft(flowId).trim()
if (draft) repo.addScene(flowId, draft, 'draft')
const scenes = repo.listScenes(flowId)
if (scenes.length === 0) throw new Error('no scenes to merge')
const merged = scenes.map((s) => s.contentHtml).join('\n')
const chapters = new ChapterRepository(this.db)
const sortOrder = chapters.listByVolume(volumeId).length
const ch = chapters.create(volumeId, title, sortOrder, merged, origin)
new SnapshotRepository(this.db).create(ch.id, 'ai', merged, snapshotLabel)
repo.updateStep(flowId, 'completed', {
status: 'completed',
sceneDraftHtml: '',
namingPendingJson: null
})
return { chapterId: ch.id }
}
}
@@ -5,26 +5,28 @@ import type {
PlotSelection
} from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { InteractiveRepository } from '../db/repositories/interactive.repo'
import type { AiClientService } from './ai-client.service'
import { ChapterFinishService } from './chapter-finish.service'
import { SceneGenerationCore } from './scene-generation-core.service'
import { parsePlotsJson } from './interactive-parse.service'
import { stripHtml } from './word-count'
import {
buildNamingResumeMessages,
buildPlotSuggestMessages,
buildRefineMessages,
buildSceneGenerateMessages
} from './interactive-prompt-builder.service'
import { parsePlotsJson, tryParseNamingFromBuffer } from './interactive-parse.service'
import { stripHtml } from './word-count'
export class InteractiveWritingService {
private repo: InteractiveRepository
private core: SceneGenerationCore
constructor(
private db: SqliteDb,
private aiClient: AiClientService
) {
this.repo = new InteractiveRepository(db)
this.core = new SceneGenerationCore(this.repo, aiClient)
}
start(sessionId: string): InteractiveFlow {
@@ -87,32 +89,7 @@ export class InteractiveWritingService {
const summaries = scenes.map((s) => stripHtml(s.contentHtml).slice(0, 200))
const messages = buildSceneGenerateMessages(contextText, selection, summaries)
let buffer = ''
const full = await this.aiClient.chat(
messages,
(delta) => {
buffer += delta
const naming = tryParseNamingFromBuffer(buffer)
if (naming) return
onChunk(delta, false)
},
abortSignal
)
const naming = tryParseNamingFromBuffer(full || buffer)
if (naming) {
this.repo.updateStep(flowId, 'naming_pause', {
namingPendingJson: JSON.stringify(naming),
sceneDraftHtml: ''
})
onChunk('', true)
return this.repo.get(flowId)!
}
const html = this.normalizeSceneHtml(full || buffer)
this.repo.setSceneDraft(flowId, html)
this.repo.updateStep(flowId, 'scene_review', { namingPendingJson: null })
onChunk('', true)
await this.core.generateStream(flowId, messages, onChunk, abortSignal, 'scene_review')
return this.repo.get(flowId)!
}
@@ -125,21 +102,14 @@ export class InteractiveWritingService {
const flow = this.repo.get(flowId)
if (!flow?.namingPending) throw new Error('no naming pause')
const messages = buildNamingResumeMessages(
await this.core.resolveNaming(
flowId,
chosenName,
this.repo.getSceneDraft(flowId),
flow.namingPending.context
flow.namingPending,
onChunk,
abortSignal,
'scene_review'
)
const full = await this.aiClient.chat(
messages,
(delta) => onChunk(delta, false),
abortSignal
)
const html = this.normalizeSceneHtml(full)
this.repo.setSceneDraft(flowId, html)
this.repo.updateStep(flowId, 'scene_review', { namingPendingJson: null })
onChunk('', true)
return this.repo.get(flowId)!
}
@@ -160,7 +130,7 @@ export class InteractiveWritingService {
},
abortSignal
)
const html = this.normalizeSceneHtml(full || buffer)
const html = this.core.normalizeSceneHtml(full || buffer)
this.repo.setSceneDraft(flowId, html)
onChunk('', true)
return this.repo.get(flowId)!
@@ -181,26 +151,13 @@ export class InteractiveWritingService {
}
finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: string } {
const draft = this.repo.getSceneDraft(flowId).trim()
if (draft) {
const selection = JSON.parse(this.repo.getPlotSelection(flowId) || '{}') as PlotSelection
this.repo.addScene(flowId, draft, selection.optionIds?.join('+') ?? '')
this.repo.setSceneDraft(flowId, '')
}
const scenes = this.repo.listScenes(flowId)
if (scenes.length === 0) throw new Error('no scenes to merge')
const merged = scenes.map((s) => s.contentHtml).join('\n')
const chapters = new ChapterRepository(this.db)
const sortOrder = chapters.listByVolume(volumeId).length
const ch = chapters.create(volumeId, title ?? '交互初稿', sortOrder, merged, 'interactive')
this.repo.updateStep(flowId, 'completed', {
status: 'completed',
namingPendingJson: null,
sceneDraftHtml: ''
})
return { chapterId: ch.id }
return new ChapterFinishService(this.db).finishFromFlow(
flowId,
volumeId,
title ?? '交互初稿',
'interactive',
'交互写作成章'
)
}
getSceneDraft(flowId: string): string {
@@ -228,14 +185,4 @@ export class InteractiveWritingService {
return '(无上下文摘要)'
}
}
private normalizeSceneHtml(text: string): string {
const t = text.trim()
if (!t) return ''
if (t.includes('<p>')) return t
return t
.split(/\n\n+/)
.map((p) => `<p>${p.trim()}</p>`)
.join('')
}
}
@@ -0,0 +1,83 @@
import type { InteractiveFlow, NamingPause } from '../../shared/types'
import type { InteractiveRepository } from '../db/repositories/interactive.repo'
import type { AiClientService, ChatMessage } from './ai-client.service'
import { buildNamingResumeMessages } from './interactive-prompt-builder.service'
import { tryParseNamingFromBuffer } from './interactive-parse.service'
export class SceneGenerationCore {
constructor(
private repo: InteractiveRepository,
private aiClient: AiClientService
) {}
normalizeSceneHtml(text: string): string {
const t = text.trim()
if (!t) return ''
if (t.includes('<p>')) return t
return t
.split(/\n\n+/)
.map((p) => `<p>${p.trim()}</p>`)
.join('')
}
async generateStream(
flowId: string,
messages: ChatMessage[],
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal,
reviewStep: 'scene_review' | 'auto_generate' = 'scene_review'
): Promise<{ flow: InteractiveFlow; html: string; naming: NamingPause | null }> {
let buffer = ''
const full = await this.aiClient.chat(
messages,
(delta) => {
buffer += delta
const naming = tryParseNamingFromBuffer(buffer)
if (naming) return
onChunk(delta, false)
},
abortSignal
)
const naming = tryParseNamingFromBuffer(full || buffer)
if (naming) {
this.repo.updateStep(flowId, 'naming_pause', {
namingPendingJson: JSON.stringify(naming),
sceneDraftHtml: ''
})
onChunk('', true)
return { flow: this.repo.get(flowId)!, html: '', naming }
}
const html = this.normalizeSceneHtml(full || buffer)
this.repo.setSceneDraft(flowId, html)
this.repo.updateStep(flowId, reviewStep, { namingPendingJson: null })
onChunk('', true)
return { flow: this.repo.get(flowId)!, html, naming: null }
}
async resolveNaming(
flowId: string,
chosenName: string,
namingPending: NamingPause,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal,
reviewStep: 'scene_review' | 'auto_generate' = 'scene_review'
): Promise<InteractiveFlow> {
const messages = buildNamingResumeMessages(
chosenName,
this.repo.getSceneDraft(flowId),
namingPending.context
)
const full = await this.aiClient.chat(
messages,
(delta) => onChunk(delta, false),
abortSignal
)
const html = this.normalizeSceneHtml(full)
this.repo.setSceneDraft(flowId, html)
this.repo.updateStep(flowId, reviewStep, { namingPendingJson: null })
onChunk('', true)
return this.repo.get(flowId)!
}
}
+130
View File
@@ -0,0 +1,130 @@
import type {
AiContextBinding,
AutoRhythm,
AutoWritingConfig,
InteractiveFlow,
WizardWritingConfig
} from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { InteractiveRepository } from '../db/repositories/interactive.repo'
import { SettingRepository } from '../db/repositories/setting.repo'
import type { AiClientService } from './ai-client.service'
import { buildWizardPreviewMessages } from './auto-prompt-builder.service'
import { AutoWritingService } from './auto-writing.service'
import { handoffToInteractive } from './writing-flow-handoff.service'
export class WizardWritingService {
private repo: InteractiveRepository
private autoService: AutoWritingService
constructor(
private db: SqliteDb,
private aiClient: AiClientService
) {
this.repo = new InteractiveRepository(db)
this.autoService = new AutoWritingService(db, aiClient)
}
start(sessionId: string): InteractiveFlow {
return this.repo.resetInProgress(sessionId, 'wizard')
}
getFlow(sessionId: string): InteractiveFlow | null {
return this.repo.getBySession(sessionId)
}
setGoal(flowId: string, partial: Partial<AutoWritingConfig>): InteractiveFlow {
const existing = this.repo.getModeConfig<WizardWritingConfig>(flowId)
const config: WizardWritingConfig = {
rhythm: existing.rhythm ?? 'mixed',
chapterGoal: partial.chapterGoal ?? existing.chapterGoal ?? '',
outlineItemIds: partial.outlineItemIds ?? existing.outlineItemIds,
wordMin: partial.wordMin ?? existing.wordMin ?? 2000,
wordMax: partial.wordMax ?? existing.wordMax ?? 4000
}
this.repo.setModeConfig(flowId, config)
return this.repo.updateStep(flowId, 'wizard_rhythm')
}
setRhythm(flowId: string, rhythm: AutoRhythm, styleNote?: string): InteractiveFlow {
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
this.repo.setModeConfig(flowId, { ...config, rhythm, styleNote: styleNote?.trim() || undefined })
return this.repo.updateStep(flowId, 'wizard_pov')
}
setPov(flowId: string, povSettingId?: string): InteractiveFlow {
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
this.repo.setModeConfig(flowId, { ...config, povSettingId: povSettingId || undefined })
return this.repo.updateStep(flowId, 'wizard_context')
}
confirmContext(
flowId: string,
binding: AiContextBinding,
contextSummary: string
): InteractiveFlow {
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
this.repo.setModeConfig(flowId, { ...config, contextBinding: binding })
this.repo.updateStep(flowId, 'wizard_preview', {
contextJson: JSON.stringify({ binding, contextSummary })
})
return this.repo.get(flowId)!
}
async buildPreview(flowId: string, contextText: string, povName: string): Promise<string> {
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
const messages = buildWizardPreviewMessages(config, contextText, povName)
const html = await this.aiClient.chat(messages)
const preview = html.trim().includes('<p>') ? html.trim() : `<p>${html.trim()}</p>`
this.repo.setModeConfig(flowId, { ...config, strategyPreview: preview })
return preview
}
async startGenerate(
flowId: string,
contextText: string,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal
): Promise<InteractiveFlow> {
this.repo.updateStep(flowId, 'wizard_generating')
const wizardConfig = this.repo.getModeConfig<WizardWritingConfig>(flowId)
const autoConfig: AutoWritingConfig = {
chapterGoal: wizardConfig.chapterGoal,
outlineItemIds: wizardConfig.outlineItemIds,
wordMin: wizardConfig.wordMin ?? 2000,
wordMax: wizardConfig.wordMax ?? 4000,
currentPlanIndex: 0
}
this.repo.setModeConfig(flowId, { ...wizardConfig, ...autoConfig })
this.autoService.setContextSummary(flowId, contextText)
await this.autoService.planScenes(flowId, contextText)
let flow = this.repo.get(flowId)!
let config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const planLen = config.scenePlan?.length ?? 0
while ((config.currentPlanIndex ?? 0) < planLen) {
if (abortSignal?.aborted) break
flow = await this.autoService.generateNext(flowId, contextText, onChunk, abortSignal)
if (flow.step === 'naming_pause') return flow
config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
}
return handoffToInteractive(this.repo, flowId)
}
handoffToInteractive(flowId: string): InteractiveFlow {
return handoffToInteractive(this.repo, flowId)
}
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
return this.autoService.enrichFlow(flow)
}
getPovName(povSettingId?: string): string {
if (!povSettingId) return '主角'
const setting = new SettingRepository(this.db).get(povSettingId)
return setting?.name ?? '主角'
}
}
@@ -0,0 +1,11 @@
import type { InteractiveFlow, WritingStep } from '../../shared/types'
import type { InteractiveRepository } from '../db/repositories/interactive.repo'
export function handoffToInteractive(repo: InteractiveRepository, flowId: string): InteractiveFlow {
const draft = repo.getSceneDraft(flowId)
const scenes = repo.listScenes(flowId)
let step: WritingStep = 'context_confirm'
if (draft.trim()) step = 'scene_review'
else if (scenes.length > 0) step = 'plot_suggest'
return repo.updateStep(flowId, step, { flowMode: 'interactive' })
}
+90
View File
@@ -17,6 +17,8 @@ import type {
NamingConflict,
AiStreamChunkEvent,
AiNetworkStatusEvent,
AutoRhythm,
AutoWritingConfig,
InteractiveFlow,
InteractiveGateResult,
InteractiveStreamChunkEvent,
@@ -318,6 +320,94 @@ const electronAPI = {
getSceneDraft: (bookId: string, flowId: string): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_GET_SCENE_DRAFT, { bookId, flowId })
},
auto: {
checkGate: (bookId: string): Promise<IpcResult<InteractiveGateResult>> =>
ipcRenderer.invoke(IPC.AUTO_CHECK_GATE, { bookId }),
getFlow: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow | null>> =>
ipcRenderer.invoke(IPC.AUTO_GET_FLOW, { bookId, sessionId }),
start: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_START, { bookId, sessionId }),
setGoal: (
bookId: string,
flowId: string,
config: Partial<AutoWritingConfig>
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_SET_GOAL, { bookId, flowId, config }),
planScenes: (
bookId: string,
flowId: string,
contextSummary?: string
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_PLAN_SCENES, { bookId, flowId, contextSummary }),
generate: (
bookId: string,
flowId: string,
contextSummary?: string
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_GENERATE, { bookId, flowId, contextSummary }),
resolveNaming: (
bookId: string,
flowId: string,
chosenName: string,
contextSummary?: string
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_RESOLVE_NAMING, { bookId, flowId, chosenName, contextSummary }),
pause: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_PAUSE, { bookId, flowId }),
resume: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_RESUME, { bookId, flowId }),
handoffInteractive: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_HANDOFF_INTERACTIVE, { bookId, flowId }),
finishChapter: (
bookId: string,
flowId: string,
volumeId: string,
title?: string
): Promise<IpcResult<{ chapterId: string }>> =>
ipcRenderer.invoke(IPC.AUTO_FINISH_CHAPTER, { bookId, flowId, volumeId, title }),
abort: (flowId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.AUTO_ABORT, { flowId }),
getSceneDraft: (bookId: string, flowId: string): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.AUTO_GET_SCENE_DRAFT, { bookId, flowId })
},
wizard: {
getFlow: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow | null>> =>
ipcRenderer.invoke(IPC.WIZARD_GET_FLOW, { bookId, sessionId }),
start: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_START, { bookId, sessionId }),
setGoal: (
bookId: string,
flowId: string,
config: Partial<AutoWritingConfig>
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_SET_GOAL, { bookId, flowId, config }),
setRhythm: (
bookId: string,
flowId: string,
rhythm: AutoRhythm,
styleNote?: string
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_SET_RHYTHM, { bookId, flowId, rhythm, styleNote }),
setPov: (bookId: string, flowId: string, povSettingId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_SET_POV, { bookId, flowId, povSettingId }),
confirmContext: (
bookId: string,
flowId: string,
binding: AiContextBinding
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_CONFIRM_CONTEXT, { bookId, flowId, binding }),
buildPreview: (
bookId: string,
flowId: string
): Promise<IpcResult<{ preview: string; flow: InteractiveFlow }>> =>
ipcRenderer.invoke(IPC.WIZARD_BUILD_PREVIEW, { bookId, flowId }),
startGenerate: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_START_GENERATE, { bookId, flowId }),
handoffInteractive: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_HANDOFF_INTERACTIVE, { bookId, flowId }),
abort: (flowId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.WIZARD_ABORT, { flowId })
},
wordfreq: {
analyze: (
bookId: string,
+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;
+67
View File
@@ -18,6 +18,8 @@ import type {
InteractiveFlow,
InteractiveGateResult,
InteractiveStreamChunkEvent,
AutoRhythm,
AutoWritingConfig,
PlotOption,
PlotSelection,
IpcResult,
@@ -240,6 +242,71 @@ export interface ElectronAPI {
abort: (flowId: string) => Promise<IpcResult<void>>
getSceneDraft: (bookId: string, flowId: string) => Promise<IpcResult<string>>
}
auto: {
checkGate: (bookId: string) => Promise<IpcResult<InteractiveGateResult>>
getFlow: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow | null>>
start: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow>>
setGoal: (
bookId: string,
flowId: string,
config: Partial<AutoWritingConfig>
) => Promise<IpcResult<InteractiveFlow>>
planScenes: (
bookId: string,
flowId: string,
contextSummary?: string
) => Promise<IpcResult<InteractiveFlow>>
generate: (
bookId: string,
flowId: string,
contextSummary?: string
) => Promise<IpcResult<InteractiveFlow>>
resolveNaming: (
bookId: string,
flowId: string,
chosenName: string,
contextSummary?: string
) => Promise<IpcResult<InteractiveFlow>>
pause: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
resume: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
handoffInteractive: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
finishChapter: (
bookId: string,
flowId: string,
volumeId: string,
title?: string
) => Promise<IpcResult<{ chapterId: string }>>
abort: (flowId: string) => Promise<IpcResult<void>>
getSceneDraft: (bookId: string, flowId: string) => Promise<IpcResult<string>>
}
wizard: {
getFlow: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow | null>>
start: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow>>
setGoal: (
bookId: string,
flowId: string,
config: Partial<AutoWritingConfig>
) => Promise<IpcResult<InteractiveFlow>>
setRhythm: (
bookId: string,
flowId: string,
rhythm: AutoRhythm,
styleNote?: string
) => Promise<IpcResult<InteractiveFlow>>
setPov: (bookId: string, flowId: string, povSettingId: string) => Promise<IpcResult<InteractiveFlow>>
confirmContext: (
bookId: string,
flowId: string,
binding: AiContextBinding
) => Promise<IpcResult<InteractiveFlow>>
buildPreview: (
bookId: string,
flowId: string
) => Promise<IpcResult<{ preview: string; flow: InteractiveFlow }>>
startGenerate: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
handoffInteractive: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
abort: (flowId: string) => Promise<IpcResult<void>>
}
wordfreq: {
analyze: (
bookId: string,
+24 -1
View File
@@ -77,5 +77,28 @@ export const IPC = {
INTERACTIVE_FINISH_CHAPTER: 'interactive:finishChapter',
INTERACTIVE_ABORT: 'interactive:abort',
INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk',
INTERACTIVE_GET_SCENE_DRAFT: 'interactive:getSceneDraft'
INTERACTIVE_GET_SCENE_DRAFT: 'interactive:getSceneDraft',
AUTO_CHECK_GATE: 'auto:checkGate',
AUTO_GET_FLOW: 'auto:getFlow',
AUTO_START: 'auto:start',
AUTO_SET_GOAL: 'auto:setGoal',
AUTO_PLAN_SCENES: 'auto:planScenes',
AUTO_GENERATE: 'auto:generate',
AUTO_RESOLVE_NAMING: 'auto:resolveNaming',
AUTO_PAUSE: 'auto:pause',
AUTO_RESUME: 'auto:resume',
AUTO_HANDOFF_INTERACTIVE: 'auto:handoffInteractive',
AUTO_FINISH_CHAPTER: 'auto:finishChapter',
AUTO_GET_SCENE_DRAFT: 'auto:getSceneDraft',
AUTO_ABORT: 'auto:abort',
WIZARD_GET_FLOW: 'wizard:getFlow',
WIZARD_START: 'wizard:start',
WIZARD_SET_GOAL: 'wizard:setGoal',
WIZARD_SET_RHYTHM: 'wizard:setRhythm',
WIZARD_SET_POV: 'wizard:setPov',
WIZARD_CONFIRM_CONTEXT: 'wizard:confirmContext',
WIZARD_BUILD_PREVIEW: 'wizard:buildPreview',
WIZARD_START_GENERATE: 'wizard:startGenerate',
WIZARD_HANDOFF_INTERACTIVE: 'wizard:handoffInteractive',
WIZARD_ABORT: 'wizard:abort'
} as const
+49 -3
View File
@@ -9,7 +9,7 @@ export type ThemeId =
export type Language = 'zh-CN' | 'en'
export type ChapterStatus = 'draft' | 'review' | 'done'
export type ChapterOrigin = 'manual' | 'interactive'
export type ChapterOrigin = 'manual' | 'interactive' | 'auto' | 'wizard'
export type BookStatus = 'draft' | 'ongoing' | 'done'
export type PublishStatus = 'draft' | 'ready' | 'published'
export type OutlineStatus = 'pending' | 'writing' | 'done' | 'abandoned'
@@ -285,6 +285,49 @@ export type InteractiveStep =
| 'scene_review'
| 'completed'
export type WritingFlowMode = 'interactive' | 'auto' | 'wizard'
export type AutoRhythm = 'relaxed' | 'tense' | 'mixed'
export type AutoStep =
| 'goal_setup'
| 'scene_plan'
| 'auto_generate'
| 'paused'
| 'completed'
export type WizardStep =
| 'wizard_goal'
| 'wizard_rhythm'
| 'wizard_pov'
| 'wizard_context'
| 'wizard_preview'
| 'wizard_generating'
export type WritingStep = InteractiveStep | AutoStep | WizardStep
export interface ScenePlanItem {
label: string
summary: string
}
export interface AutoWritingConfig {
chapterGoal: string
outlineItemIds?: string[]
wordMin: number
wordMax: number
scenePlan?: ScenePlanItem[]
currentPlanIndex?: number
}
export interface WizardWritingConfig extends AutoWritingConfig {
rhythm: AutoRhythm
styleNote?: string
povSettingId?: string
strategyPreview?: string
contextBinding?: AiContextBinding
}
export type InteractiveFlowStatus = 'in_progress' | 'completed' | 'abandoned'
export interface PlotOption {
@@ -315,8 +358,10 @@ export interface InteractiveScene {
export interface InteractiveFlow {
id: string
sessionId: string
step: InteractiveStep
flowMode: WritingFlowMode
step: WritingStep
status: InteractiveFlowStatus
modeConfig: AutoWritingConfig | WizardWritingConfig | Record<string, never>
scenes: InteractiveScene[]
namingPending?: NamingPause | null
contextSummary?: string
@@ -329,7 +374,8 @@ export interface InteractiveGateResult {
export interface InteractiveStreamChunkEvent {
flowId: string
phase: 'scene' | 'refine' | 'naming_resume'
flowMode?: WritingFlowMode
phase: 'scene' | 'refine' | 'naming_resume' | 'plan'
delta: string
done: boolean
}