docs: add P2.1 and P3 design spec for v0.3.0

Covers P2 backlog (chapter drag, search replace, inspiration voice, mention jump, reference pin) and strict P3-A (LM Studio chat, context injection, offline fallback, naming check) with no-mock testing requirements.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 14:26:56 +08:00
parent aac51bf183
commit fd04ca7ea2
@@ -0,0 +1,545 @@
# 笔临 P2.1 + P3 设计规格
**版本**1.0
**日期**2026-07-07
**范围**:P2 遗留补完 + P3 AI 基础(严格 P3-A:对话模式实装,写作模式 Tab 占位)
**前置**P2 已交付(v0.2.0`aac51bf`
**参考**`design/readme.md` v1.1 §5.3.8 / §5.6 / §6.1–§6.2 / §6.8–§6.9、`design/ui_pc.html`
**目标版本**v0.3.0
---
## 1. 背景与目标
### 1.1 阶段定位
P2.1 补齐 P2 规格中明确延期的交互能力(章节拖拽、搜索替换、灵感语音、`@` 跳转、引用钉住)。P3 在 v0.2.0 信息管理能力之上,交付 **可对话的本地 AI 助手**:会话持久化、上下文注入、快捷命令、离线降级、命名一致性检查,以及 `ai` 类型快照。
### 1.2 已确认决策(Brainstorming 2026-07-07
| 维度 | 决策 |
|------|------|
| 范围 | **P2.1 全量** + **P3 严格(选项 A** |
| P3 写作模式 | 「对话」实装;「交互 / 自动 / 向导」Tab **仅 UI 占位** → P4 |
| AI 后端 | **LM Studio**OpenAI 兼容 API),开发/测试/E2E 统一配置 |
| 服务地址 | `http://127.0.0.1:1234` |
| 模型 | `gemma-4-e4b-it` |
| 测试原则 | **禁止 Mock**;不得以「本地服务未启动」为由 skip 测试 |
| 架构 | **主进程直连**`AiClientService` + IPC 流式推送) |
| 平台 | Windows 优先(延续 P0P2 |
### 1.3 验收标准
用户能完成:
1. 左栏章节同卷/跨卷拖拽排序,重启后顺序保持
2. 全局搜索:输入关键词 → 预览替换影响范围 → 确认后批量替换章节内容
3. `Ctrl+Shift+I` 打开灵感捕获模态,文字/语音输入后保存到灵感列表
4. 点击正文中 `@设定名` 跳转到对应设定编辑
5. 引用面板钉住后保持可见,可与 AI 右栏互斥规则共存
6. 设置页配置 LM Studio 地址与模型;右栏 AI 面板新建会话、发送消息、流式收到回复
7. 勾选章节/大纲/设定/灵感作为上下文,点击「更新上下文」后下一轮对话携带最新摘要
8. 使用 `/续写` 等快捷命令触发预设 prompt
9. 云端后端配置下断网时 AI 输入禁用并显示离线横幅;LM Studio 配置下断网仍可用
10. 知识库 Tab 运行命名一致性检查,逐条确认后可批量替换
11. AI 内容写入章节前自动创建 `type='ai'` 快照
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| §6.5 交互 / 自动 / 向导写作全流程 | P4 |
| §6.3 文风学习与风格滑杆 | P4 |
| §6.4 Skill / MCP 扩展 | P4+ |
| §6.6 大纲生成章节骨架 | P4 |
| §6.7 知识自动抽取与审核工作流(列表 UI) | P4 |
| `knowledge_entries` / `timeline_events` 表 | P4/P5/P6 |
| 导入导出(`#importModal` | P7 |
| 驾驶舱、章间衔接、更新计划 | P5 |
---
## 2. 架构
### 2.1 进程模型(延续 P2
```
┌─────────────────────────────────────────────────────────┐
│ 主进程 │
│ · node:sqlite(每书 .sqliteschema v3
│ · AiClientService → fetch LM Studio /v1/chat/completions│
│ · AiSessionRepository / AiContextBuilder │
│ · NetworkMonitor → 推送 online/offline │
│ · 既有 FTS / Search / Snapshot / Chapter 服务 │
│ · IPC HandlersIpcResult 信封 + ai:stream-chunk 事件) │
└───────────────────────────┬─────────────────────────────┘
│ contextBridge
┌───────────────────────────▼─────────────────────────────┐
│ 渲染进程 │
│ · AiPanel(替换 RightPanel AI 占位) │
│ · ContextEditorModal / NamingCheckModal │
│ · InspirationModal(全局快捷键) │
│ · useAiStore(会话、流式缓冲、disabled
│ · SearchModal 替换区 / 章节 drag-and-drop │
└─────────────────────────────────────────────────────────┘
```
**约束**(延续 P0P2):
- 禁止渲染进程直接 `fetch` AI API;所有推理请求经主进程
- API Key 仅存 `global_settings.json`(主进程读写)
- 流式输出通过 `webContents.send(IPC.AI_STREAM_CHUNK, payload)` 推送,不阻塞 IPC invoke
### 2.2 AI 客户端(推荐方案)
主进程 `AiClientService`
- 使用 Node.js 内置 `fetch` 调用 OpenAI Chat Completions 兼容端点
- 支持 `stream: true`;解析 SSE `data: {...}`
- `AbortController` 支持 `ai:abort`
- 超时默认 60s`GlobalSettings.aiConfig.timeoutMs`
- 错误映射为 `IpcError`,含 `recoverable: true`(超时/连接拒绝)
**默认配置**`global_settings.json`):
```typescript
interface AiConfig {
backend: 'lmstudio' | 'openai' | 'anthropic'
baseUrl: string // 默认 'http://127.0.0.1:1234'
model: string // 默认 'gemma-4-e4b-it'
apiKey?: string // openai/anthropic 时使用;lmstudio 可空
timeoutMs: number // 默认 60_000
maxTokens: number // 默认 2048
}
```
**环境变量覆盖**(测试/CI,可选):
| 变量 | 作用 |
|------|------|
| `BILIN_AI_BASE_URL` | 覆盖 `baseUrl` |
| `BILIN_AI_MODEL` | 覆盖 `model` |
未设置时使用上述默认值;**测试不得 skip**。
### 2.3 里程碑
| 阶段 | 交付 | 周期 |
|------|------|------|
| **P2.1a** | 章节拖拽 + `chapter:move` IPC | ~2 天 |
| **P2.1b** | 搜索替换 UI + E2E | ~1 天 |
| **P2.1c** | 灵感模态 + 语音 + `@` 跳转 + 引用钉住 | ~2 天 |
| **P3.0** | Schema v3 + AiClientService + AI 设置页 | ~2 天 |
| **P3.1** | 会话 CRUD + 消息持久化 + 流式 IPC | ~3 天 |
| **P3.2** | 上下文编辑器 + 快捷命令 | ~2 天 |
| **P3.3** | 离线降级 + 命名检查 + AI 快照 | ~2 天 |
| **P3.4** | 全量测试 + v0.3.0 | ~1 天 |
---
## 3. 数据模型
### 3.1 Schema v3 迁移
`schema_version` 2 → 3。增量 `schema-v3.sql` + `migrate.ts` 扩展。
```sql
CREATE TABLE ai_sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
context_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE ai_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (session_id) REFERENCES ai_sessions(id) ON DELETE CASCADE
);
CREATE INDEX idx_ai_messages_session ON ai_messages(session_id, created_at);
```
**说明**`ai_sessions` 存于**每本书**的 `.sqlite`(与章节等同库),非全局。
**`context_json` 结构**
```typescript
interface AiContextBinding {
chapterIds: string[]
outlineIds: string[]
settingIds: string[]
inspirationIds: string[]
}
```
**全局 userData**`global_settings.json` 扩展,非书籍 DB):
```typescript
interface GlobalSettings {
// ...existing P2 fields
aiConfig: AiConfig
namingIgnoreWords: string[]
}
```
### 3.2 共享类型扩展(`src/shared/types.ts`
```typescript
export type AiBackend = 'lmstudio' | 'openai' | 'anthropic'
export type AiMessageRole = 'user' | 'assistant' | 'system'
export type AiWritingMode = 'chat' | 'interactive' | 'auto' | 'wizard' // UI only; P3 仅 chat 实装
export interface AiSession {
id: string
title: string
model: string
archived: boolean
context: AiContextBinding
createdAt: string
updatedAt: string
}
export interface AiMessage {
id: string
sessionId: string
role: AiMessageRole
content: string
createdAt: string
}
export interface NamingConflict {
termA: string
termB: string
confidence: number
reason: string
suggestedCanonical?: string
}
```
---
## 4. IPC 通道
### 4.1 新增常量(`ipc-channels.ts`
```typescript
// Chapter
CHAPTER_MOVE: 'chapter:move',
// AI
AI_SESSION_LIST: 'ai:sessionList',
AI_SESSION_CREATE: 'ai:sessionCreate',
AI_SESSION_UPDATE: 'ai:sessionUpdate',
AI_SESSION_DELETE: 'ai:sessionDelete',
AI_MESSAGE_LIST: 'ai:messageList',
AI_CHAT: 'ai:chat',
AI_ABORT: 'ai:abort',
AI_BUILD_CONTEXT: 'ai:buildContext',
AI_NAMING_CHECK: 'ai:namingCheck',
AI_STREAM_CHUNK: 'ai:streamChunk', // main → renderer event
AI_NETWORK_STATUS: 'ai:networkStatus', // main → renderer event
```
### 4.2 Handler 契约
| 通道 | 参数 | 返回 |
|------|------|------|
| `chapter:move` | `{ bookId, chapterId, volumeId, sortOrder }` | `Chapter` |
| `ai:sessionList` | `{ bookId, includeArchived? }` | `AiSession[]` |
| `ai:sessionCreate` | `{ bookId, title? }` | `AiSession` |
| `ai:sessionUpdate` | `{ bookId, sessionId, patch }` | `AiSession` |
| `ai:sessionDelete` | `{ bookId, sessionId }` | `void` |
| `ai:messageList` | `{ bookId, sessionId }` | `AiMessage[]` |
| `ai:chat` | `{ bookId, sessionId, content, slashCommand? }` | `{ messageId }` |
| `ai:abort` | `{ messageId }` | `void` |
| `ai:buildContext` | `{ bookId, binding }` | `{ preview: ContextPreviewItem[] }` |
| `ai:namingCheck` | `{ bookId }` | `NamingConflict[]` |
**`ai:chat` 流程**
1. 持久化 user message
2. `AiContextBuilder` 组装 messages 数组(system + 历史 + user
3. 调用 `AiClientService.chatStream(...)`
4. 流式推送 `ai:streamChunk`;完成后持久化 assistant message
---
## 5. P2.1 功能规格
### 5.1 章节拖拽排序
**UI**:左栏章节列表项 `draggable`;拖拽指示线(同卷内)。
**逻辑**
- 同卷:重算 `sort_order`0..n-1),批量 `chapter:move` 或单次 `chapter.repo.move`
- 跨卷:更新 `volume_id` + 目标卷末尾 `sort_order`
- 拖拽结束触发 `flushEditorSave()`(若当前章节被移动)
**Repository**
```typescript
move(chapterId: string, volumeId: string, sortOrder: number): Chapter
```
### 5.2 全局搜索替换
对齐 `design/readme.md` §5.6.1 底部替换区。
**UI**`SearchModal` 扩展):
- 折叠区「替换」:替换文本输入 + 「预览变更」+ 「执行替换」
- 预览调用 `search:replace(dryRun: true)` → 列表展示受影响条目
- 确认后 `search:replace(dryRun: false)` → toast 显示替换数量
- 替换后刷新 `useBookStore` 章节列表;若当前章被替换则刷新编辑器
**范围**:P2 已支持全书;本阶段补 UI,不扩展 scope select(可后续加)。
### 5.3 灵感快速捕获
**UI**`#inspirationModal`(对齐 `ui_pc.html`
- 标题、内容、标签(逗号分隔)
- 语音按钮:`webkitSpeechRecognition``zh-CN`),实时填入内容框
- 保存 → `inspiration:create` + 可选 `switchTarget`
- 不打断编辑器:关闭后焦点回 `.ProseMirror`
**快捷键**`Ctrl+Shift+I``captureInspiration` 已有 handler,改为打开模态而非直接 create)
### 5.4 `@提及` 反向跳转
`SettingMention` Mark 增加 `onClick` / `editorProps` 处理:
- 点击带 `data-setting-mention` 的 span → `switchTarget({ kind: 'setting', id })`
- 光标样式 `pointer`
### 5.5 引用面板钉住
`useReferenceStore` 扩展:
```typescript
pinned: boolean
setPinned(open: boolean): void
```
- 钉住按钮(📌)在 `ref-header`
- `pinned === true``setOpen(false)` 不隐藏面板
- 与 AI 右栏互斥:引用打开时仍隐藏 RightPanel(延续 P2);钉住引用 + 需 AI 时用户手动关闭引用
---
## 6. P3 功能规格
### 6.1 AI 面板 UI`AiPanel.tsx`
对齐 `ui_pc.html` `#ai-chat`
| 区域 | 行为 |
|------|------|
| 会话栏 | `<select>` + 新建按钮;重命名(双击 inline);右键归档/删除 |
| 模式 Tab | 对话 **active**;交互/自动/向导 → `feature.comingSoon` |
| 上下文预览行 | 显示摘要计数;点击打开 `ContextEditorModal` |
| 离线横幅 | `aiStore.disabled` 时显示 `#offlineBanner` |
| 消息区 | user/assistant 气泡;assistant 流式追加 |
| 快捷命令 | `/续写` `/扩写` `/润色` `/总结` `/纠错` chip 点击填入 |
| 输入区 | Enter 发送;生成中禁用 + 「停止」调 `ai:abort` |
| 插入编辑器 | assistant 消息旁「插入」→ 纯文本插入章节光标(触发 AI 快照) |
### 6.2 上下文注入(`AiContextBuilder`
**源数据**
| 类型 | 提取内容 | 单条上限 |
|------|----------|----------|
| chapter | `title` + `stripHtml(content)` 截断 | 2000 字符 |
| outline | `title` + `description` 截断 | 500 字符 |
| setting | `name` + `description` 截断 | 500 字符 |
| inspiration | `title` + `content` 截断 | 500 字符 |
**总量上限**~16KB plain text;超出时按 sort_order 新近优先截断。
**System prompt 模板**i18n):
```
你是笔临 AI 写作助手。作者笔名:{penName}。书籍:{bookName}。
以下是与本书相关的上下文(由作者勾选):
{contextBlocks}
请用简体中文回答,尊重作者的创作主权。
```
**「更新上下文」**:重新 `ai:buildContext` 并更新 `context_json` 勾选不变;仅刷新预览摘要。
### 6.3 快捷命令
| 命令 | 预设意图 |
|------|----------|
| `/续写` | 基于当前章节末尾续写 200–400 字 |
| `/扩写` | 扩展选中或最近一段描写 |
| `/润色` | 改善文笔,保持情节不变 |
| `/总结` | 总结当前章节要点 |
| `/纠错` | 指出语法/标点问题 |
实现:解析以 `/` 开头的输入 → 映射 i18n 模板 → 作为 user message 发送(UI 仍显示原命令)。
### 6.4 离线降级(§6.8
**主进程 `NetworkMonitor`**
- 监听 `net.isOnline()` + 定时探测(可选 `HEAD``https://1.1.1.1`
- 状态变化 → `AI_NETWORK_STATUS`
**渲染进程规则**
```typescript
const disabled =
settings.aiConfig.backend !== 'lmstudio' && !networkOnline
```
- `disabled` 时:输入框 `disabled`,快捷命令灰显,横幅可见
- `lmstudio` 后端:**永不**因离线禁用
- 网络恢复:toast `ai.networkRestored`
### 6.5 命名一致性检查(§5.6.2)
**入口**:知识库 Tab 顶部按钮「命名一致性检查」
**流程**
1. 收集 `settings[].name` + 全书章节 plainText
2. 构造 prompt,要求模型返回 **纯 JSON 数组** `NamingConflict[]`
3. `NamingCheckModal` 列表:termA / termB / 置信度 / 原因
4. 操作:忽略(加入 `namingIgnoreWords`)、替换为 canonical(调 `search:replace`
5. 解析失败:toast 错误,展示原始响应片段(调试用)
### 6.6 AI 快照(§5.4.1
触发点:
- `AiPanel`「插入到编辑器」
- 任意将 AI 生成内容写入 `chapters.content` 的路径
行为:`snapshot:create(chapterId, 'ai', 'AI 生成前', currentContent)` → 再写入新内容。
### 6.7 AI 设置页
**位置**:系统设置 → 新 Tab「AI 配置」
| 字段 | 说明 |
|------|------|
| 后端类型 | select: LM Studio / OpenAI / Anthropic |
| 服务地址 | textLM Studio 默认 `http://127.0.0.1:1234` |
| 模型 | text(默认 `gemma-4-e4b-it` |
| API Key | password(云端后端时显示) |
| 超时 | number,秒 |
**§6.9 许可提示**:选择云端后端时显示服务条款链接文案。
**测试连接**按钮:发送最小 chat 请求,toast 成功/失败。
---
## 7. 测试
### 7.1 原则
- **禁止 Mock** `fetch` / AI 响应
- **禁止** `test.skipIf(!aiAvailable)` 或类似条件跳过
- 集成/E2E 必须对 `http://127.0.0.1:1234` 发起真实请求
- 服务未启动时测试 **失败**,迫使环境就绪
### 7.2 单元 / 集成
| 文件 | 场景 |
|------|------|
| `tests/main/migrate-v3.test.ts` | v2→v3 迁移 |
| `tests/main/ai-session.repo.test.ts` | 会话/消息 CRUD |
| `tests/main/chapter.repo.test.ts` | `move` 排序 |
| `tests/main/ai-client.test.ts` | 真实 LM Studio 请求,响应非空 |
| `tests/main/ai-context-builder.test.ts` | 上下文截断与拼接 |
| `tests/main/naming-check.test.ts` | JSON 解析(mock 仅解析器,不 mock HTTP |
### 7.3 E2E
| ID | 场景 |
|----|------|
| E2E-P21-DRAG-01 | 拖拽章节改变顺序,重启后保持 |
| E2E-P21-REP-01 | 搜索 → 预览替换 → 执行 → 正文更新 |
| E2E-P21-INSP-01 | Ctrl+Shift+I 模态保存灵感 |
| E2E-P3-CHAT-01 | 新建会话 → 发送「你好」→ assistant 非空 |
| E2E-P3-CTX-01 | 勾选设定 → 更新上下文 → 摘要行更新 |
| E2E-P3-CMD-01 | 点击 `/总结` → 发送 → 收到回复 |
| E2E-P3-NAME-01 | 命名检查模态出现 ≥1 行(需种子数据) |
| E2E-P3-OFF-01 | 配置 openai + 模拟离线 → 输入禁用(可用 evaluate 设 `aiStore` |
### 7.4 手动验收
1. LM Studio 运行 `gemma-4-e4b-it`,笔临设置页「测试连接」成功
2. 右栏多轮对话,重启后会话与消息保留
3. 上下文勾选后 AI 回答能引用设定名称
4. 命名检查对「林远/林悦」类冲突给出建议
---
## 8. UI / CSS
- 新增/扩展 `layout.css``.ai-session-bar``.ai-mode-tabs``.chat-messages``.chat-msg``.quick-cmds``.offline-banner``.naming-modal`
- 对齐 `design/ui_pc.html` 行 12032076 结构与 class 命名
- 灵感模态对齐 `#inspirationModal`
- i18n`ai.*``naming.*``inspiration.voice*` 等键
---
## 9. 风险与缓解
| 风险 | 缓解 |
|------|------|
| LM Studio 未启动导致 CI 失败 | 文档明确前置条件;本地开发脚本检测端口 |
| 流式 IPC 消息乱序 | `messageId` 关联;渲染进程按 id 缓冲 |
| 大上下文超 token | 硬限制 16KB + 截断策略 |
| 命名检查 JSON 解析失败 | 重试一次;失败 toast + 日志 |
| Web Speech API 不可用 | 语音按钮 disabled + tooltipE2E 不测语音 |
| `gemma-4-e4b-it` 模型名变更 | 环境变量 `BILIN_AI_MODEL` 覆盖 |
---
## 10. Definition of Donev0.3.0
- [ ] P2.1ac + P3.03 全部里程碑完成
- [ ] §7 测试全部 PASS(含真实 LM Studio 集成测试)
- [ ] `npm run build` 无 error
- [ ] 交互/自动/向导 Tab 为占位,不 crash
- [ ] Windows 手动验收 §7.4 四项通过
---
## Spec Self-Review
| 检查项 | 结果 |
|--------|------|
| Placeholder 扫描 | 交互/自动/向导/知识库列表明确 P4;无 TBD |
| 内部一致性 | AI 主进程直连贯穿 IPC/测试;context_json 与 AiContextBinding 对齐 |
| 范围 | 单规格可支撑一份 implementation planP2.1 与 P3 边界清晰 |
| 歧义 | P3-A 已确认;LM Studio 默认值与环境变量覆盖已写明 |
| 测试 | 禁止 Mock/skip 已作为硬性原则写入 §7.1 |
---
## 附录:与 P2 规格差异
| 项 | P2 规格 | 本规格 |
|----|---------|--------|
| 搜索替换 UI | P2.6 规划 | P2.1b 实装 |
| 灵感语音 | P2.1 占位 | P2.1c 实装 |
| 章节拖拽 | P2.1 | P2.1a 实装 |
| AI 命名检查 | P3 占位 | P3.3 实装 |
| ai 快照 | P3 | P3.3 实装 |
| ai_sessions 表 | P3/P5/P6 | P3.0 实装(每书 DB |
| 写作模式 | — | P3 UI 占位,P4 实装 |