Files
bilin/docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md
T
2026-07-06 17:46:16 +08:00

34 KiB
Raw Blame History

笔临 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 构建各步 promptIPC interactive:* + interactive:streamChunk 事件;渲染进程 InteractivePanel + useInteractiveStore。测试 禁止 MockAI 集成/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:1234model=gemma-4-e4b-itBILIN_AI_BASE_URL / BILIN_AI_MODEL 环境变量由主进程读取)


File Map

文件 职责
.gitignore 忽略 test-results/
playwright.config.ts 全局 timeout 180s
src/main/db/schema-v4.sql v4 DDL
src/main/db/migrate.ts v3→v4
src/main/db/repositories/interactive.repo.ts flow / scene CRUD
src/main/db/repositories/chapter.repo.ts origin 列读写
src/main/services/interactive-gate.service.ts 门槛检查
src/main/services/interactive-prompt-builder.service.ts 各步 prompt
src/main/services/interactive-parse.service.ts JSON 解析(走向/命名)
src/main/services/interactive-writing.service.ts 步骤机
src/main/ipc/handlers/interactive.handler.ts IPC + 流式
src/main/ipc/register.ts 注册 handler
src/main/services/book-registry.ts getInteractiveRepo
src/shared/types.ts 交互类型 + ChapterOrigin
src/shared/ipc-channels.ts 新通道
src/preload/index.ts API + 事件
src/shared/electron-api.d.ts 类型
src/renderer/stores/useInteractiveStore.ts 渲染状态
src/renderer/components/ai/InteractivePanel.tsx 主 UI
src/renderer/components/ai/PlotOptionCard.tsx 走向卡片
src/renderer/components/ai/AiPanel.tsx 模式切换 + 挂载
src/renderer/styles/layout.css 交互 UI 样式
public/locales/zh-CN/translation.json 文案
public/locales/en/translation.json 文案
tests/main/migrate-v4.test.ts 迁移测试
tests/main/interactive-gate.test.ts 门槛测试
tests/main/interactive-plots.test.ts IT-INT-01
tests/main/interactive-scene.test.ts IT-INT-02/03
tests/main/interactive-finish.test.ts IT-INT-04
e2e/interactive-writing.spec.ts E2E-INT-*

Task 0: 工程配套(gitignore + 测试超时)

Files:

  • Modify: .gitignore

  • Modify: playwright.config.ts

  • Modify: tests/main/ai-client.test.ts

  • Modify: e2e/ai-chat.spec.ts

  • Step 1: .gitignore 追加

test-results/
  • Step 2: Playwright 全局超时

playwright.config.ts

export default defineConfig({
  testDir: './e2e',
  timeout: 180_000,
  workers: 1,
  // ...
})
  • Step 3: 现有 AI 用例超时上调(可选但推荐)

tests/main/ai-client.test.ts}, 180_000)

e2e/ai-chat.spec.tsAI 用例 test.setTimeout(180_000);助手 expect 保持或升至 120_000

  • Step 4: 验证
npm run test

Expected: 全部 PASS(需 LM Studio 运行)

  • Step 5: Commit
git add .gitignore playwright.config.ts tests/main/ai-client.test.ts e2e/ai-chat.spec.ts
git commit -m "chore: ignore test-results and raise AI test timeouts"

Task 1: Schema v4 + 共享类型 + IPC 通道

Files:

  • Create: src/main/db/schema-v4.sql

  • Modify: src/main/db/migrate.ts

  • Modify: src/shared/types.ts

  • Modify: src/shared/ipc-channels.ts

  • Create: tests/main/migrate-v4.test.ts

  • Step 1: 写迁移失败测试

tests/main/migrate-v4.test.ts

