Files
bilin/docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md
T

1030 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 笔临 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` 构建各步 promptIPC `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: FAILversion 仍为 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<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` 注册**
```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, '<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`**
```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, '<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**
```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<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**
```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, '<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 }`)。
核心方法:
```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<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`**
```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' ? (
<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**
```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: 单元全部 PASSE2E 全部 PASSLM 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 验收 19 | 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`*