diff --git a/docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md b/docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md new file mode 100644 index 0000000..0cd2925 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md @@ -0,0 +1,1029 @@ +# 笔临 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`: + +```typescript +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: 验证** + +```bash +npm run test +``` + +Expected: 全部 PASS(需 LM Studio 运行) + +- [ ] **Step 5: Commit** + +```bash +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`: + +```typescript +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** + +```bash +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`** + +```typescript +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` 追加** + +```typescript +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** + +```bash +npm run test -- tests/main/migrate-v4.test.ts +``` + +- [ ] **Step 8: Commit** + +```bash +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`** + +```typescript +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 | 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 + | 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[] + 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): 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` 注册** + +```typescript +import { InteractiveRepository } from '../db/repositories/interactive.repo' + +getInteractiveRepo(bookId: string): InteractiveRepository { + return new InteractiveRepository(this.getDb(bookId)) +} +``` + +- [ ] **Step 3: Commit** + +```bash +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`: + +```typescript +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, '

有正文

') + expect(checkInteractiveGate(db).eligible).toBe(true) + }) + + it('IT-INT-05: accepts book with outline only', () => { + new OutlineRepository(db).create(null, '节点', '

描述

', 0) + expect(checkInteractiveGate(db).eligible).toBe(true) + }) +}) +``` + +- [ ] **Step 2: 实现 `interactive-gate.service.ts`** + +```typescript +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, '

', ''), '

', ''))) > 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** + +```bash +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`: + +```typescript +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`** + +```typescript +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 + 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 + 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** + +```bash +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): + +```typescript +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, '

场景一

', 'A') + repo.addScene(flowId, '

场景二

', '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 }`)。 + +核心方法: + +```typescript +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** + +```bash +npm run test -- tests/main/interactive-finish.test.ts +``` + +- [ ] **Step 4: Commit** + +```bash +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`:注册 `activeInteractive` AbortController Map;流式时 `win.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, payload)` +- `finishChapter`:需 `{ bookId, flowId, volumeId, title? }` + +- [ ] **Step 2: `register.ts` 注册** + +```typescript +import { registerInteractiveHandlers } from './handlers/interactive.handler' + +registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig)) +``` + +- [ ] **Step 3: preload 暴露 `interactive` 命名空间** + +```typescript +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** + +```bash +npm run build +``` + +Expected: 编译无 TS 错误 + +- [ ] **Step 5: Commit** + +```bash +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** + +```typescript +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: 全量单元测试** + +```bash +npm run test +``` + +Expected: 50+ tests PASS(需 LM Studio) + +- [ ] **Step 4: Commit** + +```bash +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`** + +```typescript +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 + loadFlow: (bookId: string, sessionId: string) => Promise + start: (bookId: string, sessionId: string) => Promise + mount: () => void + unmount: () => void + // ... suggestPlots, selectPlot, generateScene, etc. +} + +export const useInteractiveStore = create((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`** + +```typescript +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' ? ( + +) : ( + /* 现有 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** + +```bash +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` 成功后** + +```typescript +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** + +```bash +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)** + +```typescript +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)** + +```typescript +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** + +```bash +npm run test:e2e -- e2e/interactive-writing.spec.ts +``` + +- [ ] **Step 5: Commit** + +```bash +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: 全量测试** + +```bash +npm run test +npm run build +npm run test:e2e +``` + +Expected: 单元全部 PASS;E2E 全部 PASS(LM Studio 运行中) + +- [ ] **Step 4: Commit + tag(可选)** + +```bash +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`*