import { describe, it, expect, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import schemaV1 from '../../src/main/db/schema-v1.sql?raw'
import schemaV2 from '../../src/main/db/schema-v2.sql?raw'
import schemaV3 from '../../src/main/db/schema-v3.sql?raw'
import type { SqliteDb } from '../../src/main/db/types'

describe('migrate v4', () => {
  let db: SqliteDb
  afterEach(() => db?.close())

  it('applies v4 on fresh database', () => {
    db = new DatabaseSync(':memory:')
    migrate(db)
    const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
    expect(version.v).toBe(4)
    expect(db.prepare("SELECT origin FROM chapters LIMIT 0").run()).toBeDefined()
    expect(db.prepare("SELECT name FROM sqlite_master WHERE name='interactive_flows'").get()).toBeTruthy()
    expect(db.prepare("SELECT name FROM sqlite_master WHERE name='interactive_scenes'").get()).toBeTruthy()
  })

  it('migrates v3 database to v4', () => {
    db = new DatabaseSync(':memory:')
    db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
      version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')), description TEXT
    )`)
    db.exec(schemaV1)
    db.exec(schemaV2)
    db.exec(schemaV3)
    db.prepare('INSERT INTO schema_version (version) VALUES (1),(2),(3)').run()
    migrate(db)
    const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
    expect(version.v).toBe(4)
  })
})
  • Step 2: 运行确认 FAIL
npm run test -- tests/main/migrate-v4.test.ts

Expected: FAILversion 仍为 3 或表不存在)

  • Step 3: 创建 schema-v4.sql

内容同 spec §2.3chapters.origininteractive_flowsinteractive_scenes 及索引)。

  • Step 4: 更新 migrate.ts
import schemaV4 from './schema-v4.sql?raw'

const CURRENT_VERSION = 4

function applyV4(db: SqliteDb): void {
  tryAlter(db, "ALTER TABLE chapters ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'")
  db.exec(schemaV4)
}

// 在 migrate() 末尾追加:
if (current < 4) {
  applyV4(db)
  db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(4, 'P4 interactive schema')
}
  • Step 5: types.ts 追加类型

按 spec §3 追加:InteractiveStepInteractiveFlowStatusChapterOriginPlotOptionNamingPausePlotSelectionInteractiveSceneInteractiveFlowInteractiveGateResultInteractiveStreamChunkEvent

Chapter 接口增加 origin?: ChapterOrigin

  • Step 6: ipc-channels.ts 追加
INTERACTIVE_CHECK_GATE: 'interactive:checkGate',
INTERACTIVE_GET_FLOW: 'interactive:getFlow',
INTERACTIVE_START: 'interactive:start',
INTERACTIVE_CONFIRM_CONTEXT: 'interactive:confirmContext',
INTERACTIVE_SUGGEST_PLOTS: 'interactive:suggestPlots',
INTERACTIVE_SELECT_PLOT: 'interactive:selectPlot',
INTERACTIVE_GENERATE_SCENE: 'interactive:generateScene',
INTERACTIVE_RESOLVE_NAMING: 'interactive:resolveNaming',
INTERACTIVE_REFINE_SCENE: 'interactive:refineScene',
INTERACTIVE_ACCEPT_SCENE: 'interactive:acceptScene',
INTERACTIVE_FINISH_CHAPTER: 'interactive:finishChapter',
INTERACTIVE_ABORT: 'interactive:abort',
INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk',
  • Step 7: 运行测试 PASS
npm run test -- tests/main/migrate-v4.test.ts
  • Step 8: Commit
git add src/main/db/schema-v4.sql src/main/db/migrate.ts src/shared/types.ts src/shared/ipc-channels.ts tests/main/migrate-v4.test.ts
git commit -m "feat: add schema v4 and interactive shared types"

Task 2: InteractiveRepository

Files:

  • Create: src/main/db/repositories/interactive.repo.ts

  • Modify: src/main/services/book-registry.ts

  • Step 1: 实现 InteractiveRepository

import { randomUUID } from 'crypto'
import type {
  AiContextBinding,
  InteractiveFlow,
  InteractiveFlowStatus,
  InteractiveScene,
  InteractiveStep,
  NamingPause,
  PlotSelection
} from '../../../shared/types'
import type { SqliteDb } from '../types'

export class InteractiveRepository {
  constructor(private db: SqliteDb) {}

  getBySession(sessionId: string): InteractiveFlow | null {
    const row = this.db
      .prepare(
        `SELECT * FROM interactive_flows WHERE session_id = ? AND status = 'in_progress' ORDER BY updated_at DESC LIMIT 1`
      )
      .get(sessionId) as Record<string, unknown> | undefined
    return row ? this.mapFlow(row) : null
  }

  get(flowId: string): InteractiveFlow | null {
    const row = this.db.prepare('SELECT * FROM interactive_flows WHERE id = ?').get(flowId) as
      | Record<string, unknown>
      | undefined
    return row ? this.mapFlow(row) : null
  }

  create(sessionId: string): InteractiveFlow {
    const id = randomUUID()
    this.db
      .prepare(
        `INSERT INTO interactive_flows (id, session_id, step, status, context_json) VALUES (?, ?, 'context_confirm', 'in_progress', '{}')`
      )
      .run(id, sessionId)
    return this.get(id)!
  }

  resetInProgress(sessionId: string): InteractiveFlow {
    this.db
      .prepare(`UPDATE interactive_flows SET status = 'abandoned' WHERE session_id = ? AND status = 'in_progress'`)
      .run(sessionId)
    return this.create(sessionId)
  }

  updateStep(flowId: string, step: InteractiveStep, patch: {
    contextJson?: string
    plotSelectionJson?: string | null
    namingPendingJson?: string | null
    status?: InteractiveFlowStatus
  }): InteractiveFlow {
    const sets: string[] = [`step = ?`, `updated_at = datetime('now')`]
    const vals: unknown[] = [step]
    if (patch.contextJson !== undefined) {
      sets.push('context_json = ?')
      vals.push(patch.contextJson)
    }
    if (patch.plotSelectionJson !== undefined) {
      sets.push('plot_selection_json = ?')
      vals.push(patch.plotSelectionJson)
    }
    if (patch.namingPendingJson !== undefined) {
      sets.push('naming_pending_json = ?')
      vals.push(patch.namingPendingJson)
    }
    if (patch.status !== undefined) {
      sets.push('status = ?')
      vals.push(patch.status)
    }
    vals.push(flowId)
    this.db.prepare(`UPDATE interactive_flows SET ${sets.join(', ')} WHERE id = ?`).run(...vals)
    return this.get(flowId)!
  }

  listScenes(flowId: string): InteractiveScene[] {
    const rows = this.db
      .prepare('SELECT * FROM interactive_scenes WHERE flow_id = ? ORDER BY sort_order')
      .all(flowId) as Record<string, unknown>[]
    return rows.map((r) => ({
      id: r.id as string,
      sortOrder: r.sort_order as number,
      contentHtml: r.content_html as string,
      plotLabel: r.plot_label as string,
      refinedCount: r.refined_count as number
    }))
  }

  addScene(flowId: string, contentHtml: string, plotLabel: string): InteractiveScene {
    const id = randomUUID()
    const max = this.db
      .prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM interactive_scenes WHERE flow_id = ?')
      .get(flowId) as { m: number }
    const sortOrder = max.m + 1
    this.db
      .prepare(
        `INSERT INTO interactive_scenes (id, flow_id, sort_order, content_html, plot_label) VALUES (?, ?, ?, ?, ?)`
      )
      .run(id, flowId, sortOrder, contentHtml, plotLabel)
    return this.listScenes(flowId).find((s) => s.id === id)!
  }

  private mapFlow(row: Record<string, unknown>): InteractiveFlow {
    const flowId = row.id as string
    let namingPending: NamingPause | null = null
    if (row.naming_pending_json) {
      try {
        namingPending = JSON.parse(row.naming_pending_json as string) as NamingPause
      } catch {
        namingPending = null
      }
    }
    return {
      id: flowId,
      sessionId: row.session_id as string,
      step: row.step as InteractiveStep,
      status: row.status as InteractiveFlowStatus,
      scenes: this.listScenes(flowId),
      namingPending
    }
  }
}
  • Step 2: book-registry.ts 注册
import { InteractiveRepository } from '../db/repositories/interactive.repo'

getInteractiveRepo(bookId: string): InteractiveRepository {
  return new InteractiveRepository(this.getDb(bookId))
}
  • Step 3: Commit
git add src/main/db/repositories/interactive.repo.ts src/main/services/book-registry.ts
git commit -m "feat: add InteractiveRepository for flow and scene persistence"

Task 3: 门槛检查 + Chapter origin

Files:

  • Create: src/main/services/interactive-gate.service.ts

  • Create: tests/main/interactive-gate.test.ts

  • Modify: src/main/db/repositories/chapter.repo.ts

  • Modify: src/main/services/word-count.ts(若需导出 stripHtml,或从 naming-check 复用)

  • Step 1: 写门槛失败测试

tests/main/interactive-gate.test.ts

import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import { OutlineRepository } from '../../src/main/db/repositories/outline.repo'
import { checkInteractiveGate } from '../../src/main/services/interactive-gate.service'
import type { SqliteDb } from '../../src/main/db/types'

describe('checkInteractiveGate', () => {
  let db: SqliteDb
  beforeEach(() => {
    db = new DatabaseSync(':memory:')
    migrate(db)
  })
  afterEach(() => db.close())

  it('IT-INT-05: rejects empty book', () => {
    const result = checkInteractiveGate(db)
    expect(result.eligible).toBe(false)
  })

  it('IT-INT-05: accepts book with chapter content', () => {
    const vols = new VolumeRepository(db)
    const volId = vols.create('卷一', 0).id
    const chapters = new ChapterRepository(db)
    chapters.create(volId, '第一章', 0, '<p>有正文</p>')
    expect(checkInteractiveGate(db).eligible).toBe(true)
  })

  it('IT-INT-05: accepts book with outline only', () => {
    new OutlineRepository(db).create(null, '节点', '<p>描述</p>', 0)
    expect(checkInteractiveGate(db).eligible).toBe(true)
  })
})
  • Step 2: 实现 interactive-gate.service.ts
import type { InteractiveGateResult } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { stripHtml } from './word-count'

export function checkInteractiveGate(db: SqliteDb): InteractiveGateResult {
  const chapterWithBody = db
    .prepare(`SELECT id FROM chapters WHERE length(trim(replace(replace(content, '<p>', ''), '</p>', ''))) > 0 LIMIT 1`)
    .get()
  if (chapterWithBody) return { eligible: true }

  const outlineCount = db.prepare('SELECT COUNT(*) AS c FROM outline_items').get() as { c: number }
  const settingCount = db.prepare('SELECT COUNT(*) AS c FROM settings').get() as { c: number }
  if (outlineCount.c > 0 || settingCount.c > 0) return { eligible: true }

  const anyChapter = db.prepare('SELECT COUNT(*) AS c FROM chapters').get() as { c: number }
  return {
    eligible: false,
    reason: anyChapter.c === 0 ? 'no_chapter' : 'no_outline_or_setting'
  }
}

(实现时可用 stripHtml 遍历章节列表,与 spec 语义一致即可。)

  • Step 3: chapter.repo.ts 支持 origin

mapRow 增加 origin: (row.origin as ChapterOrigin) ?? 'manual'

create 签名扩展:create(volumeId, title, sortOrder, content = '', origin: ChapterOrigin = 'manual')

INSERT 增加 origin 列。

  • Step 4: 运行 PASS + Commit
npm run test -- tests/main/interactive-gate.test.ts
git commit -m "feat: add interactive gate check and chapter origin column"

Task 4: Prompt 构建与 JSON 解析

Files:

  • Create: src/main/services/interactive-parse.service.ts

  • Create: src/main/services/interactive-prompt-builder.service.ts

  • Create: tests/main/interactive-parse.test.ts

  • Step 1: 解析单元测试(无 AI)

tests/main/interactive-parse.test.ts

import { describe, it, expect } from 'vitest'
import { parsePlotsJson, parseNamingRequiredJson } from '../../src/main/services/interactive-parse.service'

describe('interactive-parse', () => {
  it('parses three plot options', () => {
    const json = `{"plots":[{"id":"A","label":"锋芒","summary":"林远展露天赋引起轰动,同门侧目,长老暗中关注其来历与隐患。"},{"id":"B","label":"藏锋","summary":"林远按长老暗示收敛灵力,被安排在外门修行,暗中调查测灵石异变。"},{"id":"C","label":"转折","summary":"测灵石碎裂引发异象,闭关太上长老出关,宗门上下震动。"}]}`
    const plots = parsePlotsJson(json)
    expect(plots).toHaveLength(3)
    expect(plots[0].summary.length).toBeGreaterThanOrEqual(10)
  })

  it('parses naming_required payload', () => {
    const json = `{"type":"naming_required","elementType":"角色","context":"需要为新同伴命名","options":["名1","名2","名3","名4","名5"]}`
    const pause = parseNamingRequiredJson(json)
    expect(pause.options).toHaveLength(5)
  })
})
  • Step 2: 实现 interactive-parse.service.ts
import type { NamingPause, PlotOption } from '../../shared/types'

export function parsePlotsJson(response: string): PlotOption[] {
  const trimmed = response.trim()
  const start = trimmed.indexOf('{')
  const end = trimmed.lastIndexOf('}')
  if (start < 0 || end <= start) throw new Error('plots JSON not found')
  const parsed = JSON.parse(trimmed.slice(start, end + 1)) as { plots?: unknown }
  if (!Array.isArray(parsed.plots) || parsed.plots.length < 3) {
    throw new Error('plots array invalid')
  }
  return parsed.plots.slice(0, 3).map((p, i) => {
    const row = p as Record<string, unknown>
    const ids = ['A', 'B', 'C'] as const
    return {
      id: ids[i],
      label: String(row.label ?? ids[i]),
      summary: String(row.summary ?? '')
    }
  })
}

export function parseNamingRequiredJson(response: string): NamingPause {
  const trimmed = response.trim()
  const start = trimmed.indexOf('{')
  const end = trimmed.lastIndexOf('}')
  const parsed = JSON.parse(trimmed.slice(start, end + 1)) as Record<string, unknown>
  if (parsed.type !== 'naming_required') throw new Error('not naming_required')
  const options = Array.isArray(parsed.options) ? parsed.options.map(String) : []
  if (options.length < 5) throw new Error('naming options < 5')
  return {
    elementType: String(parsed.elementType ?? '元素'),
    context: String(parsed.context ?? ''),
    options: options.slice(0, 5)
  }
}

export function tryParseNamingFromStream(buffer: string): NamingPause | null {
  const t = buffer.trim()
  if (!t.startsWith('{') || !t.includes('naming_required')) return null
  try {
    return parseNamingRequiredJson(t)
  } catch {
    return null
  }
}
  • Step 3: 实现 interactive-prompt-builder.service.ts

导出函数:

  • buildPlotSuggestMessages(contextText: string, sceneSummaries: string[]): ChatMessage[]
  • buildSceneGenerateMessages(contextText, plotSelection, sceneSummaries): ChatMessage[]
  • buildNamingResumeMessages(chosenName: string, partialScene: string): ChatMessage[]
  • buildRefineMessages(currentSceneHtml: string, instruction: string): ChatMessage[]

System prompt 明确要求 JSON 格式或 HTML 段落;走向步要求恰好 3 条。

  • Step 4: 运行 PASS + Commit
npm run test -- tests/main/interactive-parse.test.ts
git commit -m "feat: add interactive prompt builder and JSON parsers"

Task 5: InteractiveWritingService(步骤机)

Files:

  • Create: src/main/services/interactive-writing.service.ts

  • Create: tests/main/interactive-finish.test.ts

  • Step 1: 写无章 AI 的 finish 测试

tests/main/interactive-finish.test.tsIT-INT-04,无 LM Studio):

import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { InteractiveRepository } from '../../src/main/db/repositories/interactive.repo'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
import type { SqliteDb } from '../../src/main/db/types'

describe('InteractiveWritingService.finishChapter', () => {
  let db: SqliteDb
  let service: InteractiveWritingService
  let volId: string
  let sessionId: string
  let flowId: string

  beforeEach(() => {
    db = new DatabaseSync(':memory:')
    migrate(db)
    volId = new VolumeRepository(db).create('卷一', 0).id
    const session = new AiSessionRepository(db).create('测试', 'test-model')
    sessionId = session.id
    const flow = new InteractiveRepository(db).create(sessionId)
    flowId = flow.id
    const repo = new InteractiveRepository(db)
    repo.addScene(flowId, '<p>场景一</p>', 'A')
    repo.addScene(flowId, '<p>场景二</p>', 'B')
    service = new InteractiveWritingService(db, {
      chat: async () => '',
      chatStream: async (_msgs, onChunk) => {
        onChunk('delta', false)
        onChunk('', true)
        return ''
      }
    })
  })
  afterEach(() => db.close())

  it('IT-INT-04: merges scenes into new interactive chapter', () => {
    const { chapterId } = service.finishChapter(flowId, volId, '交互初稿')
    const ch = new ChapterRepository(db).get(chapterId)!
    expect(ch.origin).toBe('interactive')
    expect(ch.content).toContain('场景一')
    expect(ch.content).toContain('场景二')
  })
})
  • Step 2: 实现 InteractiveWritingService

构造函数注入 db + AiClientService(或窄接口 { chat, chatStream })。

核心方法:

finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: string } {
  const repo = new InteractiveRepository(this.db)
  const flow = repo.get(flowId)
  if (!flow) throw new Error('flow not found')
  const scenes = repo.listScenes(flowId)
  const merged = scenes.map((s) => s.contentHtml).join('\n')
  const chapters = new ChapterRepository(this.db)
  const sortOrder = chapters.listByVolume(volumeId).length
  const ch = chapters.create(volumeId, title ?? '交互初稿', sortOrder, merged, 'interactive')
  repo.updateStep(flowId, 'completed', { status: 'completed', namingPendingJson: null })
  return { chapterId: ch.id }
}

其余方法按 spec 步骤机实现;suggestPlotsaiClient.chat + parsePlotsJson(失败重试 1 次);generateScene 流式累积 buffer,开头检测 tryParseNamingFromStream → 写 naming_pending_json 并切 naming_pause

  • Step 3: 运行 PASS
npm run test -- tests/main/interactive-finish.test.ts
  • Step 4: Commit
git commit -m "feat: add InteractiveWritingService with finishChapter"

Task 6: IPC Handler + Preload

Files:

  • Create: src/main/ipc/handlers/interactive.handler.ts

  • Modify: src/main/ipc/register.ts

  • Modify: src/preload/index.ts

  • Modify: src/shared/electron-api.d.ts

  • Step 1: 实现 registerInteractiveHandlers

模式参照 ai.handler.ts

  • checkGatecheckInteractiveGate(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 注册

import { registerInteractiveHandlers } from './handlers/interactive.handler'

registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
  • Step 3: preload 暴露 interactive 命名空间
interactive: {
  checkGate: (bookId: string) => ipcRenderer.invoke(IPC.INTERACTIVE_CHECK_GATE, { bookId }),
  getFlow: (bookId: string, sessionId: string) => ipcRenderer.invoke(IPC.INTERACTIVE_GET_FLOW, { bookId, sessionId }),
  start: (bookId: string, sessionId: string) => ipcRenderer.invoke(IPC.INTERACTIVE_START, { bookId, sessionId }),
  // ... 其余方法
  onStreamChunk: (cb) => { /* 同 onAiStreamChunk 模式 */ }
}
  • Step 4: 手动 smoke
npm run build

Expected: 编译无 TS 错误

  • Step 5: Commit
git commit -m "feat: wire interactive IPC handlers and preload API"

Task 7: AI 集成测试(IT-INT-01/02/03

Files:

  • Create: tests/main/interactive-plots.test.ts

  • Create: tests/main/interactive-scene.test.ts

  • Step 1: IT-INT-01

import { describe, it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'

describe('interactive plots integration', () => {
  it('IT-INT-01: suggestPlots returns 3 options from LM Studio', async () => {
    const db = new DatabaseSync(':memory:')
    migrate(db)
    const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
    const service = new InteractiveWritingService(db, new AiClientService(() => DEFAULT_AI_CONFIG))
    const flow = service.start(session.id)
    service.confirmContext(flow.id, { chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] }, '测试上下文:林远参加入门考核。')
    const plots = await service.suggestPlots(flow.id)
    expect(plots).toHaveLength(3)
    db.close()
  }, 180_000)
})
  • Step 2: IT-INT-02 / IT-INT-03interactive-scene.test.ts

  • selectPlot,再 generateScene 流式收集,断言正文长度 > 100

  • 若模型返回 naming JSON,走 resolveNaming 后续写

超时:240_000

  • Step 3: 全量单元测试
npm run test

Expected: 50+ tests PASS(需 LM Studio

  • Step 4: Commit
git commit -m "test: add interactive LM Studio integration tests"

Task 8: useInteractiveStore + InteractivePanel UI

Files:

  • Create: src/renderer/stores/useInteractiveStore.ts

  • Create: src/renderer/components/ai/PlotOptionCard.tsx

  • Create: src/renderer/components/ai/InteractivePanel.tsx

  • Modify: src/renderer/components/ai/AiPanel.tsx

  • Modify: src/renderer/styles/layout.css

  • Step 1: useInteractiveStore

import { create } from 'zustand'
import type { InteractiveFlow, PlotOption } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'

interface InteractiveState {
  gateEligible: boolean
  gateReason?: string
  flow: InteractiveFlow | null
  plots: PlotOption[]
  streamBuffer: string
  streaming: boolean
  checkGate: (bookId: string) => Promise<void>
  loadFlow: (bookId: string, sessionId: string) => Promise<void>
  start: (bookId: string, sessionId: string) => Promise<void>
  mount: () => void
  unmount: () => void
  // ... suggestPlots, selectPlot, generateScene, etc.
}

export const useInteractiveStore = create<InteractiveState>((set, get) => ({
  gateEligible: false,
  flow: null,
  plots: [],
  streamBuffer: '',
  streaming: false,
  checkGate: async (bookId) => {
    const r = await ipcCall(() => window.electronAPI.interactive.checkGate(bookId))
    set({ gateEligible: r.eligible, gateReason: r.reason })
  },
  mount: () => {
    const unsub = window.electronAPI.interactive.onStreamChunk((p) => {
      set((s) => ({
        streamBuffer: p.done ? s.streamBuffer : s.streamBuffer + p.delta,
        streaming: !p.done
      }))
    })
    // store unsub for unmount
  },
  // ...
}))
  • Step 2: PlotOptionCard.tsx

可点击卡片,data-testid={plot-option-${id}},展示 label + summary。

  • Step 3: InteractivePanel.tsx

flow.step 条件渲染;关键 testid

  • interactive-panel

  • interactive-step-indicator

  • interactive-start

  • plot-option-A|B|C

  • interactive-scene-preview

  • naming-option-0naming-custom

  • interactive-refine-input

  • interactive-accept-scene

  • interactive-finish-chapter

  • Step 4: 修改 AiPanel.tsx

import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'

// mount 时 checkGate(bookId)
const handleModeClick = (mode: AiWritingMode): void => {
  if (mode === 'auto' || mode === 'wizard') {
    showToast(t('feature.comingSoon'))
    return
  }
  if (mode === 'interactive' && !useInteractiveStore.getState().gateEligible) {
    showToast(t('interactive.gateBlocked'))
    return
  }
  setWritingMode(mode)
  if (mode === 'interactive') {
    void useInteractiveStore.getState().start(bookId!, activeSessionId!)
  }
}

// 渲染区:
{writingMode === 'interactive' ? (
  <InteractivePanel bookId={bookId} sessionId={activeSessionId} />
) : (
  /* 现有 chat UI */
)}

模式 Tabinteractive!gateEligibledisabled + title={t('interactive.gateTooltip')}

  • Step 5: CSS

layout.css 追加 .interactive-panel.plot-option-card.naming-options.interactive-step-bar(参考 ui_pc.html .plot-opt)。

  • Step 6: Commit
git commit -m "feat: add InteractivePanel UI and wire AiPanel mode switch"

Task 9: i18n + 成章导航

Files:

  • Modify: public/locales/zh-CN/translation.json

  • Modify: public/locales/en/translation.json

  • Modify: src/renderer/stores/useInteractiveStore.tsfinish 后导航)

  • Step 1: 追加文案键

interactive.gateBlockedinteractive.gateTooltipinteractive.step.contextinteractive.step.plotinteractive.step.sceneinteractive.step.reviewinteractive.startinteractive.acceptSceneinteractive.finishChapterinteractive.chapterCreatedinteractive.namingTitleinteractive.refinePlaceholder

  • Step 2: finishChapter 成功后
import { useBookStore } from '@renderer/stores/useBookStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useEditStore } from '@renderer/stores/useEditStore'

const { chapterId } = await ipcCall(() =>
  window.electronAPI.interactive.finishChapter(bookId, flowId, volumeId, title)
)
await useBookStore.getState().refreshChapters()
useBookStore.getState().setSelectedChapter(chapterId)
useEditStore.getState().setTarget({ kind: 'chapter', id: chapterId })
useAppStore.getState().setView('editor')
showToast(t('interactive.chapterCreated'))
  • Step 3: Commit
git commit -m "feat: add interactive i18n and chapter navigation after finish"

Task 10: E2E 测试

Files:

  • Create: e2e/interactive-writing.spec.ts

  • Step 1: E2E-INT-02(门槛,无 AI

test('E2E-INT-02: interactive tab disabled on empty book', async () => {
  // 新建空书(跳过样例章节)→ 打开 AI 面板 → interactive tab disabled
  const tab = page.getByRole('button', { name: /交互写作/ })
  await expect(tab).toBeDisabled({ timeout: 10_000 })
})
  • Step 2: E2E-INT-01(完整流程,300s
test('E2E-INT-01: interactive flow creates chapter', async () => {
  test.setTimeout(300_000)
  // onboarding 样本书 或 写入章节正文
  await page.getByRole('button', { name: /交互写作/ }).click()
  await page.getByTestId('interactive-start').click()
  await expect(page.getByTestId('plot-option-A')).toBeVisible({ timeout: 120_000 })
  await page.getByTestId('plot-option-A').click()
  await page.getByTestId('interactive-confirm-plot').click()
  await expect(page.getByTestId('interactive-scene-preview')).not.toBeEmpty({ timeout: 120_000 })
  await page.getByTestId('interactive-accept-scene').click()
  await page.getByTestId('interactive-finish-chapter').click()
  await expect(page.locator('.ProseMirror')).not.toBeEmpty({ timeout: 30_000 })
})
  • Step 3: E2E-INT-03(重启恢复,300s

进入交互 mid-flow → 关闭 app → 重开 → getFlow 恢复步骤 UI。

  • Step 4: 运行 E2E
npm run test:e2e -- e2e/interactive-writing.spec.ts
  • Step 5: Commit
git commit -m "test: add interactive writing E2E specs"

Task 11: v0.4.0 交付

Files:

  • Modify: package.json

  • Modify: README.md

  • Modify: src/renderer/components/settings/SettingsPage.tsxabout 版本号)

  • Modify: docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md(勾选完成项)

  • Step 1: 版本号 → 0.4.0

  • Step 2: README 功能概览追加 P4 交互写作

  • Step 3: 全量测试

npm run test
npm run build
npm run test:e2e

Expected: 单元全部 PASSE2E 全部 PASSLM Studio 运行中)

  • Step 4: Commit + tag(可选)
git commit -m "chore: release v0.4.0 with interactive writing mode"
git tag v0.4.0

计划自检

检查项 结果
Spec §1.3 验收 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