df75d9c560
Co-authored-by: Cursor <cursoragent@cursor.com>
1030 lines
34 KiB
Markdown
1030 lines
34 KiB
Markdown
# 笔临 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<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: 单元全部 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`*
|