Co-authored-by: Cursor <cursoragent@cursor.com>
34 KiB
笔临 P4 交互式写作 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 交付 v0.4.0:实装交互式写作全流程(剧情走向 → 场景生成 → 命名暂停 → 微调 → 成章落盘),schema v4 持久化,工程项含 test-results/ gitignore 与 LM Studio 测试超时调整。
Architecture: 主进程 InteractiveWritingService 步骤机 + interactive_flows/interactive_scenes 表;InteractivePromptBuilder 构建各步 prompt;IPC interactive:* + interactive:streamChunk 事件;渲染进程 InteractivePanel + useInteractiveStore。测试 禁止 Mock,AI 集成/E2E 打真实 LM Studio。
Tech Stack: Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、i18next、Vitest、Playwright
Spec: docs/superpowers/specs/2026-07-08-bilin-p4-interactive-design.md
LM Studio 默认: baseUrl=http://127.0.0.1:1234,model=gemma-4-e4b-it(BILIN_AI_BASE_URL / BILIN_AI_MODEL 环境变量由主进程读取)
File Map
| 文件 | 职责 |
|---|---|
.gitignore |
忽略 test-results/ |
playwright.config.ts |
全局 timeout 180s |
src/main/db/schema-v4.sql |
v4 DDL |
src/main/db/migrate.ts |
v3→v4 |
src/main/db/repositories/interactive.repo.ts |
flow / scene CRUD |
src/main/db/repositories/chapter.repo.ts |
origin 列读写 |
src/main/services/interactive-gate.service.ts |
门槛检查 |
src/main/services/interactive-prompt-builder.service.ts |
各步 prompt |
src/main/services/interactive-parse.service.ts |
JSON 解析(走向/命名) |
src/main/services/interactive-writing.service.ts |
步骤机 |
src/main/ipc/handlers/interactive.handler.ts |
IPC + 流式 |
src/main/ipc/register.ts |
注册 handler |
src/main/services/book-registry.ts |
getInteractiveRepo |
src/shared/types.ts |
交互类型 + ChapterOrigin |
src/shared/ipc-channels.ts |
新通道 |
src/preload/index.ts |
API + 事件 |
src/shared/electron-api.d.ts |
类型 |
src/renderer/stores/useInteractiveStore.ts |
渲染状态 |
src/renderer/components/ai/InteractivePanel.tsx |
主 UI |
src/renderer/components/ai/PlotOptionCard.tsx |
走向卡片 |
src/renderer/components/ai/AiPanel.tsx |
模式切换 + 挂载 |
src/renderer/styles/layout.css |
交互 UI 样式 |
public/locales/zh-CN/translation.json |
文案 |
public/locales/en/translation.json |
文案 |
tests/main/migrate-v4.test.ts |
迁移测试 |
tests/main/interactive-gate.test.ts |
门槛测试 |
tests/main/interactive-plots.test.ts |
IT-INT-01 |
tests/main/interactive-scene.test.ts |
IT-INT-02/03 |
tests/main/interactive-finish.test.ts |
IT-INT-04 |
e2e/interactive-writing.spec.ts |
E2E-INT-* |
Task 0: 工程配套(gitignore + 测试超时)
Files:
-
Modify:
.gitignore -
Modify:
playwright.config.ts -
Modify:
tests/main/ai-client.test.ts -
Modify:
e2e/ai-chat.spec.ts -
Step 1:
.gitignore追加
test-results/
- Step 2: Playwright 全局超时
playwright.config.ts:
export default defineConfig({
testDir: './e2e',
timeout: 180_000,
workers: 1,
// ...
})
- Step 3: 现有 AI 用例超时上调(可选但推荐)
tests/main/ai-client.test.ts:}, 180_000)
e2e/ai-chat.spec.ts:AI 用例 test.setTimeout(180_000);助手 expect 保持或升至 120_000。
- Step 4: 验证
npm run test
Expected: 全部 PASS(需 LM Studio 运行)
- Step 5: Commit
git add .gitignore playwright.config.ts tests/main/ai-client.test.ts e2e/ai-chat.spec.ts
git commit -m "chore: ignore test-results and raise AI test timeouts"
Task 1: Schema v4 + 共享类型 + IPC 通道
Files:
-
Create:
src/main/db/schema-v4.sql -
Modify:
src/main/db/migrate.ts -
Modify:
src/shared/types.ts -
Modify:
src/shared/ipc-channels.ts -
Create:
tests/main/migrate-v4.test.ts -
Step 1: 写迁移失败测试
tests/main/migrate-v4.test.ts:
import { describe, it, expect, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import schemaV1 from '../../src/main/db/schema-v1.sql?raw'
import schemaV2 from '../../src/main/db/schema-v2.sql?raw'
import schemaV3 from '../../src/main/db/schema-v3.sql?raw'
import type { SqliteDb } from '../../src/main/db/types'
describe('migrate v4', () => {
let db: SqliteDb
afterEach(() => db?.close())
it('applies v4 on fresh database', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(4)
expect(db.prepare("SELECT origin FROM chapters LIMIT 0").run()).toBeDefined()
expect(db.prepare("SELECT name FROM sqlite_master WHERE name='interactive_flows'").get()).toBeTruthy()
expect(db.prepare("SELECT name FROM sqlite_master WHERE name='interactive_scenes'").get()).toBeTruthy()
})
it('migrates v3 database to v4', () => {
db = new DatabaseSync(':memory:')
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')), description TEXT
)`)
db.exec(schemaV1)
db.exec(schemaV2)
db.exec(schemaV3)
db.prepare('INSERT INTO schema_version (version) VALUES (1),(2),(3)').run()
migrate(db)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(4)
})
})
- Step 2: 运行确认 FAIL
npm run test -- tests/main/migrate-v4.test.ts
Expected: FAIL(version 仍为 3 或表不存在)
- Step 3: 创建
schema-v4.sql
内容同 spec §2.3(chapters.origin、interactive_flows、interactive_scenes 及索引)。
- Step 4: 更新
migrate.ts
import schemaV4 from './schema-v4.sql?raw'
const CURRENT_VERSION = 4
function applyV4(db: SqliteDb): void {
tryAlter(db, "ALTER TABLE chapters ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'")
db.exec(schemaV4)
}
// 在 migrate() 末尾追加:
if (current < 4) {
applyV4(db)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(4, 'P4 interactive schema')
}
- Step 5:
types.ts追加类型
按 spec §3 追加:InteractiveStep、InteractiveFlowStatus、ChapterOrigin、PlotOption、NamingPause、PlotSelection、InteractiveScene、InteractiveFlow、InteractiveGateResult、InteractiveStreamChunkEvent。
Chapter 接口增加 origin?: ChapterOrigin。
- Step 6:
ipc-channels.ts追加
INTERACTIVE_CHECK_GATE: 'interactive:checkGate',
INTERACTIVE_GET_FLOW: 'interactive:getFlow',
INTERACTIVE_START: 'interactive:start',
INTERACTIVE_CONFIRM_CONTEXT: 'interactive:confirmContext',
INTERACTIVE_SUGGEST_PLOTS: 'interactive:suggestPlots',
INTERACTIVE_SELECT_PLOT: 'interactive:selectPlot',
INTERACTIVE_GENERATE_SCENE: 'interactive:generateScene',
INTERACTIVE_RESOLVE_NAMING: 'interactive:resolveNaming',
INTERACTIVE_REFINE_SCENE: 'interactive:refineScene',
INTERACTIVE_ACCEPT_SCENE: 'interactive:acceptScene',
INTERACTIVE_FINISH_CHAPTER: 'interactive:finishChapter',
INTERACTIVE_ABORT: 'interactive:abort',
INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk',
- Step 7: 运行测试 PASS
npm run test -- tests/main/migrate-v4.test.ts
- Step 8: Commit
git add src/main/db/schema-v4.sql src/main/db/migrate.ts src/shared/types.ts src/shared/ipc-channels.ts tests/main/migrate-v4.test.ts
git commit -m "feat: add schema v4 and interactive shared types"
Task 2: InteractiveRepository
Files:
-
Create:
src/main/db/repositories/interactive.repo.ts -
Modify:
src/main/services/book-registry.ts -
Step 1: 实现
InteractiveRepository
import { randomUUID } from 'crypto'
import type {
AiContextBinding,
InteractiveFlow,
InteractiveFlowStatus,
InteractiveScene,
InteractiveStep,
NamingPause,
PlotSelection
} from '../../../shared/types'
import type { SqliteDb } from '../types'
export class InteractiveRepository {
constructor(private db: SqliteDb) {}
getBySession(sessionId: string): InteractiveFlow | null {
const row = this.db
.prepare(
`SELECT * FROM interactive_flows WHERE session_id = ? AND status = 'in_progress' ORDER BY updated_at DESC LIMIT 1`
)
.get(sessionId) as Record<string, unknown> | undefined
return row ? this.mapFlow(row) : null
}
get(flowId: string): InteractiveFlow | null {
const row = this.db.prepare('SELECT * FROM interactive_flows WHERE id = ?').get(flowId) as
| Record<string, unknown>
| undefined
return row ? this.mapFlow(row) : null
}
create(sessionId: string): InteractiveFlow {
const id = randomUUID()
this.db
.prepare(
`INSERT INTO interactive_flows (id, session_id, step, status, context_json) VALUES (?, ?, 'context_confirm', 'in_progress', '{}')`
)
.run(id, sessionId)
return this.get(id)!
}
resetInProgress(sessionId: string): InteractiveFlow {
this.db
.prepare(`UPDATE interactive_flows SET status = 'abandoned' WHERE session_id = ? AND status = 'in_progress'`)
.run(sessionId)
return this.create(sessionId)
}
updateStep(flowId: string, step: InteractiveStep, patch: {
contextJson?: string
plotSelectionJson?: string | null
namingPendingJson?: string | null
status?: InteractiveFlowStatus
}): InteractiveFlow {
const sets: string[] = [`step = ?`, `updated_at = datetime('now')`]
const vals: unknown[] = [step]
if (patch.contextJson !== undefined) {
sets.push('context_json = ?')
vals.push(patch.contextJson)
}
if (patch.plotSelectionJson !== undefined) {
sets.push('plot_selection_json = ?')
vals.push(patch.plotSelectionJson)
}
if (patch.namingPendingJson !== undefined) {
sets.push('naming_pending_json = ?')
vals.push(patch.namingPendingJson)
}
if (patch.status !== undefined) {
sets.push('status = ?')
vals.push(patch.status)
}
vals.push(flowId)
this.db.prepare(`UPDATE interactive_flows SET ${sets.join(', ')} WHERE id = ?`).run(...vals)
return this.get(flowId)!
}
listScenes(flowId: string): InteractiveScene[] {
const rows = this.db
.prepare('SELECT * FROM interactive_scenes WHERE flow_id = ? ORDER BY sort_order')
.all(flowId) as Record<string, unknown>[]
return rows.map((r) => ({
id: r.id as string,
sortOrder: r.sort_order as number,
contentHtml: r.content_html as string,
plotLabel: r.plot_label as string,
refinedCount: r.refined_count as number
}))
}
addScene(flowId: string, contentHtml: string, plotLabel: string): InteractiveScene {
const id = randomUUID()
const max = this.db
.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM interactive_scenes WHERE flow_id = ?')
.get(flowId) as { m: number }
const sortOrder = max.m + 1
this.db
.prepare(
`INSERT INTO interactive_scenes (id, flow_id, sort_order, content_html, plot_label) VALUES (?, ?, ?, ?, ?)`
)
.run(id, flowId, sortOrder, contentHtml, plotLabel)
return this.listScenes(flowId).find((s) => s.id === id)!
}
private mapFlow(row: Record<string, unknown>): InteractiveFlow {
const flowId = row.id as string
let namingPending: NamingPause | null = null
if (row.naming_pending_json) {
try {
namingPending = JSON.parse(row.naming_pending_json as string) as NamingPause
} catch {
namingPending = null
}
}
return {
id: flowId,
sessionId: row.session_id as string,
step: row.step as InteractiveStep,
status: row.status as InteractiveFlowStatus,
scenes: this.listScenes(flowId),
namingPending
}
}
}
- Step 2:
book-registry.ts注册
import { InteractiveRepository } from '../db/repositories/interactive.repo'
getInteractiveRepo(bookId: string): InteractiveRepository {
return new InteractiveRepository(this.getDb(bookId))
}
- Step 3: Commit
git add src/main/db/repositories/interactive.repo.ts src/main/services/book-registry.ts
git commit -m "feat: add InteractiveRepository for flow and scene persistence"
Task 3: 门槛检查 + Chapter origin
Files:
-
Create:
src/main/services/interactive-gate.service.ts -
Create:
tests/main/interactive-gate.test.ts -
Modify:
src/main/db/repositories/chapter.repo.ts -
Modify:
src/main/services/word-count.ts(若需导出stripHtml,或从 naming-check 复用) -
Step 1: 写门槛失败测试
tests/main/interactive-gate.test.ts:
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import { OutlineRepository } from '../../src/main/db/repositories/outline.repo'
import { checkInteractiveGate } from '../../src/main/services/interactive-gate.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('checkInteractiveGate', () => {
let db: SqliteDb
beforeEach(() => {
db = new DatabaseSync(':memory:')
migrate(db)
})
afterEach(() => db.close())
it('IT-INT-05: rejects empty book', () => {
const result = checkInteractiveGate(db)
expect(result.eligible).toBe(false)
})
it('IT-INT-05: accepts book with chapter content', () => {
const vols = new VolumeRepository(db)
const volId = vols.create('卷一', 0).id
const chapters = new ChapterRepository(db)
chapters.create(volId, '第一章', 0, '<p>有正文</p>')
expect(checkInteractiveGate(db).eligible).toBe(true)
})
it('IT-INT-05: accepts book with outline only', () => {
new OutlineRepository(db).create(null, '节点', '<p>描述</p>', 0)
expect(checkInteractiveGate(db).eligible).toBe(true)
})
})
- Step 2: 实现
interactive-gate.service.ts
import type { InteractiveGateResult } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { stripHtml } from './word-count'
export function checkInteractiveGate(db: SqliteDb): InteractiveGateResult {
const chapterWithBody = db
.prepare(`SELECT id FROM chapters WHERE length(trim(replace(replace(content, '<p>', ''), '</p>', ''))) > 0 LIMIT 1`)
.get()
if (chapterWithBody) return { eligible: true }
const outlineCount = db.prepare('SELECT COUNT(*) AS c FROM outline_items').get() as { c: number }
const settingCount = db.prepare('SELECT COUNT(*) AS c FROM settings').get() as { c: number }
if (outlineCount.c > 0 || settingCount.c > 0) return { eligible: true }
const anyChapter = db.prepare('SELECT COUNT(*) AS c FROM chapters').get() as { c: number }
return {
eligible: false,
reason: anyChapter.c === 0 ? 'no_chapter' : 'no_outline_or_setting'
}
}
(实现时可用 stripHtml 遍历章节列表,与 spec 语义一致即可。)
- Step 3:
chapter.repo.ts支持origin
mapRow 增加 origin: (row.origin as ChapterOrigin) ?? 'manual'。
create 签名扩展:create(volumeId, title, sortOrder, content = '', origin: ChapterOrigin = 'manual')。
INSERT 增加 origin 列。
- Step 4: 运行 PASS + Commit
npm run test -- tests/main/interactive-gate.test.ts
git commit -m "feat: add interactive gate check and chapter origin column"
Task 4: Prompt 构建与 JSON 解析
Files:
-
Create:
src/main/services/interactive-parse.service.ts -
Create:
src/main/services/interactive-prompt-builder.service.ts -
Create:
tests/main/interactive-parse.test.ts -
Step 1: 解析单元测试(无 AI)
tests/main/interactive-parse.test.ts:
import { describe, it, expect } from 'vitest'
import { parsePlotsJson, parseNamingRequiredJson } from '../../src/main/services/interactive-parse.service'
describe('interactive-parse', () => {
it('parses three plot options', () => {
const json = `{"plots":[{"id":"A","label":"锋芒","summary":"林远展露天赋引起轰动,同门侧目,长老暗中关注其来历与隐患。"},{"id":"B","label":"藏锋","summary":"林远按长老暗示收敛灵力,被安排在外门修行,暗中调查测灵石异变。"},{"id":"C","label":"转折","summary":"测灵石碎裂引发异象,闭关太上长老出关,宗门上下震动。"}]}`
const plots = parsePlotsJson(json)
expect(plots).toHaveLength(3)
expect(plots[0].summary.length).toBeGreaterThanOrEqual(10)
})
it('parses naming_required payload', () => {
const json = `{"type":"naming_required","elementType":"角色","context":"需要为新同伴命名","options":["名1","名2","名3","名4","名5"]}`
const pause = parseNamingRequiredJson(json)
expect(pause.options).toHaveLength(5)
})
})
- Step 2: 实现
interactive-parse.service.ts
import type { NamingPause, PlotOption } from '../../shared/types'
export function parsePlotsJson(response: string): PlotOption[] {
const trimmed = response.trim()
const start = trimmed.indexOf('{')
const end = trimmed.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('plots JSON not found')
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as { plots?: unknown }
if (!Array.isArray(parsed.plots) || parsed.plots.length < 3) {
throw new Error('plots array invalid')
}
return parsed.plots.slice(0, 3).map((p, i) => {
const row = p as Record<string, unknown>
const ids = ['A', 'B', 'C'] as const
return {
id: ids[i],
label: String(row.label ?? ids[i]),
summary: String(row.summary ?? '')
}
})
}
export function parseNamingRequiredJson(response: string): NamingPause {
const trimmed = response.trim()
const start = trimmed.indexOf('{')
const end = trimmed.lastIndexOf('}')
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as Record<string, unknown>
if (parsed.type !== 'naming_required') throw new Error('not naming_required')
const options = Array.isArray(parsed.options) ? parsed.options.map(String) : []
if (options.length < 5) throw new Error('naming options < 5')
return {
elementType: String(parsed.elementType ?? '元素'),
context: String(parsed.context ?? ''),
options: options.slice(0, 5)
}
}
export function tryParseNamingFromStream(buffer: string): NamingPause | null {
const t = buffer.trim()
if (!t.startsWith('{') || !t.includes('naming_required')) return null
try {
return parseNamingRequiredJson(t)
} catch {
return null
}
}
- Step 3: 实现
interactive-prompt-builder.service.ts
导出函数:
buildPlotSuggestMessages(contextText: string, sceneSummaries: string[]): ChatMessage[]buildSceneGenerateMessages(contextText, plotSelection, sceneSummaries): ChatMessage[]buildNamingResumeMessages(chosenName: string, partialScene: string): ChatMessage[]buildRefineMessages(currentSceneHtml: string, instruction: string): ChatMessage[]
System prompt 明确要求 JSON 格式或 HTML 段落;走向步要求恰好 3 条。
- Step 4: 运行 PASS + Commit
npm run test -- tests/main/interactive-parse.test.ts
git commit -m "feat: add interactive prompt builder and JSON parsers"
Task 5: InteractiveWritingService(步骤机)
Files:
-
Create:
src/main/services/interactive-writing.service.ts -
Create:
tests/main/interactive-finish.test.ts -
Step 1: 写无章 AI 的 finish 测试
tests/main/interactive-finish.test.ts(IT-INT-04,无 LM Studio):
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { InteractiveRepository } from '../../src/main/db/repositories/interactive.repo'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('InteractiveWritingService.finishChapter', () => {
let db: SqliteDb
let service: InteractiveWritingService
let volId: string
let sessionId: string
let flowId: string
beforeEach(() => {
db = new DatabaseSync(':memory:')
migrate(db)
volId = new VolumeRepository(db).create('卷一', 0).id
const session = new AiSessionRepository(db).create('测试', 'test-model')
sessionId = session.id
const flow = new InteractiveRepository(db).create(sessionId)
flowId = flow.id
const repo = new InteractiveRepository(db)
repo.addScene(flowId, '<p>场景一</p>', 'A')
repo.addScene(flowId, '<p>场景二</p>', 'B')
service = new InteractiveWritingService(db, {
chat: async () => '',
chatStream: async (_msgs, onChunk) => {
onChunk('delta', false)
onChunk('', true)
return ''
}
})
})
afterEach(() => db.close())
it('IT-INT-04: merges scenes into new interactive chapter', () => {
const { chapterId } = service.finishChapter(flowId, volId, '交互初稿')
const ch = new ChapterRepository(db).get(chapterId)!
expect(ch.origin).toBe('interactive')
expect(ch.content).toContain('场景一')
expect(ch.content).toContain('场景二')
})
})
- Step 2: 实现
InteractiveWritingService
构造函数注入 db + AiClientService(或窄接口 { chat, chatStream })。
核心方法:
finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: string } {
const repo = new InteractiveRepository(this.db)
const flow = repo.get(flowId)
if (!flow) throw new Error('flow not found')
const scenes = repo.listScenes(flowId)
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')
repo.updateStep(flowId, 'completed', { status: 'completed', namingPendingJson: null })
return { chapterId: ch.id }
}
其余方法按 spec 步骤机实现;suggestPlots 调 aiClient.chat + parsePlotsJson(失败重试 1 次);generateScene 流式累积 buffer,开头检测 tryParseNamingFromStream → 写 naming_pending_json 并切 naming_pause。
- Step 3: 运行 PASS
npm run test -- tests/main/interactive-finish.test.ts
- Step 4: Commit
git commit -m "feat: add InteractiveWritingService with finishChapter"
Task 6: IPC Handler + Preload
Files:
-
Create:
src/main/ipc/handlers/interactive.handler.ts -
Modify:
src/main/ipc/register.ts -
Modify:
src/preload/index.ts -
Modify:
src/shared/electron-api.d.ts -
Step 1: 实现
registerInteractiveHandlers
模式参照 ai.handler.ts:
-
checkGate→checkInteractiveGate(registry.getDb(bookId)) -
getFlow/start/confirmContext/ … → 委托InteractiveWritingService -
generateScene/refineScene/resolveNaming:注册activeInteractiveAbortController Map;流式时win.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, payload) -
finishChapter:需{ bookId, flowId, volumeId, title? } -
Step 2:
register.ts注册
import { registerInteractiveHandlers } from './handlers/interactive.handler'
registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
- Step 3: preload 暴露
interactive命名空间
interactive: {
checkGate: (bookId: string) => ipcRenderer.invoke(IPC.INTERACTIVE_CHECK_GATE, { bookId }),
getFlow: (bookId: string, sessionId: string) => ipcRenderer.invoke(IPC.INTERACTIVE_GET_FLOW, { bookId, sessionId }),
start: (bookId: string, sessionId: string) => ipcRenderer.invoke(IPC.INTERACTIVE_START, { bookId, sessionId }),
// ... 其余方法
onStreamChunk: (cb) => { /* 同 onAiStreamChunk 模式 */ }
}
- Step 4: 手动 smoke
npm run build
Expected: 编译无 TS 错误
- Step 5: Commit
git commit -m "feat: wire interactive IPC handlers and preload API"
Task 7: AI 集成测试(IT-INT-01/02/03)
Files:
-
Create:
tests/main/interactive-plots.test.ts -
Create:
tests/main/interactive-scene.test.ts -
Step 1: IT-INT-01
import { describe, it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
describe('interactive plots integration', () => {
it('IT-INT-01: suggestPlots returns 3 options from LM Studio', async () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
const service = new InteractiveWritingService(db, new AiClientService(() => DEFAULT_AI_CONFIG))
const flow = service.start(session.id)
service.confirmContext(flow.id, { chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] }, '测试上下文:林远参加入门考核。')
const plots = await service.suggestPlots(flow.id)
expect(plots).toHaveLength(3)
db.close()
}, 180_000)
})
-
Step 2: IT-INT-02 / IT-INT-03(
interactive-scene.test.ts) -
先
selectPlot,再generateScene流式收集,断言正文长度 > 100 -
若模型返回 naming JSON,走
resolveNaming后续写
超时:240_000
- Step 3: 全量单元测试
npm run test
Expected: 50+ tests PASS(需 LM Studio)
- Step 4: Commit
git commit -m "test: add interactive LM Studio integration tests"
Task 8: useInteractiveStore + InteractivePanel UI
Files:
-
Create:
src/renderer/stores/useInteractiveStore.ts -
Create:
src/renderer/components/ai/PlotOptionCard.tsx -
Create:
src/renderer/components/ai/InteractivePanel.tsx -
Modify:
src/renderer/components/ai/AiPanel.tsx -
Modify:
src/renderer/styles/layout.css -
Step 1:
useInteractiveStore
import { create } from 'zustand'
import type { InteractiveFlow, PlotOption } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
interface InteractiveState {
gateEligible: boolean
gateReason?: string
flow: InteractiveFlow | null
plots: PlotOption[]
streamBuffer: string
streaming: boolean
checkGate: (bookId: string) => Promise<void>
loadFlow: (bookId: string, sessionId: string) => Promise<void>
start: (bookId: string, sessionId: string) => Promise<void>
mount: () => void
unmount: () => void
// ... suggestPlots, selectPlot, generateScene, etc.
}
export const useInteractiveStore = create<InteractiveState>((set, get) => ({
gateEligible: false,
flow: null,
plots: [],
streamBuffer: '',
streaming: false,
checkGate: async (bookId) => {
const r = await ipcCall(() => window.electronAPI.interactive.checkGate(bookId))
set({ gateEligible: r.eligible, gateReason: r.reason })
},
mount: () => {
const unsub = window.electronAPI.interactive.onStreamChunk((p) => {
set((s) => ({
streamBuffer: p.done ? s.streamBuffer : s.streamBuffer + p.delta,
streaming: !p.done
}))
})
// store unsub for unmount
},
// ...
}))
- Step 2:
PlotOptionCard.tsx
可点击卡片,data-testid={plot-option-${id}},展示 label + summary。
- Step 3:
InteractivePanel.tsx
按 flow.step 条件渲染;关键 testid:
-
interactive-panel -
interactive-step-indicator -
interactive-start -
plot-option-A|B|C -
interactive-scene-preview -
naming-option-0…naming-custom -
interactive-refine-input -
interactive-accept-scene -
interactive-finish-chapter -
Step 4: 修改
AiPanel.tsx
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
// mount 时 checkGate(bookId)
const handleModeClick = (mode: AiWritingMode): void => {
if (mode === 'auto' || mode === 'wizard') {
showToast(t('feature.comingSoon'))
return
}
if (mode === 'interactive' && !useInteractiveStore.getState().gateEligible) {
showToast(t('interactive.gateBlocked'))
return
}
setWritingMode(mode)
if (mode === 'interactive') {
void useInteractiveStore.getState().start(bookId!, activeSessionId!)
}
}
// 渲染区:
{writingMode === 'interactive' ? (
<InteractivePanel bookId={bookId} sessionId={activeSessionId} />
) : (
/* 现有 chat UI */
)}
模式 Tab:interactive 在 !gateEligible 时 disabled + title={t('interactive.gateTooltip')}。
- Step 5: CSS
在 layout.css 追加 .interactive-panel、.plot-option-card、.naming-options、.interactive-step-bar(参考 ui_pc.html .plot-opt)。
- Step 6: Commit
git commit -m "feat: add InteractivePanel UI and wire AiPanel mode switch"
Task 9: i18n + 成章导航
Files:
-
Modify:
public/locales/zh-CN/translation.json -
Modify:
public/locales/en/translation.json -
Modify:
src/renderer/stores/useInteractiveStore.ts(finish 后导航) -
Step 1: 追加文案键
interactive.gateBlocked、interactive.gateTooltip、interactive.step.context、interactive.step.plot、interactive.step.scene、interactive.step.review、interactive.start、interactive.acceptScene、interactive.finishChapter、interactive.chapterCreated、interactive.namingTitle、interactive.refinePlaceholder
- Step 2:
finishChapter成功后
import { useBookStore } from '@renderer/stores/useBookStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useEditStore } from '@renderer/stores/useEditStore'
const { chapterId } = await ipcCall(() =>
window.electronAPI.interactive.finishChapter(bookId, flowId, volumeId, title)
)
await useBookStore.getState().refreshChapters()
useBookStore.getState().setSelectedChapter(chapterId)
useEditStore.getState().setTarget({ kind: 'chapter', id: chapterId })
useAppStore.getState().setView('editor')
showToast(t('interactive.chapterCreated'))
- Step 3: Commit
git commit -m "feat: add interactive i18n and chapter navigation after finish"
Task 10: E2E 测试
Files:
-
Create:
e2e/interactive-writing.spec.ts -
Step 1: E2E-INT-02(门槛,无 AI)
test('E2E-INT-02: interactive tab disabled on empty book', async () => {
// 新建空书(跳过样例章节)→ 打开 AI 面板 → interactive tab disabled
const tab = page.getByRole('button', { name: /交互写作/ })
await expect(tab).toBeDisabled({ timeout: 10_000 })
})
- Step 2: E2E-INT-01(完整流程,300s)
test('E2E-INT-01: interactive flow creates chapter', async () => {
test.setTimeout(300_000)
// onboarding 样本书 或 写入章节正文
await page.getByRole('button', { name: /交互写作/ }).click()
await page.getByTestId('interactive-start').click()
await expect(page.getByTestId('plot-option-A')).toBeVisible({ timeout: 120_000 })
await page.getByTestId('plot-option-A').click()
await page.getByTestId('interactive-confirm-plot').click()
await expect(page.getByTestId('interactive-scene-preview')).not.toBeEmpty({ timeout: 120_000 })
await page.getByTestId('interactive-accept-scene').click()
await page.getByTestId('interactive-finish-chapter').click()
await expect(page.locator('.ProseMirror')).not.toBeEmpty({ timeout: 30_000 })
})
- Step 3: E2E-INT-03(重启恢复,300s)
进入交互 mid-flow → 关闭 app → 重开 → getFlow 恢复步骤 UI。
- Step 4: 运行 E2E
npm run test:e2e -- e2e/interactive-writing.spec.ts
- Step 5: Commit
git commit -m "test: add interactive writing E2E specs"
Task 11: v0.4.0 交付
Files:
-
Modify:
package.json -
Modify:
README.md -
Modify:
src/renderer/components/settings/SettingsPage.tsx(about 版本号) -
Modify:
docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md(勾选完成项) -
Step 1: 版本号 →
0.4.0 -
Step 2: README 功能概览追加 P4 交互写作
-
Step 3: 全量测试
npm run test
npm run build
npm run test:e2e
Expected: 单元全部 PASS;E2E 全部 PASS(LM Studio 运行中)
- Step 4: Commit + tag(可选)
git commit -m "chore: release v0.4.0 with interactive writing mode"
git tag v0.4.0
计划自检
| 检查项 | 结果 |
|---|---|
| Spec §1.3 验收 1–9 | Task 3/5/8/9/10 覆盖 |
| schema v4 | Task 1 |
| 命名暂停 | Task 4/5/8 |
| 持久化恢复 | Task 2/5/10 E2E-INT-03 |
| gitignore + 超时 | Task 0 |
| 禁止 Mock | Task 7/10 明确 LM Studio |
| 无 TBD | ✓ |
Plan complete. Spec: docs/superpowers/specs/2026-07-08-bilin-p4-interactive-design.md