# 笔临 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 (`- [x]`) 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` - [x] **Step 1: `.gitignore` 追加** ``` test-results/ ``` - [x] **Step 2: Playwright 全局超时** `playwright.config.ts`: ```typescript export default defineConfig({ testDir: './e2e', timeout: 180_000, workers: 1, // ... }) ``` - [x] **Step 3: 现有 AI 用例超时上调(可选但推荐)** `tests/main/ai-client.test.ts`:`}, 180_000)` `e2e/ai-chat.spec.ts`:AI 用例 `test.setTimeout(180_000)`;助手 `expect` 保持或升至 `120_000`。 - [x] **Step 4: 验证** ```bash npm run test ``` Expected: 全部 PASS(需 LM Studio 运行) - [x] **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` - [x] **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) }) }) ``` - [x] **Step 2: 运行确认 FAIL** ```bash npm run test -- tests/main/migrate-v4.test.ts ``` Expected: FAIL(version 仍为 3 或表不存在) - [x] **Step 3: 创建 `schema-v4.sql`** 内容同 spec §2.3(`chapters.origin`、`interactive_flows`、`interactive_scenes` 及索引)。 - [x] **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') } ``` - [x] **Step 5: `types.ts` 追加类型** 按 spec §3 追加:`InteractiveStep`、`InteractiveFlowStatus`、`ChapterOrigin`、`PlotOption`、`NamingPause`、`PlotSelection`、`InteractiveScene`、`InteractiveFlow`、`InteractiveGateResult`、`InteractiveStreamChunkEvent`。 `Chapter` 接口增加 `origin?: ChapterOrigin`。 - [x] **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', ``` - [x] **Step 7: 运行测试 PASS** ```bash npm run test -- tests/main/migrate-v4.test.ts ``` - [x] **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` - [x] **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 } } } ``` - [x] **Step 2: `book-registry.ts` 注册** ```typescript import { InteractiveRepository } from '../db/repositories/interactive.repo' getInteractiveRepo(bookId: string): InteractiveRepository { return new InteractiveRepository(this.getDb(bookId)) } ``` - [x] **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 复用) - [x] **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) }) }) ``` - [x] **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 语义一致即可。) - [x] **Step 3: `chapter.repo.ts` 支持 `origin`** `mapRow` 增加 `origin: (row.origin as ChapterOrigin) ?? 'manual'`。 `create` 签名扩展:`create(volumeId, title, sortOrder, content = '', origin: ChapterOrigin = 'manual')`。 INSERT 增加 `origin` 列。 - [x] **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` - [x] **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) }) }) ``` - [x] **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 } } ``` - [x] **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 条。 - [x] **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` - [x] **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('场景二') }) }) ``` - [x] **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`。 - [x] **Step 3: 运行 PASS** ```bash npm run test -- tests/main/interactive-finish.test.ts ``` - [x] **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` - [x] **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? }` - [x] **Step 2: `register.ts` 注册** ```typescript import { registerInteractiveHandlers } from './handlers/interactive.handler' registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig)) ``` - [x] **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 模式 */ } } ``` - [x] **Step 4: 手动 smoke** ```bash npm run build ``` Expected: 编译无 TS 错误 - [x] **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` - [x] **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) }) ``` - [x] **Step 2: IT-INT-02 / IT-INT-03**(`interactive-scene.test.ts`) - 先 `selectPlot`,再 `generateScene` 流式收集,断言正文长度 > 100 - 若模型返回 naming JSON,走 `resolveNaming` 后续写 超时:`240_000` - [x] **Step 3: 全量单元测试** ```bash npm run test ``` Expected: 50+ tests PASS(需 LM Studio) - [x] **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` - [x] **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 }, // ... })) ``` - [x] **Step 2: `PlotOptionCard.tsx`** 可点击卡片,`data-testid={`plot-option-${id}`}`,展示 label + summary。 - [x] **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` - [x] **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')}`。 - [x] **Step 5: CSS** 在 `layout.css` 追加 `.interactive-panel`、`.plot-option-card`、`.naming-options`、`.interactive-step-bar`(参考 `ui_pc.html` `.plot-opt`)。 - [x] **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 后导航) - [x] **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` - [x] **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')) ``` - [x] **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` - [x] **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 }) }) ``` - [x] **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 }) }) ``` - [x] **Step 3: E2E-INT-03(重启恢复,300s)** 进入交互 mid-flow → 关闭 app → 重开 → `getFlow` 恢复步骤 UI。 - [x] **Step 4: 运行 E2E** ```bash npm run test:e2e -- e2e/interactive-writing.spec.ts ``` - [x] **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`(勾选完成项) - [x] **Step 1: 版本号 → `0.4.0`** - [x] **Step 2: README 功能概览追加 P4 交互写作** - [x] **Step 3: 全量测试** ```bash npm run test npm run build npm run test:e2e ``` Expected: 单元全部 PASS;E2E 全部 PASS(LM Studio 运行中) - [x] **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`*