Compare commits

..

34 Commits

Author SHA1 Message Date
bing 958bb18539 fix(ui): make chapter bridge manual-only and add release gate
Remove auto chapter-bridge prompt on book/chapter open so users trigger it from the cockpit. Fix AiPanel duplicate import that broke dev builds, and add release smoke tests plus test:release/test:ci scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 19:09:18 +08:00
bing 8e5045b882 fix(test): stabilize E2E cockpit flow and add LM Studio novel integration test
Harden cockpit dismiss timing and global settings navigation in E2E helpers, tune short-chapter AI prompts for local models, fix electron-updater CJS loading, and add a 10-chapter auto-writing integration test against LM Studio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:03:19 +08:00
bing fe421ffc55 feat(w5): ship v2.0.0 platform sync, undo persistence, and updates
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 18:24:51 +08:00
bing cb6b4c3731 feat(w4): ship v1.5.0 timeline, character arc, and compliance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 17:40:42 +08:00
bing c5c0b7329b feat(w3): ship v1.4.0 export matrix, cockpit tasks, and focus ambient
Add ExportMatrixService for txt/md/docx/pdf, extend ExportModal, cockpit outline tasks, focus ambient sound, and E2E-PDF-01 with 134 passing unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:56:53 +08:00
bing c10be87d18 feat(w3): add continuous read mode and Wave 3 plan
Introduce ReadModeOverlay with device width and font theme persistence, sidebar and volume menu entry, plus E2E-READ-01 through 03.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:38:54 +08:00
bing fe6e411d2c feat(w2): ship v1.3.0 project pack UI, preload, and E2E
Wire .novel/.novel-all export-import through modals, settings backup, and home bulk export with progress IPC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:31:16 +08:00
bing a4412793f4 feat(w2): add PackService for .novel and .novel-all export/import
Introduce pack-bootstrap chunk, ZipArchive-based PackService with progress IPC,
book registry import helpers, and IT-PACK-01 / IT-ALL-02 integration tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:36:47 +08:00
bing 9f2b1ae3e0 docs: add Wave 2 project pack implementation plan for v1.3.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:25:19 +08:00
bing fe127ec3ed feat: ship v1.2.0 Wave 1 foundation with book settings, chapter management, and focus mode
Deliver schema v9, book/chapter IPC extensions, BookSettingsTab, chapter meta/tags,
AI session menu, focus mode and TTS, template context enrich, and Wave 1 E2E coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:22:58 +08:00
bing 64da95f7e1 docs: add Wave 1 foundation implementation plan for v1.2.0
Covers schema v9, book settings, volume/chapter management UI, template context, AI session menu, focus mode, and TTS per master completion spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:23:19 +08:00
bing cc97c66d6f docs: add master completion design spec for full design/readme alignment
Define five-wave Master Program (v1.2 through v2.0) covering foundation UI, project packs, export/read modes, creative depth features, and platform infrastructure per brainstorming option A.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:18:01 +08:00
bing adf877861d feat: ship v1.1.0 writer toolkit with templates, import, export, and inspiration capture
Implement chapter templates, txt/md/docx import, submission export presets, and global inspiration shortcut. Split import handlers into a separate main bundle and fix EditorLayout setTarget loop that broke E2E.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 13:53:22 +08:00
bing ea4819847f docs: add v1.1 writer toolkit implementation plan
Break down templates, import, submission export, and inspiration capture into twelve tracked tasks for the daily writing workflow release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 11:07:49 +08:00
bing 027d7c7559 docs: add v1.1 writer toolkit design spec for templates, import, and export
Capture chapter templates, txt/md/docx import, submission presets, and global inspiration capture for the daily writing workflow release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 11:05:06 +08:00
bing 4adafa1936 feat: ship v1.0.0 with character graph and writing analytics
Add P7 Cytoscape relationship graph with setting CRUD, P9 cockpit analytics driven by writing_sessions, chapter POV selection, and full test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 10:48:31 +08:00
bing 2c9b034a29 docs: add P7 character graph and P9 writing analytics implementation plan for v1.0.0
Break down writing_sessions telemetry, Cytoscape graph UI, and cockpit analytics into twelve implementation tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 10:07:51 +08:00
bing e64353f608 docs: add P7 character graph and P9 writing analytics design spec for v1.0.0
Define Cytoscape relationship graph MVP, writing_sessions telemetry, and cockpit analytics cards for the v1.0.0 release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 10:04:56 +08:00
bing d4122c8f95 feat: ship v0.9.0 with pomodoro, achievements, and injection history
Add pomodoro timer with goal notifications, writing streak milestones, and knowledge injection logging with UI previews across AI writing flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 19:15:52 +08:00
bing 255f1a99a0 docs: add P5.3 and P6.1 goals and injection history implementation plan for v0.9.0
Break down pomodoro, milestones, notifications, and knowledge injection logging into 11 tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 18:53:49 +08:00
bing 1099cb2b0e docs: add P5.3 and P6.1 goals and injection history design spec for v0.9.0
Define pomodoro, milestone badges, goal notifications, and knowledge injection logging for the next release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 18:51:45 +08:00
bing a39e06ff07 feat: ship v0.8.0 with writing logs and AI knowledge extraction
Deliver P5.2 writing day logs, cockpit heatmap, streak tracking, and chapter knowledge auto-extraction with merge review.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 18:26:29 +08:00
bing f331ddae86 docs: add P5.2 writing logs and AI extraction implementation plan for v0.8.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 17:34:14 +08:00
bing 72c8e13210 docs: add P5.2 writing logs and AI knowledge extraction design spec for v0.8.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 16:42:04 +08:00
bing aa2c7dfed3 feat: ship v0.7.0 with AI knowledge context integration
Add semi-automatic knowledge suggestions and user-confirmed injection across chat, interactive, auto, and wizard modes; fix writing flows to persist full systemPrompt in contextJson.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 15:33:53 +08:00
bing 2afbb83c43 docs: add P6 AI knowledge context implementation plan for v0.7.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 14:24:19 +08:00
bing b5ae04af18 docs: add P6 AI knowledge context integration design spec for v0.7.0
Define semi-auto knowledge retrieval, unified AiContextBuilder injection across all four AI modes, and systemPrompt fix for writing flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 14:19:56 +08:00
bing b33d2e7b34 feat: ship v0.6.0 with cockpit, knowledge base, and chapter bridge
Deliver P5 writer workflow: writing cockpit, manual knowledge CRUD with review, chapter bridge with optional AI, publish status, and stock buffer reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 23:03:11 +08:00
bing 78f046890d docs: add P5 workflow and knowledge MVP implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 22:36:40 +08:00
bing a02a665b0f docs: add P5 workflow and knowledge MVP design spec for v0.6.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 22:34:40 +08:00
bing 5d3e264cf3 test: complete P4.1 LM Studio coverage and fix auto/wizard handoff
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 21:45:18 +08:00
bing 6a949b54ba feat: ship v0.5.0 with auto and wizard writing modes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 20:57:31 +08:00
bing 86b66a311a docs: add P4.1 auto and wizard writing implementation plan for v0.5.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 20:47:50 +08:00
bing ab33213f99 docs: add P4.1 auto and wizard writing design spec for v0.5.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 20:43:53 +08:00
304 changed files with 33216 additions and 530 deletions
+2
View File
@@ -11,6 +11,8 @@ logs/
.DS_Store
Thumbs.db
release/
!tests/release/
!tests/release/**
*.novel
*.novel-all
test-results/
+64 -4
View File
@@ -1,9 +1,63 @@
# 笔临 (Bilin)
长篇创作智能协作平台 — Electron 桌面客户端 v0.4.0P4 交互式写作
长篇创作智能协作平台 — Electron 桌面客户端 v2.0.0(平台基建
## 功能概览(v0.4.0
## 功能概览(v2.0.0
- 撤销堆栈持久化:每章最多 50 条,跨会话 Ctrl+Z
- 云同步:加密 `.novel-all` → WebDAV / 本地目录
- 崩溃日志:`electron-log` + 异常退出提示
- 自动更新:`electron-updater`E2E 模式禁用)
- 扩展 API 骨架、内置 Skill / MCP 占位
## 功能概览(v1.5.0
- 故事时间线:章节/手动事件聚合、角色出生年冲突检测
- 角色弧线:设定 Tab 聚合知识库节点
- 敏感词合规:本地词库 + 编辑器波浪下划线
- 引用详情抽屉、章节悬停预览、自定义 AI 快捷命令
## 功能概览(v1.4.0
- 导出矩阵:章/卷/全书 → txt、md、docx(含目录)、pdfA4
- 连续阅读模式:设备宽度、字体主题、虚拟 3 章翻页
- 驾驶舱今日大纲任务、专注模式环境音效
## 功能概览(v1.3.0
- `.novel` 单书项目包导出/导入(含数据库、元数据、附件)
- `.novel-all` 批量导出/导入全部书籍与全局设置
- 主页「导出所有书籍」、设置页备份区、导出模态项目包选项
- 大文件导出确认与进度推送
## 功能概览(v1.2.0
- 书籍设置:书名/分类/目标字数/简介/封面、驾驶舱偏好、删书
- 分卷右键管理、章节双击改名/删除、章节元数据与标签
- 模板变量自动填充(含大纲项与角色列表)
- AI 会话重命名/归档/导出/删除
- 专注模式快捷键、TTS 朗读、顶栏日更进度条
## 功能概览(v1.1.0
- 章节模板与快速新建章(§10
- 导入 txt / md / docx 并自动分章(§11.1
- 投稿预设与单章导出(复制剪贴板 / 存 txt)(§20.2)
- 全局灵感捕获增强:任意界面快捷键、未开书可选书、保存仅 toast(§20.6)
## 功能概览(v1.0.0
- 角色关系图谱:Cytoscape 可视化、设定面板关系 CRUD、三种布局(P7)
- 个人写作分析:今日字速、时段热力、POV/卷分布、近 7 日趋势(P9)
- 章节视角(POV)选择与驾驶舱内嵌分析区
- AI 章节知识自动抽取、合并审核、高置信度批量采纳(P5.2)
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5)
- 章间衔接:上下章片段 + 可选 AI 建议(P5)
- 更新计划:发布状态、存稿阈值提醒(P5)
- 自动写作:章节目标、场景规划、连续生成、暂停转交互(P4.1)
- 向导式写作:5 步引导、策略预览、生成后交互微调(P4.1)
- 交互式写作:剧情走向建议、场景生成、命名暂停、微调确认、成章落盘(P4)
- 章节拖拽、全局搜索替换、灵感捕获、`@` 跳转、引用钉住(P2.1
- AI 对话助手:会话持久化、流式输出、上下文编辑、快捷命令(P3)
@@ -22,14 +76,20 @@ npm run dev
```bash
npm run build
npm run test # 单元 / 集成测试
npm run test:e2e # E2E 测试(需先 build
npm run test:release # 发布前快速门禁:build + 导入/产物检查 + 启动冒烟 E2E
npm run test # 全量单元/集成(含 LM Studio 十章小说 IT,耗时长
npm run test:ci # 完整 CIbuild + 全量 UT/IT + 全量 E2E
npm run test:e2e # 仅 E2E(需先 build
```
**AI 相关测试前置条件**:本地 [LM Studio](http://127.0.0.1:1234) 需运行,模型默认 `gemma-4-e4b-it`(可通过环境变量 `BILIN_AI_MODEL` / `BILIN_AI_BASE_URL` 覆盖)。
## 文档
- P5 作家工作流 + 知识库设计规格:`docs/superpowers/specs/2026-07-06-bilin-p5-workflow-knowledge-design.md`
- P5 实现计划:`docs/superpowers/plans/2026-07-06-bilin-p5-workflow-knowledge.md`
- P4.1 自动/向导写作设计规格:`docs/superpowers/specs/2026-07-09-bilin-p41-auto-wizard-design.md`
- P4.1 实现计划:`docs/superpowers/plans/2026-07-09-bilin-p41-auto-wizard.md`
- P4 交互式写作设计规格:`docs/superpowers/specs/2026-07-08-bilin-p4-interactive-design.md`
- P4 实现计划:`docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md`
- P2.1 + P3 设计规格:`docs/superpowers/specs/2026-07-07-bilin-p21-p3-design.md`
+47
View File
@@ -0,0 +1,47 @@
# 笔临扩展开发指南
笔临 v2.0.0 提供 **扩展 API 骨架**,用于声明第三方扩展点。当前版本**不加载或执行**第三方代码,仅校验 `manifest` 并展示占位列表。
## BilinExtension 清单
在扩展包的 `bilin-extension.json` 中声明:
```json
{
"name": "my-extension",
"version": "1.0.0",
"description": "示例扩展",
"main": "index.js",
"permissions": ["filesystem.read"],
"contributes": {
"importers": [{ "id": "myfmt", "extensions": [".mybook"], "label": "My Format" }],
"exporters": [{ "id": "myexport", "extensions": [".mybook"], "label": "Export My Format" }]
}
}
```
## 扩展点
| 扩展点 | 说明 |
|--------|------|
| `sidePanels` | 侧栏自定义面板 |
| `importers` | 自定义导入格式 |
| `exporters` | 自定义导出格式 |
| `aiBackends` | 可选 AI 后端 |
| `skills` | AI Skill |
| `editorExtensions` | TipTap 节点/标记 |
| `menuItems` | 工具栏/右键菜单 |
## 安全
- 扩展默认沙箱运行,需声明 `permissions`
- 敏感权限需用户在设置中显式授权(后续版本)
- 扩展仅能通过笔临提供的 API 与核心交互
## 开发者模式
设置 → 扩展 → 开启「开发者模式」后,可从本地文件夹读取 manifest 做校验(不执行 `main`)。
## 相关类型
`src/shared/extension-api.ts``src/main/services/extension-registry.ts`
@@ -0,0 +1,818 @@
# 笔临 P5 作家工作流 + 知识库 MVP 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.6.0:驾驶舱 + 存稿/更新计划 + 章间衔接(可选 AI)+ 知识库 MVP(手动 CRUD / 审核 / 伏笔追踪)。
**Architecture:** schema v6 新增 `knowledge_entries` / `book_preferences` / `chapter_bridge_dismiss``KnowledgeService` 负责 CRUD 与遗忘判定;`CockpitService` 只读聚合字数与存稿;`ChapterBridgeService` 提取相邻章片段 + 可选 `AiClientService`;渲染层 `CockpitModal` / `ChapterBridgeModal` / 重写 `KnowledgePanel`。测试 **禁止 Mock** AI 集成/E2E 打真实 LM Studio。
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、Jotai、i18next、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-06-bilin-p5-workflow-knowledge-design.md`
**LM Studio 默认:** `baseUrl=http://127.0.0.1:1234``model=gemma-4-e4b-it`
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v6.sql` | v6 DDL |
| `src/main/db/migrate.ts` | v5→v6 |
| `src/main/db/repositories/knowledge.repo.ts` | knowledge_entries CRUD |
| `src/main/db/repositories/book-prefs.repo.ts` | book_preferences KV |
| `src/main/db/repositories/bridge-dismiss.repo.ts` | chapter_bridge_dismiss |
| `src/main/services/knowledge.service.ts` | 审核、遗忘判定、bookmark 同步 |
| `src/main/services/cockpit.service.ts` | 驾驶舱聚合 |
| `src/main/services/chapter-bridge.service.ts` | 衔接文本 + AI |
| `src/main/db/repositories/chapter.repo.ts` | publish_status 读写 |
| `src/main/ipc/handlers/knowledge.handler.ts` | knowledge IPC |
| `src/main/ipc/handlers/cockpit.handler.ts` | cockpit IPC |
| `src/main/ipc/handlers/bridge.handler.ts` | bridge IPC |
| `src/main/ipc/register.ts` | 注册 handler |
| `src/shared/types.ts` | KnowledgeEntry / CockpitSummary 等 |
| `src/shared/ipc-channels.ts` | 新通道 |
| `src/preload/index.ts` | knowledge / cockpit / bridge API |
| `src/shared/electron-api.d.ts` | 类型 |
| `src/renderer/stores/useKnowledgeStore.ts` | 知识库状态 |
| `src/renderer/stores/useCockpitStore.ts` | 驾驶舱状态 |
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 重写 |
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 编辑对话框 |
| `src/renderer/components/cockpit/CockpitModal.tsx` | 驾驶舱 UI |
| `src/renderer/components/bridge/ChapterBridgeModal.tsx` | 衔接 UI |
| `src/renderer/components/layout/TopBar.tsx` | 驾驶舱按钮 |
| `src/renderer/components/layout/EditorLayout.tsx` | 发布状态、bridge 触发、statusbar |
| `src/renderer/components/settings/SettingsPage.tsx` | updateSchedule |
| `src/main/services/global-settings.ts` | 默认值扩展 |
| `public/locales/zh-CN/translation.json` | 文案 |
| `public/locales/en/translation.json` | 文案 |
| `tests/main/migrate-v6.test.ts` | UT-MIG-06 |
| `tests/main/knowledge.service.test.ts` | UT-KNOW-* |
| `tests/main/cockpit.service.test.ts` | UT-COCK-01 |
| `tests/main/chapter-bridge.test.ts` | UT-BRIDGE-01 / IT-BRIDGE-01 |
| `e2e/cockpit-knowledge.spec.ts` | E2E-COCK-* / KNOW-* / BRIDGE-* / STOCK-* |
---
## Task 1: Schema v6 + 类型 + IPC 通道
**Files:**
- Create: `src/main/db/schema-v6.sql`
- Modify: `src/main/db/migrate.ts`
- Modify: `src/shared/types.ts`
- Modify: `src/shared/ipc-channels.ts`
- Create: `tests/main/migrate-v6.test.ts`
- Modify: `tests/main/migrate-v5.test.ts`(期望 version **6**
- [ ] **Step 1: 写迁移失败测试**
`tests/main/migrate-v6.test.ts`
```typescript
import { describe, it, expect, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import type { SqliteDb } from '../../src/main/db/types'
describe('migrate v6', () => {
let db: SqliteDb
afterEach(() => db?.close())
it('UT-MIG-06: applies v6 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(6)
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[]
expect(tables.some((t) => t.name === 'knowledge_entries')).toBe(true)
expect(tables.some((t) => t.name === 'book_preferences')).toBe(true)
expect(tables.some((t) => t.name === 'chapter_bridge_dismiss')).toBe(true)
})
})
```
- [ ] **Step 2: 运行确认 FAIL**
```bash
npm run test -- tests/main/migrate-v6.test.ts
```
- [ ] **Step 3: 实现 schema v6**
`src/main/db/schema-v6.sql`(与 spec §3.1 一致)。
`src/main/db/migrate.ts`
```typescript
import schemaV6 from './schema-v6.sql?raw'
const CURRENT_VERSION = 6
// ...
if (current < 6) {
db.exec(schemaV6)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(6, 'P5 knowledge/cockpit schema')
}
```
`src/shared/types.ts` 新增:
```typescript
export type KnowledgeType = 'character' | 'foreshadow' | 'location' | 'relationship' | 'fact'
export type KnowledgeStatus = 'pending' | 'approved' | 'rejected'
export type ForeshadowStatus = 'buried' | 'partial' | 'resolved'
export type UpdateSchedule = 'daily' | 'alternate' | 'weekly' | 'none'
export interface KnowledgeEntry {
id: string
type: KnowledgeType
title: string
content: string
importance: number
status: KnowledgeStatus
foreshadowStatus?: ForeshadowStatus
sourceChapterId?: string
lastMentionChapterId?: string
linkedBookmarkId?: string
createdAt: string
updatedAt: string
}
export interface CreateKnowledgeInput {
type: KnowledgeType
title: string
content?: string
importance?: number
status?: KnowledgeStatus
foreshadowStatus?: ForeshadowStatus
sourceChapterId?: string
lastMentionChapterId?: string
linkedBookmarkId?: string
}
export interface CockpitSummary {
totalWords: number
volumeWords: number
stockReadyCount: number
stockThreshold: number
foreshadowBuried: number
foreshadowResolved: number
foreshadowForgotten: number
}
export interface ChapterBridgeResult {
previousChapterId: string | null
previousTail: string
currentHead: string
aiSuggestion?: string
}
```
扩展 `GlobalSettings`
```typescript
updateSchedule: UpdateSchedule // default 'none'
stockBufferThreshold: number // default 3
```
扩展 `Chapter`
```typescript
publishStatus?: PublishStatus
```
`src/shared/ipc-channels.ts` 追加 spec §4.1 全部通道。
- [ ] **Step 4: 更新 migrate-v5 期望 version 6;运行 PASS**
```bash
npm run test -- tests/main/migrate-v5.test.ts tests/main/migrate-v6.test.ts
```
- [ ] **Step 5: Commit**
```bash
git add src/main/db/schema-v6.sql src/main/db/migrate.ts src/shared/types.ts src/shared/ipc-channels.ts tests/main/migrate-v6.test.ts tests/main/migrate-v5.test.ts
git commit -m "feat: add schema v6 for knowledge and cockpit workflow"
```
---
## Task 2: Repository 层
**Files:**
- Create: `src/main/db/repositories/knowledge.repo.ts`
- Create: `src/main/db/repositories/book-prefs.repo.ts`
- Create: `src/main/db/repositories/bridge-dismiss.repo.ts`
- Modify: `src/main/db/repositories/chapter.repo.ts`
- Create: `tests/main/knowledge.repo.test.ts`
- [ ] **Step 1: UT-KNOW-01 失败测试**
```typescript
it('UT-KNOW-01: CRUD and approve updates status', () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const repo = new KnowledgeRepository(db)
const entry = repo.create({ type: 'foreshadow', title: '测灵石异常', foreshadowStatus: 'buried' })
expect(entry.status).toBe('pending')
const approved = repo.update(entry.id, { status: 'approved' })
expect(approved.status).toBe('approved')
db.close()
})
```
- [ ] **Step 2: 实现 KnowledgeRepository**
```typescript
export class KnowledgeRepository {
create(input: CreateKnowledgeInput): KnowledgeEntry { /* INSERT */ }
get(id: string): KnowledgeEntry | null
list(filter?: { status?: KnowledgeStatus; type?: KnowledgeType }): KnowledgeEntry[]
update(id: string, patch: Partial<...>): KnowledgeEntry
delete(id: string): void
countByStatus(status: KnowledgeStatus): number
}
```
`mapRow` 蛇形 ↔ 驼峰映射。
- [ ] **Step 3: BookPrefsRepository + BridgeDismissRepository**
```typescript
// book-prefs.repo.ts
get(key: string): string | null
set(key: string, value: string): void
// bridge-dismiss.repo.ts
isDismissed(chapterId: string): boolean
dismiss(chapterId: string): void
```
- [ ] **Step 4: ChapterRepository 扩展 publish_status**
`mapRow` 增加 `publishStatus``update` patch 支持 `publishStatus`;新增:
```typescript
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter
countByPublishStatus(status: PublishStatus): number
getPreviousChapter(chapterId: string): Chapter | null // 按 sort_order 找上一章
```
- [ ] **Step 5: 运行 PASS + Commit**
```bash
npm run test -- tests/main/knowledge.repo.test.ts
git commit -m "feat: add knowledge and bridge repositories with publish status"
```
---
## Task 3: KnowledgeService
**Files:**
- Create: `src/main/services/knowledge.service.ts`
- Create: `tests/main/knowledge.service.test.ts`
- [ ] **Step 1: UT-KNOW-02 遗忘判定测试**
```typescript
it('UT-KNOW-02: forgotten when sort_order gap >= 20', () => {
// 创建 25 章,在第 1 章埋 foreshadow approved buried
// latestOrder - mentionOrder >= 20 → forgotten count 1
})
```
- [ ] **Step 2: UT-KNOW-03 bookmark 同步测试**
```typescript
it('UT-KNOW-03: createFromBookmark creates pending entry', () => {
const bm = bookmarkRepo.create(chapterId, 0, '测灵石发热', 'foreshadow')
const entry = service.createFromBookmark(bm.id)
expect(entry.status).toBe('pending')
expect(entry.linkedBookmarkId).toBe(bm.id)
})
```
- [ ] **Step 3: 实现 KnowledgeService**
```typescript
const FORGOTTEN_GAP = 20
export class KnowledgeService {
list(filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean })
create(input: CreateKnowledgeInput): KnowledgeEntry
update(id: string, patch: Partial<CreateKnowledgeInput>): KnowledgeEntry
delete(id: string): void
approve(id: string): KnowledgeEntry
reject(id: string): KnowledgeEntry
batchApprove(ids: string[]): number
createFromBookmark(bookmarkId: string): KnowledgeEntry
getForeshadowStats(): { buried: number; resolved: number; forgotten: number }
countPending(): number
private isForgotten(entry: KnowledgeEntry): boolean {
if (entry.type !== 'foreshadow' || entry.status !== 'approved' || entry.foreshadowStatus !== 'buried') return false
const mentionId = entry.lastMentionChapterId ?? entry.sourceChapterId
if (!mentionId) return false
const mentionOrder = chapterRepo.get(mentionId)?.sortOrder ?? 0
const latestOrder = Math.max(...chapterRepo.list().map((c) => c.sortOrder), 0)
return latestOrder - mentionOrder >= FORGOTTEN_GAP
}
}
```
- [ ] **Step 4: IT-KNOW-04 批量采纳**
```typescript
it('IT-KNOW-04: batchApprove clears pending', () => {
// create 3 pending → batchApprove → countPending === 0
})
```
- [ ] **Step 5: 运行 PASS + Commit**
```bash
npm run test -- tests/main/knowledge.service.test.ts
git commit -m "feat: add KnowledgeService with foreshadow forgotten logic"
```
---
## Task 4: CockpitService + GlobalSettings 扩展
**Files:**
- Create: `src/main/services/cockpit.service.ts`
- Modify: `src/main/services/global-settings.ts`
- Create: `tests/main/cockpit.service.test.ts`
- [ ] **Step 1: UT-COCK-01 测试**
```typescript
it('UT-COCK-01: getSummary counts words and ready stock', () => {
volRepo.create('卷一', 0)
chapterRepo.create(volId, 'A', 0, '<p>hello world</p>')
chapterRepo.setPublishStatus(chId, 'ready')
const summary = service.getSummary(activeVolumeId)
expect(summary.totalWords).toBeGreaterThan(0)
expect(summary.stockReadyCount).toBe(1)
})
```
- [ ] **Step 2: 实现 CockpitService**
```typescript
export class CockpitService {
constructor(
private db: SqliteDb,
private settings: GlobalSettingsService
) {}
getSummary(activeVolumeId?: string): CockpitSummary {
const chapters = new ChapterRepository(this.db).list()
const totalWords = chapters.reduce((s, c) => s + stripHtml(c.content).length, 0)
const volumeWords = activeVolumeId
? chapters.filter((c) => c.volumeId === activeVolumeId).reduce(...)
: totalWords
const stockReadyCount = new ChapterRepository(this.db).countByPublishStatus('ready')
const stats = new KnowledgeService(this.db).getForeshadowStats()
const { stockBufferThreshold } = this.settings.get()
return { totalWords, volumeWords, stockReadyCount, stockThreshold: stockBufferThreshold, ...stats }
}
shouldShowOnOpen(): boolean {
return new BookPrefsRepository(this.db).get('cockpitSeen') !== 'true'
}
markSeen(): void {
new BookPrefsRepository(this.db).set('cockpitSeen', 'true')
}
}
```
- [ ] **Step 3: global-settings defaults**
```typescript
updateSchedule: 'none',
stockBufferThreshold: 3,
```
`get()` merge 时补默认值。
- [ ] **Step 4: 运行 PASS + Commit**
```bash
npm run test -- tests/main/cockpit.service.test.ts
git commit -m "feat: add CockpitService and extend global settings"
```
---
## Task 5: ChapterBridgeService
**Files:**
- Create: `src/main/services/chapter-bridge.service.ts`
- Create: `tests/main/chapter-bridge.test.ts`
- [ ] **Step 1: UT-BRIDGE-01 文本提取测试**
```typescript
it('UT-BRIDGE-01: extracts tail and head', () => {
const prev = chapterRepo.create(volId, '上一章', 0, '<p>' + '甲'.repeat(400) + '</p>')
const cur = chapterRepo.create(volId, '本章', 1, '<p>开头内容</p>')
const result = service.getBridgeSync(cur.id) // 无 AI 同步方法
expect(result.previousTail.length).toBeLessThanOrEqual(300)
expect(result.previousTail.length).toBeGreaterThan(50)
expect(result.currentHead).toContain('开头')
})
```
- [ ] **Step 2: 实现 ChapterBridgeService**
```typescript
export class ChapterBridgeService {
shouldPrompt(chapterId: string): boolean {
if (dismissRepo.isDismissed(chapterId)) return false
const ch = chapterRepo.get(chapterId)
if (!ch || stripHtml(ch.content).length >= 200) return false
return chapterRepo.getPreviousChapter(chapterId) !== null
}
extract(chapterId: string): ChapterBridgeResult {
const prev = chapterRepo.getPreviousChapter(chapterId)
const cur = chapterRepo.get(chapterId)!
const prevText = prev ? stripHtml(prev.content) : ''
const curText = stripHtml(cur.content)
return {
previousChapterId: prev?.id ?? null,
previousTail: prevText.slice(-300),
currentHead: curText.slice(0, 200)
}
}
async getBridge(chapterId: string, withAi: boolean): Promise<ChapterBridgeResult> {
const base = this.extract(chapterId)
if (!withAi || !base.previousTail) return base
try {
const suggestion = await this.aiClient.chat([
{ role: 'user', content: `你是连载小说编辑。上一章末尾:「${base.previousTail}」。本章开头:「${base.currentHead || '(尚未撰写)'}」。用 2-3 句话指出衔接是否顺畅,并给出一个开头方向建议。不要续写正文。` }
])
return { ...base, aiSuggestion: suggestion.trim() }
} catch {
return base
}
}
dismiss(chapterId: string): void {
dismissRepo.dismiss(chapterId)
}
}
```
- [ ] **Step 3: IT-BRIDGE-01240s LM Studio**
```typescript
it('IT-BRIDGE-01: getBridge returns aiSuggestion from LM Studio', async () => {
// 两章有内容 → getBridge(id, true) → aiSuggestion.length > 10
}, 240_000)
```
- [ ] **Step 4: 运行 PASS + Commit**
```bash
npm run test -- tests/main/chapter-bridge.test.ts
git commit -m "feat: add ChapterBridgeService with optional AI suggestion"
```
---
## Task 6: IPC + Preload
**Files:**
- Create: `src/main/ipc/handlers/knowledge.handler.ts`
- Create: `src/main/ipc/handlers/cockpit.handler.ts`
- Create: `src/main/ipc/handlers/bridge.handler.ts`
- Modify: `src/main/ipc/handlers/chapter.handler.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- [ ] **Step 1: 实现 handlers**
参照 `auto.handler.ts` 模式,`wrap(() => ...)``BookRegistryService.getDb(bookId)`
`knowledge.handler.ts``list` / `create` / `update` / `delete` / `approve` / `reject` / `batchApprove` / `createFromBookmark` / `stats`
`cockpit.handler.ts``getSummary(bookId, volumeId?)` / `markSeen` / `shouldShowOnOpen`
`bridge.handler.ts``get(bookId, chapterId, withAi?)` / `dismiss` / `shouldPrompt`
`chapter.handler.ts` 新增:
```typescript
IPC.CHAPTER_SET_PUBLISH_STATUS chapterRepo.setPublishStatus(chapterId, status)
```
- [ ] **Step 2: register.ts**
```typescript
registerKnowledgeHandlers(books, settings)
registerCockpitHandlers(books, settings)
registerBridgeHandlers(books, settings, aiClient)
```
- [ ] **Step 3: preload 命名空间**
```typescript
knowledge: { list, create, update, delete, approve, reject, batchApprove, createFromBookmark, stats },
cockpit: { getSummary, markSeen, shouldShowOnOpen },
bridge: { get, dismiss, shouldPrompt },
chapter: { /* 现有 */ setPublishStatus },
```
- [ ] **Step 4: build 验证**
```bash
npm run build
```
- [ ] **Step 5: Commit**
```bash
git commit -m "feat: add knowledge cockpit bridge IPC and preload"
```
---
## Task 7: KnowledgePanel UI
**Files:**
- Create: `src/renderer/stores/useKnowledgeStore.ts`
- Create: `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx`
- Modify: `src/renderer/components/knowledge/KnowledgePanel.tsx`
- Modify: `src/renderer/styles/layout.css`
- [ ] **Step 1: useKnowledgeStore**
```typescript
interface KnowledgeState {
entries: KnowledgeEntry[]
stats: { buried: number; resolved: number; forgotten: number }
tab: 'pending' | 'all' | 'foreshadow'
load: (bookId: string) => Promise<void>
setTab: (tab: ...) => void
create / update / delete / approve / batchApprove
openForgottenFilter: () => void // tab=foreshadow + forgottenOnly
}
```
- [ ] **Step 2: KnowledgeEditorDialog**
Radix Dialog 或现有 `.modal-overlay` 模式;字段:type select、title、content textarea、importance 1-5、foreshadowStatustype=foreshadow 时)、sourceChapter select、「直接采纳」checkbox。
- [ ] **Step 3: 重写 KnowledgePanel**
布局见 spec §6.4;保留 `naming-check-open`Tab 切换过滤列表;`data-testid` 与 spec 一致。
- [ ] **Step 4: CSS `.knowledge-panel` / `.kb-item` 对齐 ui_pc.html**
- [ ] **Step 5: Commit**
```bash
git commit -m "feat: implement KnowledgePanel with review workflow UI"
```
---
## Task 8: CockpitModal + TopBar + 首次打开
**Files:**
- Create: `src/renderer/stores/useCockpitStore.ts`
- Create: `src/renderer/components/cockpit/CockpitModal.tsx`
- Modify: `src/renderer/components/layout/TopBar.tsx`
- Modify: `src/renderer/stores/useBookStore.ts`openBook 后检查 cockpit
- Modify: `src/renderer/App.tsx``EditorLayout.tsx`(挂载 CockpitModal
- [ ] **Step 1: useCockpitStore**
```typescript
loadSummary(bookId, volumeId?)
open / close
markSeen(bookId)
shouldShowOnOpen(bookId): Promise<boolean>
```
- [ ] **Step 2: CockpitModal**
加载 summary 展示卡片;按钮:
- `cockpit-resume-edit` → close + 恢复 lastChapterId
- `cockpit-bridge-check` → close + 打开 ChapterBridgeModal
- `cockpit-forgotten-foreshadow` → close + `useKnowledgeStore.openForgottenFilter()` + 切 rightPanel knowledge
- [ ] **Step 3: TopBar**
替换 `comingSoon``data-testid="topbar-cockpit"``useCockpitStore.open()`
- [ ] **Step 4: 首次打开书籍**
`useBookStore.openBook` 成功后:
```typescript
if (await ipcCall(() => window.electronAPI.cockpit.shouldShowOnOpen(bookId))) {
await ipcCall(() => window.electronAPI.cockpit.markSeen(bookId))
useCockpitStore.getState().open()
}
```
- [ ] **Step 5: Commit**
```bash
git commit -m "feat: add CockpitModal and first-open behavior"
```
---
## Task 9: ChapterBridgeModal + EditorLayout 集成
**Files:**
- Create: `src/renderer/components/bridge/ChapterBridgeModal.tsx`
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
- Modify: `src/renderer/stores/useAppStore.ts`bridgeOpen 状态,可选)
- [ ] **Step 1: ChapterBridgeModal**
打开时 `bridge.get(bookId, chapterId, true)`;展示 tail/head/aiSuggestioncheckbox dismiss + `bridge-start-writing` 关闭。
- [ ] **Step 2: EditorLayout 自动触发**
`switchTarget` 到 chapter 后:
```typescript
const should = await ipcCall(() => window.electronAPI.bridge.shouldPrompt(chapterId))
if (should) setTimeout(() => setBridgeOpen(true), 500)
```
- [ ] **Step 3: 驾驶舱手动触发**
CockpitModal `cockpit-bridge-check` 设置 `bridgeChapterId` 为当前章并打开 modal。
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add ChapterBridgeModal with auto prompt on new chapter"
```
---
## Task 10: 发布状态 + 存稿提醒 + 设置
**Files:**
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
- Modify: `src/renderer/stores/useBookStore.ts`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- [ ] **Step 1: 章节 publish 循环按钮**
`chapter-item` 内增加小图标按钮 `data-testid={`chapter-publish-${ch.id}`}`,点击循环 `draft → ready → published`,调用 `chapter.setPublishStatus`,刷新 chapters。
- [ ] **Step 2: StatusBar 存稿警告**
EditorLayout `#editor-statusbar` 内:
```typescript
const summary = await cockpit.getSummary(bookId, activeVolumeId)
if (settings.updateSchedule !== 'none' && summary.stockReadyCount < summary.stockThreshold) {
// 渲染 data-testid="status-stock-warning"
}
```
书籍打开时 load summarypublish 变更后 refresh。
- [ ] **Step 3: SettingsPage**
新增「更新计划」区块:`updateSchedule` select、`stockBufferThreshold` number input (1-20)。
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add publish status UI stock warning and update schedule settings"
```
---
## Task 11: 地标同步 + i18n
**Files:**
- Modify: `src/renderer/components/editor/TipTapEditor.tsx` 或 landmark 插入流程
- Modify: `public/locales/zh-CN/translation.json`
- Modify: `public/locales/en/translation.json`
- [ ] **Step 1: 插入 foreshadow 地标时 checkbox**
在 insert landmark 对话框(或插入后 toast 行动)增加「同步到知识库」;勾选则 `knowledge.createFromBookmark(bookmarkId)`
若当前无独立 insert UI,在 `LandmarkModal` 或 TipTap landmark 命令回调中加 post-create hook。
- [ ] **Step 2: i18n 键**
`cockpit.*``bridge.*``knowledge.*``publish.status.draft/ready/published``settings.updateSchedule.*``stock.warning`
- [ ] **Step 3: Commit**
```bash
git commit -m "feat: add foreshadow landmark sync and P5 i18n strings"
```
---
## Task 12: E2E 测试
**Files:**
- Create: `e2e/cockpit-knowledge.spec.ts`
- [ ] **Step 1: 无 AI 门槛测试**
```typescript
test('E2E-COCK-01: cockpit modal shows stats', async () => { /* openBookWithContent → topbar-cockpit → cockpit-total-words visible */ })
test('E2E-COCK-02: first open shows cockpit once', async () => { /* 开书两次 */ })
test('E2E-PUB-01: publish ready increases stock count', async () => { /* chapter-publish click → cockpit-stock-count */ })
test('E2E-KNOW-01: create approve knowledge entry', async () => { /* knowledge-new → pending tab → approve */ })
test('E2E-BRIDGE-01: new chapter triggers bridge modal', async () => { /* 新章 → bridge-previous-tail visible */ })
test('E2E-STOCK-01: low stock warning in statusbar', async () => { /* set threshold + schedule in settings, 0 ready chapters */ })
```
- [ ] **Step 2: LM Studio 测试(300s**
```typescript
test('E2E-BRIDGE-02: bridge shows AI suggestion', async () => {
test.setTimeout(300_000)
// 两章有内容 → 手动打开 bridge → bridge-ai-suggestion 非空
})
test('E2E-KNOW-02: foreshadow landmark sync', async () => { /* 插入 foreshadow + sync */ })
```
- [ ] **Step 3: 运行 E2E**
```bash
npm run test:e2e -- e2e/cockpit-knowledge.spec.ts
```
- [ ] **Step 4: Commit**
```bash
git commit -m "test: add P5 cockpit knowledge and bridge E2E specs"
```
---
## Task 13: v0.6.0 交付
**Files:**
- Modify: `package.json`
- Modify: `README.md`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`(版本号展示)
- Modify: `docs/superpowers/plans/2026-07-06-bilin-p5-workflow-knowledge.md`(勾选完成项)
- [ ] **Step 1: 版本号 → `0.6.0`**
- [ ] **Step 2: README 功能概览追加 P5**
```markdown
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5)
- 章间衔接:上下章片段 + 可选 AI 建议(P5)
- 更新计划:发布状态、存稿阈值提醒(P5)
```
- [ ] **Step 3: 全量测试**
```bash
npm run build
npm run test
npm run test:e2e
```
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: ship v0.6.0 with cockpit knowledge and chapter bridge"
```
---
## Spec Coverage Checklist
| Spec 要求 | Task |
|-----------|------|
| schema v6 三表 | Task 1 |
| Knowledge CRUD / 审核 / 遗忘 | Task 23, 7 |
| Cockpit 聚合 + 首次打开 | Task 4, 8 |
| ChapterBridge + AI | Task 5, 9 |
| publish_status UI | Task 10 |
| updateSchedule / 存稿提醒 | Task 4, 10 |
| 地标 foreshadow 同步 | Task 11 |
| IPC / preload | Task 6 |
| i18n | Task 11 |
| 全部测试 ID | Task 15, 12 |
| v0.6.0 DoD | Task 13 |
@@ -0,0 +1,764 @@
# 笔临 P5.2 写作日志 + AI 知识抽取 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.8.0`writing_logs` 日更追踪(保存差分 + 成章补计)、驾驶舱热力图/连更、状态栏日更进度,以及 AI 章节知识自动抽取 + 合并审核增强,与 P6 注入形成上下游闭环。
**Architecture:** 全局 `userData/writing_logs.sqlite``(date, bookId)` 日字数;每书 schema v7 增加 `chapter_writing_snapshots``knowledge_entries` 抽取元数据列;`ChapterWritingTracker``CHAPTER_UPDATE` / `finishChapter``KnowledgeExtractionService` 异步调用 `AiClientService` 落库 `pending`。测试 **禁止 Mock** AI 集成/E2E。
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、i18next、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-07-bilin-p52-writing-logs-extraction-design.md`
**LM Studio 默认:** `baseUrl=http://127.0.0.1:1234``model=gemma-4-e4b-it`
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/types.ts` | `WritingStats``ExtractedKnowledgeItem``KnowledgeExtractionResult`;扩展 `KnowledgeEntry``CockpitSummary``GlobalSettings` |
| `src/shared/ipc-channels.ts` | `WRITING_GET_STATS``KNOWLEDGE_EXTRACT_CHAPTER``KNOWLEDGE_APPROVE_MERGE``KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE` |
| `src/main/db/schema-v7.sql` | `chapter_writing_snapshots` + knowledge ALTER |
| `src/main/db/migrate.ts` | v7 迁移 |
| `src/main/db/repositories/writing-log.repo.ts` | 全局 writing_logs |
| `src/main/db/repositories/chapter-writing-snapshot.repo.ts` | 每书 snapshot |
| `src/main/db/repositories/knowledge.repo.ts` | 抽取列读写、`createExtracted``batchApproveHighConfidence` |
| `src/main/services/writing-log.service.ts` | 日更、连更、热力图 |
| `src/main/services/chapter-writing-tracker.service.ts` | `recordDelta` / `supplementOnFinish` |
| `src/main/services/knowledge-extraction-prompt.ts` | prompt + `parseExtractionJson` |
| `src/main/services/knowledge-extraction.service.ts` | `extractForChapter` |
| `src/main/services/knowledge.service.ts` | `approveMerge``clearMergeTarget` |
| `src/main/services/cockpit.service.ts` | 附加 `writingStats` |
| `src/main/services/global-settings.ts` | 新设置默认值 |
| `src/main/ipc/handlers/writing.handler.ts` | `writing:getStats` |
| `src/main/ipc/handlers/knowledge.handler.ts` | 抽取/合并 IPC |
| `src/main/ipc/handlers/chapter.handler.ts` | 挂钩 tracker |
| `src/main/ipc/handlers/interactive.handler.ts` | finish 后 supplement + 异步抽取 |
| `src/main/ipc/handlers/auto.handler.ts` | 同上 |
| `src/main/ipc/register.ts` | 注入 `WritingLogService` |
| `src/preload/index.ts` + `electron-api.d.ts` | 新 API |
| `src/renderer/components/cockpit/WritingHeatmap.tsx` | Canvas 热力图 |
| `src/renderer/components/cockpit/CockpitModal.tsx` | 今日进度/连更/热力图 |
| `src/renderer/components/layout/EditorLayout.tsx` | 状态栏日更进度 |
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 批次/合并/批量采纳 |
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 合并预填模式 |
| `src/renderer/components/settings/SettingsPage.tsx` | 抽取设置 |
| `src/renderer/components/layout/ChapterList.tsx` 或编辑器菜单 | 抽取本章入口 |
| `public/locales/*/translation.json` | P5.2 文案 |
| `tests/main/writing-log.test.ts` | UT-LOG-* / IT-LOG-01 |
| `tests/main/knowledge-extraction.test.ts` | UT-EXT-* / IT-EXT-* |
| `e2e/writing-logs.spec.ts` | E2E-LOG-* |
| `e2e/knowledge-extraction.spec.ts` | E2E-EXT-* |
---
## Task 1: 类型 + IPC 通道 + GlobalSettings
**Files:**
- Modify: `src/shared/types.ts`
- Modify: `src/shared/ipc-channels.ts`
- Modify: `src/main/services/global-settings.ts`
- Modify: `src/renderer/stores/useSettingsStore.ts`
- [x] **Step 1: 扩展 types**
`src/shared/types.ts` 追加:
```typescript
export interface WritingDayLog {
date: string
bookId: string
wordCount: number
}
export interface WritingStats {
todayWords: number
dailyGoal: number
goalProgress: number
streakDays: number
heatmap: WritingDayLog[]
}
export interface ExtractedKnowledgeItem {
type: KnowledgeType
title: string
content: string
importance?: number
confidence: number
foreshadowStatus?: ForeshadowStatus
suggestedMergeId?: string
proposedPatch?: Partial<CreateKnowledgeInput>
}
export interface KnowledgeExtractionResult {
batchId: string
chapterId: string
items: ExtractedKnowledgeItem[]
}
```
扩展 `KnowledgeEntry`
```typescript
extractionConfidence?: number
mergeTargetId?: string
extractionBatchId?: string
```
扩展 `CockpitSummary`
```typescript
writingStats?: WritingStats
```
扩展 `GlobalSettings`
```typescript
knowledgeAutoExtract?: boolean
knowledgeExtractConfidenceThreshold?: number
```
- [x] **Step 2: IPC 通道**
```typescript
WRITING_GET_STATS: 'writing:getStats',
KNOWLEDGE_EXTRACT_CHAPTER: 'knowledge:extractChapter',
KNOWLEDGE_APPROVE_MERGE: 'knowledge:approveMerge',
KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE: 'knowledge:batchApproveHighConfidence',
```
- [x] **Step 3: global-settings 默认值**
```typescript
knowledgeAutoExtract: true,
knowledgeExtractConfidenceThreshold: 0.8,
```
`get()` merge 补默认。
- [x] **Step 4: useSettingsStore 同步默认**
- [x] **Step 5: build**
```bash
npm run build
```
---
## Task 2: schema v7 + KnowledgeRepository 扩展
**Files:**
- Create: `src/main/db/schema-v7.sql`
- Modify: `src/main/db/migrate.ts`
- Modify: `src/main/db/repositories/knowledge.repo.ts`
- Create: `tests/main/migrate-v7.test.ts`
- [x] **Step 1: schema-v7.sql**
```sql
CREATE TABLE IF NOT EXISTS chapter_writing_snapshots (
chapter_id TEXT PRIMARY KEY,
logged_word_count INTEGER NOT NULL DEFAULT 0,
finish_supplemented INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
```
- [x] **Step 2: migrate.ts**
```typescript
import schemaV7 from './schema-v7.sql?raw'
const CURRENT_VERSION = 7
// in migrate(), after v6:
if (current < 7) {
db.exec(schemaV7)
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN extraction_confidence REAL')
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN merge_target_id TEXT')
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN extraction_batch_id TEXT')
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(7, 'P5.2 writing logs extraction')
}
```
- [x] **Step 3: knowledge.repo mapRow + createExtracted**
更新 `mapRow` 读取三列;新增:
```typescript
createExtracted(input: CreateKnowledgeInput & {
extractionConfidence?: number
mergeTargetId?: string
extractionBatchId?: string
}): KnowledgeEntry {
const id = randomUUID()
this.db.prepare(
`INSERT INTO knowledge_entries (
id, type, title, content, importance, status, foreshadow_status,
source_chapter_id, last_mention_chapter_id, linked_bookmark_id,
extraction_confidence, merge_target_id, extraction_batch_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id, input.type, input.title, input.content ?? '', input.importance ?? 3,
input.status ?? 'pending', input.foreshadowStatus ?? null,
input.sourceChapterId ?? null, input.lastMentionChapterId ?? null, input.linkedBookmarkId ?? null,
input.extractionConfidence ?? null, input.mergeTargetId ?? null, input.extractionBatchId ?? null
)
return this.get(id)!
}
batchApproveHighConfidence(threshold: number): number {
const rows = this.db.prepare(
`SELECT id FROM knowledge_entries
WHERE status = 'pending' AND merge_target_id IS NULL
AND extraction_confidence IS NOT NULL AND extraction_confidence >= ?`
).all(threshold) as { id: string }[]
let count = 0
for (const { id } of rows) {
this.update(id, { status: 'approved' })
count++
}
return count
}
clearMergeTarget(id: string): KnowledgeEntry {
this.db.prepare('UPDATE knowledge_entries SET merge_target_id = NULL, updated_at = datetime(\'now\') WHERE id = ?').run(id)
return this.get(id)!
}
```
- [x] **Step 4: migrate-v7 测试**
```typescript
it('UT-MIG-07: v6 database migrates to v7 with snapshot table and knowledge columns', () => {
const db = new DatabaseSync(':memory:')
migrate(db) // fresh → v7
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'").get()
expect(tables).toBeTruthy()
db.close()
})
```
- [x] **Step 5: 运行**
```bash
npm run test -- tests/main/migrate-v7.test.ts
```
---
## Task 3: WritingLogService(全局)
**Files:**
- Create: `src/main/db/repositories/writing-log.repo.ts`
- Create: `src/main/services/writing-log.service.ts`
- Create: `tests/main/writing-log.test.ts`
- [x] **Step 1: WritingLogRepository**
全局 sqlite 路径 `join(userDataDir, 'writing_logs.sqlite')``migrate()` 建表;方法:
```typescript
addToday(bookId: string, delta: number, date?: string): number
getToday(bookId: string, date?: string): number
listRange(bookId: string, fromDate: string, toDate: string): WritingDayLog[]
```
`addToday``INSERT ... ON CONFLICT DO UPDATE SET word_count = MAX(0, word_count + excluded.word_count)`
日期工具:
```typescript
export function localDateString(d = new Date()): string {
return d.toLocaleDateString('sv-SE') // YYYY-MM-DD
}
```
- [x] **Step 2: WritingLogService**
```typescript
export class WritingLogService {
constructor(private repo: WritingLogRepository, private settings: GlobalSettingsService) {}
addToday(bookId: string, delta: number): number {
return this.repo.addToday(bookId, delta)
}
getStats(bookId: string): WritingStats {
const dailyGoal = this.settings.get().dailyWordGoal ?? 0
const today = localDateString()
const todayWords = this.repo.getToday(bookId, today)
const goalProgress = dailyGoal > 0 ? todayWords / dailyGoal : 0
const heatmap = this.buildHeatmap(bookId, 365)
const streakDays = dailyGoal > 0 ? this.calcStreak(bookId, dailyGoal) : 0
return { todayWords, dailyGoal, goalProgress, streakDays, heatmap }
}
private buildHeatmap(bookId: string, days: number): WritingDayLog[] { /* 填充 0 */ }
private calcStreak(bookId: string, goal: number): number { /* 从昨日向前 */ }
}
```
- [x] **Step 3: UT-LOG-01~04**
```typescript
it('UT-LOG-01: addToday accumulates +200', () => { /* ... */ })
it('UT-LOG-02: negative delta clamps at 0', () => { /* ... */ })
it('UT-LOG-04: streak counts consecutive goal-met days', () => { /* seed 3 days */ })
```
- [x] **Step 4: 运行**
```bash
npm run test -- tests/main/writing-log.test.ts
```
---
## Task 4: ChapterWritingTracker + Snapshot Repo
**Files:**
- Create: `src/main/db/repositories/chapter-writing-snapshot.repo.ts`
- Create: `src/main/services/chapter-writing-tracker.service.ts`
- Modify: `tests/main/writing-log.test.ts`
- [x] **Step 1: ChapterWritingSnapshotRepository**
```typescript
get(chapterId: string): { loggedWordCount: number; finishSupplemented: boolean } | null
upsert(chapterId: string, loggedWordCount: number, finishSupplemented?: boolean): void
```
- [x] **Step 2: ChapterWritingTracker**
```typescript
export class ChapterWritingTracker {
constructor(
private bookDb: SqliteDb,
private bookId: string,
private writingLogs: WritingLogService
) {}
recordDelta(chapterId: string, newWordCount: number): void {
const snap = this.snapRepo.get(chapterId)
const logged = snap?.loggedWordCount ?? 0
const delta = newWordCount - logged
if (delta !== 0) this.writingLogs.addToday(this.bookId, delta)
this.snapRepo.upsert(chapterId, newWordCount, snap?.finishSupplemented ?? false)
}
supplementOnFinish(chapterId: string, wordCount: number): void {
const snap = this.snapRepo.get(chapterId)
const logged = snap?.loggedWordCount ?? 0
const gap = wordCount - logged
if (gap > 0) this.writingLogs.addToday(this.bookId, gap)
this.snapRepo.upsert(chapterId, wordCount, true)
}
}
```
- [x] **Step 3: UT-LOG-03 supplementOnFinish**
- [x] **Step 4: 运行测试**
```bash
npm run test -- tests/main/writing-log.test.ts
```
---
## Task 5: 挂钩 CHAPTER_UPDATE + finishChapter
**Files:**
- Modify: `src/main/ipc/handlers/chapter.handler.ts`
- Modify: `src/main/ipc/handlers/interactive.handler.ts`
- Modify: `src/main/ipc/handlers/auto.handler.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `tests/main/writing-log.test.ts`IT-LOG-01
- [x] **Step 1: register.ts 创建 WritingLogService 单例**
```typescript
const writingLogs = new WritingLogService(new WritingLogRepository(userData), settings)
registerChapterHandlers(books, writingLogs)
registerInteractiveHandlers(registry, settings, aiClient, books, writingLogs, ...)
```
工厂函数:
```typescript
function trackerFor(registry: BookRegistryService, bookId: string, writingLogs: WritingLogService) {
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs)
}
```
- [x] **Step 2: chapter.handler CHAPTER_UPDATE 后**
```typescript
wrap(() => {
const chapter = registry.getChapterRepo(bookId).update(...)
ftsSync.upsert(...)
trackerFor(registry, bookId, writingLogs).recordDelta(chapterId, chapter.wordCount)
registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter
})
```
- [x] **Step 3: interactive/auto finishChapter 后**
`finishChapter` 返回 `{ chapterId }` 后:
```typescript
const ch = registry.getChapterRepo(bookId).get(chapterId)!
trackerFor(registry, bookId, writingLogs).supplementOnFinish(chapterId, ch.wordCount)
if (settings.get().knowledgeAutoExtract !== false) {
setImmediate(() => void extractKnowledge(registry, bookId, chapterId, aiClient, settings))
}
return result
```
抽取辅助放在 `src/main/services/knowledge-extraction-runner.ts`(避免 handler 膨胀)。
- [x] **Step 4: IT-LOG-01**
两次 `CHAPTER_UPDATE` 模拟,断言 `writing_logs` 累计。
- [x] **Step 5: 运行**
```bash
npm run test -- tests/main/writing-log.test.ts
```
---
## Task 6: Cockpit 扩展 + writing IPC + preload
**Files:**
- Modify: `src/main/services/cockpit.service.ts`
- Create: `src/main/ipc/handlers/writing.handler.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- [x] **Step 1: CockpitService 注入 WritingLogService**
```typescript
getSummary(activeVolumeId?: string, bookId?: string): CockpitSummary {
const base = { /* existing */ }
const writingStats = bookId ? this.writingLogs.getStats(bookId) : undefined
return { ...base, writingStats }
}
```
更新 `cockpit.handler.ts``bookId`
- [x] **Step 2: writing.handler**
```typescript
ipcMain.handle(IPC.WRITING_GET_STATS, (_e, { bookId }: { bookId: string }) =>
wrap(() => writingLogs.getStats(bookId))
)
```
- [x] **Step 3: preload**
```typescript
writing: {
getStats: (bookId: string): Promise<IpcResult<WritingStats>> =>
ipcRenderer.invoke(IPC.WRITING_GET_STATS, { bookId })
},
```
- [x] **Step 4: build**
```bash
npm run build
```
---
## Task 7: KnowledgeExtraction prompt + parse + service
**Files:**
- Create: `src/main/services/knowledge-extraction-prompt.ts`
- Create: `src/main/services/knowledge-extraction.service.ts`
- Create: `tests/main/knowledge-extraction.test.ts`
- [x] **Step 1: knowledge-extraction-prompt.ts**
```typescript
export function buildExtractionPrompt(
chapterTitle: string,
plainText: string,
approvedTitles: Array<{ id: string; title: string }>
): string { /* 中文指令 + JSON schema 说明 */ }
export function parseExtractionJson(raw: string): ExtractedKnowledgeItem[] {
const cleaned = raw.replace(/```json\n?|\n?```/g, '').trim()
const arr = JSON.parse(cleaned) as ExtractedKnowledgeItem[]
if (!Array.isArray(arr)) throw new Error('expected array')
return arr.map((item) => ({
type: item.type,
title: String(item.title).trim(),
content: String(item.content ?? '').trim(),
importance: item.importance ?? 3,
confidence: Number(item.confidence) || 0.5,
foreshadowStatus: item.foreshadowStatus,
suggestedMergeId: item.suggestedMergeId ?? undefined,
proposedPatch: item.proposedPatch
})).filter((x) => x.title.length > 0)
}
```
- [x] **Step 2: KnowledgeExtractionService**
```typescript
export class KnowledgeExtractionService {
async extractForChapter(chapterId: string): Promise<KnowledgeExtractionResult> {
const chapter = this.chapterRepo.get(chapterId)
if (!chapter) throw new Error('Chapter not found')
const plain = stripHtml(chapter.content).slice(0, 12000)
if (!plain.trim()) throw new Error('empty chapter')
const approved = this.knowledgeRepo.list({ status: 'approved' }).slice(0, 30)
const prompt = buildExtractionPrompt(chapter.title, plain, approved.map((e) => ({ id: e.id, title: e.title })))
let response = await this.aiClient.chat([{ role: 'user', content: prompt }])
let items: ExtractedKnowledgeItem[]
try {
items = parseExtractionJson(response)
} catch {
response = await this.aiClient.chat([
{ role: 'user', content: prompt },
{ role: 'user', content: '仅返回 JSON 数组,不要 markdown 或其它说明。' }
])
items = parseExtractionJson(response)
}
const batchId = randomUUID()
const saved: ExtractedKnowledgeItem[] = []
for (const item of items) {
const mergeTargetId = item.suggestedMergeId && this.knowledgeRepo.get(item.suggestedMergeId) ? item.suggestedMergeId : undefined
this.knowledgeRepo.createExtracted({
type: item.type,
title: item.title,
content: item.content,
importance: item.importance,
status: 'pending',
foreshadowStatus: item.type === 'foreshadow' ? (item.foreshadowStatus ?? 'buried') : undefined,
sourceChapterId: chapterId,
extractionConfidence: item.confidence,
mergeTargetId,
extractionBatchId: batchId
})
saved.push({ ...item, suggestedMergeId: mergeTargetId })
}
return { batchId, chapterId, items: saved }
}
}
```
- [x] **Step 3: UT-EXT-01 / UT-EXT-02**
`parseExtractionJson` 测合法 JSON 与 `suggestedMergeId` 字段。
- [x] **Step 4: 运行**
```bash
npm run test -- tests/main/knowledge-extraction.test.ts -t "UT-EXT"
```
---
## Task 8: Knowledge 合并 IPC + KnowledgeService
**Files:**
- Modify: `src/main/services/knowledge.service.ts`
- Modify: `src/main/ipc/handlers/knowledge.handler.ts`
- Modify: `src/preload/index.ts`
- Modify: `tests/main/knowledge-extraction.test.ts`
- [x] **Step 1: KnowledgeService.approveMerge**
```typescript
approveMerge(pendingId: string): KnowledgeEntry {
const pending = this.repo.get(pendingId)
if (!pending?.mergeTargetId) throw new Error('not a merge suggestion')
const target = this.repo.get(pending.mergeTargetId)
if (!target || target.status !== 'approved') throw new Error('merge target missing')
const patch: Partial<CreateKnowledgeInput> = {
content: pending.content,
importance: pending.importance,
lastMentionChapterId: pending.lastMentionChapterId ?? pending.sourceChapterId
}
const updated = this.repo.update(target.id, patch)
this.repo.update(pendingId, { status: 'rejected' })
return updated
}
saveMergeAsNew(pendingId: string): KnowledgeEntry {
return this.repo.clearMergeTarget(pendingId)
}
```
- [x] **Step 2: knowledge.handler IPC**
```typescript
ipcMain.handle(IPC.KNOWLEDGE_EXTRACT_CHAPTER, (_e, { bookId, chapterId }) =>
wrap(() => new KnowledgeExtractionService(registry.getDb(bookId), aiClient).extractForChapter(chapterId))
)
ipcMain.handle(IPC.KNOWLEDGE_APPROVE_MERGE, (_e, { bookId, pendingId }) =>
wrap(() => new KnowledgeService(registry.getDb(bookId)).approveMerge(pendingId))
)
ipcMain.handle(IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE, (_e, { bookId, threshold }) =>
wrap(() => {
const t = threshold ?? settings.get().knowledgeExtractConfidenceThreshold ?? 0.8
return new KnowledgeRepository(registry.getDb(bookId)).batchApproveHighConfidence(t)
})
)
```
- [x] **Step 3: IT-EXT-01 / IT-EXT-02**240s
- [x] **Step 4: IT-EXT-03** — 抽取 → approve → `KnowledgeRetrievalService.suggest` 含新条目
---
## Task 9: 驾驶舱 UI + 状态栏日更进度
**Files:**
- Create: `src/renderer/components/cockpit/WritingHeatmap.tsx`
- Modify: `src/renderer/components/cockpit/CockpitModal.tsx`
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
- Modify: `src/renderer/styles/layout.css`
- [x] **Step 1: WritingHeatmap 组件**
Props`heatmap: WritingDayLog[]`Canvas 13×7 格(52 周近似 365 天);四档颜色;`data-testid="cockpit-heatmap"`
- [x] **Step 2: CockpitModal 扩展**
`cockpit-grid` 下方:
```tsx
{summary.writingStats && summary.writingStats.dailyGoal > 0 && (
<div data-testid="cockpit-today-progress">...</div>
)}
{summary.writingStats && summary.writingStats.streakDays > 0 && (
<div data-testid="cockpit-streak">{t('cockpit.streak', { days: summary.writingStats.streakDays })}</div>
)}
<WritingHeatmap heatmap={summary.writingStats?.heatmap ?? []} />
```
- [x] **Step 3: EditorLayout 状态栏**
```tsx
const writingStats = useWritingStatsStore() // 或 cockpit summary 缓存
{dailyGoal > 0 && (
<span className={`status-daily-goal ${progress >= 1 ? 'goal-met' : progress >= 0.8 ? 'goal-near' : ''}`} data-testid="status-daily-goal">
{t('status.dailyGoal', { current: todayWords, goal: dailyGoal })}
</span>
)}
```
章节保存后 `writing.getStats` 刷新(监听 save 或轮询 `useBookStore` wordCount)。
- [x] **Step 4: build 验证**
```bash
npm run build
```
---
## Task 10: 知识库 UI + 设置 + 抽取入口
**Files:**
- Modify: `src/renderer/components/knowledge/KnowledgePanel.tsx`
- Modify: `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx`
- Modify: `src/renderer/stores/useKnowledgeStore.ts`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- Modify: `src/renderer/components/layout/ChapterList.tsx`(或 `EditorLayout` 章节菜单)
- Modify: `public/locales/zh-CN/translation.json`
- Modify: `public/locales/en/translation.json`
- [x] **Step 1: KnowledgePanel 待审核增强**
- 卡片显示 `extractionConfidence` 百分比徽章
- `mergeTargetId` 时显示 `t('knowledge.mergeSuggest')` + `data-testid="knowledge-merge-review-{id}"`
- 工具栏按钮 `knowledge-batch-high-confidence`
- 工具栏「从当前章抽取」`knowledge-extract-current`(需 `selectedChapterId`
- [x] **Step 2: KnowledgeEditorDialog 合并模式**
props`mergeTarget?: KnowledgeEntry``initialPatch?: Partial<CreateKnowledgeInput>`
底部按钮:「确认更新」→ `approveMerge`;「作为新条目」→ `saveMergeAsNew` 后常规编辑。
- [x] **Step 3: SettingsPage**
`settings-knowledge-auto-extract` 开关;`settings-extract-threshold` 数字输入。
- [x] **Step 4: 章节抽取入口**
章节列表项或编辑器右键:`data-testid="chapter-extract-knowledge"``knowledge.extractChapter`
- [x] **Step 5: i18n** — spec §9 全部键
---
## Task 11: E2E + v0.8.0 交付
**Files:**
- Create: `e2e/writing-logs.spec.ts`
- Create: `e2e/knowledge-extraction.spec.ts`
- Modify: `package.json`
- Modify: `README.md`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`about 版本)
- Modify: `docs/superpowers/plans/2026-07-07-bilin-p52-writing-logs-extraction.md`(勾选完成项)
- [x] **Step 1: E2E-LOG-01 / E2E-LOG-02**
写 100 字保存 → 打开驾驶舱 → `cockpit-today-progress` 更新;`cockpit-heatmap` 可见。
- [x] **Step 2: E2E-EXT-01 / E2E-EXT-02 / E2E-EXT-03**300s timeout on AI tests
- [x] **Step 3: 全量测试**
```bash
npm run build
npm run test
npm run test:e2e -- e2e/writing-logs.spec.ts e2e/knowledge-extraction.spec.ts
```
- [x] **Step 4: 版本 0.8.0**
`package.json``README.md` 追加:
```markdown
- 写作日志与热力图、AI 章节知识自动抽取(P5.2)
```
- [x] **Step 5: Commit**
```bash
git commit -m "feat: ship v0.8.0 with writing logs and AI knowledge extraction"
```
---
## Spec Coverage Checklist
| Spec 要求 | Task |
|-----------|------|
| writing_logs 全局表 | Task 3 |
| chapter_writing_snapshots v7 | Task 2, 4 |
| 保存差分 + 成章补计 | Task 4, 5 |
| 连更 + 热力图 | Task 3, 9 |
| 状态栏日更进度 | Task 9 |
| knowledgeAutoExtract 设置 | Task 1, 5, 10 |
| AI 抽取五类 + JSON | Task 7 |
| 合并建议 approveMerge | Task 8, 10 |
| 批量高置信度 | Task 2, 8, 10 |
| P6 suggestContext 联动 | Task 8 IT-EXT-03 |
| UT-LOG / UT-EXT / IT / E2E | Task 38, 11 |
| v0.8.0 DoD | Task 11 |
@@ -0,0 +1,685 @@
# 笔临 P5.3 + P6.1 写作目标 + 知识注入历史 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.9.0:§8 番茄钟(15/25/45)、连更里程碑徽章(7/30/100)、日更/番茄/里程碑通知;P6.1 写作流三模式知识注入历史记录与知识库反向链接 UI。
**Architecture:** 主进程 `PomodoroService` + `AchievementService` + `GoalNotificationService` 操作全局 `writing_logs.sqlite` 扩展表;每书 schema v8 增加 `knowledge_injection_logs``interactive/auto/wizard``confirmContext` 成功后异步写 injection log;渲染进程状态栏番茄控件 + 驾驶舱成就区 + 知识库注入预览。番茄 tick 采用主进程 push `GOAL_NOTIFICATION` / `POMODORO_TICK` 事件。
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、i18next、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-07-bilin-p53-p61-goals-injection-design.md`
**E2E 番茄加速:** `BILIN_E2E=1``POMODORO_TEST_SEC=3` 时覆盖倒计时(仅测试,不暴露生产 UI)
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/types.ts` | `PomodoroState``WritingAchievement``KnowledgeInjectionLog`;扩展 `CockpitSummary``GlobalSettings` |
| `src/shared/ipc-channels.ts` | pomodoro / achievement / injection / push 通道 |
| `src/main/db/schema-v8.sql` | `knowledge_injection_logs` |
| `src/main/db/migrate.ts` | v8 |
| `src/main/db/repositories/writing-log.repo.ts` | 扩展 migratemilestones + pomodoro_daily |
| `src/main/db/repositories/writing-milestone.repo.ts` | 成就 CRUD |
| `src/main/db/repositories/pomodoro-daily.repo.ts` | 番茄日统计 |
| `src/main/db/repositories/knowledge-injection.repo.ts` | 每书 injection logs |
| `src/main/services/pomodoro.service.ts` | 番茄状态机 |
| `src/main/services/achievement.service.ts` | 里程碑检测 |
| `src/main/services/goal-notification.service.ts` | Toast push + Notification |
| `src/main/services/knowledge-injection.service.ts` | 注入记录 |
| `src/main/services/writing-log.service.ts` | addToday 后触发 goal-met + milestones |
| `src/main/services/cockpit.service.ts` | achievements + pomodoroTodayCount |
| `src/main/ipc/handlers/pomodoro.handler.ts` | 番茄 IPC |
| `src/main/ipc/handlers/achievement.handler.ts` | 成就 IPC |
| `src/main/ipc/handlers/knowledge.handler.ts` | listInjections |
| `src/main/ipc/handlers/interactive.handler.ts` | confirmContext 挂钩 |
| `src/main/ipc/handlers/auto.handler.ts` | 同上 |
| `src/main/ipc/handlers/wizard.handler.ts` | 同上 |
| `src/main/ipc/register.ts` | 注入新服务 |
| `src/preload/index.ts` + `electron-api.d.ts` | 新 API + push 监听 |
| `src/renderer/components/layout/EditorLayout.tsx` | 番茄控件 + goal 通知 |
| `src/renderer/components/cockpit/CockpitModal.tsx` | 成就区 |
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 注入预览 |
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 完整历史 |
| `src/renderer/components/settings/SettingsPage.tsx` | 番茄/通知设置 |
| `src/renderer/stores/usePomodoroStore.ts` | 番茄 UI 状态 |
| `public/locales/*/translation.json` | P5.3/P6.1 文案 |
| `tests/main/pomodoro.test.ts` | UT-POM-* |
| `tests/main/achievement.test.ts` | UT-ACH-* / IT-GOAL-01 |
| `tests/main/knowledge-injection.test.ts` | UT/IT-INJ-* |
| `tests/main/migrate-v8.test.ts` | UT-MIG-08 |
| `e2e/writing-goals.spec.ts` | E2E-GOAL-* |
| `e2e/knowledge-injection-history.spec.ts` | E2E-INJ-01 |
---
## Task 1: 类型 + IPC + GlobalSettings
**Files:**
- Modify: `src/shared/types.ts`
- Modify: `src/shared/ipc-channels.ts`
- Modify: `src/main/services/global-settings.ts`
- Modify: `src/renderer/stores/useSettingsStore.ts`
- [x] **Step 1: 扩展 types**
```typescript
export type PomodoroDuration = 15 | 25 | 45
export type WritingMilestone = 'streak_7' | 'streak_30' | 'streak_100'
export type InjectionFlowMode = 'interactive' | 'auto' | 'wizard'
export interface PomodoroState {
running: boolean
paused: boolean
remainingSec: number
bookId: string | null
}
export interface WritingAchievement {
milestone: WritingMilestone
unlockedAt: string
}
export interface KnowledgeInjectionLog {
id: string
knowledgeEntryId: string
flowMode: InjectionFlowMode
flowId: string
chapterId?: string
injectedAt: string
}
export interface GoalNotificationPayload {
kind: 'daily_met' | 'milestone' | 'pomodoro_complete' | 'pomodoro_cancelled_book_switch'
messageKey: string
messageParams?: Record<string, string | number>
}
```
扩展 `CockpitSummary`
```typescript
achievements?: WritingAchievement[]
pomodoroTodayCount?: number
```
扩展 `GlobalSettings`
```typescript
pomodoroDurationMinutes?: PomodoroDuration
enableSystemNotifications?: boolean
enableGoalNotifications?: boolean
dailyGoalNotifiedDate?: string // YYYY-MM-DD,防重复达标 Toast
```
- [x] **Step 2: IPC 通道**
```typescript
POMODORO_START: 'pomodoro:start',
POMODORO_PAUSE: 'pomodoro:pause',
POMODORO_RESUME: 'pomodoro:resume',
POMODORO_CANCEL: 'pomodoro:cancel',
POMODORO_GET_STATE: 'pomodoro:getState',
ACHIEVEMENT_LIST: 'achievement:list',
KNOWLEDGE_LIST_INJECTIONS: 'knowledge:listInjections',
POMODORO_TICK: 'pomodoro:tick', // main → renderer push
GOAL_NOTIFICATION: 'goal:notification', // main → renderer push
```
- [x] **Step 3: global-settings 默认值**
```typescript
pomodoroDurationMinutes: 25,
enableSystemNotifications: false,
enableGoalNotifications: true,
```
- [x] **Step 4: useSettingsStore 同步**
- [x] **Step 5: build**
```bash
npm run build
```
---
## Task 2: 全局 sqlite 扩展 + Milestone/Pomodoro Repos
**Files:**
- Modify: `src/main/db/repositories/writing-log.repo.ts`
- Create: `src/main/db/repositories/writing-milestone.repo.ts`
- Create: `src/main/db/repositories/pomodoro-daily.repo.ts`
- Create: `tests/main/achievement.test.ts`(先写 milestone repo 部分)
- [x] **Step 1: writing-log.repo migrate 扩展**
`migrate()` 追加:
```sql
CREATE TABLE IF NOT EXISTS writing_milestones (
book_id TEXT NOT NULL, milestone TEXT NOT NULL, unlocked_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (book_id, milestone)
);
CREATE TABLE IF NOT EXISTS pomodoro_daily (
date TEXT NOT NULL, book_id TEXT NOT NULL,
completed_count INTEGER NOT NULL DEFAULT 0, total_words INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, book_id)
);
```
- [x] **Step 2: WritingMilestoneRepository**
```typescript
export class WritingMilestoneRepository {
constructor(private db: DatabaseSync) {}
list(bookId: string): WritingAchievement[] { /* SELECT */ }
has(bookId: string, milestone: WritingMilestone): boolean { /* ... */ }
unlock(bookId: string, milestone: WritingMilestone): WritingAchievement { /* INSERT OR IGNORE */ }
}
```
- [x] **Step 3: PomodoroDailyRepository**
```typescript
recordComplete(bookId: string, wordDelta: number, date?: string): void {
// INSERT ... ON CONFLICT DO UPDATE completed_count += 1, total_words += wordDelta
}
getTodayCount(bookId: string): number
```
- [x] **Step 4: UT-ACH repo smoke**
```typescript
it('UT-ACH-01: unlock streak_7 once', () => {
const repo = new WritingMilestoneRepository(db)
repo.unlock('b1', 'streak_7')
expect(repo.has('b1', 'streak_7')).toBe(true)
})
```
- [x] **Step 5: 运行**
```bash
npm run test -- tests/main/achievement.test.ts -t "UT-ACH-01"
```
---
## Task 3: schema v8 + KnowledgeInjectionRepository
**Files:**
- Create: `src/main/db/schema-v8.sql`
- Modify: `src/main/db/migrate.ts`
- Create: `src/main/db/repositories/knowledge-injection.repo.ts`
- Create: `tests/main/migrate-v8.test.ts`
- Create: `tests/main/knowledge-injection.test.ts`
- [x] **Step 1: schema-v8.sql**(见 spec §3.2
- [x] **Step 2: migrate.ts**
```typescript
import schemaV8 from './schema-v8.sql?raw'
const CURRENT_VERSION = 8
if (current < 8) {
db.exec(schemaV8)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection')
}
```
- [x] **Step 3: KnowledgeInjectionRepository**
```typescript
insertBatch(rows: Array<Omit<KnowledgeInjectionLog, 'id' | 'injectedAt'>>): void
listForEntry(entryId: string, limit = 50): KnowledgeInjectionLog[]
```
- [x] **Step 4: UT-MIG-08 + UT-INJ-01**
```typescript
it('UT-MIG-08: v7 database migrates to v8 with injection table', () => { /* ... */ })
it('UT-INJ-01: insertBatch creates 3 rows', () => { /* ... */ })
```
- [x] **Step 5: 运行**
```bash
npm run test -- tests/main/migrate-v8.test.ts tests/main/knowledge-injection.test.ts
```
---
## Task 4: PomodoroService
**Files:**
- Create: `src/main/services/pomodoro.service.ts`
- Create: `tests/main/pomodoro.test.ts`
- [x] **Step 1: PomodoroService 实现**
```typescript
export class PomodoroService {
private state: PomodoroState = { running: false, paused: false, remainingSec: 0, bookId: null }
private wordSnapshot = 0
private timer: NodeJS.Timeout | null = null
constructor(
private writingLogs: WritingLogService,
private pomodoroDaily: PomodoroDailyRepository,
private settings: GlobalSettingsService,
private notify: GoalNotificationService,
private pushTick: (state: PomodoroState) => void
) {}
start(bookId: string): PomodoroState {
if (this.state.running && this.state.bookId !== bookId) this.cancel()
const duration = this.resolveDurationSec()
this.wordSnapshot = this.writingLogs.getStats(bookId).todayWords
this.state = { running: true, paused: false, remainingSec: duration, bookId }
this.startTimer()
return this.state
}
private resolveDurationSec(): number {
if (process.env.BILIN_E2E === '1' && process.env.POMODORO_TEST_SEC) {
return Number(process.env.POMODORO_TEST_SEC) || 3
}
const mins = this.settings.get().pomodoroDurationMinutes ?? 25
return mins * 60
}
private complete(): void {
const bookId = this.state.bookId!
const delta = Math.max(0, this.writingLogs.getStats(bookId).todayWords - this.wordSnapshot)
this.pomodoroDaily.recordComplete(bookId, delta)
this.notify.notifyPomodoroComplete(bookId, delta)
this.reset()
}
// pause, resume, cancel, getState, tick...
}
```
- [x] **Step 2: UT-POM-01 / UT-POM-02**
```typescript
it('UT-POM-01: complete records word delta', () => { /* mock writingLogs */ })
it('UT-POM-02: start different book cancels previous', () => { /* ... */ })
```
- [x] **Step 3: 运行**
```bash
npm run test -- tests/main/pomodoro.test.ts
```
---
## Task 5: GoalNotificationService + AchievementService
**Files:**
- Create: `src/main/services/goal-notification.service.ts`
- Create: `src/main/services/achievement.service.ts`
- Modify: `src/main/services/writing-log.service.ts`
- Modify: `tests/main/achievement.test.ts`
- [x] **Step 1: GoalNotificationService**
```typescript
export class GoalNotificationService {
constructor(
private settings: GlobalSettingsService,
private getMainWindow: () => BrowserWindow | null
) {}
pushToast(payload: GoalNotificationPayload): void {
this.getMainWindow()?.webContents.send(IPC.GOAL_NOTIFICATION, payload)
}
maybeSystemNotify(title: string, body: string): void {
if (!this.settings.get().enableSystemNotifications) return
try {
new Notification({ title, body }).show()
} catch { /* ignore permission denied */ }
}
notifyDailyGoalMet(bookId: string, current: number, goal: number): void { /* ... */ }
notifyMilestone(milestone: WritingMilestone): void { /* ... */ }
notifyPomodoroComplete(bookId: string, words: number): void { /* ... */ }
}
```
- [x] **Step 2: AchievementService**
```typescript
checkMilestones(bookId: string): WritingAchievement[] {
const { dailyWordGoal } = this.settings.get()
if (dailyWordGoal <= 0) return []
const streak = this.writingLogs.getStats(bookId).streakDays
const unlocked: WritingAchievement[] = []
for (const [threshold, m] of [[7,'streak_7'],[30,'streak_30'],[100,'streak_100']] as const) {
if (streak >= threshold && !this.repo.has(bookId, m)) {
const a = this.repo.unlock(bookId, m)
this.notify.notifyMilestone(m)
unlocked.push(a)
}
}
return unlocked
}
```
- [x] **Step 3: WritingLogService.addToday 扩展**
`addToday` 返回后:
```typescript
const stats = this.getStats(bookId)
if (stats.dailyGoal > 0 && stats.todayWords >= stats.dailyGoal) {
this.goalNotifications.maybeNotifyDailyMet(bookId, stats) // 内部检查 dailyGoalNotifiedDate
}
this.achievementService.checkMilestones(bookId)
```
- [x] **Step 4: UT-ACH-02 + IT-GOAL-01**
- [x] **Step 5: 运行**
```bash
npm run test -- tests/main/achievement.test.ts
```
---
## Task 6: KnowledgeInjectionService + confirmContext 挂钩
**Files:**
- Create: `src/main/services/knowledge-injection.service.ts`
- Modify: `src/main/ipc/handlers/interactive.handler.ts`
- Modify: `src/main/ipc/handlers/auto.handler.ts`
- Modify: `src/main/ipc/handlers/wizard.handler.ts`
- Modify: `tests/main/knowledge-injection.test.ts`
- [x] **Step 1: KnowledgeInjectionService**
```typescript
export class KnowledgeInjectionService {
record(bookDb: SqliteDb, input: {
knowledgeIds: string[]
flowMode: InjectionFlowMode
flowId: string
chapterId?: string
}): void {
if (input.knowledgeIds.length === 0) return
const knowledgeRepo = new KnowledgeRepository(bookDb)
const injectionRepo = new KnowledgeInjectionRepository(bookDb)
const rows = input.knowledgeIds
.filter((id) => knowledgeRepo.get(id)?.status === 'approved')
.map((id) => ({ knowledgeEntryId: id, flowMode: input.flowMode, flowId: input.flowId, chapterId: input.chapterId }))
injectionRepo.insertBatch(rows)
}
}
```
- [x] **Step 2: interactive.handler 挂钩**
`INTERACTIVE_CONFIRM_CONTEXT``buildFlowContextPayload` 成功后:
```typescript
try {
new KnowledgeInjectionService().record(registry.getDb(bookId), {
knowledgeIds: binding.knowledgeIds,
flowMode: 'interactive',
flowId,
chapterId: registry.getMeta(bookId)?.lastChapterId ?? undefined
})
} catch (err) {
console.error('[knowledge-injection]', err)
}
```
- [x] **Step 3: auto / wizard 同理**
- [x] **Step 4: IT-INJ-01**
直接调用 handler 或 service + 内存 DB,断言 `knowledge_injection_logs` 行数。
- [x] **Step 5: 运行**
```bash
npm run test -- tests/main/knowledge-injection.test.ts
```
---
## Task 7: IPC + register + preload + Cockpit 扩展
**Files:**
- Create: `src/main/ipc/handlers/pomodoro.handler.ts`
- Create: `src/main/ipc/handlers/achievement.handler.ts`
- Modify: `src/main/ipc/handlers/knowledge.handler.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/main/services/cockpit.service.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- [x] **Step 1: pomodoro.handler + achievement.handler**
- [x] **Step 2: knowledge.handler**
```typescript
ipcMain.handle(IPC.KNOWLEDGE_LIST_INJECTIONS, (_e, { bookId, entryId, limit }) =>
wrap(() => new KnowledgeInjectionService(registry.getDb(bookId)).listForEntry(entryId, limit))
)
```
- [x] **Step 3: register.ts 注入**
```typescript
const goalNotify = new GoalNotificationService(settings, () => getMainWindow())
const achievementService = new AchievementService(writingLogs, milestoneRepo, settings, goalNotify)
writingLogs.setAchievementHooks(achievementService, goalNotify)
const pomodoro = new PomodoroService(writingLogs, pomodoroDailyRepo, settings, goalNotify, pushTick)
registerPomodoroHandlers(pomodoro)
registerAchievementHandlers(achievementService)
```
- [x] **Step 4: preload**
```typescript
pomodoro: {
start: (bookId: string) => ipcRenderer.invoke(IPC.POMODORO_START, { bookId }),
pause: () => ipcRenderer.invoke(IPC.POMODORO_PAUSE),
resume: () => ipcRenderer.invoke(IPC.POMODORO_RESUME),
cancel: () => ipcRenderer.invoke(IPC.POMODORO_CANCEL),
getState: () => ipcRenderer.invoke(IPC.POMODORO_GET_STATE)
},
onPomodoroTick: (cb) => { ipcRenderer.on(IPC.POMODORO_TICK, cb); return () => ... },
onGoalNotification: (cb) => { ipcRenderer.on(IPC.GOAL_NOTIFICATION, cb); return () => ... },
```
- [x] **Step 5: CockpitService 扩展**
```typescript
achievements: this.achievementService.listAchievements(bookId),
pomodoroTodayCount: this.pomodoroDaily.getTodayCount(bookId),
```
- [x] **Step 6: build**
```bash
npm run build
```
---
## Task 8: 状态栏番茄 + Goal 通知监听
**Files:**
- Create: `src/renderer/stores/usePomodoroStore.ts`
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
- Modify: `src/renderer/App.tsx`(注册 onGoalNotification 全局 Toast
- Modify: `src/renderer/styles/layout.css`
- [x] **Step 1: usePomodoroStore**
订阅 `onPomodoroTick`actions`start/pause/resume/cancel`
- [x] **Step 2: EditorLayout 状态栏控件**
```tsx
<div className="pomodoro-control" data-testid="pomodoro-control">
{!running ? (
<button onClick={() => void start(currentBookId!)}></button>
) : (
<>
<button onClick={() => void (paused ? resume() : pause())}>{paused ? '▶' : '⏸'}</button>
<span>{formatMmSs(remainingSec)}</span>
<button onClick={() => void cancel()}></button>
</>
)}
</div>
```
- [x] **Step 3: App.tsx goal notification**
```typescript
useEffect(() => {
return window.electronAPI.onGoalNotification((payload) => {
showToast(t(payload.messageKey, payload.messageParams))
})
}, [])
```
- [x] **Step 4: build 验证**
```bash
npm run build
```
---
## Task 9: 驾驶舱成就区 + 设置页
**Files:**
- Modify: `src/renderer/components/cockpit/CockpitModal.tsx`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- Modify: `public/locales/zh-CN/translation.json`
- Modify: `public/locales/en/translation.json`
- [x] **Step 1: CockpitModal 成就区**
```tsx
{summary.achievements && summary.achievements.length > 0 && (
<div data-testid="cockpit-achievements">...</div>
)}
<div data-testid="cockpit-pomodoro-today">
{t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
</div>
```
- [x] **Step 2: SettingsPage 三项设置**
`settings-pomodoro-duration` / `settings-system-notifications` / `settings-goal-notifications`
- [x] **Step 3: i18n**spec §7 全部键,中英)
- [x] **Step 4: build**
```bash
npm run build
```
---
## Task 10: 知识库注入历史 UI
**Files:**
- Modify: `src/renderer/components/knowledge/KnowledgePanel.tsx`
- Modify: `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx`
- Modify: `src/renderer/stores/useKnowledgeStore.ts`
- [x] **Step 1: KnowledgePanel 加载注入预览**
条目 render 时调用 `knowledge.listInjections(bookId, entry.id, 3)`(或 batch 加载 map)。
```tsx
<div data-testid={`knowledge-injection-preview-${entry.id}`}>
{injections.map((log) => (
<div key={log.id}>{t('knowledge.injectionEntry', { date, mode, chapter })}</div>
))}
</div>
```
- [x] **Step 2: KnowledgeEditorDialog 完整历史**
`data-testid="knowledge-injection-history"`;章节名点击 `switchTarget({ kind:'chapter', id })`
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 11: E2E + v0.9.0 交付
**Files:**
- Create: `e2e/writing-goals.spec.ts`
- Create: `e2e/knowledge-injection-history.spec.ts`
- Modify: `package.json`
- Modify: `README.md`
- Modify: `tests/main/migrate-v7.test.ts` 等(若 CURRENT_VERSION→8,期望版本改为 8
- [x] **Step 1: E2E-GOAL-01~03**
- E2E-GOAL-01:设 dailyWordGoal=100,打字保存,断言 toast / status-daily-goal
- E2E-GOAL-02:通过测试 hook 或 seed API 写入 streak_7 milestone,打开驾驶舱断言 `cockpit-achievements`
- E2E-GOAL-03`POMODORO_TEST_SEC=3`,启动番茄,等待完成 Toast
- [x] **Step 2: E2E-INJ-01**
向导 confirm 知识上下文(可 stub 空 AI),断言 `knowledge-injection-preview-*` 可见。
- [x] **Step 3: 全量测试**
```bash
npm run build
npm run test
npm run test:e2e -- e2e/writing-goals.spec.ts e2e/knowledge-injection-history.spec.ts
```
- [x] **Step 4: 版本 0.9.0**
`package.json``README.md``SettingsPage` about 版本号。
- [x] **Step 5: 更新 migrate-v2~v7 测试期望版本 → 8**(与 P5.2 v7 时同样处理)
---
## Spec Coverage Checklist
| Spec 要求 | Task |
|-----------|------|
| 番茄钟 15/25/45 + 状态栏 UI | Task 1, 4, 8 |
| 番茄完成字数 + pomodoro_daily | Task 2, 4 |
| 7/30/100 里程碑 + 成就区 | Task 2, 5, 9 |
| Toast + 系统通知 + 设置开关 | Task 1, 5, 8, 9 |
| 日更首次达标通知 | Task 5 |
| knowledge_injection_logs v8 | Task 3 |
| 三模式 confirmContext 挂钩 | Task 6 |
| 知识库注入预览 + 完整历史 | Task 10 |
| UT/IT/E2E | Task 26, 11 |
| v0.9.0 DoD | Task 11 |
---
## Execution Notes
- `WritingLogService` 需轻量 refactor 接受 `AchievementService` / `GoalNotificationService` 回调(constructor 注入或 setter,避免循环依赖:Achievement 依赖 WritingLogWritingLog 回调 Achievement
- 切换书籍时:`useBookStore` 订阅 `currentBookId` 变化 → 若 pomodoro running 且 bookId 不同 → `cancel()` + toast `pomodoro.switchedBook`
- 注入历史 E2E **禁止 Mock AI** 指生成类测试;confirmContext 本身不调用 AI,可纯 UI 流程
@@ -0,0 +1,927 @@
# 笔临 P6 AI 知识上下文整合 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.7.0:已审核知识库条目半自动推荐 + 用户勾选确认 + 注入对话/交互/自动/向导四种 AI 模式;修复写作流 `contextJson` 仅存标题摘要的问题。
**Architecture:** 新增 `KnowledgeRetrievalService``approved` 知识条目综合打分;扩展 `AiContextBinding.knowledgeIds``AiContextBuilderService` 合并知识块;`FlowContextPayload` 统一写作流上下文存储;`KnowledgeSuggestList` + `ContextEditorModal` 知识 Tab 供四种模式共用。测试 **禁止 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-07-bilin-p6-ai-context-design.md`
**LM Studio 默认:** `baseUrl=http://127.0.0.1:1234``model=gemma-4-e4b-it`
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/types.ts` | `KnowledgeSuggestOptions``ScoredKnowledge``FlowContextPayload`;扩展 `AiContextBinding` |
| `src/shared/ipc-channels.ts` | `KNOWLEDGE_SUGGEST_CONTEXT` |
| `src/main/services/knowledge-retrieval.service.ts` | 打分 + TopN 推荐 |
| `src/main/services/ai-context-builder.service.ts` | knowledge 块 + 预算裁剪 |
| `src/main/services/flow-context.util.ts` | `buildFlowContextPayload()` 共享 |
| `src/main/services/interactive-writing.service.ts` | `readContextText``systemPrompt` |
| `src/main/db/repositories/ai-session.repo.ts` | `parseContext``knowledgeIds` |
| `src/main/ipc/handlers/knowledge.handler.ts` | `suggestContext` |
| `src/main/ipc/handlers/interactive.handler.ts` | `FlowContextPayload` |
| `src/main/ipc/handlers/auto.handler.ts` | `readContextText` 修复 |
| `src/main/ipc/handlers/wizard.handler.ts` | 同上 + preview 知识列表 |
| `src/main/ipc/handlers/ai.handler.ts` | binding 默认 `knowledgeIds: []` |
| `src/main/services/global-settings.ts` | `knowledgeAutoSuggest` / `knowledgeSuggestTopN` 默认值 |
| `src/preload/index.ts` | `knowledge.suggestContext` |
| `src/shared/electron-api.d.ts` | 类型 |
| `src/renderer/components/ai/KnowledgeSuggestList.tsx` | 共享勾选 UI |
| `src/renderer/components/ai/ContextEditorModal.tsx` | 知识 Tab + 推荐区 |
| `src/renderer/components/ai/InteractivePanel.tsx` | context_confirm 知识区 |
| `src/renderer/components/ai/AutoPanel.tsx` | goal 后知识确认 |
| `src/renderer/components/ai/WizardPanel.tsx` | wizard_context 知识区 |
| `src/renderer/lib/ai-context-summary.ts` | 摘要含知识计数 |
| `src/renderer/stores/useInteractiveStore.ts` | binding 含 knowledgeIds |
| `public/locales/zh-CN/translation.json` | P6 文案 |
| `public/locales/en/translation.json` | P6 文案 |
| `tests/main/knowledge-retrieval.test.ts` | UT-CTX-01/04 |
| `tests/main/ai-context-builder.test.ts` | UT-CTX-02/03(扩展) |
| `tests/main/flow-context.test.ts` | FlowContextPayload 单元 |
| `tests/main/ai-context-integration.test.ts` | IT-CTX-01/02/03 |
| `e2e/ai-context.spec.ts` | E2E-CTX-* |
---
## Task 1: 类型 + IPC 通道 + 默认 binding
**Files:**
- Modify: `src/shared/types.ts`
- Modify: `src/shared/ipc-channels.ts`
- Modify: `src/main/db/repositories/ai-session.repo.ts`
- [x] **Step 1: 扩展 types**
`src/shared/types.ts` 追加:
```typescript
export interface KnowledgeSuggestOptions {
currentChapterId?: string
goalText?: string
topN?: number
}
export interface ScoredKnowledge {
entry: KnowledgeEntry
score: number
reasons: string[]
}
export interface FlowContextPayload {
binding: AiContextBinding
systemPrompt: string
contextSummary: string
}
```
扩展 `AiContextBinding`
```typescript
export interface AiContextBinding {
chapterIds: string[]
outlineIds: string[]
settingIds: string[]
inspirationIds: string[]
knowledgeIds: string[]
}
```
扩展 `ContextPreviewKind``| 'knowledge'`
扩展 `ContextPreviewItem``knowledgeType?: KnowledgeType`
扩展 `GlobalSettings`(可选,本 Task 仅类型,Task 2 实现默认值):
```typescript
knowledgeAutoSuggest: boolean
knowledgeSuggestTopN: number
```
- [x] **Step 2: IPC 通道**
`src/shared/ipc-channels.ts`
```typescript
KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext',
```
- [x] **Step 3: ai-session.repo 默认 knowledgeIds**
```typescript
const EMPTY_CONTEXT: AiContextBinding = {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
}
function parseContext(json: string): AiContextBinding {
try {
const parsed = JSON.parse(json) as Partial<AiContextBinding>
return {
chapterIds: parsed.chapterIds ?? [],
outlineIds: parsed.outlineIds ?? [],
settingIds: parsed.settingIds ?? [],
inspirationIds: parsed.inspirationIds ?? [],
knowledgeIds: parsed.knowledgeIds ?? []
}
} catch {
return { ...EMPTY_CONTEXT }
}
}
```
- [x] **Step 4: 全局修补 EMPTY_BINDING**
搜索 `inspirationIds: []` 且无 `knowledgeIds``AiContextBinding` 字面量(`ContextEditorModal.tsx``useInteractiveStore.ts` 等),全部补上 `knowledgeIds: []`
- [x] **Step 5: build 验证**
```bash
npm run build
```
- [x] **Step 6: Commit**
```bash
git add src/shared/types.ts src/shared/ipc-channels.ts src/main/db/repositories/ai-session.repo.ts
git commit -m "feat: extend AiContextBinding with knowledgeIds for P6"
```
---
## Task 2: KnowledgeRetrievalService
**Files:**
- Create: `src/main/services/knowledge-retrieval.service.ts`
- Create: `tests/main/knowledge-retrieval.test.ts`
- [x] **Step 1: UT-CTX-01 / UT-CTX-04 失败测试**
`tests/main/knowledge-retrieval.test.ts`
```typescript
import { describe, it, expect, 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 { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('KnowledgeRetrievalService', () => {
let db: SqliteDb
afterEach(() => db?.close())
it('UT-CTX-01: ranks by importance + chapter proximity + goal match', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const volId = new VolumeRepository(db).create('卷一', 0).id
const chapterRepo = new ChapterRepository(db)
const cur = chapterRepo.create(volId, '当前章', 5, '<p>x</p>')
const near = chapterRepo.create(volId, '近章', 4, '<p>x</p>')
chapterRepo.create(volId, '远章', 0, '<p>x</p>')
const repo = new KnowledgeRepository(db)
const low = repo.create({
type: 'fact',
title: '无关事实',
content: '普通内容',
importance: 2,
status: 'approved'
})
const high = repo.create({
type: 'foreshadow',
title: '测灵石异常',
content: '测灵石在入门考核中发热',
importance: 5,
status: 'approved',
foreshadowStatus: 'buried',
sourceChapterId: near.id
})
const service = new KnowledgeRetrievalService(db)
const result = service.suggest({
currentChapterId: cur.id,
goalText: '测灵石入门考核',
topN: 5
})
expect(result[0].entry.id).toBe(high.id)
expect(result[0].score).toBeGreaterThan(
result.find((r) => r.entry.id === low.id)!.score
)
expect(result[0].reasons.length).toBeGreaterThan(0)
})
it('UT-CTX-04: resolved foreshadow scores lower than buried', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const volId = new VolumeRepository(db).create('卷一', 0).id
const chId = new ChapterRepository(db).create(volId, '章', 0).id
const repo = new KnowledgeRepository(db)
const buried = repo.update(
repo.create({
type: 'foreshadow',
title: '埋设伏笔',
importance: 3,
status: 'approved',
foreshadowStatus: 'buried',
sourceChapterId: chId
}).id,
{ status: 'approved' }
)
const resolved = repo.create({
type: 'foreshadow',
title: '已回收伏笔',
importance: 3,
status: 'approved',
foreshadowStatus: 'resolved',
sourceChapterId: chId
})
const service = new KnowledgeRetrievalService(db)
const scores = service.suggest({ currentChapterId: chId, topN: 10 })
const buriedScore = scores.find((s) => s.entry.id === buried.id)!.score
const resolvedScore = scores.find((s) => s.entry.id === resolved.id)!.score
expect(buriedScore).toBeGreaterThan(resolvedScore)
})
})
```
- [x] **Step 2: 运行确认 FAIL**
```bash
npm run test -- tests/main/knowledge-retrieval.test.ts
```
- [x] **Step 3: 实现 KnowledgeRetrievalService**
`src/main/services/knowledge-retrieval.service.ts` 核心:
```typescript
import type { KnowledgeEntry, KnowledgeSuggestOptions, ScoredKnowledge } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
const STOP_WORDS = new Set(['的', '了', '在', '是', '与', '和', 'the', 'a', 'an'])
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[\s,,。!?、;;:]+/)
.map((t) => t.trim())
.filter((t) => t.length >= 2 && !STOP_WORDS.has(t))
}
function overlapRatio(a: string, b: string): number {
const ta = new Set(tokenize(a))
const tb = tokenize(b)
if (ta.size === 0 || tb.length === 0) return 0
let hit = 0
for (const t of tb) if (ta.has(t)) hit++
return hit / tb.length
}
function chapterProximityBonus(
entry: KnowledgeEntry,
currentSort: number | null,
chapterSort: Map<string, number>
): number {
if (currentSort == null) return 0
const ids = [entry.sourceChapterId, entry.lastMentionChapterId].filter(Boolean) as string[]
if (ids.length === 0) return 0
let best = Infinity
for (const id of ids) {
const order = chapterSort.get(id)
if (order != null) best = Math.min(best, Math.abs(currentSort - order))
}
if (best === Infinity) return 0
if (best === 0) return 30
if (best <= 3) return 20
if (best <= 10) return 10
return 0
}
export class KnowledgeRetrievalService {
constructor(private db: SqliteDb) {}
suggest(opts: KnowledgeSuggestOptions = {}): ScoredKnowledge[] {
const topN = opts.topN ?? 8
const entries = new KnowledgeRepository(this.db).list({ status: 'approved' })
if (entries.length === 0) return []
const chapters = new ChapterRepository(this.db).list()
const sortMap = new Map(chapters.map((c) => [c.id, c.sortOrder]))
const currentSort = opts.currentChapterId
? (sortMap.get(opts.currentChapterId) ?? null)
: null
const goalText = opts.goalText ?? ''
const scored: ScoredKnowledge[] = entries.map((entry) => {
let score = entry.importance * 20
const reasons: string[] = []
const prox = chapterProximityBonus(entry, currentSort, sortMap)
score += prox
if (prox > 0) reasons.push('knowledge.reason.chapterRelevant')
if (entry.type === 'foreshadow') {
const fs = entry.foreshadowStatus ?? 'buried'
if (fs === 'buried') {
score += 25
reasons.push('knowledge.reason.foreshadowBuried')
} else if (fs === 'partial') score += 15
}
if (goalText) {
const text = `${entry.title} ${entry.content}`
const ratio = overlapRatio(goalText, text)
score += Math.round(ratio * 30)
if (ratio > 0.2) reasons.push('knowledge.reason.goalMatch')
}
if (entry.importance >= 4) reasons.push('knowledge.reason.highImportance')
return { entry, score, reasons: [...new Set(reasons)] }
})
return scored.sort((a, b) => b.score - a.score).slice(0, topN)
}
}
```
- [x] **Step 4: 运行 PASS**
```bash
npm run test -- tests/main/knowledge-retrieval.test.ts
```
- [x] **Step 5: Commit**
```bash
git add src/main/services/knowledge-retrieval.service.ts tests/main/knowledge-retrieval.test.ts
git commit -m "feat: add KnowledgeRetrievalService with scoring for context suggest"
```
---
## Task 3: 扩展 AiContextBuilder + flow-context 工具
**Files:**
- Modify: `src/main/services/ai-context-builder.service.ts`
- Create: `src/main/services/flow-context.util.ts`
- Modify: `tests/main/ai-context-builder.test.ts`
- Create: `tests/main/flow-context.test.ts`
- [x] **Step 1: UT-CTX-02 / UT-CTX-03 测试**
`tests/main/ai-context-builder.test.ts` 追加:
```typescript
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
it('UT-CTX-02: build includes knowledge preview items', () => {
const know = new KnowledgeRepository(db)
const entry = know.create({
type: 'foreshadow',
title: '测灵石',
content: '石头发热',
status: 'approved'
})
const result = aiContextBuilder.build(
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [entry.id] },
registry,
bookId,
'作者'
)
expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true)
expect(result.systemPrompt).toContain('【知识·伏笔】')
expect(result.systemPrompt).toContain('测灵石')
})
it('UT-CTX-03: caps knowledge section at 4000 chars', () => {
const know = new KnowledgeRepository(db)
const ids: string[] = []
for (let i = 0; i < 20; i++) {
const e = know.create({
type: 'fact',
title: `条目${i}`,
content: '长'.repeat(400),
importance: 5,
status: 'approved'
})
ids.push(e.id)
}
const result = aiContextBuilder.build(
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: ids },
registry,
bookId,
'作者'
)
const knowledgeChars = result.preview
.filter((p) => p.kind === 'knowledge')
.reduce((s, p) => s + p.charCount, 0)
expect(knowledgeChars).toBeLessThanOrEqual(4000)
})
```
`tests/main/flow-context.test.ts`
```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { BookRegistryService } from '../../src/main/services/book-registry'
import { buildFlowContextPayload } from '../../src/main/services/flow-context.util'
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
describe('flow-context.util', () => {
let userDir: string
let registry: BookRegistryService
let bookId: string
beforeEach(() => {
userDir = mkdtempSync(join(tmpdir(), 'bilin-flow-ctx-'))
registry = new BookRegistryService(userDir)
bookId = registry.create({ name: '书', category: '玄幻' }).id
})
afterEach(() => rmSync(userDir, { recursive: true, force: true }))
it('buildFlowContextPayload stores systemPrompt and summary', () => {
const db = registry.getDb(bookId)
const entry = new KnowledgeRepository(db).create({
type: 'fact',
title: '测试知识',
content: '内容',
status: 'approved'
})
const payload = buildFlowContextPayload(
{
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: [entry.id]
},
registry,
bookId,
'作者'
)
expect(payload.systemPrompt).toContain('测试知识')
expect(payload.contextSummary).toContain('1')
db.close()
})
})
```
- [x] **Step 2: 运行确认 FAIL**
```bash
npm run test -- tests/main/ai-context-builder.test.ts tests/main/flow-context.test.ts
```
- [x] **Step 3: 扩展 ai-context-builder**
要点:
- `KNOWLEDGE_CHAR_LIMIT = 500``KNOWLEDGE_TOTAL_LIMIT = 4000`
- `knowledgeTypeLabel()` 映射五种类型
-`candidates` 循环后追加 knowledge 块,带 `sortKey: entry.importance * 1000`(高 important 先保留)
- 知识区单独裁剪至 4000 后再参与总 16KB 裁剪
- 所有现有 `build()` 调用 binding 需含 `knowledgeIds`(测试已补)
`src/main/services/flow-context.util.ts`
```typescript
import type { AiContextBinding, FlowContextPayload } from '../../shared/types'
import type { BookRegistryService } from './book-registry'
import { aiContextBuilder } from './ai-context-builder.service'
export function buildFlowContextPayload(
binding: AiContextBinding,
registry: BookRegistryService,
bookId: string,
penName: string
): FlowContextPayload {
const built = aiContextBuilder.build(binding, registry, bookId, penName)
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 }
for (const p of built.preview) {
if (p.kind in counts) counts[p.kind as keyof typeof counts]++
}
const contextSummary = `${counts.chapter}章·${counts.outline}纲·${counts.setting}设·${counts.inspiration}感·${counts.knowledge}`
return { binding, systemPrompt: built.systemPrompt, contextSummary }
}
```
- [x] **Step 4: 运行 PASS**
```bash
npm run test -- tests/main/ai-context-builder.test.ts tests/main/flow-context.test.ts
```
- [x] **Step 5: Commit**
```bash
git add src/main/services/ai-context-builder.service.ts src/main/services/flow-context.util.ts tests/main/
git commit -m "feat: extend AiContextBuilder with knowledge blocks and flow payload helper"
```
---
## Task 4: IPC suggestContext + preload
**Files:**
- Modify: `src/main/ipc/handlers/knowledge.handler.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- Modify: `src/main/services/global-settings.ts`
- Modify: `src/renderer/stores/useSettingsStore.ts`
- [x] **Step 1: knowledge.handler 追加**
```typescript
ipcMain.handle(
IPC.KNOWLEDGE_SUGGEST_CONTEXT,
(
_e,
{ bookId, opts }: { bookId: string; opts?: KnowledgeSuggestOptions }
) => wrap(() => {
const settings = /* inject via handler param or read topN from settings */
new KnowledgeRetrievalService(registry.getDb(bookId)).suggest(opts)
})
)
```
`registerKnowledgeHandlers` 签名改为接收 `GlobalSettingsService``topN` 默认 `settings.get().knowledgeSuggestTopN ?? 8`
- [x] **Step 2: global-settings 默认值**
```typescript
knowledgeAutoSuggest: true,
knowledgeSuggestTopN: 8,
```
`get()` merge 补默认。
- [x] **Step 3: preload + electron-api.d.ts**
```typescript
suggestContext: (
bookId: string,
opts?: KnowledgeSuggestOptions
): Promise<IpcResult<ScoredKnowledge[]>>
```
- [x] **Step 4: build**
```bash
npm run build
```
- [x] **Step 5: Commit**
```bash
git commit -m "feat: add knowledge suggestContext IPC and global settings defaults"
```
---
## Task 5: 写作流 systemPrompt 修复
**Files:**
- Modify: `src/main/services/interactive-writing.service.ts`
- Modify: `src/main/ipc/handlers/interactive.handler.ts`
- Modify: `src/main/ipc/handlers/auto.handler.ts`
- Modify: `src/main/ipc/handlers/wizard.handler.ts`
- Modify: `src/main/services/wizard-writing.service.ts``buildPreview` 追加知识列表)
- [x] **Step 1: interactive confirmContext 存 FlowContextPayload**
`interactive.handler.ts` `INTERACTIVE_CONFIRM_CONTEXT`
```typescript
const payload = buildFlowContextPayload(binding, registry, bookId, settings.get().penName)
const flow = service.confirmContext(flowId, payload)
```
`InteractiveWritingService.confirmContext` 改为:
```typescript
confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow {
this.repo.updateStep(flowId, 'plot_suggest', {
contextJson: JSON.stringify(payload)
})
return this.repo.get(flowId)!
}
private readContextText(flowId: string): string {
try {
const parsed = JSON.parse(this.repo.getContextJson(flowId)) as FlowContextPayload
return parsed.systemPrompt || '(无上下文)'
} catch {
return '(无上下文)'
}
}
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
try {
const parsed = JSON.parse(this.repo.getContextJson(flow.id)) as FlowContextPayload
return { ...flow, contextSummary: parsed.contextSummary }
} catch {
return flow
}
}
```
- [x] **Step 2: auto.handler / wizard.handler readContextText**
替换本地 `readContextText` 为读 `systemPrompt`
```typescript
function readContextText(registry, bookId, flowId): string {
const row = registry.getDb(bookId)
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { context_json: string } | undefined
try {
const parsed = JSON.parse(row?.context_json ?? '{}') as FlowContextPayload
return parsed.systemPrompt ?? '(无上下文)'
} catch {
return '(无上下文)'
}
}
```
- [x] **Step 3: wizard confirmContext 同 interactive**
`wizard.handler.ts` `WIZARD_CONFIRM_CONTEXT` 使用 `buildFlowContextPayload`
- [x] **Step 4: buildPreview 追加知识标题**
`WizardWritingService.buildPreview` 返回前,从 `payload.binding.knowledgeIds` 查标题,append `\n将注入知识:A、B、C`
- [x] **Step 5: auto planScenes 前自动 confirm**
`AutoPanel``planScenes` 调用前,若 flow 无 `systemPrompt`,用 session binding + 默认推荐 knowledgeIds 调 `interactive.confirmContext` 等价逻辑(可新增 `auto:confirmContext` IPC 或复用 `buildFlowContextPayload` + `setContextSummary` 扩展为存完整 payload)。
推荐:新增 `auto.handler` `AUTO_CONFIRM_CONTEXT` 与 interactive 对称。
- [x] **Step 6: Commit**
```bash
git commit -m "fix: store full systemPrompt in writing flow contextJson for P6"
```
---
## Task 6: KnowledgeSuggestList + ContextEditorModal
**Files:**
- Create: `src/renderer/components/ai/KnowledgeSuggestList.tsx`
- Modify: `src/renderer/components/ai/ContextEditorModal.tsx`
- Modify: `src/renderer/lib/ai-context-summary.ts`
- [x] **Step 1: KnowledgeSuggestList**
Props
```typescript
interface KnowledgeSuggestListProps {
items: ScoredKnowledge[]
selectedIds: string[]
onToggle: (id: string) => void
}
```
渲染 checkbox + title + `t(\`knowledge.type.${entry.type}\`)` + reason badges `t(reason)`。
- [x] **Step 2: ContextEditorModal 扩展**
新增 props`goalText?: string`
打开时:
```typescript
const chapterId = useBookStore.getState().selectedChapterId ?? undefined
const suggested = await ipcCall(() =>
window.electronAPI.knowledge.suggestContext(bookId, { currentChapterId: chapterId, goalText })
)
// 默认 merge suggested ids into binding.knowledgeIds
```
新增 Tab `knowledge`;顶部推荐区 + `context-refresh-suggest`;下方 approved 全列表(`knowledge.list(bookId, { status: 'approved' })`)可勾选。
- [x] **Step 3: formatContextSummary 含 knowledge**
```typescript
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 }
// ...
return t('ai.contextSummary', { ...counts })
```
更新 `ai.contextSummary` i18n 为 `{{chapters}}章·{{outlines}}纲·{{settings}}设·{{inspirations}}感·{{knowledge}}知`。
- [x] **Step 4: Commit**
```bash
git commit -m "feat: add KnowledgeSuggestList and knowledge tab in context editor"
```
---
## Task 7: 交互 / 自动 / 向导 Panel 集成
**Files:**
- Modify: `src/renderer/components/ai/InteractivePanel.tsx`
- Modify: `src/renderer/components/ai/AutoPanel.tsx`
- Modify: `src/renderer/components/ai/WizardPanel.tsx`
- Modify: `src/renderer/stores/useInteractiveStore.ts`
- Modify: `src/renderer/stores/useAutoStore.ts`confirmContext
- Modify: `src/renderer/components/ai/AiPanel.tsx`(传 goalText 空)
- [x] **Step 1: InteractivePanel context_confirm**
- mount 时 `suggestContext` → state `suggested`
- 内联 `<KnowledgeSuggestList>``data-testid="interactive-knowledge-suggest"`
- 勾选同步到 session `context.knowledgeIds``ai.sessionUpdate`
- `confirmContextAndStart` 传含 `knowledgeIds` 的 binding
- [x] **Step 2: AutoPanel**
- `goal_setup` 区域下方折叠「知识上下文」`data-testid="auto-knowledge-suggest"`
- `goalText` = `chapterGoal` + 选中大纲 description
- `planScenes` 前调用 `auto.confirmContext`Task 5 新增 IPC
- [x] **Step 3: WizardPanel wizard_context**
- 同 Interactive 内联推荐列表 `data-testid="wizard-knowledge-suggest"`
- `handleContextClose` 传 `goalText` = goal + styleNote
- [x] **Step 4: Commit**
```bash
git commit -m "feat: integrate knowledge suggest into interactive auto wizard panels"
```
---
## Task 8: i18n
**Files:**
- Modify: `public/locales/zh-CN/translation.json`
- Modify: `public/locales/en/translation.json`
- [x] **Step 1: 追加 spec §9 全部键 + `ai.contextSummary` 更新**
- [x] **Step 2: Commit**
```bash
git commit -m "feat: add P6 AI context i18n strings"
```
---
## Task 9: 集成测试
**Files:**
- Create: `tests/main/ai-context-integration.test.ts`
- [x] **Step 1: IT-CTX-01**
```typescript
it('IT-CTX-01: suggestContext returns entries when approved exist', () => {
// setup approved entry → suggest → length >= 1
})
```
- [x] **Step 2: IT-CTX-02**
通过 `aiContextBuilder.build` 含 knowledgeIds 断言 `systemPrompt` 含 `【知识·`(无需 Electron IPC)。
- [x] **Step 3: IT-CTX-03240s**
```typescript
it('IT-CTX-03: interactive confirm then suggestPlots uses knowledge context', async () => {
// setup book + knowledge + interactive flow
// confirmContext with knowledgeIds
// suggestPlots returns 3 options
}, 240_000)
```
- [x] **Step 4: 运行**
```bash
npm run test -- tests/main/ai-context-integration.test.ts
```
- [x] **Step 5: Commit**
```bash
git commit -m "test: add P6 AI context integration tests"
```
---
## Task 10: E2E
**Files:**
- Create: `e2e/ai-context.spec.ts`
- [x] **Step 1: E2E-CTX-01**
```typescript
test('E2E-CTX-01: context editor knowledge tab shows suggestions', async () => {
// create book → add approved knowledge via panel → open AI context editor → context-tab-knowledge visible
})
```
- [x] **Step 2: E2E-CTX-02**
```typescript
test('E2E-CTX-02: interactive context_confirm shows knowledge suggest', async () => {
// open interactive → interactive-knowledge-suggest visible
})
```
- [x] **Step 3: E2E-CTX-03300s**
```typescript
test('E2E-CTX-03: chat with knowledge context gets AI reply', async () => {
test.setTimeout(300_000)
// add knowledge → select in context → send chat → non-empty reply
})
```
- [x] **Step 4: 运行**
```bash
npm run test:e2e -- e2e/ai-context.spec.ts
```
- [x] **Step 5: Commit**
```bash
git commit -m "test: add P6 AI context E2E specs"
```
---
## Task 11: v0.7.0 交付
**Files:**
- Modify: `package.json`
- Modify: `README.md`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`about 版本号)
- Modify: `docs/superpowers/plans/2026-07-07-bilin-p6-ai-context.md`(勾选完成项)
- [x] **Step 1: 版本号 `0.7.0`**
- [x] **Step 2: README 追加**
```markdown
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
```
- [x] **Step 3: 全量测试**
```bash
npm run build
npm run test
npm run test:e2e
```
- [x] **Step 4: Commit**
```bash
git commit -m "feat: ship v0.7.0 with AI knowledge context integration"
```
---
## Spec Coverage Checklist
| Spec 要求 | Task |
|-----------|------|
| KnowledgeRetrievalService 打分 | Task 2 |
| AiContextBinding.knowledgeIds | Task 1 |
| AiContextBuilder 知识块 | Task 3 |
| FlowContextPayload / systemPrompt 修复 | Task 3, 5 |
| knowledge:suggestContext IPC | Task 4 |
| ContextEditorModal 知识 Tab | Task 6 |
| 对话静默注入 | Task 5AI_CHAT 已用 build+ Task 6 |
| 交互 context_confirm | Task 7 |
| 自动写作知识确认 | Task 5, 7 |
| 向导 wizard_context | Task 7 |
| global-settings 默认值 | Task 4 |
| UT-CTX-01~04 | Task 2, 3 |
| IT-CTX-01~03 | Task 9 |
| E2E-CTX-01~03 | Task 10 |
| v0.7.0 DoD | Task 11 |
@@ -0,0 +1,741 @@
# 笔临 P7 角色关系图谱 + P9 个人写作分析 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:** 交付 v1.0.0:§7 角色关系图谱(Cytoscape + 设定关系 CRUD+ §9 个人写作分析(`writing_sessions` 驱动速度/时段热力 + POV/卷分布),驾驶舱内嵌展示。
**Architecture:** 全局 `writing_logs.sqlite` 扩展 `writing_sessions` 表;`ChapterWritingTracker.recordDelta` 同步写 session`CharacterGraphService``settings.properties.relationships` 构建图 DTO`WritingAnalyticsService` 聚合 sessions/logs/pomodoro/章节;渲染进程 Cytoscape 模态 + 驾驶舱分析组件并行 IPC 请求。
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Cytoscape.js、cytoscape-cose-bilkent、Zustand、i18next、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-p7-graph-p9-analytics-design.md`
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/types.ts` | `CharacterRelationship``CharacterGraph*``WritingAnalyticsSummary` |
| `src/shared/ipc-channels.ts` | graph.* / analytics.summary |
| `src/main/db/repositories/writing-log.repo.ts` | migrate 扩展 writing_sessions |
| `src/main/db/repositories/writing-session.repo.ts` | session CRUD |
| `src/main/db/repositories/chapter.repo.ts` | povCharacterId mapRow/update |
| `src/main/services/writing-session.service.ts` | 5min idle 状态机 |
| `src/main/services/writing-analytics.service.ts` | getSummary 聚合 |
| `src/main/services/character-graph.service.ts` | 图谱 DTO + 关系 CRUD |
| `src/main/services/chapter-writing-tracker.service.ts` | recordDelta → session |
| `src/main/ipc/handlers/graph.handler.ts` | 图谱 IPC |
| `src/main/ipc/handlers/analytics.handler.ts` | 分析 IPC |
| `src/main/ipc/register.ts` | 注入新服务 |
| `src/preload/index.ts` + `electron-api.d.ts` | graph / analytics API |
| `src/renderer/components/graph/CharacterGraphModal.tsx` | Cytoscape UI |
| `src/renderer/stores/useGraphStore.ts` | 图谱模态状态 |
| `src/renderer/components/setting/SettingRelationshipsEditor.tsx` | 关系 CRUD |
| `src/renderer/components/cockpit/CockpitAnalytics.tsx` | 分析卡片 |
| `src/renderer/components/cockpit/HourlyHeatmap.tsx` | 24 槽热力 |
| `src/renderer/components/cockpit/CockpitModal.tsx` | 嵌入分析区 + 打开图谱 |
| `src/renderer/components/layout/EditorLayout.tsx` | chapter-pov-select |
| `src/renderer/components/setting/SettingList.tsx` | setting-open-graph |
| `src/renderer/styles/layout.css` | graph / analytics 样式 |
| `public/locales/*/translation.json` | graph / analytics / relationship / chapter.pov |
| `tests/main/writing-session.test.ts` | UT-SESSION-* |
| `tests/main/character-graph.test.ts` | UT-GRAPH-* / IT-GRAPH-01 |
| `tests/main/writing-analytics.test.ts` | UT-ANALYTICS-* / IT-ANALYTICS-01 |
| `e2e/character-graph.spec.ts` | E2E-GRAPH-* |
| `e2e/writing-analytics.spec.ts` | E2E-ANALYTICS-* |
---
## Task 1: 类型 + IPC 通道
**Files:**
- Modify: `src/shared/types.ts`
- Modify: `src/shared/ipc-channels.ts`
- [x] **Step 1: 扩展 types**
```typescript
export interface CharacterRelationship {
id: string
targetId: string
label: string
intimacy: 1 | 2 | 3 | 4 | 5
}
export interface CharacterGraphNode {
id: string
label: string
}
export interface CharacterGraphEdge {
id: string
source: string
target: string
label: string
intimacy: 1 | 2 | 3 | 4 | 5
}
export interface CharacterGraphData {
nodes: CharacterGraphNode[]
edges: CharacterGraphEdge[]
truncated: boolean
nodeCount: number
}
export interface WritingAnalyticsSummary {
todayWords: number
todaySpeedWpm: number | null
weekTrend: { date: string; words: number }[]
hourlyHeatmap: number[]
pomodoroToday: number
pomodoroAvgWords: number | null
povDistribution: { characterId: string; name: string; words: number }[]
volumeDistribution: { volumeId: string; title: string; words: number }[]
}
```
`SettingEntry.properties` 使用文档约定 `relationships?: CharacterRelationship[]`JSDoc 或 inline 注释即可)。
- [x] **Step 2: IPC 通道**
```typescript
GRAPH_GET_DATA: 'graph:getData',
GRAPH_UPSERT_RELATIONSHIP: 'graph:upsertRelationship',
GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship',
ANALYTICS_SUMMARY: 'analytics:summary',
```
- [x] **Step 3: build 验证**
```bash
npm run build
```
---
## Task 2: writing_sessions 表 + Repository
**Files:**
- Modify: `src/main/db/repositories/writing-log.repo.ts`
- Create: `src/main/db/repositories/writing-session.repo.ts`
- Create: `tests/main/writing-session.test.ts`repo 部分)
- [x] **Step 1: writing-log.repo migrate 扩展**
`migrate()``CREATE TABLE` 块追加:
```sql
CREATE TABLE IF NOT EXISTS writing_sessions (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
chapter_id TEXT,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
word_delta INTEGER NOT NULL DEFAULT 0,
date TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_book_date ON writing_sessions (book_id, date);
CREATE INDEX IF NOT EXISTS idx_sessions_book_started ON writing_sessions (book_id, started_at);
```
- [x] **Step 2: WritingSessionRepository**
```typescript
export class WritingSessionRepository {
constructor(private db: DatabaseSync) {}
insert(row: { id: string; bookId: string; chapterId?: string; startedAt: string; endedAt: string; wordDelta: number; date: string }): void
updateActivity(id: string, endedAt: string, wordDelta: number): void
listForBookDate(bookId: string, date: string): WritingSessionRow[]
listSince(bookId: string, sinceIso: string): WritingSessionRow[]
getById(id: string): WritingSessionRow | null
}
```
- [x] **Step 3: UT repo smoke**
```typescript
it('UT-SESSION-03: insert and updateActivity accumulates word_delta', () => {
const repo = new WritingSessionRepository(db)
const id = randomUUID()
repo.insert({ id, bookId: 'b1', chapterId: 'c1', startedAt: '2026-07-08T10:00:00', endedAt: '2026-07-08T10:05:00', wordDelta: 50, date: '2026-07-08' })
repo.updateActivity(id, '2026-07-08T10:10:00', 30)
expect(repo.getById(id)!.wordDelta).toBe(80)
})
```
- [x] **Step 4: 运行**
```bash
npm run test -- tests/main/writing-session.test.ts
```
---
## Task 3: WritingSessionService 状态机
**Files:**
- Create: `src/main/services/writing-session.service.ts`
- Modify: `tests/main/writing-session.test.ts`
- [x] **Step 1: 实现**
```typescript
const IDLE_MS = 5 * 60 * 1000
export class WritingSessionService {
private active = new Map<string, { sessionId: string; lastEndedAt: number }>()
constructor(private repo: WritingSessionRepository) {}
recordActivity(bookId: string, chapterId: string | undefined, delta: number): void {
if (delta === 0) return
const now = new Date()
const nowIso = now.toISOString()
const cached = this.active.get(bookId)
const idle = !cached || now.getTime() - cached.lastEndedAt > IDLE_MS
if (idle) {
const id = randomUUID()
this.repo.insert({
id, bookId, chapterId,
startedAt: nowIso, endedAt: nowIso,
wordDelta: delta,
date: localDateString()
})
this.active.set(bookId, { sessionId: id, lastEndedAt: now.getTime() })
} else {
this.repo.updateActivity(cached!.sessionId, nowIso, delta)
this.active.set(bookId, { sessionId: cached!.sessionId, lastEndedAt: now.getTime() })
}
}
endActive(bookId: string): void {
this.active.delete(bookId)
}
}
```
- [x] **Step 2: UT-SESSION-01 / UT-SESSION-02**
使用 `vi.useFakeTimers()`
```typescript
it('UT-SESSION-01: within 5min merges into one session', () => { /* advance 2min, recordActivity twice, expect 1 row */ })
it('UT-SESSION-02: after 6min creates two sessions', () => { /* advance 6min between calls */ })
```
- [x] **Step 3: 运行**
```bash
npm run test -- tests/main/writing-session.test.ts
```
---
## Task 4: ChapterWritingTracker + chapter POV 字段
**Files:**
- Modify: `src/main/services/chapter-writing-tracker.service.ts`
- Modify: `src/main/db/repositories/chapter.repo.ts`
- Modify: `src/main/ipc/handlers/chapter.handler.ts`UpdateChapterParams 含 povCharacterId
- Modify: `src/shared/types.ts`UpdateChapterParams
- Modify: `src/main/ipc/register.ts`trackerFor 注入 sessions
- [x] **Step 1: chapter.repo mapRow + update**
```typescript
// mapRow 增加
povCharacterId: (row.pov_character_id as string) || null,
// update patch 增加 povCharacterId?: string | null
// UPDATE SQL 增加 pov_character_id = ?
```
- [x] **Step 2: trackerFor 签名**
```typescript
export function trackerFor(
registry: BookRegistryService,
bookId: string,
writingLogs: WritingLogService,
sessions: WritingSessionService
): ChapterWritingTracker
recordDelta(...) {
if (delta !== 0) {
this.writingLogs.addToday(this.bookId, delta)
this.sessions.recordActivity(this.bookId, chapterId, delta)
}
// ...
}
supplementOnFinish(...) {
if (gap > 0) {
this.writingLogs.addToday(this.bookId, gap)
this.sessions.recordActivity(this.bookId, chapterId, gap)
}
}
```
- [x] **Step 3: register.ts**
```typescript
const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb())
const writingSessions = new WritingSessionService(writingSessionRepo)
// registerChapterHandlers(books, writingLogs, writingSessions)
```
- [x] **Step 4: build**
```bash
npm run build
```
---
## Task 5: CharacterGraphService
**Files:**
- Create: `src/main/services/character-graph.service.ts`
- Create: `tests/main/character-graph.test.ts`
- [x] **Step 1: getData 边去重**
```typescript
export class CharacterGraphService {
constructor(private registry: BookRegistryService) {}
getData(bookId: string, filter: 'all' | 'connected' = 'all'): CharacterGraphData {
const settings = new SettingRepository(this.registry.getDb(bookId)).list()
const characters = settings.filter((s) => s.type === 'character')
const truncated = characters.length > 100
// 收集 raw edges from properties.relationships
// dedupeEdges: key = sorted([source,target]).join('|'), keep max intimacy
// filter connected: nodes with degree > 0
// if truncated: slice nodes to 100 by sortOrder/name
}
}
```
- [x] **Step 2: upsertRelationship / deleteRelationship**
```typescript
upsertRelationship(bookId, sourceId, input: Partial<CharacterRelationship> & { targetId: string; label: string; intimacy: number }): CharacterRelationship {
// validate source/target are character; targetId !== sourceId
// merge into properties.relationships by id or push new uuid
// settingRepo.update(sourceId, { properties: { ...existing, relationships } })
}
deleteRelationship(bookId, sourceId, relationshipId): void
```
- [x] **Step 3: UT-GRAPH-01 / UT-GRAPH-02 / UT-GRAPH-03**
内存 DB + migrate + 创建 3 character settings + 2 relationships。
- [x] **Step 4: 运行**
```bash
npm run test -- tests/main/character-graph.test.ts
```
---
## Task 6: WritingAnalyticsService
**Files:**
- Create: `src/main/services/writing-analytics.service.ts`
- Create: `tests/main/writing-analytics.test.ts`
- [x] **Step 1: getSummary 实现**
```typescript
export class WritingAnalyticsService {
constructor(
private writingLogs: WritingLogService,
private sessionRepo: WritingSessionRepository,
private pomodoroDaily: PomodoroDailyRepository,
private registry: BookRegistryService
) {}
getSummary(bookId: string): WritingAnalyticsSummary {
const today = localDateString()
const todaySessions = this.sessionRepo.listForBookDate(bookId, today)
const todayWords = this.writingLogs.getStats(bookId).todayWords
const todaySpeedWpm = computeWpm(todaySessions) // null if zero duration
const weekTrend = buildWeekTrend(this.writingLogs, bookId)
const hourlyHeatmap = buildHourlyHeatmap(this.sessionRepo.listSince(bookId, thirtyDaysAgoIso))
// povDistribution from ChapterRepository + SettingRepository names
// volumeDistribution from VolumeRepository titles
// pov 未指定 → characterId: '__none__'
}
}
```
辅助函数:
```typescript
function computeWpm(sessions: { wordDelta: number; startedAt: string; endedAt: string }[]): number | null {
const totalWords = sessions.reduce((s, x) => s + x.wordDelta, 0)
const totalMin = sessions.reduce((s, x) => s + minutesBetween(x.startedAt, x.endedAt), 0)
if (totalMin <= 0) return null
return Math.round(totalWords / totalMin)
}
```
- [x] **Step 2: UT-ANALYTICS-01 / UT-ANALYTICS-02**
- [x] **Step 3: IT-ANALYTICS-01**
```typescript
it('IT-ANALYTICS-01: recordDelta feeds session then getSummary', () => {
const tracker = trackerFor(registry, bookId, writingLogs, sessions)
tracker.recordDelta(chapterId, 100)
const summary = analytics.getSummary(bookId)
expect(summary.todaySpeedWpm).toBeGreaterThan(0)
})
```
- [x] **Step 4: 运行**
```bash
npm run test -- tests/main/writing-analytics.test.ts
```
---
## Task 7: IPC + register + preload
**Files:**
- Create: `src/main/ipc/handlers/graph.handler.ts`
- Create: `src/main/ipc/handlers/analytics.handler.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/main/ipc/handlers/chapter.handler.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- [x] **Step 1: graph.handler**
```typescript
export function registerGraphHandlers(registry: BookRegistryService): void {
const svc = () => new CharacterGraphService(registry)
ipcMain.handle(IPC.GRAPH_GET_DATA, (_e, { bookId, filter }) => wrap(() => svc().getData(bookId, filter)))
ipcMain.handle(IPC.GRAPH_UPSERT_RELATIONSHIP, (_e, { bookId, sourceId, relationship }) =>
wrap(() => svc().upsertRelationship(bookId, sourceId, relationship)))
ipcMain.handle(IPC.GRAPH_DELETE_RELATIONSHIP, (_e, { bookId, sourceId, relationshipId }) =>
wrap(() => { svc().deleteRelationship(bookId, sourceId, relationshipId) }))
}
```
- [x] **Step 2: analytics.handler**
```typescript
export function registerAnalyticsHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService,
sessionRepo: WritingSessionRepository,
pomodoroDaily: PomodoroDailyRepository
): void {
const svc = new WritingAnalyticsService(writingLogs, sessionRepo, pomodoroDaily, registry)
ipcMain.handle(IPC.ANALYTICS_SUMMARY, (_e, { bookId }) => wrap(() => svc.getSummary(bookId)))
}
```
- [x] **Step 3: register.ts 串联**
```typescript
const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb())
const writingSessions = new WritingSessionService(writingSessionRepo)
registerGraphHandlers(books)
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
registerChapterHandlers(books, writingLogs, writingSessions)
```
- [x] **Step 4: preload**
```typescript
graph: {
getData: (bookId, filter?) => ipcRenderer.invoke(IPC.GRAPH_GET_DATA, { bookId, filter }),
upsertRelationship: (bookId, sourceId, relationship) => ...,
deleteRelationship: (bookId, sourceId, relationshipId) => ...
},
analytics: {
getSummary: (bookId) => ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId })
}
```
- [x] **Step 5: build**
```bash
npm run build
```
---
## Task 8: Cytoscape 依赖 + CharacterGraphModal
**Files:**
- Modify: `package.json`
- Create: `src/renderer/stores/useGraphStore.ts`
- Create: `src/renderer/components/graph/CharacterGraphModal.tsx`
- Create: `src/renderer/lib/graph-cytoscape.ts`init / layout / intimacy 颜色)
- Modify: `src/renderer/components/layout/EditorLayout.tsx`(挂载模态)
- Modify: `src/renderer/styles/layout.css`
- [x] **Step 1: 安装依赖**
```bash
npm install cytoscape cytoscape-cose-bilkent --legacy-peer-deps
npm install -D @types/cytoscape --legacy-peer-deps
```
- [x] **Step 2: useGraphStore**
```typescript
interface GraphStore {
open: boolean
data: CharacterGraphData | null
loading: boolean
openModal: () => void
close: () => void
load: (bookId: string, filter?: 'all' | 'connected') => Promise<void>
}
```
- [x] **Step 3: CharacterGraphModal 骨架**
```tsx
export function CharacterGraphModal(): React.JSX.Element | null {
const { open, data, loading, close, load } = useGraphStore()
const bookId = useBookStore((s) => s.currentBookId)
const cyRef = useRef<cytoscape.Core | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open || !bookId) return
void load(bookId)
}, [open, bookId, load])
useEffect(() => {
if (!open || !data || !containerRef.current) return
cyRef.current = initCytoscape(containerRef.current, data)
return () => { cyRef.current?.destroy(); cyRef.current = null }
}, [open, data])
// 布局按钮 graph-layout-cose/grid/circle
// tap node → sidebar; tap edge → sidebar edit intimacy
// truncated toast
}
```
`initCytoscape` 注册 `cytoscape.use(coseBilkent)`,边颜色按 intimacy 映射 spec §6.1。
- [x] **Step 4: EditorLayout 挂载 `<CharacterGraphModal />`**
- [x] **Step 5: build**
```bash
npm run build
```
---
## Task 9: SettingRelationshipsEditor + 入口
**Files:**
- Create: `src/renderer/components/setting/SettingRelationshipsEditor.tsx`
- Modify: `src/renderer/components/editor/TipTapEditor.tsx`character setting 下方嵌入)
- Modify: `src/renderer/components/setting/SettingList.tsx`setting-open-graph 按钮)
- [x] **Step 1: SettingRelationshipsEditor**
`target.kind === 'setting'` 且 entry.type === 'character'
```tsx
<div data-testid="setting-relationships">
{relationships.map((r) => (
<div key={r.id}>{targetName(r.targetId)} · {r.label} · {r.intimacy}
<button onClick={() => void deleteRel(r.id)}></button>
</div>
))}
<button data-testid="setting-relationship-add" onClick={openAddForm}>+</button>
</div>
```
保存调用 `window.electronAPI.graph.upsertRelationship`
- [x] **Step 2: SettingList 工具栏**
```tsx
<button data-testid="setting-open-graph" onClick={() => useGraphStore.getState().openModal()}>
{t('graph.title')}
</button>
```
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 10: CockpitAnalytics + POV 选择
**Files:**
- Create: `src/renderer/components/cockpit/CockpitAnalytics.tsx`
- Create: `src/renderer/components/cockpit/HourlyHeatmap.tsx`
- Modify: `src/renderer/components/cockpit/CockpitModal.tsx`
- Modify: `src/renderer/components/layout/EditorLayout.tsx`chapter-pov-select
- Modify: `src/renderer/stores/useCockpitStore.ts`(可选:loadAnalytics
- [x] **Step 1: CockpitModal 并行加载**
```typescript
useEffect(() => {
if (!open || !bookId) return
void Promise.all([
loadSummary(bookId, activeVolumeId ?? undefined),
ipcCall(() => window.electronAPI.analytics.getSummary(bookId)).then(setAnalytics)
])
}, [open, bookId, activeVolumeId])
```
- [x] **Step 2: CockpitAnalytics 组件**
```tsx
<div className="cockpit-analytics" data-testid="cockpit-analytics">
<div data-testid="cockpit-speed-today">{speedLabel}</div>
<HourlyHeatmap data={analytics.hourlyHeatmap} testId="cockpit-hourly-heatmap" />
<div data-testid="cockpit-pov-chart">{/* top 5 bars */}</div>
<div data-testid="cockpit-volume-chart">{/* volume bars */}</div>
<div data-testid="cockpit-week-trend">{/* 7 mini bars */}</div>
</div>
<button data-testid="cockpit-open-graph" onClick={() => useGraphStore.getState().openModal()}>
{t('cockpit.openGraph')}
</button>
```
- [x] **Step 3: chapter-pov-select**
`#editor-statusbar``isChapterTarget`
```tsx
<select
data-testid="chapter-pov-select"
value={chapter.povCharacterId ?? ''}
onChange={(e) => void updatePov(e.target.value || null)}
>
<option value="">{t('analytics.povUnassigned')}</option>
{characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
```
`updatePov``chapter.update(bookId, chapterId, { povCharacterId })`.
- [x] **Step 4: build**
```bash
npm run build
```
---
## Task 11: i18n + 样式
**Files:**
- Modify: `public/locales/zh-CN/translation.json`
- Modify: `public/locales/en/translation.json`
- Modify: `src/renderer/styles/layout.css`
- [x] **Step 1: 添加 spec §7 全部 i18n 键(graph / relationship / analytics / chapter.pov / cockpit.openGraph**
- [x] **Step 2: CSS**
```css
.character-graph-modal .modal-content { max-width: 95vw; width: 1100px; }
#character-graph-cy { min-height: 480px; flex: 1; }
.graph-sidebar { width: 260px; ... }
.cockpit-analytics { margin-top: 16px; display: grid; gap: 12px; }
.hourly-heatmap { display: flex; gap: 2px; height: 32px; }
.hourly-heatmap-cell { flex: 1; border-radius: 2px; background: var(--bg-tertiary); }
.pov-bar, .volume-bar { height: 8px; background: var(--accent); border-radius: 4px; }
```
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 12: E2E + v1.0.0 交付
**Files:**
- Create: `e2e/character-graph.spec.ts`
- Create: `e2e/writing-analytics.spec.ts`
- Modify: `package.json`
- Modify: `README.md`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- [x] **Step 1: E2E-GRAPH-01~04**
```typescript
// 创建书 → 新建 character 设定 A/B → SettingRelationshipsEditor 添加关系
// → setting-open-graph → expect character-graph-cy visible
// → tap node → sidebar 含角色名
// → graph-layout-circle click
```
- [x] **Step 2: E2E-ANALYTICS-01~02**
```typescript
// E2E-ANALYTICS-01: 写作保存 → topbar-cockpit → cockpit-speed-today 非空
// E2E-ANALYTICS-02: chapter-pov-select 选角色 → 刷新驾驶舱 → cockpit-pov-chart 含角色名
```
- [x] **Step 3: 全量测试**
```bash
npm run build
npm run test
npm run test:e2e -- e2e/character-graph.spec.ts e2e/writing-analytics.spec.ts
```
- [x] **Step 4: 版本 1.0.0**
`package.json` / `README.md` / Settings 关于页 → `v1.0.0`
- [x] **Step 5: 勾选本 plan 全部 checkbox**
---
## Spec Coverage Checklist
| Spec 要求 | Task |
|-----------|------|
| writing_sessions + 5min 合并 | Task 2, 3, 4 |
| recordDelta 写 session | Task 4 |
| CharacterGraphService + 边去重 | Task 5 |
| WritingAnalyticsService | Task 6 |
| graph / analytics IPC | Task 7 |
| Cytoscape 模态 + 三布局 | Task 8 |
| SettingRelationshipsEditor | Task 9 |
| 驾驶舱分析区 | Task 10 |
| chapter POV | Task 4, 10 |
| i18n | Task 11 |
| UT/IT/E2E | Task 26, 12 |
| v1.0.0 DoD | Task 12 |
---
## Execution Notes
- `CharacterGraphService``SettingRelationshipsEditor` 共用 `graph.upsertRelationship` IPC,避免 renderer 直接 patch `properties` JSON
- Cytoscape 仅在 modal open 时 mountclose 必须 `destroy()` 防内存泄漏
- 书籍切换时调用 `writingSessions.endActive(oldBookId)`(在 `useBookStore.openBook` 或 main 层 hook
- POV 分布 `__none__` 桶在 i18n 显示为 `analytics.povUnassigned`
- E2E 图谱/分析 **禁止 Mock AI**
@@ -0,0 +1,663 @@
# 笔临 v1.1 作家日更工具包 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:** 交付 v1.1.0:§10 章节模板 + 快速新建、§11.1 txt/md/docx 导入、§20.2 投稿预设单章导出、§20.6 全局灵感捕获增强。
**Architecture:** `GlobalSettings` 扩展 `chapterTemplates` / `submissionPresets`;渲染进程 `chapter-template.ts` 做变量替换后复用 `chapter.create/update`;主进程 `ImportService`mammoth/marked + `chapter-splitter`)与 `ExportService``submission-formatter` + clipboard);灵感模态解除视图限制且保存不跳转。
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、mammoth、marked、Zustand、i18next、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-v11-writer-toolkit-design.md`
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/types.ts` | `ChapterTemplate``SubmissionPreset``Import*``Export*` DTO |
| `src/shared/builtin-templates.ts` | 内置模板与投稿预设常量 |
| `src/shared/ipc-channels.ts` | `import.*` / `export.*` / `IMPORT_PROGRESS` |
| `src/main/lib/chapter-splitter.ts` | txt/md/html 分章 |
| `src/main/lib/submission-formatter.ts` | HTML → 投稿纯文本 |
| `src/main/services/import.service.ts` | 读文件、预览、创建书+章 |
| `src/main/services/export.service.ts` | 格式化章节、剪贴板、存盘 |
| `src/main/ipc/handlers/import.handler.ts` | 导入 IPC + progress 事件 |
| `src/main/ipc/handlers/export.handler.ts` | 导出 IPC |
| `src/main/ipc/register.ts` | 注册 import/export |
| `src/preload/index.ts` + `electron-api.d.ts` | import / export API |
| `src/renderer/lib/chapter-template.ts` | 模板变量替换 |
| `src/renderer/components/chapter/TemplateQuickCreateModal.tsx` | 快速新建章 |
| `src/renderer/components/settings/TemplateSettingsTab.tsx` | 模板 CRUD |
| `src/renderer/components/settings/SubmissionPresetTab.tsx` | 投稿预设 CRUD |
| `src/renderer/components/import/ImportBookModal.tsx` | 导入 UI |
| `src/renderer/components/export/ExportModal.tsx` | 导出 UI |
| `src/renderer/components/inspiration/InspirationModal.tsx` | 全局灵感增强 |
| `src/renderer/components/home/HomePage.tsx` | 导入入口 |
| `src/renderer/components/layout/EditorLayout.tsx` | 快速新建/导出入口 |
| `src/renderer/App.tsx` | 全局灵感快捷键 |
| `src/renderer/stores/useSettingsStore.ts` | 新 settings 字段默认值 |
| `src/main/services/global-settings.ts` | defaults 合并 |
| `public/locales/*/translation.json` | template / import / export / submission / inspiration |
| `src/renderer/styles/layout.css` | 模板/导入/导出样式 |
| `tests/main/chapter-template.test.ts` | UT-TEMPL-* |
| `tests/main/chapter-splitter.test.ts` | UT-IMPORT-01 |
| `tests/main/submission-formatter.test.ts` | UT-EXPORT-* |
| `tests/main/import.service.test.ts` | IT-IMPORT-01 |
| `e2e/chapter-template.spec.ts` | E2E-TEMPL-01 |
| `e2e/import-book.spec.ts` | E2E-IMPORT-01 |
| `e2e/export-submission.spec.ts` | E2E-EXPORT-01 |
| `e2e/inspiration-capture.spec.ts` | E2E-INSP-01 |
---
## Task 1: 类型 + 内置常量 + GlobalSettings
**Files:**
- Modify: `src/shared/types.ts`
- Create: `src/shared/builtin-templates.ts`
- Modify: `src/main/services/global-settings.ts`
- Modify: `src/renderer/stores/useSettingsStore.ts`
- [x] **Step 1: 扩展 types**
```typescript
export interface ChapterTemplate {
id: string
name: string
body: string
defaultStatus?: ChapterStatus
builtin?: boolean
}
export interface SubmissionPreset {
id: string
name: string
titleFormat: string
indentParagraphs: boolean
includeAuthorsNote: boolean
paragraphGap: 'single' | 'double'
}
export type ImportSplitMode = 'auto' | 'paragraph' | 'regex'
export interface ImportChapterPreview {
title: string
contentHtml: string
wordCount: number
}
export interface ImportPreviewResult {
fileName: string
fileSizeBytes: number
chapters: ImportChapterPreview[]
warnings: string[]
}
export interface ImportExecuteParams {
filePath: string
bookName: string
category: string
splitMode: ImportSplitMode
customRegex?: string
}
// GlobalSettings 追加:
chapterTemplates?: ChapterTemplate[]
submissionPresets?: SubmissionPreset[]
defaultSubmissionPresetId?: string
```
- [x] **Step 2: builtin-templates.ts**
```typescript
import type { ChapterTemplate, SubmissionPreset } from './types'
export const BUILTIN_CHAPTER_TEMPLATES: ChapterTemplate[] = [
{
id: 'builtin-generic',
name: '通用模板',
body: '## 第{chapterNumber}章 {chapterTitle}\n\n',
builtin: true
},
{
id: 'builtin-webnovel',
name: '网文常用',
body: '## 第{chapterNumber}章 {chapterTitle}\n\n\n\n——\n作者的话:\n',
builtin: true
}
]
export const BUILTIN_SUBMISSION_PRESETS: SubmissionPreset[] = [
{
id: 'builtin-webnovel',
name: '通用网文',
titleFormat: '# 第{chapterNumber}章 {chapterTitle}',
indentParagraphs: true,
includeAuthorsNote: true,
paragraphGap: 'double'
}
]
export function mergeChapterTemplates(custom: ChapterTemplate[] = []): ChapterTemplate[] {
return [...BUILTIN_CHAPTER_TEMPLATES, ...custom.filter((t) => !t.builtin)]
}
export function mergeSubmissionPresets(custom: SubmissionPreset[] = []): SubmissionPreset[] {
return [...BUILTIN_SUBMISSION_PRESETS, ...custom.filter((p) => p.id !== 'builtin-webnovel')]
}
```
- [x] **Step 3: global-settings defaults**
`defaults()` 追加 `chapterTemplates: []`, `submissionPresets: []`, `defaultSubmissionPresetId: 'builtin-webnovel'`
- [x] **Step 4: useSettingsStore 初始字段对齐**
- [x] **Step 5: build**
```bash
npm run build
```
---
## Task 2: chapter-template 引擎 + 单元测试
**Files:**
- Create: `src/renderer/lib/chapter-template.ts`
- Create: `tests/main/chapter-template.test.ts`
- [x] **Step 1: 实现 replaceChapterTemplate**
```typescript
export interface TemplateContext {
chapterNumber: number
chapterTitle: string
penName: string
date: string
volumeName: string
previousChapterTitle: string
outlineItem: string
characterList: string
}
const VARS = [
'chapterNumber', 'chapterTitle', 'penName', 'date',
'volumeName', 'previousChapterTitle', 'outlineItem', 'characterList'
] as const
export function replaceChapterTemplate(body: string, ctx: TemplateContext): string {
let out = body
for (const key of VARS) {
out = out.split(`{${key}}`).join(String(ctx[key]))
}
return out
}
export function plainTextToEditorHtml(text: string): string {
return text
.split(/\n{2,}/)
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
}
```
- [x] **Step 2: UT-TEMPL-01~03**
```typescript
it('UT-TEMPL-01: replaces chapterNumber and chapterTitle', () => {
const result = replaceChapterTemplate('第{chapterNumber}章 {chapterTitle}', {
chapterNumber: 5, chapterTitle: '觉醒',
penName: '', date: '', volumeName: '', previousChapterTitle: '',
outlineItem: '', characterList: ''
})
expect(result).toBe('第5章 觉醒')
})
```
- [x] **Step 3: 运行**
```bash
npm run test -- tests/main/chapter-template.test.ts
```
---
## Task 3: TemplateQuickCreateModal + 章节侧栏入口
**Files:**
- Create: `src/renderer/components/chapter/TemplateQuickCreateModal.tsx`
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
- [x] **Step 1: TemplateQuickCreateModal**
- `data-testid="chapter-quick-new"` 打开模态
-`useSettingsStore` + `mergeChapterTemplates` 展示模板卡片
- 创建流程:`chapter.create``replaceChapterTemplate``plainTextToEditorHtml``chapter.update({ content })`
- 若模板 `defaultStatus` 设置则 `chapter.update({ status })`
- `switchTarget` 到新章
- [x] **Step 2: EditorLayout**
在章节面板底部保留原「+ 新章」按钮,新增「模板新建」按钮 `data-testid="chapter-quick-new"`
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 4: 设置页章节模板 Tab
**Files:**
- Create: `src/renderer/components/settings/TemplateSettingsTab.tsx`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- [x] **Step 1: TemplateSettingsTab**
- `data-testid="settings-chapter-templates"`
- 内置模板只读展示
- 自定义:`setting-template-add` 新建;编辑 name/body;删除(uuid id
- 保存:`settings.update({ chapterTemplates: customOnly })`
- [x] **Step 2: SettingsPage 导航**
新增 section `'templates'`,侧栏项 `settings.templates`
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 5: submission-formatter + 单元测试
**Files:**
- Create: `src/main/lib/submission-formatter.ts`
- Create: `tests/main/submission-formatter.test.ts`
- [x] **Step 1: 实现**
```typescript
import { stripHtml } from '../services/word-count'
import type { SubmissionPreset } from '../../shared/types'
export interface FormatChapterInput {
chapterNumber: number
chapterTitle: string
contentHtml: string
preset: SubmissionPreset
}
export function formatChapterForSubmission(input: FormatChapterInput): string {
let body = stripHtml(input.contentHtml)
if (!input.preset.includeAuthorsNote) {
body = body.split(/\n——\n/)[0] ?? body
}
const paragraphs = body.split(/\n+/).filter((p) => p.trim())
const indented = input.preset.indentParagraphs
? paragraphs.map((p) => `  ${p.trim()}`)
: paragraphs.map((p) => p.trim())
const gap = input.preset.paragraphGap === 'double' ? '\n\n' : '\n'
const title = input.preset.titleFormat
.replace('{chapterNumber}', String(input.chapterNumber))
.replace('{chapterTitle}', input.chapterTitle)
return `${title}\n\n${indented.join(gap)}`
}
```
- [x] **Step 2: UT-EXPORT-01 / UT-EXPORT-02**
- [x] **Step 3: 运行**
```bash
npm run test -- tests/main/submission-formatter.test.ts
```
---
## Task 6: ExportService + IPC + preload
**Files:**
- Create: `src/main/services/export.service.ts`
- Create: `src/main/ipc/handlers/export.handler.ts`
- Modify: `src/shared/ipc-channels.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- [x] **Step 1: ExportService**
```typescript
export class ExportService {
constructor(
private registry: BookRegistryService,
private settings: GlobalSettingsService
) {}
formatChapter(bookId: string, chapterId: string, presetId: string): string {
const db = this.registry.getDb(bookId)
const chapter = new ChapterRepository(db).get(chapterId)
if (!chapter) throw new Error('Chapter not found')
const presets = mergeSubmissionPresets(this.settings.get().submissionPresets)
const preset = presets.find((p) => p.id === presetId) ?? BUILTIN_SUBMISSION_PRESETS[0]
const chapters = new ChapterRepository(db).list().filter((c) => c.volumeId === chapter.volumeId)
const chapterNumber = chapters.findIndex((c) => c.id === chapterId) + 1
return formatChapterForSubmission({
chapterNumber, chapterTitle: chapter.title,
contentHtml: chapter.content, preset
})
}
copyToClipboard(text: string): void {
clipboard.writeText(text)
}
async saveTxt(text: string, defaultName: string): Promise<string | null> {
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [{ name: 'Text', extensions: ['txt'] }]
})
if (canceled || !filePath) return null
writeFileSync(filePath, text, 'utf8')
return filePath
}
}
```
- [x] **Step 2: export.handler + register + preload**
```typescript
EXPORT_FORMAT_CHAPTER: 'export:formatChapter',
EXPORT_COPY_CLIPBOARD: 'export:copyClipboard',
EXPORT_SAVE_TXT: 'export:saveTxt',
```
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 7: ExportModal + 投稿预设设置 Tab + 快捷键
**Files:**
- Create: `src/renderer/components/export/ExportModal.tsx`
- Create: `src/renderer/components/settings/SubmissionPresetTab.tsx`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
- Modify: `src/renderer/App.tsx`
- [x] **Step 1: ExportModal**
- `data-testid="export-modal"`
- `export-preset-select` 下拉(mergeSubmissionPresets
- 预览区:调用 `export.formatChapter` 显示前 500 字
- `export-copy-clipboard` / `export-save-txt`
- [x] **Step 2: SubmissionPresetTab**
- `data-testid="settings-submission-presets"`
- CRUD 自定义预设(不可删 builtin-webnovel
- [x] **Step 3: EditorLayout 工具栏 + App exportBook 快捷键**
```typescript
if (action === 'exportBook') {
if (view !== 'editor' || !chapterId) return
useExportStore.getState().openModal()
return
}
```
创建 `useExportStore`open/close,类似 useGraphStore)。
- [x] **Step 4: build**
```bash
npm run build
```
---
## Task 8: InspirationModal 全局增强
**Files:**
- Modify: `src/renderer/components/inspiration/InspirationModal.tsx`
- Modify: `src/renderer/App.tsx`
- Modify: `src/renderer/styles/layout.css`
- [x] **Step 1: App.tsx 移除 view 限制**
```typescript
if (action === 'captureInspiration') {
useAppStore.getState().setInspirationModalOpen(true)
return
}
```
- [x] **Step 2: InspirationModal**
-`bookId` 时显示 `inspiration-book-select``useBookStore.books``lastOpenedAt` 排序)
- `handleSave` 删除 `switchTarget` / `setSidebarPanel`;改为 `showToast(t('inspiration.saved'))`
- 添加 `inspiration-modal--capture` class
- [x] **Step 3: E2E 预备 build**
```bash
npm run build
```
---
## Task 9: chapter-splitter + 单元测试
**Files:**
- Create: `src/main/lib/chapter-splitter.ts`
- Create: `tests/main/chapter-splitter.test.ts`
- [x] **Step 1: splitPlainText**
```typescript
export function splitPlainText(
text: string,
mode: ImportSplitMode,
customRegex?: string
): Array<{ title: string; body: string }> {
// auto: 尝试 MARKDOWN_HEADING 再 CHINESE_CHAPTER
// paragraph: split \n\s*\n
// regex: new RegExp(customRegex, 'gm') 分行匹配
}
```
- [x] **Step 2: UT-IMPORT-01** — 样本文本 3 章标题
- [x] **Step 3: 运行**
```bash
npm run test -- tests/main/chapter-splitter.test.ts
```
---
## Task 10: ImportService + IPC + 依赖安装
**Files:**
- Create: `src/main/services/import.service.ts`
- Create: `src/main/ipc/handlers/import.handler.ts`
- Modify: `package.json`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/preload/index.ts`
- Create: `tests/main/import.service.test.ts`
- [x] **Step 1: 安装依赖**
```bash
npm install mammoth marked --legacy-peer-deps
npm install -D @types/marked --legacy-peer-deps
```
- [x] **Step 2: ImportService**
```typescript
export class ImportService {
async pickFile(): Promise<{ canceled: boolean; filePath?: string }> { /* dialog */ }
async preview(filePath: string, splitMode: ImportSplitMode, customRegex?: string): Promise<ImportPreviewResult> {
const stat = statSync(filePath)
const ext = extname(filePath).toLowerCase()
let html: string
if (ext === '.docx') html = (await mammoth.convertToHtml({ path: filePath })).value
else if (ext === '.md') html = marked.parse(readFileSync(filePath, 'utf8')) as string
else html = plainTextToEditorHtml(readFileSync(filePath, 'utf8'))
const plain = stripHtml(html)
const parts = splitPlainText(plain, splitMode, customRegex)
// map to ImportChapterPreview with wordCount
}
async execute(params: ImportExecuteParams, onProgress?: (c: number, t: number) => void): Promise<string> {
const preview = await this.preview(...)
const meta = this.registry.create({ name: params.bookName, category: params.category, createSampleChapter: false })
try {
const db = this.registry.getDb(meta.id)
const volId = new VolumeRepository(db).list()[0]?.id ?? new VolumeRepository(db).create('第一卷', 0).id
for (let i = 0; i < preview.chapters.length; i++) {
const ch = preview.chapters[i]
new ChapterRepository(db).create(volId, ch.title, i, ch.contentHtml)
onProgress?.(i + 1, preview.chapters.length)
}
return meta.id
} catch (e) {
this.registry.delete(meta.id)
throw e
}
}
}
```
- [x] **Step 3: import.handler**
- `IMPORT_PICK_FILE` / `IMPORT_PREVIEW` / `IMPORT_EXECUTE`
- progress`BrowserWindow.getAllWindows()[0]?.webContents.send(IPC.IMPORT_PROGRESS, { current, total })`
- [x] **Step 4: IT-IMPORT-01**
- [x] **Step 5: 运行**
```bash
npm run test -- tests/main/import.service.test.ts
```
---
## Task 11: ImportBookModal + HomePage
**Files:**
- Create: `src/renderer/components/import/ImportBookModal.tsx`
- Modify: `src/renderer/components/home/HomePage.tsx`
- Modify: `src/renderer/App.tsx`(挂载 ImportBookModal
- [x] **Step 1: ImportBookModal**
- `data-testid="import-book-modal"`
- 浏览 → `import.pickFile`
- 分章规则 select + 条件 regex 输入
- 预览按钮 → `import.preview` 显示章数与前 5 标题
- `fileSizeBytes > 20 * 1024 * 1024` → confirm 对话框
- 执行 → 监听 `onImportProgress``import-progress-bar`
- 成功 → `openBook(bookId)` + 切 editor
- [x] **Step 2: HomePage 替换 comingSoon**
```typescript
onClick={() => setImportOpen(true)}
```
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 12: i18n + CSS + E2E + v1.1.0
**Files:**
- Modify: `public/locales/zh-CN/translation.json`
- Modify: `public/locales/en/translation.json`
- Modify: `src/renderer/styles/layout.css`
- Create: `e2e/chapter-template.spec.ts`
- Create: `e2e/import-book.spec.ts`
- Create: `e2e/export-submission.spec.ts`
- Create: `e2e/inspiration-capture.spec.ts`
- Modify: `package.json`
- Modify: `README.md`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`(关于页版本)
- [x] **Step 1: i18n 键**spec §8 全部)
- [x] **Step 2: CSS**
```css
.template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
.import-preview-list { font-size: 12px; max-height: 120px; overflow: auto; }
.export-preview { font-family: var(--font-mono); font-size: 11px; white-space: pre-wrap; max-height: 200px; overflow: auto; }
.inspiration-modal--capture { z-index: 10000; }
```
- [x] **Step 3: E2E**
```typescript
// E2E-TEMPL-01: 模板新建章 → ProseMirror 含模板正文
// E2E-IMPORT-01: 导入 fixture txt → 多章节可见
// E2E-EXPORT-01: 导出复制 → 剪贴板含 # 第
// E2E-INSP-01: 主页 Ctrl+Shift+I → toast,仍在 home
```
- [x] **Step 4: 全量验证**
```bash
npm run build
npm run test
npm run test:e2e -- e2e/chapter-template.spec.ts e2e/import-book.spec.ts e2e/export-submission.spec.ts e2e/inspiration-capture.spec.ts
```
- [x] **Step 5: 版本 1.1.0**
`package.json` / `README.md` / Settings 关于页 → `v1.1.0`
- [x] **Step 6: 勾选本 plan 全部 checkbox**
---
## Spec Coverage Checklist
| Spec 要求 | Task |
|-----------|------|
| chapterTemplates + 内置模板 | Task 1, 4 |
| 模板变量 + 快速新建 | Task 2, 3 |
| submissionPresets + 格式化 | Task 1, 5, 6 |
| ExportModal + 剪贴板/txt | Task 7 |
| 全局灵感 + 不跳转 | Task 8 |
| txt/md/docx 导入 + 分章 | Task 9, 10, 11 |
| >20MB 确认 + 进度条 | Task 11 |
| i18n / E2E / v1.1.0 | Task 12 |
---
## Execution Notes
- `plainTextToEditorHtml``chapter-template.ts` 可共享;主进程导入侧可 import 同逻辑或 duplicate 最小 HTML 包装
- 导入 `.docx` 用 mammoth HTML 再 `stripHtml` 分章(章标题在纯文本层匹配)
- `export.formatChapter` 与模板 `{chapterNumber}` 均用卷内 sortOrder 计算序号
- E2E 导入使用 `tests/fixtures/import-three-chapters.txt` fixture 文件
- 用户未要求时不自动 git commit;每 Task 完成后勾选 plan checkbox
@@ -0,0 +1,452 @@
# 笔临 Wave 1 基础治理 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:** 交付 v1.2.0:清除 Wave 1 范围内全部 `comingSoon` 占位,补全书籍/分卷/章节管理 UI,模板变量自动填充,AI 会话管理,专注模式与 TTS 快捷键。
**Architecture:** schema v9 扩展 `chapter_tags` / `published_at` / `book_preferences.always_show_cockpit`;扩展 `BookMeta``BOOK_UPDATE_META`;渲染进程新增 `BookSettingsTab``FocusMode` overlay、`ChapterMetaPanel`;复用已有 `volume.*` / `chapter.delete` IPC`chapter-template` 上下文从 outlines/settings 聚合。
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、i18next、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §5
**Master Program:** Wave 1/5 → v1.2.0
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v9.sql` | chapter_tags 表 |
| `src/main/db/migrate.ts` | CURRENT_VERSION=9published_at/word_count_threshold |
| `src/main/db/repositories/chapter-tag.repo.ts` | 章节标签 CRUD |
| `src/main/db/repositories/chapter.repo.ts` | published_at、summary、story_time、threshold 读写 |
| `src/main/services/book-registry.ts` | updateMeta 扩展 name/category/targetWordCount/coverPath/synopsis |
| `src/main/services/cockpit.service.ts` | always_show_cockpit 逻辑 |
| `src/main/ipc/handlers/book.handler.ts` | BOOK_UPDATE_META 扩展 |
| `src/main/ipc/handlers/chapter.handler.ts` | CHAPTER_TAG_*、published_at on setPublishStatus |
| `src/main/ipc/handlers/ai.handler.ts` | AI_SESSION_EXPORT |
| `src/shared/types.ts` | BookMeta、UpdateBookMetaParams、ChapterTag |
| `src/shared/ipc-channels.ts` | 新 IPC 常量 |
| `src/preload/index.ts` + `electron-api.d.ts` | bookPrefs、chapterTags、sessionExport |
| `src/shared/chapter-template.ts` | `buildTemplateContext()` 含 outlineItem/characterList |
| `src/renderer/components/book/BookSettingsTab.tsx` | 右栏书籍设置 |
| `src/renderer/components/chapter/ChapterMetaPanel.tsx` | summary/story_time/tags |
| `src/renderer/components/volume/VolumeContextMenu.tsx` | 分卷右键 |
| `src/renderer/components/focus/FocusModeOverlay.tsx` | 专注模式 |
| `src/renderer/lib/tts-controller.ts` | TTS 朗读 |
| `src/renderer/components/layout/RightPanel.tsx` | 接入 BookSettingsTab |
| `src/renderer/components/layout/EditorLayout.tsx` | 章双击改名、删除、meta、阈值变色 |
| `src/renderer/components/layout/TopBar.tsx` | 日更进度条(细条) |
| `src/renderer/components/ai/AiSessionMenu.tsx` | 会话重命名/归档/导出/删除 |
| `src/renderer/components/ai/AiPanel.tsx` | 接入 AiSessionMenu |
| `src/renderer/components/wordfreq/WordFreqPanel.tsx` | 命名检查入口 |
| `src/renderer/App.tsx` | focusMode / toggleTTS 快捷键 |
| `src/renderer/stores/useBookPrefsStore.ts` | alwaysShowCockpit |
| `public/locales/*/translation.json` | bookSettings / chapterMeta / focus / tts |
| `tests/main/chapter-template-context.test.ts` | UT outlineItem/characterList |
| `tests/main/chapter-tag.repo.test.ts` | IT 标签 |
| `e2e/book-settings.spec.ts` | E2E 书籍设置+删书 |
| `e2e/chapter-management.spec.ts` | E2E 章改名+删除 |
| `e2e/focus-tts.spec.ts` | E2E 专注+TTS |
| `e2e/chapter-template.spec.ts` | 扩展 E2E-TEMPL-05 |
---
## Task 1: Schema v9 + 类型扩展
**Files:**
- Create: `src/main/db/schema-v9.sql`
- Modify: `src/main/db/migrate.ts`
- Modify: `src/shared/types.ts`
- Modify: `src/shared/ipc-channels.ts`
- [x] **Step 1: schema-v9.sql**
```sql
CREATE TABLE IF NOT EXISTS chapter_tags (
chapter_id TEXT NOT NULL,
tag TEXT NOT NULL,
color TEXT DEFAULT '',
PRIMARY KEY (chapter_id, tag),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
```
- [x] **Step 2: migrate.ts**`CURRENT_VERSION = 9`,新增 block
```typescript
if (current < 9) {
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN published_at TEXT DEFAULT NULL')
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN word_count_threshold INTEGER DEFAULT NULL')
db.exec(schemaV9)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(9, 'W1 foundation')
}
```
- [x] **Step 3: types.ts** — 扩展:
```typescript
export interface BookMeta {
// existing...
coverPath?: string | null
synopsis?: string
}
export interface UpdateBookMetaParams {
name?: string
category?: string
targetWordCount?: number | null
coverPath?: string | null
synopsis?: string
lastChapterId?: string | null
status?: BookStatus
}
export interface ChapterTag {
chapterId: string
tag: string
color: string
}
// Chapter 已有 summary?, storyTime? — 确保 chapter.update IPC 接受它们
```
- [x] **Step 4: ipc-channels.ts**
```typescript
CHAPTER_TAG_LIST: 'chapter:tagList',
CHAPTER_TAG_SET: 'chapter:tagSet',
CHAPTER_TAG_REMOVE: 'chapter:tagRemove',
BOOK_PREFS_GET: 'book:prefsGet',
BOOK_PREFS_SET: 'book:prefsSet',
AI_SESSION_EXPORT: 'ai:sessionExport',
```
- [x] **Step 5: 运行** `npm run build` — 应通过
---
## Task 2: Repository + BookRegistry 扩展
**Files:**
- Create: `src/main/db/repositories/chapter-tag.repo.ts`
- Modify: `src/main/db/repositories/chapter.repo.ts`
- Modify: `src/main/services/book-registry.ts`
- Modify: `src/main/services/cockpit.service.ts`
- [x] **Step 1: ChapterTagRepository**
```typescript
export class ChapterTagRepository {
constructor(private db: SqliteDb) {}
list(chapterId: string): ChapterTag[] { /* SELECT */ }
set(chapterId: string, tag: string, color = ''): void { /* INSERT OR REPLACE */ }
remove(chapterId: string, tag: string): void { /* DELETE */ }
listAll(): ChapterTag[] { /* for book open */ }
}
```
- [x] **Step 2: chapter.repo update**`update()``setPublishStatus()` 支持 `summary`, `storyTime`, `wordCountThreshold`, `publishedAt`
```typescript
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
const patch: Parameters<typeof this.update>[1] = { publishStatus }
if (publishStatus === 'published') {
patch.publishedAt = new Date().toISOString()
}
return this.update(id, patch)
}
```
- [x] **Step 3: book-registry.updateMeta** — 签名改为 `Partial<Pick<BookMeta, 'name' | 'category' | 'targetWordCount' | 'coverPath' | 'synopsis' | 'lastOpenedAt' | 'lastChapterId' | 'status'>>`
- [x] **Step 4: cockpit.service**
```typescript
shouldShowOnOpen(): boolean {
const prefs = new BookPrefsRepository(this.db)
if (prefs.get('alwaysShowCockpit') === 'true') return true
return prefs.get('cockpitSeen') !== 'true'
}
```
- [x] **Step 5: UT** `tests/main/chapter-tag.repo.test.ts` — 创建章+标签+列表
Run: `npm run test -- --run tests/main/chapter-tag.repo.test.ts`
---
## Task 3: IPC handlers + preload
**Files:**
- Modify: `src/main/ipc/handlers/book.handler.ts`
- Modify: `src/main/ipc/handlers/chapter.handler.ts`
- Create: `src/main/ipc/handlers/book-prefs.handler.ts`
- Modify: `src/main/ipc/handlers/ai.handler.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- [x] **Step 1: book.handler**`BOOK_UPDATE_META` 接受 `UpdateBookMetaParams`
- [x] **Step 2: chapter.handler** — 扩展 `CHAPTER_UPDATE` 接受 `summary`, `storyTime`, `wordCountThreshold`;新增:
```typescript
ipcMain.handle(IPC.CHAPTER_TAG_LIST, (_e, { bookId, chapterId }) =>
wrap(() => new ChapterTagRepository(registry.getDb(bookId)).list(chapterId)))
// TAG_SET / TAG_REMOVE 同理
```
- [x] **Step 3: book-prefs.handler**
```typescript
ipcMain.handle(IPC.BOOK_PREFS_GET, (_e, { bookId, key }) =>
wrap(() => new BookPrefsRepository(registry.getDb(bookId)).get(key)))
ipcMain.handle(IPC.BOOK_PREFS_SET, (_e, { bookId, key, value }) =>
wrap(() => { new BookPrefsRepository(registry.getDb(bookId)).set(key, value) }))
```
- [x] **Step 4: ai.handler**`AI_SESSION_EXPORT`:读 messages,格式化 markdown`dialog.showSaveDialog`,写文件
- [x] **Step 5: preload**`book.updateMeta` 扩展;`bookPrefs.get/set``chapterTag.list/set/remove``ai.session.export(bookId, sessionId, format)`
- [x] **Step 6: build** `npm run build`
---
## Task 4: 模板上下文 enrich
**Files:**
- Modify: `src/shared/chapter-template.ts`
- Modify: `src/renderer/components/chapter/TemplateQuickCreateModal.tsx`
- Create: `tests/main/chapter-template-context.test.ts`
- [x] **Step 1: 新增 `buildTemplateContext()`**
```typescript
export function buildTemplateContext(input: {
chapterTitle: string
penName: string
volumes: Volume[]
chapters: Chapter[]
outlines: OutlineItem[]
settings: SettingEntry[]
activeVolumeId: string | null
selectedChapterId?: string | null
}): TemplateContext {
const volChapters = input.chapters
.filter((c) => c.volumeId === input.activeVolumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const chapterNumber = volChapters.length + 1
const outlineItem =
input.outlines.find((o) => o.chapterId === input.selectedChapterId)?.title ?? ''
const characterList = input.settings
.filter((s) => s.type === 'character')
.slice(0, 8)
.map((s) => s.name)
.join('、')
return {
chapterNumber,
chapterTitle: input.chapterTitle,
penName: input.penName,
date: new Date().toLocaleDateString('zh-CN'),
volumeName: input.volumes.find((v) => v.id === input.activeVolumeId)?.name ?? '',
previousChapterTitle: volChapters.at(-1)?.title ?? '',
outlineItem,
characterList
}
}
```
- [x] **Step 2: TemplateQuickCreateModal** — 用 `buildTemplateContext` 替换内联 `buildContext`
- [x] **Step 3: UT**
```typescript
it('UT-TEMPL-05: characterList from settings', () => {
const ctx = buildTemplateContext({ /* 2 characters */ })
expect(ctx.characterList).toContain('林远')
})
```
Run: `npm run test -- --run tests/main/chapter-template-context.test.ts`
---
## Task 5: BookSettingsTab
**Files:**
- Create: `src/renderer/components/book/BookSettingsTab.tsx`
- Create: `src/renderer/stores/useBookPrefsStore.ts`
- Modify: `src/renderer/components/layout/RightPanel.tsx`
- [x] **Step 1: BookSettingsTab** — 表单字段:
- 书名、分类、目标字数、简介(textarea)
- 封面:按钮选文件 → 存 `userData/covers/{bookId}.jpg` 路径到 meta.coverPath
- 「每次打开显示驾驶舱」checkbox → `bookPrefs.set('alwaysShowCockpit', 'true'|'false')`
- 危险区:删书 → confirm → `book.delete``loadBooks``setView('home')`
- [x] **Step 2: RightPanel**`book-settings` panel 渲染 `<BookSettingsTab />`
- [x] **Step 3: i18n**`bookSettings.*` 键(中英)
---
## Task 6: 分卷右键 + 章节管理 UI
**Files:**
- Create: `src/renderer/components/volume/VolumeContextMenu.tsx`
- Create: `src/renderer/components/chapter/ChapterMetaPanel.tsx`
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
- [x] **Step 1: VolumeContextMenu** — 绑定分卷标题 `onContextMenu`
- 重命名:prompt → `volume.update`
- 编辑简述:prompt → `volume.update({ description })`
- 删除:若卷内无章 → `volume.delete`;有章则 toast 错误
- [x] **Step 2: 章节双击改名**`chapter-item` `onDoubleClick` → input inline → `chapter.update({ title })`
- [x] **Step 3: 章节删除** — 右键或 hover 图标 → confirm → `chapter.delete``refreshChapters`
- [x] **Step 4: ChapterMetaPanel** — 侧栏或状态栏按钮打开:
- summary、story_time 输入
- 标签 chips + 添加输入
- word_count_threshold 数字
- [x] **Step 5: 发布日期**`setPublishStatus` 后本地 chapter 显示 `published_at`(只读)
- [x] **Step 6: 字数阈值变色** — 状态栏 `wordCount.chapter``currentChapter.wordCountThreshold` 比较,超则橙色 class
---
## Task 7: AI 会话菜单
**Files:**
- Create: `src/renderer/components/ai/AiSessionMenu.tsx`
- Modify: `src/renderer/components/ai/AiPanel.tsx`
- Modify: `src/renderer/stores/useAiStore.ts`
- [x] **Step 1: AiSessionMenu** — 会话选择器旁「⋯」按钮:
- 重命名 → `ai.session.update({ title })`
- 归档/取消归档 → `{ archived: true/false }`
- 导出 → 子菜单 txt/md → `ai.session.export`
- 删除 → confirm → `ai.session.delete`
- [x] **Step 2: useAiStore**`loadSessions(bookId, true)` 加载含归档;归档会话 optgroup 或折叠区
- [x] **Step 3: i18n**`ai.session.*`
---
## Task 8: 专注模式 + TTS
**Files:**
- Create: `src/renderer/components/focus/FocusModeOverlay.tsx`
- Create: `src/renderer/lib/tts-controller.ts`
- Modify: `src/renderer/App.tsx`
- Modify: `src/renderer/styles/layout.css`
- [x] **Step 1: FocusModeOverlay**`useAppStore.focusMode` boolean
- true 时 `#app``focus-mode` classCSS 隐藏 sidebars/right-panel/topbar 非必要按钮)
- Esc 或按钮退出
- [x] **Step 2: App.tsx**`focusMode` 动作调用 `setFocusMode(true)`;再次按退出
- [x] **Step 3: tts-controller.ts**
```typescript
export function speakText(text: string, lang = 'zh-CN'): void {
const u = new SpeechSynthesisUtterance(text)
u.lang = lang
window.speechSynthesis.cancel()
window.speechSynthesis.speak(u)
}
export function stopSpeaking(): void {
window.speechSynthesis.cancel()
}
```
- [x] **Step 4: App.tsx**`toggleTTS`:若正在朗读则 stop;否则读当前章 `editor.getText()` 或选区
- [x] **Step 5: i18n**`focus.*` / `tts.*`
---
## Task 9: 占位清零 + 顶栏进度
**Files:**
- Modify: `src/renderer/components/wordfreq/WordFreqPanel.tsx`
- Modify: `src/renderer/components/layout/TopBar.tsx`
- Modify: `src/renderer/stores/useAppStore.ts`
- [x] **Step 1: WordFreqPanel** — 底部按钮 `onClick` 改为 `setNamingOpen(true)` 或触发 KnowledgePanel 命名检查(与 `NamingCheckModal` 联动)
- [x] **Step 2: TopBar** — 在 `top-spacer` 前加细进度条:
-`useWritingStatsStore` + `dailyWordGoal`
- `goalProgress` 映射 width%;≥100% 绿色,≥80% 橙色
- [x] **Step 3: 全局 grep** `feature.comingSoon` — Wave 1 范围内应无功能入口残留
---
## Task 10: E2E + 版本 v1.2.0
**Files:**
- Create: `e2e/book-settings.spec.ts`
- Create: `e2e/chapter-management.spec.ts`
- Create: `e2e/focus-tts.spec.ts`
- Modify: `e2e/chapter-template.spec.ts`
- Modify: `e2e/helpers.ts`
- Modify: `package.json`, `README.md`, `SettingsPage.tsx`
- [x] **Step 1: e2e/helpers.ts**`openBookSettings(page)` helper
- [x] **Step 2: E2E-BOOK-01** — 改书名、勾选 always cockpit、删书
- [x] **Step 3: E2E-CHAP-01** — 双击改标题、删除章
- [x] **Step 4: E2E-FOCUS-01** — Ctrl+Alt+F 进入专注,Esc 退出
- [x] **Step 5: E2E-TEMPL-05** — 模板含 `{characterList}` 的自定义模板(设置页创建)→ 快速新建 → 编辑器含角色名
- [x] **Step 6: 全量验证**
```bash
npm run build
npm run test
npm run test:e2e -- e2e/book-settings.spec.ts e2e/chapter-management.spec.ts e2e/focus-tts.spec.ts e2e/chapter-template.spec.ts
```
- [x] **Step 7: 版本**`package.json` / README / Settings 关于页 → **v1.2.0**
- [x] **Step 8: 勾选本 plan 全部 checkbox**
---
## Plan 自检
| Spec §5 要求 | Task |
|-------------|------|
| 5.1 书籍设置 Tab | Task 5 |
| 5.2 分卷管理 | Task 6 |
| 5.3 章节管理 | Task 6 |
| 5.4 模板变量 | Task 4 |
| 5.5 AI 会话 UI | Task 7 |
| 5.6 快捷键 | Task 8 |
| 5.7 用词分析 | Task 9 |
| 5.8 验收 E2E | Task 10 |
无 TBD 占位。
---
## 后续波次计划(待 W1 完成后编写)
| 文件 | 版本 |
|------|------|
| `docs/superpowers/plans/2026-07-08-bilin-w2-project-pack.md` | v1.3.0 |
| `docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md` | v1.4.0 |
| `docs/superpowers/plans/2026-07-08-bilin-w4-creative-depth.md` | v1.5.0 |
| `docs/superpowers/plans/2026-07-08-bilin-w5-platform.md` | v2.0.0 |
@@ -0,0 +1,157 @@
# 笔临 Wave 2 项目包 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:** 交付 v1.3.0`.novel` 单书项目包与 `.novel-all` 批量包导入导出,主页/设置/导出模态接入,大文件进度与取消。
**Architecture:** `pack-bootstrap` 独立 chunk 动态 `import('archiver')``PackService` 读写 ZIP + `manifest.json` v1;导出复制 `book.sqlite` + `meta.json` + 可选 `attachments/`;导入默认新 UUID 注册;`PACK_PROGRESS` IPC 推送进度;>500MB 导出前确认。
**Tech Stack:** Electron 36、archiver、node:fs、TypeScript、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §4.6、§6
**Master Program:** Wave 2/5 → v1.3.0
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/novel-pack.ts` | manifest v1 类型、formatVersion 常量 |
| `src/main/pack-bootstrap.ts` | 延迟加载 archiver,注册 pack IPC |
| `src/main/services/pack.service.ts` | exportBook/importBook/exportAll/importAll |
| `src/main/ipc/handlers/pack.handler.ts` | PACK_* IPC |
| `src/shared/ipc-channels.ts` | PACK_EXPORT_BOOK 等 |
| `src/shared/types.ts` | PackProgressEvent、PackImportReport |
| `src/preload/index.ts` + `electron-api.d.ts` | pack.export/import API |
| `src/renderer/components/export/ExportAllModal.tsx` | 主页导出全部 |
| `src/renderer/components/import/ImportBookModal.tsx` | 支持 .novel/.novel-all |
| `src/renderer/components/export/ExportModal.tsx` | 增加 .novel 选项 |
| `src/renderer/components/home/HomePage.tsx` | 导出全部入口 |
| `src/renderer/components/settings/BackupSettingsTab.tsx` | 设置页备份区 |
| `electron.vite.config.ts` | pack-bootstrap 入口 |
| `tests/main/pack.service.test.ts` | IT-ALL-02 等 |
| `e2e/pack-export-import.spec.ts` | 单书 .novel E2E |
| `e2e/pack-export-all.spec.ts` | E2E-ALL-03 |
---
## Task 1: 依赖 + 类型 + pack-bootstrap
**Files:**
- Modify: `package.json`
- Create: `src/shared/novel-pack.ts`
- Create: `src/main/pack-bootstrap.ts`
- Modify: `electron.vite.config.ts`
- [x] **Step 1:** `npm install archiver` + dev `@types/archiver`
- [x] **Step 2:** `novel-pack.ts``NOVEL_FORMAT_VERSION = 1``NovelManifest``NovelAllManifest`
- [x] **Step 3:** `pack-bootstrap.ts` — 导出 `bootstrapPackHandlers(registry, settings)`
- [x] **Step 4:** `electron.vite.config.ts` — 增加 `pack-bootstrap` input`external: ['archiver']`
- [x] **Step 5:** `npm run build` 通过
---
## Task 2: PackService 单书导出/导入
**Files:**
- Create: `src/main/services/pack.service.ts`
- Modify: `src/main/services/book-registry.ts``importFromDb(meta, sqlitePath)` 辅助
- [x] **Step 1:** `exportBook(bookId, destPath)` — ZIP: manifest + meta + book.sqlite + attachments
- [x] **Step 2:** `importBook(filePath)` — 校验 formatVersion,新 UUID,注册 meta,复制 sqlite
- [x] **Step 3:** 名称冲突时追加后缀 `(2)`
- [x] **Step 4:** UT `tests/main/pack.service.test.ts` — 导出再导入章节数一致
---
## Task 3: exportAll / importAll + 进度
**Files:**
- Modify: `src/main/services/pack.service.ts`
- Modify: `src/shared/types.ts`
- [x] **Step 1:** `exportAll(destPath)` — global_settings + books/{id}/
- [x] **Step 2:** `importAll(filePath, strategy)` — skip/overwrite/new
- [x] **Step 3:** `onProgress` 回调 + `PACK_PROGRESS` 广播
- [x] **Step 4:** `estimateSize` + >500MB 确认标志
- [x] **Step 5:** AbortController 取消支持
- [x] **Step 6:** UT IT-ALL-02 — 3 书导出删库再导入
---
## Task 4: IPC + preload
**Files:**
- Create: `src/main/ipc/handlers/pack.handler.ts`
- Modify: `src/main/pack-bootstrap.ts`
- Modify: `src/main/index.ts`
- Modify: `src/preload/index.ts``electron-api.d.ts`
- [x] **Step 1:** IPC 常量与 handler 注册
- [x] **Step 2:** preload `pack.exportBook/importBook/exportAll/importAll/pickFile`
- [x] **Step 3:** `onPackProgress` 事件订阅
---
## Task 5: ExportModal + ImportBookModal
**Files:**
- Modify: `src/renderer/components/export/ExportModal.tsx`
- Modify: `src/renderer/components/import/ImportBookModal.tsx`
- Modify: `public/locales/*/translation.json`
- [x] **Step 1:** ExportModal 增加「笔临项目包 (.novel)」
- [x] **Step 2:** ImportBookModal 文件过滤含 `.novel`/`.novel-all`
- [x] **Step 3:** `.novel` 直接导入;`.novel-all` 打开策略选择 + 进度条
---
## Task 6: ExportAllModal + HomePage
**Files:**
- Create: `src/renderer/components/export/ExportAllModal.tsx`
- Modify: `src/renderer/components/home/HomePage.tsx`
- Modify: `src/renderer/stores/useAppStore.ts`(可选 modal 状态)
- [x] **Step 1:** `ExportAllModal` — 选路径、大小确认、进度、取消
- [x] **Step 2:** HomePage「导出所有书籍」启用并打开 modal
---
## Task 7: 设置页备份区
**Files:**
- Create: `src/renderer/components/settings/BackupSettingsTab.tsx`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- [x] **Step 1:** 导出当前书 / 导出全部按钮
- [x] **Step 2:** i18n `backup.*`
---
## Task 8: E2E + v1.3.0
**Files:**
- Create: `e2e/pack-export-import.spec.ts`
- Create: `e2e/pack-export-all.spec.ts`
- Modify: `e2e/helpers.ts`
- Modify: `package.json``README.md``SettingsPage.tsx`
- [x] **Step 1:** E2E 单书 .novel 导出再导入
- [x] **Step 2:** E2E-ALL-03 主页导出全部
- [x] **Step 3:** 全量 `npm run test` + 相关 E2E
- [x] **Step 4:** 版本 **v1.3.0**
- [x] **Step 5:** 勾选本 plan 全部 checkbox
---
## Plan 自检
| Spec §6 要求 | Task |
|-------------|------|
| PackService 四 API | Task 23 |
| 进度 IPC | Task 34 |
| UI 入口 | Task 57 |
| IT-ALL-02 / E2E | Task 8 |
@@ -0,0 +1,137 @@
# 笔临 Wave 3 导出阅读 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:** 交付 v1.4.0:导出矩阵(txt/md/docx/pdf)、连续阅读模式、驾驶舱补全、专注环境音。
**Architecture:** `ExportMatrixService` 扩展 `ExportService` 模式;docx 用 `docx` 库;pdf 用 Electron `printToPDF` + HTML 模板;`ReadModeOverlay` 虚拟 3 章 + scroll-snap;阅读偏好写入 `GlobalSettings`
**Tech Stack:** Electron 36、docx、TypeScript、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §7
**Master Program:** Wave 3/5 → v1.4.0
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/export-matrix.ts` | ExportFormat/Scope/Options 类型 |
| `src/main/services/export-matrix.service.ts` | txt/md/docx/pdf 导出 |
| `src/main/export-matrix-bootstrap.ts` | 延迟加载 docx,注册 IPC |
| `src/main/ipc/handlers/export-matrix.handler.ts` | EXPORT_MATRIX_* |
| `src/renderer/components/read/ReadModeOverlay.tsx` | 连续阅读全屏 |
| `src/renderer/stores/useReadModeStore.ts` | 阅读模式状态 |
| `src/renderer/components/export/ExportModal.tsx` | 导出矩阵 UI |
| `src/renderer/components/cockpit/CockpitModal.tsx` | 今日大纲任务 |
| `src/renderer/components/focus/FocusModeOverlay.tsx` | 环境音效 |
| `e2e/read-mode.spec.ts` | E2E-READ-01~03 |
| `e2e/export-matrix.spec.ts` | E2E-PDF-01 等 |
---
## Task 1: 类型 + 阅读偏好
**Files:**
- Create: `src/shared/export-matrix.ts`
- Modify: `src/shared/types.ts``readModeFontTheme``readModeDeviceWidth``enableFocusAmbientSound`
- [x] **Step 1:** export-matrix 类型定义
- [x] **Step 2:** GlobalSettings 阅读/专注字段
- [x] **Step 3:** settings store 默认值
---
## Task 2: 连续阅读模式
**Files:**
- Create: `src/renderer/stores/useReadModeStore.ts`
- Create: `src/renderer/components/read/ReadModeOverlay.tsx`
- Modify: `EditorLayout.tsx``VolumeContextMenu.tsx``App.tsx``layout.css`
- [x] **Step 1:** ReadModeOverlay 全屏 + 虚拟 3 章 + scroll-snap
- [x] **Step 2:** 设备宽度 / 字体主题切换与持久化
- [x] **Step 3:** 章节侧栏 + 分卷右键入口
- [x] **Step 4:** i18n `readMode.*`
---
## Task 3: ExportMatrixService txt/md
**Files:**
- Create: `src/main/services/export-matrix.service.ts`
- Create: `src/main/ipc/handlers/export-matrix.handler.ts`
- [x] **Step 1:** 章/卷/全书范围解析
- [x] **Step 2:** txt/md 导出 + 选项(批注/AI 标注)
- [x] **Step 3:** UT 章级 txt 导出
---
## Task 4: docx 导出
**Files:**
- Modify: `package.json``docx`
- Modify: `export-matrix.service.ts`
- [x] **Step 1:** docx 目录与章节结构
- [x] **Step 2:** IT-DOCX-01 含目录
---
## Task 5: pdf 导出
**Files:**
- Modify: `export-matrix.service.ts`
- [x] **Step 1:** HTML 模板 + printToPDF
- [x] **Step 2:** A4 页眉页脚
- [x] **Step 3:** E2E mock 路径 BILIN_E2E_PDF_SAVE
---
## Task 6: ExportModal 矩阵 UI
**Files:**
- Modify: `ExportModal.tsx``preload``ipc-channels`
- [x] **Step 1:** 格式/范围/选项选择
- [x] **Step 2:** 保存对话框与 toast
---
## Task 7: 驾驶舱 + 专注环境音
**Files:**
- Modify: `CockpitModal.tsx`
- Modify: `FocusModeOverlay.tsx``SettingsPage.tsx`
- [x] **Step 1:** 今日大纲任务 Top 5pending + expectedWordCount
- [x] **Step 2:** 专注模式环境音开关与播放
---
## Task 8: E2E + v1.4.0
**Files:**
- Create: `e2e/read-mode.spec.ts``e2e/export-matrix.spec.ts`
- Modify: `package.json``README.md``SettingsPage.tsx`
- [x] **Step 1:** E2E-READ-01~03
- [x] **Step 2:** E2E-PDF-01
- [x] **Step 3:** 全量 test + 相关 E2E
- [x] **Step 4:** 版本 **v1.4.0**
- [x] **Step 5:** 勾选本 plan 全部 checkbox
---
## Plan 自检
| Spec §7 要求 | Task |
|-------------|------|
| ExportMatrix 四格式 | Task 36 |
| ReadModeOverlay | Task 2 |
| 驾驶舱大纲任务 | Task 7 |
| E2E-READ / E2E-PDF | Task 8 |
@@ -0,0 +1,80 @@
# 笔临 Wave 4 创作深化 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:** 交付 v1.5.0:故事时间线、角色弧线、敏感词合规、引用抽屉与章悬停预览、自定义快捷命令。
**Architecture:** `timeline_events` 表 + 章节 `story_time` 聚合;`ArcService` 聚合 knowledge 角色状态;`ComplianceService` 本地词库 + TipTap DecorationGlobalSettings 扩展合规/命令字段。
**Tech Stack:** Electron 36、TipTap、TypeScript、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §8
**Master Program:** Wave 4/5 → v1.5.0
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/timeline.ts` | TimelineEntry、TimelineConflict |
| `src/main/db/schema-v10.sql` | timeline_events |
| `src/main/db/repositories/timeline.repo.ts` | CRUD |
| `src/main/services/timeline.service.ts` | 列表、冲突检测 |
| `src/main/ipc/handlers/timeline.handler.ts` | TIMELINE_* |
| `src/renderer/components/timeline/TimelineModal.tsx` | 时间轴 UI |
| `src/main/services/arc.service.ts` | 角色弧线节点 |
| `src/renderer/components/setting/CharacterArcPanel.tsx` | 弧线 Tab |
| `src/main/services/compliance.service.ts` | 词库扫描 |
| `src/renderer/extensions/compliance-highlight.ts` | 下划线装饰 |
| `e2e/timeline.spec.ts` | E2E-TL-01~03 |
---
## Task 1: Schema v10 + 类型
- [x] **Step 1:** schema-v10 + migrate v10
- [x] **Step 2:** `timeline.ts` 类型
- [x] **Step 3:** migrate UT 更新
---
## Task 2: Timeline 服务 + UI
- [x] **Step 1:** TimelineRepository + TimelineService
- [x] **Step 2:** IPC + preload
- [x] **Step 3:** TimelineModal + EditorLayout 入口
- [x] **Step 4:** 冲突检测 + 红色标记
---
## Task 3: 角色弧线
- [x] **Step 1:** ArcService + ARC_GET IPC
- [x] **Step 2:** CharacterArcPanel 于 character 设定
- [x] **Step 3:** UT 弧线节点
---
## Task 4: 敏感词合规
- [x] **Step 1:** ComplianceService + IPC
- [x] **Step 2:** TipTap compliance-highlight
- [x] **Step 3:** 设置页开关 + UT 下划线
---
## Task 5: 其他补全
- [x] **Step 1:** 引用面板详情抽屉
- [x] **Step 2:** 章节悬停预览
- [x] **Step 3:** customSlashCommands 设置编辑
---
## Task 6: E2E + v1.5.0
- [x] **Step 1:** E2E-TL-01~03
- [x] **Step 2:** 全量 test + 相关 E2E
- [x] **Step 3:** 版本 **v1.5.0**
@@ -0,0 +1,78 @@
# 笔临 Wave 5 平台基建 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:** 交付 v2.0.0:撤销堆栈持久化、云同步、崩溃日志、自动更新、扩展 API 骨架、Skill/MCP 占位。
**Architecture:** Schema v11 `undo_stack``SyncService` 加密 `.novel-all``electron-log` + `electron-updater``ExtensionRegistry` stub。
**Tech Stack:** Electron 36、electron-log、electron-updater、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §9
**Master Program:** Wave 5/5 → v2.0.0
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v11.sql` | undo_stack |
| `src/main/services/undo-stack.service.ts` | 持久化撤销 |
| `src/main/services/sync.service.ts` | 本地/WebDAV 同步 |
| `src/main/services/log.service.ts` | electron-log |
| `src/main/services/update.service.ts` | electron-updater |
| `src/main/services/extension-registry.ts` | 扩展注册表 stub |
| `src/shared/extension-api.ts` | BilinExtension 类型 |
| `src/renderer/components/settings/SyncSettingsTab.tsx` | 备份与同步 |
| `e2e/timeline.spec.ts` | E2E-TL-01~03 |
---
## Task 1: Schema v11 + 撤销堆栈
- [x] **Step 1:** schema-v11 + migrate v11
- [x] **Step 2:** UndoStackRepository + Service + IPC
- [x] **Step 3:** TipTap 防抖持久化 + 跨会话 Ctrl+Z
- [x] **Step 4:** E2E-UNDO-01
---
## Task 2: 云同步
- [x] **Step 1:** sync-crypto AES-256-GCM
- [x] **Step 2:** SyncService local-folder + WebDAV
- [x] **Step 3:** SyncSettingsTab + 冲突模态
- [x] **Step 4:** IT-SYNC-01 + E2E-SYNC-01
---
## Task 3: 日志与崩溃报告
- [x] **Step 1:** LogService + 启动检测
- [x] **Step 2:** 发送报告 stub + UT-LOG-01
---
## Task 4: 自动更新
- [x] **Step 1:** UpdateService + IPC 事件
- [x] **Step 2:** 顶栏更新提示(BILIN_E2E 禁用 / BILIN_MOCK_UPDATE mock
- [x] **Step 3:** IT-UPDATE-01 + E2E-UPDATE-02
---
## Task 5: 扩展 API + Skill/MCP
- [x] **Step 1:** extension-api.ts + registry stub
- [x] **Step 2:** docs/extensions/README.md
- [x] **Step 3:** ExtensionsSettingsTab + AI Skill/MCP 占位
---
## Task 6: i18n + v2.0.0
- [x] **Step 1:** app.getLocale 首次语言
- [x] **Step 2:** 全量 test + E2E
- [x] **Step 3:** 版本 **v2.0.0**
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,454 @@
# 笔临 P5 作家工作流 + 知识库 MVP 设计规格
**版本**1.0
**日期**2026-07-06
**范围**:P5 — 驾驶舱 + 更新计划/存稿 + 章间衔接 + 知识库 MVP(手动 CRUD / 审核 / 伏笔追踪)
**前置**:P4.1 自动/向导写作已交付(v0.5.0,`5d3e264`
**参考**`design/readme.md` v1.1 §4.3 / §6.7 / §20.1 / §20.4、`design/ui_pc.html` `#dashboardModal` / `#bridgeModal` / `#knowledge`
**目标版本**v0.6.0
---
## 1. 背景与目标
### 1.1 阶段定位
v0.5.0 已交付四种 AI 写作模式;知识库 Tab 仍为占位(仅命名检查入口),顶栏驾驶舱/章间衔接为 `comingSoon`。本阶段交付 **作家日更工作流****知识库 MVP**,对齐 `design/readme.md` §20 作家专项能力,为后续 AI 知识抽取(P5.2+)与 AI 上下文注入(P6)打基础。
### 1.2 已确认决策(Brainstorming 2026-07-06
| 维度 | 决策 |
|------|------|
| 总范围 | **A 作家工作流 + B 知识库 MVP**,统一 v0.6.0 交付(方案 1) |
| 知识库 | **MVP B**`knowledge_entries` 手动 CRUD + 审核流 + 地标 `foreshadow` 同步;**不做** AI 自动抽取 |
| 驾驶舱 | **MVP B**:现有字数聚合 + `publish_status` 存稿缓冲 + 快速入口;**不含** `writing_logs` / 热力图 / 预计完本 |
| 章间衔接 | **B**:上一章末 + 本章开头文本;LM Studio 可用时追加 AI 建议;离线降级纯文本 |
| 架构 | schema **v6**`CockpitService` / `KnowledgeService` / `ChapterBridgeService` 分层 |
| 首次打开书籍 | 展示驾驶舱一次;日常打开恢复上次编辑位(§4.3) |
| AI 后端 | 衔接建议走现有 `AiClientService`(LM Studio);集成测试禁止 Mock |
| 测试超时 | 衔接 AI 集成 **240s**E2E 含 AI **300s** |
### 1.3 验收标准
用户能完成:
1. 顶栏打开**驾驶舱**,查看全书/本卷字数、存稿缓冲、伏笔摘要(已埋/已回收/疑似遗忘)
2. 在设置中配置**更新频率**与**存稿阈值**;章节列表修改 `publish_status`(草稿/待发布/已发布);存稿不足时状态栏软性提醒
3. 新章字数 < 200 时自动弹出**章间衔接**卡片(可「本章不再提示」);或从驾驶舱手动触发;LM Studio 可用时看到 AI 衔接建议
4. 在**知识库**手动新建/编辑条目,在「待审核」Tab 采纳或拒绝;伏笔类型可标记状态;从地标创建 `foreshadow` 时可同步为待审核条目
5. 驾驶舱「处理遗忘伏笔」跳转知识库并筛选疑似遗忘项;命名一致性检查入口保留
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| AI 每章自动知识抽取(§6.7 全自动) | P5.2+ |
| 知识条目注入 AI 写作上下文 | P6 |
| `writing_logs`、365 天热力图、预计完本、番茄钟(§8) | P5.2 / §8 |
| 角色弧线简报、Cytoscape 关系图谱(§7 / §20.3 | P6+ |
| 导入导出、平台投稿预设(§11 / §20.2) | P7 |
| 书籍设置面板「文风控制」(ui 原型占位) | 后续 |
---
## 2. 架构
### 2.1 进程模型
```
┌─────────────────────────────────────────────────────────────┐
│ 主进程(schema v6
│ · KnowledgeRepository / KnowledgeService │
│ · CockpitService(只读聚合) │
│ · ChapterBridgeService(文本提取 + 可选 AiClient
│ · ChapterRepository 扩展 publish_status 读写 │
│ · GlobalSettings 扩展 updateSchedule / stockBufferThreshold │
│ · book_preferencescockpitSeen、bridge 免打扰) │
└───────────────────────────┬─────────────────────────────────┘
│ IPC
┌───────────────────────────▼─────────────────────────────────┐
│ 渲染进程 │
│ · CockpitModal / ChapterBridgeModal │
│ · KnowledgePanel(待审核 | 全部 | 伏笔) │
│ · ChapterList 发布状态 + StatusBar 存稿提醒 │
│ · SettingsPage 更新计划 │
│ · TopBar 驾驶舱按钮(替换 comingSoon
└─────────────────────────────────────────────────────────────┘
```
### 2.2 模块边界
| 模块 | 职责 | 依赖 |
|------|------|------|
| `KnowledgeService` | CRUD、审核、批量操作、伏笔统计、遗忘判定 | `KnowledgeRepository`, `ChapterRepository` |
| `CockpitService` | 聚合字数、存稿数、伏笔摘要、卷占比 | `ChapterRepository`, `KnowledgeService` |
| `ChapterBridgeService` | 提取相邻章片段、生成/缓存 AI 建议 | `ChapterRepository`, `AiClientService` |
| `KnowledgePanel` | 列表 UI、编辑对话框、审核操作 | `useKnowledgeStore` |
| `useKnowledgeStore` | IPC 封装、Tab 筛选状态 | preload `knowledge.*` |
**原则**:驾驶舱只读不写;知识审核后才计入伏笔统计;衔接 AI 失败不阻断文本展示。
---
## 3. 数据模型(schema v6
### 3.1 迁移
- `CURRENT_VERSION = 6`
- 新文件 `schema-v6.sql`
```sql
CREATE TABLE knowledge_entries (
id TEXT PRIMARY KEY,
type TEXT NOT NULL, -- character/foreshadow/location/relationship/fact
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
importance INTEGER NOT NULL DEFAULT 3, -- 1-5
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected
foreshadow_status TEXT, -- buried/partial/resolved (type=foreshadow)
source_chapter_id TEXT,
last_mention_chapter_id TEXT,
linked_bookmark_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (source_chapter_id) REFERENCES chapters(id) ON DELETE SET NULL,
FOREIGN KEY (last_mention_chapter_id) REFERENCES chapters(id) ON DELETE SET NULL
);
CREATE INDEX idx_knowledge_status ON knowledge_entries(status);
CREATE INDEX idx_knowledge_type ON knowledge_entries(type);
CREATE TABLE book_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL -- JSON
);
CREATE TABLE chapter_bridge_dismiss (
chapter_id TEXT PRIMARY KEY,
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
```
- v5→v6 迁移测试:`UT-MIG-06`
### 3.2 TypeScript 类型(`src/shared/types.ts`
```typescript
export type KnowledgeType = 'character' | 'foreshadow' | 'location' | 'relationship' | 'fact'
export type KnowledgeStatus = 'pending' | 'approved' | 'rejected'
export type ForeshadowStatus = 'buried' | 'partial' | 'resolved'
export type UpdateSchedule = 'daily' | 'alternate' | 'weekly' | 'none'
export interface KnowledgeEntry { /* 字段与表一致 */ }
export interface CockpitSummary {
totalWords: number
volumeWords: number
stockReadyCount: number
stockThreshold: number
foreshadowBuried: number
foreshadowResolved: number
foreshadowForgotten: number
volumeBreakdown: { volumeId: string; name: string; words: number }[]
}
export interface ChapterBridgeResult {
previousChapterId: string | null
previousTail: string
currentHead: string
aiSuggestion?: string
}
```
### 3.3 GlobalSettings 扩展
```typescript
updateSchedule: UpdateSchedule // default 'none'
stockBufferThreshold: number // default 3
```
`publish_status` 列已在 migrate v2 尝试添加;本阶段确保 `ChapterRepository` 读写并在 UI 暴露。
### 3.4 书籍偏好(`book_preferences`
| key | value | 说明 |
|-----|-------|------|
| `cockpitSeen` | `"true"` | 首次驾驶舱已展示 |
| `openCockpitEveryTime` | `"true"` | 可选:书籍级「每次打开显示驾驶舱」(默认 false,全局可在后续扩展) |
### 3.5 伏笔「疑似遗忘」判定(MVP)
同时满足:
1. `type = 'foreshadow'``status = 'approved'``foreshadow_status = 'buried'`
2.`last_mention_chapter_id ?? source_chapter_id` 对应章节的 `sort_order``mentionOrder`
3. 全书最大 `sort_order``latestOrder`
4. `latestOrder - mentionOrder >= 20` → 计入 `foreshadowForgotten`
用户可在编辑条目时更新 `lastMentionChapterId` 或改为 `resolved` 以清除遗忘标记。
### 3.6 地标同步
创建或编辑 `bookmark.landmarkType = 'foreshadow'` 时,UI 提供 checkbox「同步到知识库」:
- 调用 `knowledge.createFromBookmark(bookmarkId)` → 新建 `pending` 条目,`linked_bookmark_id` 关联,`title` 取自地标 label
---
## 4. IPC 与 Preload
### 4.1 通道(`src/shared/ipc-channels.ts`
```typescript
// knowledge
KNOWLEDGE_LIST: 'knowledge:list'
KNOWLEDGE_CREATE: 'knowledge:create'
KNOWLEDGE_UPDATE: 'knowledge:update'
KNOWLEDGE_DELETE: 'knowledge:delete'
KNOWLEDGE_APPROVE: 'knowledge:approve'
KNOWLEDGE_REJECT: 'knowledge:reject'
KNOWLEDGE_BATCH_APPROVE: 'knowledge:batchApprove'
KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark'
KNOWLEDGE_STATS: 'knowledge:foreshadowStats'
// cockpit
COCKPIT_SUMMARY: 'cockpit:summary'
COCKPIT_MARK_SEEN: 'cockpit:markSeen'
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen'
// bridge
BRIDGE_GET: 'bridge:get'
BRIDGE_DISMISS: 'bridge:dismiss'
BRIDGE_SHOULD_PROMPT: 'bridge:shouldPrompt'
// chapter 扩展
CHAPTER_SET_PUBLISH_STATUS: 'chapter:setPublishStatus' // 或合并进现有 update
```
### 4.2 Preload 命名空间
```typescript
knowledge: { list, create, update, delete, approve, reject, batchApprove, createFromBookmark, stats }
cockpit: { getSummary, markSeen, shouldShowOnOpen }
bridge: { get, dismiss, shouldPrompt }
```
---
## 5. 服务层
### 5.1 KnowledgeService
```typescript
list(filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean })
create(input: CreateKnowledgeInput): KnowledgeEntry
update(id, patch): KnowledgeEntry
delete(id): void
approve(id) / reject(id) / batchApprove(ids[])
createFromBookmark(bookmarkId): KnowledgeEntry
getForeshadowStats(): { buried, resolved, forgotten }
countPending(): number
```
新建条目默认 `status='pending'`;用户可在对话框勾选「直接采纳」→ `approved`
### 5.2 CockpitService
```typescript
getSummary(bookId, activeVolumeId?): CockpitSummary
shouldShowOnOpen(bookId): boolean // !cockpitSeen
markSeen(bookId): void
```
字数:`stripHtml` 后求和现有 `chapters.content`;不引入 `writing_logs`
### 5.3 ChapterBridgeService
```typescript
shouldPrompt(chapterId): boolean
// 本章字数 < 200 && 存在上一章 && 未 dismiss
getBridge(chapterId, withAi?: boolean): Promise<ChapterBridgeResult>
// previousTail: 上一章 stripHtml 末 300 字
// currentHead: 本章 stripHtml 前 200 字(有则展示)
// withAi && AiClient 可用 → chat 单轮建议(240s timeout
dismiss(chapterId): void
```
**AI Prompt 模板**(简体):
> 你是连载小说编辑。上一章末尾:「{tail}」。本章开头:「{head}」。用 2–3 句话指出衔接是否顺畅,并给出一个开头方向建议。不要续写正文。
失败时返回无 `aiSuggestion`UI 隐藏 AI 区块。
---
## 6. 渲染进程 UI
### 6.1 TopBar
- 驾驶舱按钮(`data-testid="topbar-cockpit"`)打开 `CockpitModal`;移除 `comingSoon`
### 6.2 CockpitModal(对齐 `#dashboardModal`
| data-testid | 元素 |
|-------------|------|
| `cockpit-modal` | 根 overlay |
| `cockpit-total-words` | 全书总字数 |
| `cockpit-volume-words` | 本卷字数 |
| `cockpit-stock-count` | 存稿 ready 数 |
| `cockpit-foreshadow-summary` | 伏笔摘要行 |
| `cockpit-resume-edit` | 回到上次编辑 |
| `cockpit-bridge-check` | 检查章间衔接 |
| `cockpit-forgotten-foreshadow` | 跳转知识库筛选 |
打开书籍时:若 `cockpit.shouldShowOnOpen` → 自动弹出并 `markSeen`
### 6.3 ChapterBridgeModal(对齐 `#bridgeModal`
| data-testid | 元素 |
|-------------|------|
| `bridge-modal` | 根 |
| `bridge-previous-tail` | 上一章末尾 |
| `bridge-current-head` | 本章开头 |
| `bridge-ai-suggestion` | AI 建议(可选) |
| `bridge-dismiss-checkbox` | 本章不再提示 |
| `bridge-start-writing` | 开始写作 |
触发:`EditorLayout``openChapter` 后若 `bridge.shouldPrompt` → 延迟 500ms 弹出(避免与加载冲突)。
### 6.4 KnowledgePanel(替换 placeholder
布局:
1. 工具栏:「新建条目」「命名一致性检查」(保留)
2. 摘要条:伏笔统计(同 cockpit)
3. Tab`待审核` | `全部` | `伏笔`
4. 列表项:类型图标、标题、重要度星、来源章、状态标签
5. 点击 → `KnowledgeEditorDialog`(标题、类型、内容、重要度、伏笔状态、关联章)
| data-testid | 元素 |
|-------------|------|
| `knowledge-panel` | 根 |
| `knowledge-tab-pending` / `-all` / `-foreshadow` | Tab |
| `knowledge-new` | 新建 |
| `knowledge-item-{id}` | 列表项 |
| `knowledge-approve-{id}` | 单条采纳 |
| `knowledge-batch-approve` | 批量采纳待审核 |
### 6.5 章节发布状态
- 章节列表项右键或状态图标循环:`draft``ready``published`
- `data-testid="chapter-publish-{id}"`
### 6.6 StatusBar 存稿提醒
-`stockReadyCount < stockBufferThreshold``updateSchedule !== 'none'` → 显示橙色提示文案(不阻断)
- `data-testid="status-stock-warning"`
### 6.7 SettingsPage
- 更新频率下拉:`updateSchedule`
- 存稿阈值数字输入:`stockBufferThreshold`120,默认 3
### 6.8 i18n
新增键:`cockpit.*``bridge.*``knowledge.*``publish.status.*``settings.updateSchedule.*``stock.warning`
---
## 7. 错误处理
| 场景 | 行为 |
|------|------|
| 无上一章时 bridge | `shouldPrompt=false`;手动打开显示「无上一章」 |
| AI 建议超时/失败 | 仅展示文本片段;toast 可选「AI 建议不可用」 |
| 删除已关联 bookmark 的 knowledge | 保留条目,`linked_bookmark_id` 置 null |
| 空知识库 cockpit 伏笔 | 显示 0,不报错 |
| publish 更新失败 | IPC 错误 toast |
---
## 8. 测试
### 8.1 单元 / 集成(Vitest
| ID | 场景 | AI |
|----|------|-----|
| UT-MIG-06 | v5→v6`knowledge_entries` 存在 | — |
| UT-KNOW-01 | CRUD + approve 后 status 正确 | — |
| UT-KNOW-02 | 遗忘判定:sort_order 差 ≥20 | — |
| UT-KNOW-03 | createFromBookmark 创建 pending | — |
| UT-COCK-01 | getSummary 字数与存稿计数 | — |
| UT-BRIDGE-01 | 提取 tail/head 长度正确 | — |
| IT-BRIDGE-01 | getBridge 含非空 aiSuggestion | 240s LM Studio |
| IT-KNOW-04 | 批量 approve 后 pending 清零 | — |
`vitest.config.ts` 保持 `fileParallelism: false`
### 8.2 E2EPlaywright
| ID | 场景 | 超时 |
|----|------|------|
| E2E-COCK-01 | 打开驾驶舱,统计卡片可见 | 30s |
| E2E-COCK-02 | 首次开书自动弹驾驶舱,二次不再自动 | 30s |
| E2E-PUB-01 | 章节改 publish_status 为 readycockpit 存稿 +1 | 30s |
| E2E-KNOW-01 | 新建 → 待审核 → 采纳 → 列表可见 | 30s |
| E2E-KNOW-02 | 地标 foreshadow 同步知识库 | 30s |
| E2E-BRIDGE-01 | 新章触发衔接 modal,文本非空 | 30s |
| E2E-BRIDGE-02 | 衔接含 AI 建议(LM Studio | 300s |
| E2E-STOCK-01 | 存稿低于阈值,status bar 警告可见 | 30s |
---
## 9. 文件清单(实现参考)
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v6.sql` | 新表 |
| `src/main/db/repositories/knowledge.repo.ts` | 数据访问 |
| `src/main/services/knowledge.service.ts` | 业务 + 遗忘判定 |
| `src/main/services/cockpit.service.ts` | 聚合 |
| `src/main/services/chapter-bridge.service.ts` | 衔接 + AI |
| `src/main/ipc/handlers/knowledge.handler.ts` | IPC |
| `src/main/ipc/handlers/cockpit.handler.ts` | IPC |
| `src/main/ipc/handlers/bridge.handler.ts` | IPC |
| `src/renderer/components/cockpit/CockpitModal.tsx` | UI |
| `src/renderer/components/bridge/ChapterBridgeModal.tsx` | UI |
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 重写 |
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 编辑 |
| `src/renderer/stores/useKnowledgeStore.ts` | 状态 |
| `tests/main/migrate-v6.test.ts` | 迁移 |
| `tests/main/knowledge.repo.test.ts` | CRUD |
| `tests/main/cockpit.service.test.ts` | 聚合 |
| `e2e/cockpit-knowledge.spec.ts` | E2E |
---
## 10. Definition of Donev0.6.0
- [ ] schema v6 迁移 + 类型 + IPC + preload
- [ ] KnowledgeService + Panel 完整 CRUD / 审核
- [ ] CockpitModal + 首次打开逻辑
- [ ] ChapterBridgeModal + 自动/手动触发 + AI 建议
- [ ] publish_status UI + 存稿提醒 + updateSchedule 设置
- [ ] 全量测试通过(含 LM Studio IT-BRIDGE-01 / E2E-BRIDGE-02
- [ ] README / SettingsPage 版本号 → **0.6.0**
---
## 11. Spec Self-Review
| 检查项 | 结果 |
|--------|------|
| Placeholder 扫描 | 无 TBD;排除项见 §1.4 |
| 内部一致性 | 驾驶舱不依赖 writing_logs;伏笔统计仅 approved |
| 范围 | 单 spec 可支撑一份 implementation plan |
| 歧义 | 遗忘阈值固定 20 章;存稿阈值默认 3;MVP 不做 AI 抽取 |
---
*本规格为笔临 P5 子项目。AI 知识抽取、writing_logs 热力图等在 P5.2+ spec 中增量修订。*
@@ -0,0 +1,452 @@
# 笔临 P5.2 写作日志 + AI 知识抽取 设计规格
**版本**1.0
**日期**2026-07-07
**范围**P5.2 — `writing_logs` 日更追踪 + 驾驶舱热力图/连更 + AI 章节知识自动抽取与审核增强
**前置**:P6 AI 知识上下文已交付(v0.7.0,`aa2c7df`
**参考**`design/readme.md` v1.1 §6.7 / §8 / §9、`docs/superpowers/specs/2026-07-06-bilin-p5-workflow-knowledge-design.md``docs/superpowers/specs/2026-07-07-bilin-p6-ai-context-design.md`
**目标版本**v0.8.0
---
## 1. 背景与目标
### 1.1 阶段定位
v0.7.0 已交付知识半自动推荐与四种 AI 模式注入,但知识库条目仍依赖**手动录入**;驾驶舱缺少日更写作数据(`writing_logs`、热力图、连更),`dailyWordGoal` 仅有设置项未驱动 UI。本阶段(P5.2)补齐 **写作数据闭环****AI 知识抽取上游**,形成「成章/保存 → 抽取 → 审核 → P6 注入」完整链路。
### 1.2 已确认决策(Brainstorming 2026-07-07
| 维度 | 决策 |
|------|------|
| 交付范围 | **核心包**`writing_logs` + 热力图 + 连更 + AI 抽取;**不含**番茄钟 / 里程碑徽章 / 预计完本 |
| 抽取触发 | **成章自动**(默认开)+ 手动「抽取本章」+ 全局 `knowledgeAutoExtract` 开关 |
| 统计范围 | **当前书籍**:热力图、连更、`dailyWordGoal` 达标均按本书当日净增 |
| 日更累计 | **保存差分 + 成章补计**`CHAPTER_UPDATE` 记录净增;`finishChapter` 补计未入账字数 |
| 重复处理 | **AI 合并建议 + 预填更新**`suggestedMergeId` + `proposedPatch`;确认后 patch 已有条目或另存为新条目 |
| 架构 | **方案 2**:全局 `writing_logs` + 每书 `chapter_writing_snapshots` + 独立 `KnowledgeExtractionService` |
| schema | **v7** 迁移(每书);全局 `writing_logs.sqlite`userData |
| AI 测试 | 集成/E2E 含抽取时禁止 Mock,走 LM Studio |
### 1.3 验收标准
用户能完成:
1. 编辑章节并保存后,驾驶舱**今日字数**与 **365 天热力图**更新;`dailyWordGoal > 0` 时状态栏进度条反映本书当日进度(绿≥100%,橙≥80%)
2. 连更天数在驾驶舱展示(仅当 `dailyWordGoal > 0`);中断后归零
3. 交互/自动/向导 **成章落盘**后,若 `knowledgeAutoExtract` 开启,知识库「待审核」出现本批 AI 抽取项(含置信度)
4. 在知识库或章节操作区点击 **「抽取本章」** 手动触发;设置中可关闭自动抽取
5. 待审核项含 **合并建议** 时,打开预填编辑器;确认后更新已有 `approved` 条目,或选择「作为新条目」
6. 「全部采纳高置信度」批量通过 ≥ 阈值(默认 0.8)的纯新建条目;含合并建议的条目不自动批量采纳
7. 采纳后的条目可被 P6 `suggestContext` 推荐并注入 AI 上下文
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| 番茄钟、7/30/100 天里程碑徽章、达标推送通知 | §8 延后 |
| 预计完本、写作速度仪表盘、时段热力图 | §9 延后 |
| 角色弧线简报、Cytoscape 关系图谱 | P6+ / §20.3 |
| 语义 embedding 检索 | 后续 |
| 注入历史 / 反向链接 | P6.1 |
| 导入导出、平台投稿预设 | P7 |
| 关系「建议」自动写入 `settings` 表 | P6+(本阶段仅 `relationship` 类型知识条目) |
---
## 2. 架构
### 2.1 进程模型
```
┌──────────────────────────────────────────────────────────────────┐
│ 主进程 │
│ · WritingLogServiceuserData/writing_logs.sqlite
│ · ChapterWritingTracker(差分 + 成章补计) │
│ · KnowledgeExtractionServiceAiClient + 解析 JSON
│ · KnowledgeService 扩展(合并 patch、批量高置信度采纳) │
│ · CockpitService 扩展(热力图、连更、今日进度) │
│ · chapter.handler / interactive|auto finishChapter 挂钩 │
└────────────────────────────┬─────────────────────────────────────┘
│ IPC
┌────────────────────────────▼─────────────────────────────────────┐
│ 渲染进程 │
│ · CockpitModal 热力图 + 连更 + 今日进度 │
│ · EditorLayout 状态栏日更进度条 │
│ · KnowledgePanel 抽取批次、合并建议 UI、批量高置信度 │
│ · SettingsPage knowledgeAutoExtract / 置信度阈值 │
│ · 章节列表/编辑器「抽取本章」入口 │
└──────────────────────────────────────────────────────────────────┘
```
### 2.2 模块边界
| 模块 | 职责 | 依赖 |
|------|------|------|
| `WritingLogService` | 按 `(date, bookId)` 读写/累加日字数 | 全局 sqlite |
| `ChapterWritingTracker` | 维护 `chapter_writing_snapshots`,计算 delta | 每书 DB、`WritingLogService` |
| `KnowledgeExtractionService` | 构建 prompt、调用 AI、解析、落库 pending | `AiClientService`, `KnowledgeRepository` |
| `KnowledgeService` | 合并 patch、批量采纳、拒绝 | 现有 CRUD |
| `CockpitService` | 扩展 `WritingStats` 聚合 | `WritingLogService`, `GlobalSettings` |
**原则**:抽取异步不阻塞成章;抽取失败 toast + 可重试;写作追踪同步、轻量。
---
## 3. 数据模型
### 3.1 全局 userData`writing_logs.sqlite`
```sql
CREATE TABLE writing_logs (
date TEXT NOT NULL, -- YYYY-MM-DD,本地时区
book_id TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, book_id)
);
```
`WritingLogService``userDataDir` 管理,与 `books_registry.json` 同级。
### 3.2 每书 schema v7
新文件 `schema-v7.sql`
```sql
CREATE TABLE chapter_writing_snapshots (
chapter_id TEXT PRIMARY KEY,
logged_word_count INTEGER NOT NULL DEFAULT 0,
finish_supplemented INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
-- knowledge_entries 扩展列(迁移时 ALTER 或重建后拷贝)
-- extraction_confidence REAL
-- merge_target_id TEXT -- 建议合并的 approved 条目 id
-- extraction_batch_id TEXT -- 同次抽取 UUID,便于 UI 分组
```
`migrate.ts``CURRENT_VERSION = 7`v6→v7 对已有 `knowledge_entries` 执行 `ALTER TABLE ADD COLUMN`(三列均可 NULL)。
### 3.3 类型扩展(`src/shared/types.ts`
```typescript
export interface WritingDayLog {
date: string
bookId: string
wordCount: number
}
export interface WritingStats {
todayWords: number
dailyGoal: number
goalProgress: number // 01+goal=0 时为 0
streakDays: number // goal=0 时为 0
heatmap: WritingDayLog[] // 最近 365 天,含 wordCount=0 的日期
}
export interface ExtractedKnowledgeItem {
type: KnowledgeType
title: string
content: string
importance?: number
confidence: number
foreshadowStatus?: ForeshadowStatus
suggestedMergeId?: string
proposedPatch?: Partial<CreateKnowledgeInput>
}
export interface KnowledgeExtractionResult {
batchId: string
chapterId: string
items: ExtractedKnowledgeItem[]
}
```
扩展 `CockpitSummary`
```typescript
writingStats?: WritingStats
```
扩展 `KnowledgeEntry`
```typescript
extractionConfidence?: number
mergeTargetId?: string
extractionBatchId?: string
```
扩展 `GlobalSettings`
```typescript
knowledgeAutoExtract: boolean // default true
knowledgeExtractConfidenceThreshold: number // default 0.8
```
---
## 4. 日更追踪
### 4.1 差分算法(`ChapterWritingTracker`
**`recordDelta(bookId, chapterId, newWordCount)`**(在 `CHAPTER_UPDATE` 成功后调用):
1. 读取 `chapter_writing_snapshots.logged_word_count`(无则 0
2. `delta = newWordCount - logged`
3.`delta !== 0``WritingLogService.addToday(bookId, delta)`(当日累计不低于 0
4. 更新 snapshot`logged_word_count = newWordCount`
**`supplementOnFinish(bookId, chapterId, wordCount)`**(在 `finishChapter` 创建章节后调用):
1. 若 snapshot 不存在,创建 `logged=0, finish_supplemented=0`
2. `gap = wordCount - snapshot.logged_word_count`
3.`gap > 0``addToday(bookId, gap)`,更新 `logged = wordCount`
4.`finish_supplemented = 1`
删除章节时 CASCADE 删除 snapshot`writing_logs` 历史不回溯修正(接受已知限制)。
### 4.2 连更计算
- 仅当 `dailyWordGoal > 0` 时计算
- 从今日向前遍历 `writing_logs`(本书),连续天数满足 `word_count >= dailyWordGoal`
- 今日未达标时 streak 不计入今日(展示为截至昨日的连更)
### 4.3 时区
使用主进程 `Intl``toLocaleDateString('sv-SE')` 生成本地 `YYYY-MM-DD`,与作者所在系统时区一致。
---
## 5. AI 知识抽取
### 5.1 触发点
| 触发 | 条件 | 行为 |
|------|------|------|
| 成章落盘 | `finishChapter` 成功且 `knowledgeAutoExtract` | 入队 `extractForChapter(bookId, chapterId)` |
| 手动 | `knowledge:extractChapter` IPC | 同上 |
| 设置关闭 | `knowledgeAutoExtract === false` | 仅手动触发 |
抽取在 `setImmediate` / 微任务队列异步执行,不延长成章 IPC 响应时间。
### 5.2 Prompt 与解析
**输入**:章节标题 + 正文纯文本(`stripHtml`,上限 12000 字,超出截断并注明)+ 本书已有 `approved` 条目标题列表(上限 30 条,供合并判断)。
**输出 JSON 数组**(严格解析,失败重试一次「仅返回 JSON」):
```json
[
{
"type": "foreshadow",
"title": "测灵石异常",
"content": "入门考核时石头发热,暗示主角特殊灵根",
"importance": 4,
"confidence": 0.92,
"foreshadowStatus": "buried",
"suggestedMergeId": null,
"proposedPatch": null
},
{
"type": "character",
"title": "林远状态更新",
"content": "通过入门考核,声望上升",
"importance": 3,
"confidence": 0.85,
"suggestedMergeId": "uuid-of-existing-entry",
"proposedPatch": {
"content": "已通过入门考核,同门态度由轻视转为敬畏",
"importance": 4,
"lastMentionChapterId": "<chapterId>"
}
}
]
```
**类型**:五种全做 — `character` / `foreshadow` / `relationship` / `location` / `fact`
**落库**:每条 → `knowledge_entries``status=pending``source_chapter_id=chapterId``extraction_confidence``extraction_batch_id``merge_target_id`(若有)。
### 5.3 合并审核 UX(决策 D
| 场景 | UI |
|------|-----|
| 无 `mergeTargetId` | 待审核列表常规展示;采纳 → `approve` |
| 有 `mergeTargetId` | 卡片标注「建议更新:{目标标题}」;点击打开 `KnowledgeEditorDialog` **预填** `proposedPatch` + 目标条目只读摘要 |
| 确认更新 | `knowledge:approveMerge` → patch 目标 `approved` 条目,删除或标记本 pending 为 `rejected` |
| 作为新条目 | 清除 `mergeTargetId`,保留 pending,用户编辑后采纳 |
**批量「全部采纳高置信度」**:仅处理 `mergeTargetId IS NULL``confidence >= threshold` 的 pending 项。
### 5.4 IPC 新增
| 通道 | 参数 | 返回 |
|------|------|------|
| `writing:getStats` | `{ bookId }` | `WritingStats` |
| `knowledge:extractChapter` | `{ bookId, chapterId }` | `KnowledgeExtractionResult` |
| `knowledge:approveMerge` | `{ bookId, pendingId }` | `KnowledgeEntry`(更新后的目标) |
| `knowledge:batchApproveHighConfidence` | `{ bookId, threshold? }` | `number`(采纳条数) |
`CHAPTER_UPDATE` handler 内联调用 `ChapterWritingTracker`(无需新 IPC)。
---
## 6. UI 规格
### 6.1 驾驶舱(`CockpitModal`
在现有四卡片下方新增:
- **今日进度**`todayWords / dailyGoal` 文字 + 细进度条(`data-testid="cockpit-today-progress"`
- **连更**`🔥 {streakDays} 天``dailyGoal=0` 时隐藏,`data-testid="cockpit-streak"`
- **热力图**Canvas 52×7 格,365 天;颜色深浅按 `word_count` 分档(0 / 1999 / 10002999 / ≥3000);今天描边高亮(`data-testid="cockpit-heatmap"`
### 6.2 状态栏(`EditorLayout` `#editor-statusbar`
`dailyWordGoal > 0` 且当前有打开书籍:
- 显示 `今日 1234 / 3000 字` + 迷你进度条
- CSS class`goal-met`(≥100%)、`goal-near`(≥80%
- `data-testid="status-daily-goal"`
### 6.3 知识库(`KnowledgePanel`
- 待审核 Tab 顶部:本批抽取分组(按 `extractionBatchId` 折叠,显示来源章节)
- 条目卡片:置信度徽章、合并建议标签
- 工具栏:「全部采纳高置信度」`data-testid="knowledge-batch-high-confidence"`
- 合并项:「查看更新建议」`data-testid="knowledge-merge-review-{id}"`
### 6.4 抽取入口
- 章节列表项右键或编辑器菜单:**「抽取本章知识」** `data-testid="chapter-extract-knowledge"`
- 知识库工具栏:**「从当前章抽取」**(无选中章时 disabled
### 6.5 设置(`SettingsPage`
写作 / AI 分区:
- `knowledgeAutoExtract` 开关 `data-testid="settings-knowledge-auto-extract"`
- `knowledgeExtractConfidenceThreshold` 数字输入(0.5–1.0,步进 0.05)`data-testid="settings-extract-threshold"`
---
## 7. 错误处理
| 场景 | 处理 |
|------|------|
| AI 抽取 JSON 解析失败 | 重试一次;仍失败 toast「抽取失败,请稍后重试」,写 `console.error` |
| 章节正文为空 | 跳过抽取,toast「章节无内容」 |
| `suggestedMergeId` 指向已删除条目 | 视为无合并建议,按新建处理 |
| `addToday` 扣减后当日 < 0 | 钳制为 0 |
| 成章时抽取进行中 | 允许并行;同章多次抽取生成新 `batchId`,旧 pending 保留 |
---
## 8. 测试
### 8.1 测试 ID
| ID | 层级 | 场景 | 预期 |
|----|------|------|------|
| UT-LOG-01 | 单元 | `recordDelta` 保存 +200 字 | 当日 +200 |
| UT-LOG-02 | 单元 | `recordDelta` 删 100 字 | 当日 -100(不低于 0 |
| UT-LOG-03 | 单元 | `supplementOnFinish` gap=500 | 当日 +500`finish_supplemented=1` |
| UT-LOG-04 | 单元 | 连更 3 天达标 | `streakDays=3` |
| UT-EXT-01 | 单元 | 解析合法抽取 JSON | 返回 N 条 `ExtractedKnowledgeItem` |
| UT-EXT-02 | 单元 | 含 `suggestedMergeId` | `merge_target_id` 正确写入 |
| IT-LOG-01 | 集成 | `CHAPTER_UPDATE` 两次 | `writing_logs` 累计正确 |
| IT-EXT-01 | 集成 | `extractChapter` 有正文 | 返回 ≥1 pending |
| IT-EXT-02 | 集成 | `approveMerge` | 目标 `approved` 内容更新 |
| IT-EXT-03 | 集成 | 抽取 + P6 `suggestContext` | 新采纳条目出现在推荐中 |
| E2E-LOG-01 | E2E | 写 100 字保存 | 驾驶舱今日字数更新 |
| E2E-LOG-02 | E2E | 热力图可见 | `cockpit-heatmap` 存在 |
| E2E-EXT-01 | E2E | 成章后待审核有抽取项 | pending 列表非空 |
| E2E-EXT-02 | E2E | 手动抽取本章 | 待审核增加 |
| E2E-EXT-03 | E2E | 合并建议确认更新 | 目标条目内容变化 |
### 8.2 超时
- IT-EXT-01 / IT-EXT-02 / IT-EXT-03**240s**
- E2E-EXT-01 / E2E-EXT-03**300s**
### 8.3 原则
集成/E2E 含 AI 抽取时 **禁止 Mock**,使用 LM Studio 默认配置。
---
## 9. i18n 键(节选)
```json
"cockpit.todayProgress": "今日 {{current}} / {{goal}} 字",
"cockpit.streak": "连更 {{days}} 天",
"cockpit.heatmap": "写作热力图",
"status.dailyGoal": "今日 {{current}} / {{goal}}",
"knowledge.extractChapter": "抽取本章知识",
"knowledge.extractFromCurrent": "从当前章抽取",
"knowledge.batchHighConfidence": "全部采纳高置信度",
"knowledge.mergeSuggest": "建议更新:{{title}}",
"knowledge.approveMerge": "确认更新",
"knowledge.saveAsNew": "作为新条目",
"knowledge.extracting": "正在抽取知识…",
"knowledge.extractFailed": "知识抽取失败,请稍后重试",
"settings.knowledgeAutoExtract": "成章后自动抽取知识",
"settings.extractThreshold": "高置信度阈值"
```
---
## 10. 文件映射
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v7.sql` | snapshots + knowledge 扩展列 |
| `src/main/db/migrate.ts` | v7 迁移 |
| `src/main/db/repositories/writing-log.repo.ts` | 全局 writing_logs CRUD |
| `src/main/db/repositories/chapter-writing-snapshot.repo.ts` | 每书 snapshot |
| `src/main/services/writing-log.service.ts` | 日更累加、连更、热力图数据 |
| `src/main/services/chapter-writing-tracker.service.ts` | 差分 + 补计 |
| `src/main/services/knowledge-extraction.service.ts` | AI 抽取 |
| `src/main/services/knowledge-extraction-prompt.ts` | prompt 构建 + JSON 解析 |
| `src/main/services/cockpit.service.ts` | 扩展 WritingStats |
| `src/main/ipc/handlers/writing.handler.ts` | `writing:getStats` |
| `src/main/ipc/handlers/knowledge.handler.ts` | 抽取/合并 IPC |
| `src/main/ipc/handlers/chapter.handler.ts` | 挂钩 tracker |
| `src/renderer/components/cockpit/WritingHeatmap.tsx` | Canvas 热力图 |
| `src/renderer/components/cockpit/CockpitModal.tsx` | 扩展布局 |
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 批次/合并 UI |
| `tests/main/writing-log.test.ts` | UT-LOG-* |
| `tests/main/knowledge-extraction.test.ts` | UT-EXT-* / IT-EXT-* |
| `e2e/writing-logs.spec.ts` | E2E-LOG-* |
| `e2e/knowledge-extraction.spec.ts` | E2E-EXT-* |
---
## 11. Spec Coverage Checklist
| design/readme 要求 | 本规格 |
|--------------------|--------|
| §6.7 每章自动抽取 + 审核 | §5 |
| §6.7 合并/忽略/批量采纳 | §5.3 |
| §8 writing_logs + 热力图 + 连更 | §3–§4、§6.1 |
| §8 番茄钟/里程碑 | 排除 §1.4 |
| §9 驾驶舱内嵌分析 | 热力图 + 今日进度(轻量) |
| P5 延后「AI 抽取」 | 本阶段交付 |
| P5 延后 writing_logs | 本阶段交付 |
| P6 知识注入上游 | §5 + IT-EXT-03 |
---
## 12. 版本与 DoD
- `package.json`**0.8.0**
- `README.md` 功能概览追加 P5.2 一行
- `SettingsPage` about 版本号更新
- `npm run build && npm run test && npm run test:e2e` 全绿
- LM Studio 集成测试按 §8.2 超时执行
---
*本规格为笔临 P5.2 子项目。角色弧线、关系图谱、导入导出等各自在后续 spec 中增量修订。*
@@ -0,0 +1,421 @@
# 笔临 P5.3 + P6.1 写作目标补全 + 知识注入历史 设计规格
**版本**1.0
**日期**2026-07-07
**范围**:P5.3(§8 补全)番茄钟、连更里程碑徽章、达标通知;P6.1 知识库条目注入历史与反向链接
**前置**:P5.2 写作日志 + AI 知识抽取已交付(v0.8.0,`a39e06f`);P6 AI 知识上下文(v0.7.0
**参考**`design/readme.md` v1.1 §8 / §5.5.7、`docs/superpowers/specs/2026-07-07-bilin-p52-writing-logs-extraction-design.md``docs/superpowers/specs/2026-07-07-bilin-p6-ai-context-design.md`
**目标版本**v0.9.0
---
## 1. 背景与目标
### 1.1 阶段定位
v0.8.0 已交付 `writing_logs` 日更追踪、驾驶舱热力图/连更、状态栏日更进度,以及 AI 章节知识抽取与 P6 注入链路。但 `design/readme.md` §8 仍缺 **番茄钟**、**7/30/100 天连更里程碑徽章** 与 **达标/完成通知**P6 spec 延后的 **注入历史 / 反向链接** 尚未落地,作者无法在知识库条目上查看「这条知识曾在哪些写作流中被 AI 使用」。
本阶段(v0.9.0)双线交付:
- **P5.3**:补齐 §8 剩余能力,强化日更作者的正反馈与专注工具
- **P6.1**:在知识库条目上记录并展示写作流(交互/自动/向导)上下文确认时的注入历史
### 1.2 已确认决策(Brainstorming 2026-07-07
| 维度 | 决策 |
|------|------|
| 交付版本 | **v0.9.0** 单版交付 P5.3 + P6.1 |
| 架构方案 | **主进程服务分层**`PomodoroService` + `AchievementService` + `GoalNotificationService` + `KnowledgeInjectionService` |
| 统计范围 | **当前书籍**(与 P5.2 一致:连更、里程碑、番茄字数增量、成就均按 `bookId` |
| 通知 | **Toast + 可选 Electron 系统通知**;设置页可关闭系统通知与目标通知 |
| 番茄钟 UI | **状态栏紧凑控件**(▶/⏸ + 剩余时间);驾驶舱展示今日番茄完成次数 |
| 番茄时长 | 设置页 **15 / 25 / 45 分钟** 三档,默认 25 |
| 里程碑 UI | **庆祝 + 持久展示**:达成时 Toast/系统通知;驾驶舱「成就区」展示已解锁 7/30/100 天徽章 |
| P6.1 范围 | **仅知识库条目**(不含设定条目 AI 注入历史) |
| P6.1 记录场景 | **交互 / 自动 / 向导** 三模式 `confirmContext``knowledgeIds` 非空;**不含 AI 对话助手** |
| 注入历史 UI | **列表最近 3 条预览 + 编辑对话框完整时间线** |
| 切换书籍(番茄进行中) | **自动取消番茄**并 Toast 提示 |
### 1.3 验收标准
用户能完成:
1. 在状态栏启动 **15/25/45 分钟**番茄钟,暂停/取消/完成后看到剩余时间变化;完成时 Toast 显示本次新增字数
2. `enableSystemNotifications` 开启时,番茄完成与里程碑解锁可收到 **系统通知**(权限被拒则降级 Toast
3. `dailyWordGoal > 0` 且当日首次达标时收到 Toast(及可选系统通知);同一自然日不重复提醒
4. 连更达到 **7 / 30 / 100 天**(基于 P5.2 `streakDays`)时:庆祝通知 + 驾驶舱成就区永久展示对应徽章;每档仅庆祝一次
5. 交互/自动/向导 **确认知识上下文**后,被注入的 `approved` 知识条目在 **KnowledgePanel** 卡片显示最近 3 条注入记录
6. 打开 **KnowledgeEditorDialog** 可查看该条目完整注入历史(时间、模式、关联章节)
7. 注入记录失败 **不阻断** 写作流确认步骤
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| 预计完本、写作速度仪表盘、时段热力图 | §9 延后 |
| 设定条目「被 AI 注入的轮次」 | P6.2 |
| AI 对话助手注入历史 | 本阶段排除(用户确认 C) |
| 番茄钟自定义任意分钟数、休息间隔、番茄统计全屏页 | 后续 |
| 里程碑 365 天等扩展档位 | 后续 |
| 语义 embedding、注入内容 diff | 后续 |
| 导入导出、章节模板 | P7 / §10 |
---
## 2. 架构
### 2.1 进程模型
```
┌────────────────────────────────────────────────────────────────────┐
│ 主进程 │
│ · PomodoroService(倒计时、开始/暂停/取消、完成字数差分) │
│ · AchievementService7/30/100 里程碑解锁检测与持久化) │
│ · GoalNotificationServiceToast IPC + Notification,受设置控制) │
│ · WritingLogService(已有,番茄快照/差分、streakDays
│ · KnowledgeInjectionService(写入/查询 injection logs
│ · CockpitService 扩展(成就区、pomodoroTodayCount
│ · interactive/auto/wizard confirmContext 挂钩 injection 记录 │
└──────────────────────────────┬─────────────────────────────────────┘
│ IPC
┌──────────────────────────────▼─────────────────────────────────────┐
│ 渲染进程 │
│ · EditorLayout 状态栏:番茄控件 + 日更进度(已有) │
│ · CockpitModal:成就区徽章 + 今日番茄次数 │
│ · SettingsPage:番茄时长、通知开关 │
│ · KnowledgePanel:注入历史最近 3 条预览 │
│ · KnowledgeEditorDialog:完整注入历史列表 │
└────────────────────────────────────────────────────────────────────┘
```
### 2.2 模块边界
| 模块 | 职责 | 依赖 |
|------|------|------|
| `PomodoroService` | 单实例番茄状态机;完成时计算 `wordDelta` | `WritingLogService`, `GlobalSettings` |
| `AchievementService` | 读 `streakDays`,写 `writing_milestones`,触发庆祝 | `WritingLogService`, `GoalNotificationService` |
| `GoalNotificationService` | 统一发送 goal-met / milestone / pomodoro 通知 | `GlobalSettings`, `Notification`Electron |
| `KnowledgeInjectionService` | batch 写入、按 entry 查询、recent N | 每书 `KnowledgeInjectionRepository` |
| `CockpitService` | 聚合 `achievements[]``pomodoroTodayCount` | 上述服务 |
**原则**
- 番茄钟状态在主进程,保证最小化/失焦时计时准确、完成时可发系统通知
- 注入记录与 confirmContext **同事务意图**build 成功即写 log;写 log 失败仅 `console.error`,不抛给 IPC
- 成就不回溯:仅在未来 `streakDays` 首次跨档时解锁
---
## 3. 数据模型
### 3.1 全局 userData:扩展 `writing_logs.sqlite`
在现有 `writing_logs` 表基础上 **同库追加**`WritingLogRepository.migrate()` 扩展):
```sql
CREATE TABLE IF NOT EXISTS writing_milestones (
book_id TEXT NOT NULL,
milestone TEXT NOT NULL, -- 'streak_7' | 'streak_30' | 'streak_100'
unlocked_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (book_id, milestone)
);
CREATE TABLE IF NOT EXISTS pomodoro_daily (
date TEXT NOT NULL,
book_id TEXT NOT NULL,
completed_count INTEGER NOT NULL DEFAULT 0,
total_words INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, book_id)
);
```
`pomodoro_daily` 在每次番茄 **完成**`completed_count += 1``total_words += wordDelta`
### 3.2 每书 schema v8
新文件 `schema-v8.sql`
```sql
CREATE TABLE IF NOT EXISTS knowledge_injection_logs (
id TEXT PRIMARY KEY,
knowledge_entry_id TEXT NOT NULL,
flow_mode TEXT NOT NULL, -- 'interactive' | 'auto' | 'wizard'
flow_id TEXT NOT NULL,
chapter_id TEXT,
injected_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (knowledge_entry_id) REFERENCES knowledge_entries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_injection_entry_time
ON knowledge_injection_logs (knowledge_entry_id, injected_at DESC);
```
`migrate.ts``CURRENT_VERSION = 8`v7→v8 仅 `exec(schemaV8)`
### 3.3 TypeScript 类型(`src/shared/types.ts`
```typescript
export type PomodoroDuration = 15 | 25 | 45
export type WritingMilestone = 'streak_7' | 'streak_30' | 'streak_100'
export type InjectionFlowMode = 'interactive' | 'auto' | 'wizard'
export interface PomodoroState {
running: boolean
paused: boolean
remainingSec: number
bookId: string | null
startedAt: string | null
}
export interface WritingAchievement {
milestone: WritingMilestone
unlockedAt: string
}
export interface KnowledgeInjectionLog {
id: string
knowledgeEntryId: string
flowMode: InjectionFlowMode
flowId: string
chapterId?: string
injectedAt: string
}
export interface CockpitSummary {
// 已有字段…
achievements?: WritingAchievement[]
pomodoroTodayCount?: number
}
```
扩展 `GlobalSettings`
```typescript
pomodoroDurationMinutes?: PomodoroDuration // default 25
enableSystemNotifications?: boolean // default false
enableGoalNotifications?: boolean // default true
```
---
## 4. 服务行为
### 4.1 PomodoroService
- **start(bookId)**:若已有运行中番茄且 `bookId` 不同,先 cancel 再 start;记录 `wordSnapshot = WritingLogService.getStats(bookId).todayWords`
- **pause / resume / cancel**:标准状态机;cancel 清空快照
- **tick**:主进程 `setInterval(1s)` 递减;`remainingSec === 0` 触发 `complete()`
- **complete()**
- `wordDelta = max(0, todayWords_now - wordSnapshot)`
- 更新 `pomodoro_daily`
- `GoalNotificationService.notifyPomodoroComplete(bookId, wordDelta)`
- 重置状态
渲染进程每 1s 通过 IPC `pomodoro:getState` 同步 UI(或由主进程 push `POMODORO_TICK` 事件,实现时二选一,计划阶段选定 push 以减少轮询)。
### 4.2 AchievementService
-**WritingLogService 写入后****Cockpit getSummary** 时调用 `checkMilestones(bookId)`
-`streakDays`(需 `dailyWordGoal > 0`
-`streakDays >= 7` 且 DB 无 `streak_7` → insert + notify
- 同理 30、100
- `listAchievements(bookId)``WritingAchievement[]` 按 milestone 排序
### 4.3 GoalNotificationService
| 事件 | Toast | 系统通知(`enableSystemNotifications` |
|------|-------|----------------------------------------|
| 日更首次达标 | ✓(`enableGoalNotifications` | 可选 |
| 里程碑解锁 | ✓ | 可选 |
| 番茄完成 | ✓ | 可选 |
Toast 经现有 `useAppStore.showToast` 模式:主进程 `webContents.send(GOAL_NOTIFICATION, payload)` 或复用通用 notification IPC。
系统通知权限被拒:catch 并忽略,不抛错。
### 4.4 KnowledgeInjectionService
挂钩点(均在 `buildFlowContextPayload` **成功返回之后**):
- `interactive.handler``INTERACTIVE_CONFIRM_CONTEXT`
- `auto.handler``AUTO_CONFIRM_CONTEXT`
- `wizard.handler``WIZARD_CONFIRM_CONTEXT`
```typescript
recordInjection(bookId, {
knowledgeIds: binding.knowledgeIds,
flowMode: 'interactive' | 'auto' | 'wizard',
flowId,
chapterId?: string // flow 关联章或 registry meta lastChapterId
})
```
对每个 `knowledgeId`:校验 entry 存在且 `status === 'approved'`(防御性;UI 本不应注入 pending)。
查询:
- `listRecentForEntry(entryId, limit=3)`
- `listForEntry(entryId, limit=50)` 供编辑对话框
---
## 5. UI 规格
### 5.1 状态栏番茄控件
- 位置:`EditorLayout` `#editor-statusbar`,日更进度右侧
- 控件:`data-testid="pomodoro-control"`
- idle:▶ 按钮,点击 start
- running:⏸ + `MM:SS` + ✕取消
- paused:▶ 继续 + ✕取消
-`currentBookId` 时禁用
### 5.2 驾驶舱成就区
- 位置:`CockpitModal` 写作统计区下方
- `data-testid="cockpit-achievements"`
- 展示已解锁徽章(7🔥 / 30🔥 / 100🔥 或 i18n 文案);未解锁灰显或隐藏
- `data-testid="cockpit-pomodoro-today"`:今日完成番茄次数
### 5.3 设置页
| 控件 | testid | 说明 |
|------|--------|------|
| 番茄时长 | `settings-pomodoro-duration` | select: 15/25/45 |
| 系统通知 | `settings-system-notifications` | checkbox,默认 false |
| 目标通知 | `settings-goal-notifications` | checkbox,默认 true |
### 5.4 知识库注入历史
**KnowledgePanel 列表项**
- `data-testid="knowledge-injection-preview-{entryId}"`
- 最多 3 行:`{date} · {flowMode} · {chapterTitle}`
**KnowledgeEditorDialog**
- 折叠区「注入历史」`data-testid="knowledge-injection-history"`
- 完整列表,按时间倒序;章节名可点击跳转(调用现有 `switchTarget`
---
## 6. IPC 通道
```typescript
POMODORO_START: 'pomodoro:start',
POMODORO_PAUSE: 'pomodoro:pause',
POMODORO_RESUME: 'pomodoro:resume',
POMODORO_CANCEL: 'pomodoro:cancel',
POMODORO_GET_STATE: 'pomodoro:getState',
ACHIEVEMENT_LIST: 'achievement:list',
KNOWLEDGE_LIST_INJECTIONS: 'knowledge:listInjections',
GOAL_NOTIFICATION: 'goal:notification', // main → renderer push
```
preload / `electron-api.d.ts` 同步扩展。
---
## 7. i18n 键(节选)
```json
"pomodoro.start": "开始番茄钟",
"pomodoro.pause": "暂停",
"pomodoro.cancel": "取消",
"pomodoro.complete": "番茄钟完成!本次写作 {{words}} 字",
"pomodoro.switchedBook": "已切换书籍,番茄钟已取消",
"goal.dailyMet": "今日目标已达成 {{current}} / {{goal}} 字",
"achievement.streak7": "连更 7 天",
"achievement.streak30": "连更 30 天",
"achievement.streak100": "连更 100 天",
"achievement.unlocked": "解锁成就:{{name}}",
"cockpit.achievements": "写作成就",
"cockpit.pomodoroToday": "今日番茄 {{count}} 次",
"knowledge.injectionHistory": "注入历史",
"knowledge.injectionPreview": "最近注入",
"knowledge.injectionEntry": "{{date}} · {{mode}} · {{chapter}}",
"settings.pomodoroDuration": "番茄钟时长(分钟)",
"settings.systemNotifications": "启用系统通知",
"settings.goalNotifications": "启用目标达成提醒"
```
---
## 8. 测试要求
### 8.1 单元 / 集成
| 用例ID | 场景 | 预期 |
|--------|------|------|
| UT-POM-01 | start → completetodayWords +50 | wordDelta=50`pomodoro_daily` 更新 |
| UT-POM-02 | 切换 bookId start | 旧番茄 cancel,新番茄 running |
| UT-ACH-01 | streakDays=7 首次检测 | `writing_milestones` 写入 streak_7 |
| UT-ACH-02 | streak_7 已存在 | 不重复 notify |
| UT-INJ-01 | recordInjection 3 ids | 3 rows inserted |
| IT-INJ-01 | interactive confirmContext with knowledgeIds | DB 有对应 logs |
| IT-GOAL-01 | addToday 达标首次 | notification payload 发送一次 |
### 8.2 E2E(对齐 design/readme §8.2
| 用例ID | 场景 | 预期 |
|--------|------|------|
| E2E-GOAL-01 | 日更 3000 目标写 3500 | 进度变绿,达标 Toast |
| E2E-GOAL-02 | 连更 7 天(测试数据 seed) | 成就区显示 7 天徽章 |
| E2E-GOAL-03 | 启动番茄并完成(测试环境可缩短 duration mock 或 settings=15 + 加速 hook 仅限 E2E | 完成 Toast 含字数 |
| E2E-INJ-01 | 向导 confirm 知识上下文 | Panel 预览可见注入记录 |
**E2E 番茄加速**:主进程识别 `BILIN_E2E=1` 时允许 `POMODORO_TEST_SEC=3` 环境变量覆盖时长(仅 E2E,不暴露给生产 UI)。
### 8.3 原则
- 注入历史 IT/E2E **不需要** AI 调用,可纯 DB 断言
- 含通知的 E2E 断言 Toast DOM,不断言 OS 通知栏
---
## 9. 文件映射
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v8.sql` | `knowledge_injection_logs` |
| `src/main/db/migrate.ts` | v8 |
| `src/main/db/repositories/writing-milestone.repo.ts` | 全局 milestones |
| `src/main/db/repositories/pomodoro-daily.repo.ts` | 全局 pomodoro 日统计 |
| `src/main/db/repositories/knowledge-injection.repo.ts` | 每书 injection logs |
| `src/main/services/pomodoro.service.ts` | 番茄状态机 |
| `src/main/services/achievement.service.ts` | 里程碑 |
| `src/main/services/goal-notification.service.ts` | 通知 |
| `src/main/services/knowledge-injection.service.ts` | 注入记录 |
| `src/main/services/cockpit.service.ts` | 扩展 summary |
| `src/main/ipc/handlers/pomodoro.handler.ts` | 番茄 IPC |
| `src/main/ipc/handlers/achievement.handler.ts` | 成就 IPC |
| `src/main/ipc/handlers/knowledge.handler.ts` | 扩展 listInjections |
| `src/main/ipc/handlers/interactive|auto|wizard.handler.ts` | confirmContext 挂钩 |
| `src/renderer/components/layout/EditorLayout.tsx` | 番茄控件 |
| `src/renderer/components/cockpit/CockpitModal.tsx` | 成就区 |
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 注入预览 |
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 完整历史 |
| `src/renderer/components/settings/SettingsPage.tsx` | 新设置 |
| `tests/main/pomodoro.test.ts` | UT-POM-* |
| `tests/main/achievement.test.ts` | UT-ACH-* |
| `tests/main/knowledge-injection.test.ts` | UT/IT-INJ-* |
| `e2e/writing-goals.spec.ts` | E2E-GOAL-* |
| `e2e/knowledge-injection-history.spec.ts` | E2E-INJ-* |
---
## 10. Definition of Donev0.9.0
- [ ] §8 番茄钟 + 里程碑 + 通知按 §1.3 验收
- [ ] P6.1 注入历史按 §1.3 第 5–6 条验收
- [ ] schema v8 + 全局 sqlite 扩展迁移
- [ ] `npm run build` 通过
- [ ] `npm run test` 全绿(新增 UT/IT
- [ ] E2E-GOAL-01~03、E2E-INJ-01 通过
- [ ] `package.json` / README → v0.9.0
- [ ] i18n zh-CN / en 键齐全
---
*本规格为笔临 P5.3 + P6.1 子项目。§9 个人写作分析、设定条目注入历史等在后续 spec 增量修订。*
@@ -0,0 +1,389 @@
# 笔临 P6 AI 知识上下文整合 设计规格
**版本**1.0
**日期**2026-07-07
**范围**:P6 — 已审核知识库条目半自动推荐 + 用户确认 + 注入四种 AI 入口(对话 / 交互 / 自动 / 向导)
**前置**:P5 作家工作流 + 知识库 MVP 已交付(v0.6.0`b33d2e7`
**参考**`design/readme.md` v1.1 §6.1 / §6.5 / §6.7、`docs/superpowers/specs/2026-07-06-bilin-p5-workflow-knowledge-design.md`
**目标版本**v0.7.0
---
## 1. 背景与目标
### 1.1 阶段定位
v0.6.0 已交付知识库 MVP(手动 CRUD、审核、伏笔追踪)与作家工作流,但 `AiContextBuilder` 仍仅支持章节/大纲/设定/灵感四类手动勾选,**知识条目未参与任何 AI 调用**。交互/自动/向导写作流还存在技术债:`contextJson` 只存标题拼接的短摘要,而非完整 `systemPrompt`
本阶段(P6)交付 **知识上下文整合**:半自动检索推荐 + 用户勾选确认 + 统一注入四种 AI 模式,对齐 `design/readme.md` §6.1「在每轮对话前检索高重要度知识条目注入」与 §6.5 各写作模式的上下文确认步骤。
### 1.2 已确认决策(Brainstorming 2026-07-07
| 维度 | 决策 |
|------|------|
| 总范围 | **A AI 上下文整合**v0.7.0 单阶段交付 |
| 注入策略 | **半自动 + 确认**:系统推荐,用户勾选后注入 |
| AI 入口 | **四种全做**:对话、交互写作、自动写作、向导模式 |
| 检索排序 | **重要度 + 章节相关性 + 目标文本匹配**(方案 C |
| 对话 UX | **会话级确认 + 发送静默注入**(不在每条消息前弹窗) |
| 架构 | **方案 1**`KnowledgeRetrievalService` + 扩展 `AiContextBuilder` |
| schema | **无 v7 迁移**(纯服务层与 UI 扩展) |
| AI 测试 | 集成/E2E 含 AI 时禁止 Mock,走 LM Studio |
### 1.3 验收标准
用户能完成:
1.**AI 上下文编辑器** 看到系统推荐的知识条目(带推荐理由),默认勾选 Top 推荐,可手动增删
2. **对话模式**:保存上下文后发送消息,AI 回复能体现已选知识(如伏笔、角色状态)
3. **交互写作**`context_confirm` 步骤展示知识推荐列表;确认后剧情建议/场景生成使用完整上下文
4. **自动写作**:设定章节目标后确认知识上下文;场景规划与生成携带知识块
5. **向导模式**:第 4 步「确认知识条目与伏笔」展示推荐列表;策略预览列出将注入的知识标题
6.**`approved`** 知识条目参与推荐与注入;`pending`/`rejected` 不可选
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| 注入历史 / 反向链接(§5.2「被 AI 用作上下文注入的轮次」) | P6.1+ |
| AI 语义检索 / embedding | 后续 |
| 知识自动抽取(§6.7 全自动) | P5.2+ |
| 文风学习 / 风格控制面板(§6.3) | 后续 |
| `writing_logs`、热力图(§8 | P5.2 |
| 角色关系图谱 Cytoscape(§7 | P6+ |
| schema v7 迁移 | 不需要 |
---
## 2. 架构
### 2.1 进程模型
```
┌──────────────────────────────────────────────────────────────┐
│ 主进程 │
│ · KnowledgeRetrievalService(打分 + 推荐) │
│ · AiContextBuilderService(扩展 knowledgeIds 块) │
│ · KnowledgeService(只读 list approved
│ · 各 handlerAI_CHAT / INTERACTIVE_* / AUTO_* / WIZARD_* │
│ confirmContext 时 build() → 存 systemPrompt 全文 │
└───────────────────────────┬──────────────────────────────────┘
│ IPC
│ knowledge.suggestContext
│ ai.buildContext(扩展 preview
┌───────────────────────────▼──────────────────────────────────┐
│ 渲染进程 │
│ · ContextEditorModal+知识 Tab + 推荐区) │
│ · KnowledgeSuggestList(共享勾选组件) │
│ · InteractivePanel context_confirm │
│ · AutoPanel goal 后知识确认区 │
│ · WizardPanel wizard_context 步骤 │
└──────────────────────────────────────────────────────────────┘
```
### 2.2 模块边界
| 模块 | 职责 | 依赖 |
|------|------|------|
| `KnowledgeRetrievalService` | 对 `approved` 条目综合打分,返回 `ScoredKnowledge[]` | `KnowledgeRepository`, `ChapterRepository` |
| `AiContextBuilderService` | 合并传统四类 + 知识块 → `systemPrompt` + `preview` | `BookRegistryService` |
| `KnowledgeSuggestList` | 推荐列表 UI、checkbox、reason badge | 无 IPC,由父组件传 props |
| `ContextEditorModal` | 五 Tab 编辑 binding;打开时拉 suggest | preload |
**原则**
- 推荐可忽略,注入以用户最终勾选为准
- 知识块与传统上下文共享总字符预算
- 写作流修复:`contextJson` 必须存 `systemPrompt` 全文,禁止仅存标题摘要
---
## 3. 检索算法
### 3.1 输入
```typescript
interface KnowledgeSuggestOptions {
currentChapterId?: string
goalText?: string // 章节目标、大纲描述拼接、用户备注
topN?: number // default 8
}
```
### 3.2 候选集
- `status = 'approved'` 的全部 `knowledge_entries`
- 无 approved 条目时返回 `[]`
### 3.3 打分公式
对每条 entry 计算 `score`(整数,越高越优先):
| 因子 | 计算 | 上限 |
|------|------|------|
| 重要度 | `importance × 20` | 100 |
| 章节接近度 | 取 `sourceChapterId``lastMentionChapterId` 中距当前章 `sortOrder` 差值的最小值 `d``d=0→30``d≤3→20``d≤10→10`,否则 0 | 30 |
| 伏笔加权 | `foreshadow``buried→25``partial→15``resolved→0`;非伏笔 0 | 25 |
| 目标匹配 | `goalText``title+content` 做简单分词重合(去停用词后),`overlapRatio × 30` | 30 |
`reasons` 数组(供 UI badgei18n key):
- `importance ≥ 4``knowledge.reason.highImportance`
- 章节接近度 > 0 → `knowledge.reason.chapterRelevant`
- `foreshadow``buried``knowledge.reason.foreshadowBuried`
- 目标匹配 > 0.2 → `knowledge.reason.goalMatch`
`score` 降序取 Top `topN`(默认 8)。
### 3.4 默认勾选策略
- 打开上下文编辑器时,推荐区 **默认全选** Top N
- 合并进 `binding.knowledgeIds`(与用户已有手动选择取并集,去重)
- 用户取消勾选后立即从 `knowledgeIds` 移除
---
## 4. 上下文构建
### 4.1 `AiContextBinding` 扩展
```typescript
export interface AiContextBinding {
chapterIds: string[]
outlineIds: string[]
settingIds: string[]
inspirationIds: string[]
knowledgeIds: string[] // 新增,默认 []
}
```
持久化位置:
- AI 会话:`ai_sessions.context` JSON
- 写作流:`interactive_flows.context_json``binding` 字段
### 4.2 知识块格式
```
【知识·{类型标签}】{title}
{content 截断至 500 字}
```
类型标签映射:`character→角色``foreshadow→伏笔``location→地点``relationship→关系``fact→事实`
### 4.3 字符预算
| 限制 | 值 |
|------|-----|
| 单条知识 content | 500 字 |
| 知识区合计 | 4000 字 |
| 章节块 | 2000 字(现有) |
| 其它块 | 500 字(现有) |
| **总 systemPrompt** | 16 × 1024 字(现有) |
裁剪顺序:候选块按 score(知识)或 sortKey(其它)排序,超出总预算时从低优先级移除。
### 4.4 `ContextPreviewItem` 扩展
```typescript
export type ContextPreviewKind =
| 'chapter' | 'outline' | 'setting' | 'inspiration' | 'knowledge'
export interface ContextPreviewItem {
kind: ContextPreviewKind
id: string
title: string
excerpt: string
charCount: number
knowledgeType?: KnowledgeType // kind=knowledge 时
}
```
### 4.5 写作流 `contextJson` 结构(统一)
```typescript
interface FlowContextPayload {
binding: AiContextBinding
systemPrompt: string // build() 完整输出,供 AI 调用
contextSummary: string // UI 短摘要,如「3章·2设定·5知识」
}
```
**修复范围**`InteractiveWritingService.readContextText()``auto.handler``readContextText()` 改为返回 `systemPrompt`,不再仅返回标题拼接。
---
## 5. IPC
### 5.1 新增通道
| 通道 | 参数 | 返回 |
|------|------|------|
| `knowledge:suggestContext` | `{ bookId, opts: KnowledgeSuggestOptions }` | `ScoredKnowledge[]` |
### 5.2 变更通道
| 通道 | 变更 |
|------|------|
| `ai:buildContext` | binding 含 `knowledgeIds`preview 含 knowledge 项 |
| `interactive:confirmContext` | 存 `FlowContextPayload``suggestPlots``systemPrompt` |
| `wizard:confirmContext` | 同上 |
| `auto:*`plan/generate | 读 `systemPrompt` |
preload / `electron-api.d.ts` 同步扩展。
---
## 6. UI 规格
### 6.1 共享组件 `KnowledgeSuggestList`
- `data-testid="knowledge-suggest-list"`
- 每行:`checkbox` + 标题 + 类型 + `reason` badge
- 单项 `data-testid={`knowledge-suggest-${id}`}`
### 6.2 `ContextEditorModal` 扩展
- 新增 Tab`knowledge``data-testid="context-tab-knowledge"`
- 顶部 **推荐区**:调用 `suggestContext`;展示 `KnowledgeSuggestList`
- 下方 **全部 approved 列表**:可手动搜索/勾选(简单 filter 即可)
- 「刷新推荐」按钮:`data-testid="context-refresh-suggest"`
- 保存时写入 `session.context``knowledgeIds`
**goalText 来源**(打开 Modal 时传入 props):
| 调用方 | goalText |
|--------|----------|
| AiPanel | 空(仅章节相关性) |
| InteractivePanel | 空 |
| AutoPanel | `chapterGoal` + 勾选大纲 `description` 拼接 |
| WizardPanel | `chapterGoal` + `styleNote` |
**currentChapterId**`useBookStore.selectedChapterId`
### 6.3 对话模式(AiPanel
- 上下文摘要行点击 → Modal(现有)
- 发送消息:不弹窗;`AI_CHAT` 用最新 binding build 的 `systemPrompt`
- 摘要行展示知识数量,如「2章·3知识」
### 6.4 交互写作(InteractivePanel
- `context_confirm` 步骤:
- 展示 `KnowledgeSuggestList`(内联,非必须开 Modal
- 「编辑完整上下文」→ Modal
- 「确认并开始」→ `confirmContext(binding)``suggestPlots`
- `data-testid="interactive-knowledge-suggest"`
### 6.5 自动写作(AutoPanel
- `goal_setup` 提交目标后,或 goal 区域下方展示折叠「知识上下文」
- 展示推荐 + 勾选;`planScenes` 前确保 `confirmContext` 已执行(可自动用 session binding + 推荐默认勾选)
- `data-testid="auto-knowledge-suggest"`
### 6.6 向导模式(WizardPanel
- `wizard_context` 步骤:展示推荐列表 + 「编辑上下文」
- `buildPreview` 返回的 preview 文本追加「将注入知识:…」列表
- `data-testid="wizard-knowledge-suggest"`
---
## 7. 全局设置(可选扩展)
```typescript
// GlobalSettings 新增,均有默认值
knowledgeAutoSuggest: boolean // default true
knowledgeSuggestTopN: number // default 8,范围 315
```
SettingsPage AI 分区或通用分区增加开关(本阶段可仅后端默认值,UI 设置项为 Nice-to-have;若时间紧可 Phase 2 补 UI)。
---
## 8. 测试
### 8.1 测试 ID
| ID | 层级 | 场景 | 预期 |
|----|------|------|------|
| UT-CTX-01 | 单元 | 打分:高重要度 + 近章 + 目标匹配 | 得分最高条目排第一 |
| UT-CTX-02 | 单元 | `build()` 含 knowledgeIds | preview 有 `kind=knowledge` |
| UT-CTX-03 | 单元 | 知识区超 4000 字 | 按 score 裁剪 |
| UT-CTX-04 | 单元 | resolved 伏笔 | 分数低于 buried 同重要度条目 |
| IT-CTX-01 | 集成 | `suggestContext` 有 approved 数据 | 返回 ≥1 条 |
| IT-CTX-02 | 集成 | `AI_CHAT` | systemPrompt 含 `【知识·` |
| IT-CTX-03 | 集成 | 交互 confirm → suggestPlots | 使用完整 prompt240s LM Studio |
| E2E-CTX-01 | E2E | 打开上下文编辑器知识 Tab | 推荐列表可见 |
| E2E-CTX-02 | E2E | 交互 context_confirm | 知识勾选区可见 |
| E2E-CTX-03 | E2E | 对话发送含知识上下文 | AI 回复非空(300s) |
### 8.2 超时
- IT-CTX-03**240s**
- E2E-CTX-03**300s**
---
## 9. i18n 键(节选)
```json
"context.tab.knowledge": "知识库",
"context.refreshSuggest": "刷新推荐",
"context.knowledgeCount": "{{count}} 条知识",
"knowledge.reason.highImportance": "高重要度",
"knowledge.reason.chapterRelevant": "与当前章相关",
"knowledge.reason.foreshadowBuried": "伏笔待回收",
"knowledge.reason.goalMatch": "匹配章节目标",
"interactive.confirmKnowledge": "确认知识上下文",
"wizard.knowledgeList": "将注入知识"
```
---
## 10. 文件映射
| 文件 | 职责 |
|------|------|
| `src/main/services/knowledge-retrieval.service.ts` | 检索打分 |
| `src/main/services/ai-context-builder.service.ts` | 扩展 knowledge 块 |
| `src/main/services/interactive-writing.service.ts` | readContextText 修复 |
| `src/main/ipc/handlers/knowledge.handler.ts` | suggestContext |
| `src/main/ipc/handlers/interactive.handler.ts` | FlowContextPayload |
| `src/main/ipc/handlers/auto.handler.ts` | systemPrompt 读取 |
| `src/main/ipc/handlers/wizard.handler.ts` | 同上 |
| `src/main/ipc/handlers/ai.handler.ts` | binding 默认 knowledgeIds |
| `src/shared/types.ts` | 类型扩展 |
| `src/shared/ipc-channels.ts` | KNOWLEDGE_SUGGEST_CONTEXT |
| `src/preload/index.ts` | knowledge.suggestContext |
| `src/renderer/components/ai/KnowledgeSuggestList.tsx` | 共享 UI |
| `src/renderer/components/ai/ContextEditorModal.tsx` | 知识 Tab + 推荐 |
| `src/renderer/components/ai/InteractivePanel.tsx` | context_confirm 知识区 |
| `src/renderer/components/ai/AutoPanel.tsx` | 知识确认区 |
| `src/renderer/components/ai/WizardPanel.tsx` | wizard_context 知识区 |
| `tests/main/knowledge-retrieval.test.ts` | UT-CTX-* |
| `tests/main/ai-context-builder.test.ts` | UT-CTX-02/03 |
| `e2e/ai-context.spec.ts` | E2E-CTX-* |
---
## 11. Spec Coverage Checklist
| design/readme 要求 | 本规格 |
|--------------------|--------|
| §6.1 知识条目按重要度检索注入 | §3 检索 + §4 构建 |
| §6.1 上下文预览可删减 | ContextEditorModal 勾选 |
| §6.5.1 交互上下文确认 | §6.4 |
| §6.5.3 向导确认知识条目 | §6.6 |
| §6.7 重要度影响注入优先级 | §3.3 打分 |
| P5 延后项「知识注入 AI 上下文」 | 本阶段交付 |
---
## 12. 版本与 DoD
- `package.json`**0.7.0**
- `README.md` 功能概览追加 P6 一行
- `npm run build && npm run test && npm run test:e2e` 全绿
- LM Studio 集成测试按 §8.2 超时执行
@@ -0,0 +1,623 @@
# 笔临 Master Completion 全量对齐设计规格
**版本**1.0
**日期**2026-07-08
**范围**`design/readme.md` v1.1 全部未实现/占位/部分实现能力,分 5 波连续交付至 100% 对齐
**前置**v1.1.0 已交付(`adf8778`:模板 + txt/md/docx 导入 + 投稿导出 + 全局灵感)
**参考**`design/readme.md` 全文、`design/ui_pc.html`、v1.1 spec §1.4 推迟项
**目标版本**v1.2.0 → v1.3.0 → v1.4.0 → v1.5.0 → v2.0.0(五波合一 Master Program
---
## 1. 背景与目标
### 1.1 阶段定位
v1.1.0 完成作家日更工具包后,代码库与 `design/readme.md` 仍存在:
- **5 处硬占位**`comingSoon` / placeholder-box
- **v1.1 明确推迟的 9 类能力**(项目包、导出矩阵、时间线、阅读模式等)
- **~15 个零代码大模块**(云同步、自动更新、撤销堆栈、扩展 API 等)
- **大量 IPC 已有、UI 缺失**(删书、分卷管理、会话归档等)
- **部分实现与设计差距**(章节标签、发布日期、模板变量自动填充等)
Brainstorming 2026-07-08 用户确认 **方案 A:全量对齐**,包含 Wave 5 平台基建。
### 1.2 已确认决策
| 维度 | 决策 |
|------|------|
| 交付策略 | **Master Program 五波连续交付**(非单版本大爆炸) |
| 范围 | Waves 15 全做,对齐 `design/readme.md` |
| 永久排除 | 平台 API 直连投稿(YAGNI |
| 可选排除 | SQLCipher 本地库加密(§12.4 标注可选项,本 Program **不做** |
| 大依赖拆分 | 仿 v1.1 `import-bootstrap``pack-bootstrap`archiver)、`pdf-bootstrap`puppeteer 或 pdf-lib |
| `.novel` 格式 | 自研 ZIP + `manifest.json` v1,带 `appVersion` 兼容检查 |
| PDF 方案 | **Puppeteer** 渲染 HTML → PDF(对齐设计 §11.2.2 页眉页脚);开发/E2E 用 mock 或 skip |
| docx 导出 | **docx** npm 包或 HTML→docx 转换 |
| 云同步 | WebDAV + 本地目录双模式;AES-256-GCM + PBKDF2 派生密钥 |
| 自动更新 | `electron-updater`;开发模式禁用;无代码签名时保留 IPC 通道 |
| 扩展 API | **骨架 + 类型 + 注册表 + 文档**;不实现完整插件市场/沙箱加载 |
| Skill/MCP | 设置页展示内置 Skill 列表 + MCP 连接器 **配置占位** + 接口类型预留 |
| 撤销堆栈 | 每章最多 50 条,存 `undo_stack` 表;与 TipTap History 协同 |
| 测试 | 每波补齐 design 对应用例;AI 相关 IT 240s / E2E 300s |
### 1.3 总体验收标准
Master Program 完成后:
1. 代码库中 **无** `feature.comingSoon` 用于真实功能入口(仅文档/关于页说明性文案除外)
2. `design/readme.md` 各章测试表用例 **均有对应 UT/IT/E2E**(PDF/更新/同步可用 mock 或集成桩)
3. 用户可完成:书籍全生命周期管理、项目包备份恢复、多格式导出、连续阅读、时间线、合规检查、云同步、崩溃日志、检查更新
4. README 版本号升至 **v2.0.0**
### 1.4 明确不实现
| 项 | 原因 |
|----|------|
| 平台后台 API 直连投稿 | 永久 YAGNI |
| SQLCipher 数据库加密 | §12.4 可选,复杂度高 |
| 完整第三方插件市场 / 动态加载未签名扩展 | Wave 5 仅骨架 |
| 崩溃报告上传到远端 API | 保留按钮,调用 stub |
| macOS/Linux 代码签名 | 保留渠道,不实施签名 |
---
## 2. 波次规划
```
Wave 1 (v1.2.0) 基础治理 — 占位清零 + 书籍/章节/分卷/AI 会话 UI 补全
Wave 2 (v1.3.0) 项目包 — .novel / .novel-all 导入导出
Wave 3 (v1.4.0) 导出阅读 — 导出矩阵 + 连续阅读 + 专注/TTS + 驾驶舱补全
Wave 4 (v1.5.0) 创作深化 — 时间线 + 角色弧线 + 敏感词 + AI 文风/附件/骨架
Wave 5 (v2.0.0) 平台基建 — 撤销堆栈 + 云同步 + 日志 + 自动更新 + 扩展 API
```
**依赖**W1 → W2 → W3W1 → W4W2 → W5(同步依赖 .novel);W3/W4 → W5。
---
## 3. 架构
### 3.1 进程模型(目标态)
```
┌──────────────────────────────────────────────────────────────────────────┐
│ 渲染进程 │
│ BookSettingsTab / ReadModeView / TimelineView / CharacterArcTab │
│ ExportModal(扩展)/ ExportAllModal / SyncSettingsTab │
│ FocusMode overlay / TTS controller / StyleMemoryPanel │
│ ExtensionRegistry (types only, dev docs) │
└────────────────────────────┬─────────────────────────────────────────────┘
│ IPC
┌────────────────────────────▼─────────────────────────────────────────────┐
│ 主进程 index.js(核心) │
│ BookRegistry / GlobalSettings / 现有 handlers │
├────────────────────────────────────────────────────────────────────────────┤
│ 独立 bootstrap chunks(按需 dynamic import
│ · import-bootstrap.js (已有) │
│ · pack-bootstrap.js archiver, .novel/.novel-all pack/unpack │
│ · export-bootstrap.js docx, pdf, batch export │
│ · sync-bootstrap.js webdav-client, crypto encrypt │
└────────────────────────────────────────────────────────────────────────────┘
```
### 3.2 模块边界(新增)
| 模块 | 职责 | 进程 |
|------|------|------|
| `PackService` | `.novel` / `.novel-all` 打包与解包 | main (pack-bootstrap) |
| `ExportMatrixService` | txt/md/docx/pdf/批量 | main (export-bootstrap) |
| `ReadModeService` | 拼合卷章节纯文本、虚拟分页元数据 | main |
| `TimelineService` | `timeline_events` CRUD、冲突检测 | main |
| `ComplianceService` | 词库匹配、导出前扫描 | main |
| `CharacterArcService` | 从 knowledge 聚合角色节点 | main |
| `StyleMemoryService` | 章节抽样分析文风特征 | main |
| `UndoStackService` | 持久化 ProseMirror steps JSON | main |
| `SyncService` | 加密上传/下载 .novel | main (sync-bootstrap) |
| `LogService` | electron-log 初始化、崩溃检测 | main |
| `UpdateService` | electron-updater 封装 | main |
| `ExtensionRegistry` | 扩展点类型与空注册表 | shared + main stub |
### 3.3 构建配置
`electron.vite.config.ts` 增加入口:
- `pack-bootstrap`
- `export-bootstrap`
- `sync-bootstrap`
`rollupOptions.external` 包含:`archiver``puppeteer`(或 `pdf-lib`)、`docx``webdav`(或自实现 fetch WebDAV)。
`src/main/index.ts``app.whenReady`**不** 预加载所有 bootstrap;各 handler 首次调用时 `import()` 对应 chunk(与 import 模式一致)。
---
## 4. 数据模型
### 4.1 Schema v9Wave 12
**`book_preferences` 扩展**(已有表):
```sql
ALTER TABLE book_preferences ADD COLUMN always_show_cockpit INTEGER NOT NULL DEFAULT 0;
ALTER TABLE book_preferences ADD COLUMN cover_path TEXT DEFAULT NULL;
ALTER TABLE book_preferences ADD COLUMN synopsis TEXT DEFAULT '';
```
**`chapters` 扩展**(部分列 migrate 已有):
```sql
-- 已有: summary, story_time, publish_status
ALTER TABLE chapters ADD COLUMN published_at TEXT DEFAULT NULL;
ALTER TABLE chapters ADD COLUMN word_count_threshold INTEGER DEFAULT NULL;
```
**`chapter_tags` 新表**(设计 readme 已有 DDL):
```sql
CREATE TABLE IF NOT EXISTS chapter_tags (
chapter_id TEXT NOT NULL,
tag TEXT NOT NULL,
color TEXT DEFAULT '',
PRIMARY KEY (chapter_id, tag),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
```
**`volumes` 扩展**
```sql
-- description 列若不存在则 ADD
```
**`BookMeta`registry JSON)扩展**
```typescript
interface BookMeta {
// 已有字段
coverPath?: string | null
synopsis?: string
}
```
### 4.2 Schema v10Wave 4
```sql
CREATE TABLE IF NOT EXISTS timeline_events (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL, -- 冗余便于查询;实际存 book db 内可不存 book_id
kind TEXT NOT NULL, -- chapter | event | conflict
title TEXT NOT NULL,
description TEXT DEFAULT '',
story_time TEXT NOT NULL, -- 绝对或相对表达式
chapter_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE SET NULL
);
```
> 注:`timeline_events` 存于**每书 SQLite**(与 chapters 同库),非全局库。
### 4.3 Schema v11Wave 4
```sql
CREATE TABLE IF NOT EXISTS compliance_words (
id TEXT PRIMARY KEY,
word TEXT NOT NULL UNIQUE,
builtin INTEGER NOT NULL DEFAULT 0
);
```
全局词库可放 `global_settings.json``complianceWords: string[]`;内置词库用 JSON 文件 `src/shared/builtin-compliance-words.json`
### 4.4 Schema v12Wave 5
```sql
CREATE TABLE IF NOT EXISTS undo_stack (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chapter_id TEXT NOT NULL,
operation TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
```
### 4.5 GlobalSettings 扩展
```typescript
interface GlobalSettings {
// 已有
complianceCheckEnabled?: boolean
syncConfig?: SyncConfig | null
customSlashCommands?: { trigger: string; template: string }[]
styleMemoryPresets?: StylePreset[]
readModeFontTheme?: 'serif' | 'sans' | 'eye-care'
readModeDeviceWidth?: 'phone' | 'tablet' | 'desktop'
extensionDevMode?: boolean
}
interface SyncConfig {
mode: 'webdav' | 'local-folder'
webdavUrl?: string
webdavUser?: string
localFolderPath?: string
schedule: 'manual' | 'hourly' | 'daily' | 'weekly'
lastSyncAt?: string | null
lastSyncStatus?: 'ok' | 'error' | 'syncing'
}
interface SubmissionPreset {
// 已有字段
sensitiveReplacements?: { from: string; to: string }[]
}
```
### 4.6 `.novel` 包格式 v1
```
book.novel (ZIP)
├── manifest.json
│ {
│ "formatVersion": 1,
│ "appVersion": "2.0.0",
│ "exportedAt": "ISO8601",
│ "bookId": "uuid",
│ "bookName": "书名"
│ }
├── meta.json # BookMeta 快照
├── book.sqlite # 完整书籍 DB
└── attachments/ # 封面等(可选)
```
**`.novel-all` v1**
```
archive.novel-all (ZIP)
├── manifest.json # bookCount, appVersion, exportedAt
├── global_settings.json
└── books/
└── {bookId}/
├── meta.json
└── book.sqlite
```
**导入策略**
- `.novel`:默认新建书(新 UUID),`meta.name` 可冲突时追加后缀
- `.novel-all`:合并导入;`bookId` 冲突时提示跳过/覆盖/新建
- `formatVersion` 不匹配时拒绝并提示升级应用
---
## 5. Wave 1 — 基础治理(v1.2.0
### 5.1 书籍设置 Tab
替换 `RightPanel` `book-settings` 占位为 `BookSettingsTab`
- 书名(只读或改 meta)、分类、目标字数、封面路径、简介
- 「每次打开显示驾驶舱」开关 → `book_preferences.always_show_cockpit`
- 删书按钮(二次确认)→ `book.delete`
- IPC`book.updateMeta`
### 5.2 分卷管理
- 分卷右键菜单:重命名、删除(章节移入默认卷或一并删除—**默认:禁止删除非空卷,需先移章**)
- 分卷简述编辑
- `volume.update` / `volume.delete` UI 接入
### 5.3 章节管理补全
- 双击章节标题 inline 编辑 → `chapter.update`
- 章节删除(右键或图标)→ `chapter.delete`
- 章节标签 CRUD + 侧栏按标签筛选
- `summary` / `story_time` 字段:章节属性面板或状态栏入口
- 发布状态切换时,若变为 `published` 写入 `published_at = now()`
- 可选每章 `word_count_threshold`;状态栏字数变色(绿/橙规则同 design §5.2
### 5.4 模板变量
`TemplateQuickCreateModal.buildContext`
- `{outlineItem}`:当前卷中关联 `chapterId` 的大纲条目标题,无则 `''`
- `{characterList}`:从 `settings` 中 type=character 且 `setting_chapter_refs` 含当前章(或 POV 角色)拼接名称
### 5.5 AI 会话 UI
`AiPanel` 会话栏增加:
- 重命名(inline 或模态)
- 归档 / 取消归档(`ai.session.update { archived: true }`
- 导出为 `.txt` / `.md`(主进程写文件对话框)
- 删除会话(确认)
### 5.6 快捷键落地
| 快捷键 | 实现 |
|--------|------|
| `focusMode` Ctrl+Alt+F | 全屏隐藏侧栏/右栏,Esc 退出;`#app.focus-mode` CSS |
| `toggleTTS` Ctrl+Shift+R | 朗读当前章选中段落或全文;Web Speech API / 系统 TTS |
### 5.7 用词分析
- 底部「AI 命名建议」→ 打开已有 `NamingCheckModal`(非 comingSoon
### 5.8 Wave 1 验收
- [ ] 无 Wave 1 范围内 comingSoon
- [ ] E2E-TEMPL-05 `{characterList}` 通过
- [ ] 书籍设置/删书/分卷/章删改 E2E 各 1
---
## 6. Wave 2 — 项目包(v1.3.0
### 6.1 PackService
- `pack.exportBook(bookId, destPath)``.novel`
- `pack.importBook(filePath)` → newBookId
- `pack.exportAll(destPath)``.novel-all`
- `pack.importAll(filePath, strategy)` → 导入报告
- 进度 IPC`PACK_PROGRESS { current, total, phase }`
- >500MB 导出前估算并确认(§11.2.3)
### 6.2 UI
- `HomePage`「导出所有书籍」→ `ExportAllModal`
- `ExportModal` 增加「笔临项目包 .novel」选项
- `ImportBookModal` 支持 `.novel` 文件过滤
- 设置页「备份」区:手动导出当前书/全部
### 6.3 大文件
- 导入 `.novel-all` 显示进度条,支持取消(AbortController
### 6.4 Wave 2 验收
- [ ] IT-ALL-02:导出 3 书 → 删库 → 导入恢复
- [ ] E2E-ALL-03:主页导出全部
- [ ] E2E:单书 .novel 导出再导入
---
## 7. Wave 3 — 导出矩阵与阅读(v1.4.0)
### 7.1 ExportMatrixService
| 格式 | 范围 | 技术 |
|------|------|------|
| txt | 章/卷/全书 | 已有 formatter 扩展 |
| md | 章/卷/全书 | marked 逆向或模板 |
| docx | 章/卷/全书 | docx 库 + 目录 |
| pdf | 章/卷/全书 | HTML 模板 + puppeteer |
| 投稿复制 | 单章 | 已有 |
**导出选项**(模态复选框):
- 包含批注/地标
- 标注 AI 生成内容(查 snapshot type=ai
- 历史版本导出为独立文件(可选 zip 内多文件)
### 7.2 连续阅读模式
- 顶栏 👁 → `ReadModeOverlay` 全屏
- 选择当前卷;虚拟 3 章窗口;scroll-snap 翻页
- 设备宽度:360 / 768 / 100%
- 字体主题:serif / sans / eye-care`readModeFontTheme` 持久化)
- 分卷右键「阅读模式」
- Esc 退出
### 7.3 专注模式与顶栏进度
- `focusMode` 快捷键(W1 已有)增加可选环境音效(设置开关,本地 wav)
- 顶栏 `dailyWordGoal` 进度细条(与 EditorLayout 状态栏同源 `writingStats`
### 7.4 驾驶舱补全
- `always_show_cockpit` 书籍偏好生效
- 快速入口「今日大纲任务」:列出 status=pending 且 expected_word_count 的大纲 Top 5
### 7.5 Wave 3 验收
- [ ] E2E-PDF-01mock puppeteer 或 headless 短超时)
- [ ] E2E-READ-01~03
- [ ] 导出 docx 含目录 IT
---
## 8. Wave 4 — 创作深化(v1.5.0
### 8.1 故事时间线
- `TimelineView` 模态或侧栏全页;Canvas 或 CSS 时间轴
- 章节 `story_time` 自动出现在轴上;手动添加关键事件
- 相对时间 `+Nd` 解析;角色出生日期来自 `settings.properties.birthDate`
- 冲突检测:年龄不一致标红(规则见 design §5.7.1
- 点击跳转章节
### 8.2 角色弧线简报
- 设定面板 character 类型 → Tab「弧线」
- 聚合 `knowledge_entries` 中该角色 state 变更 + 关联章节
- 驾驶舱角色摘要可下钻
### 8.3 敏感词合规
- 设置 → 编辑器 → 合规检查开关
- 内置 + 用户词库;编辑器装饰下划线(TipTap Decoration
- `SubmissionPreset.sensitiveReplacements` 导出时替换
- 导出前可选扫描确认对话框
### 8.4 AI 扩展
| 功能 | 实现要点 |
|------|----------|
| 文风记忆 §6.3 | 分析选中范围章节 → 特征 JSON → 滑杆 UI → 保存预设 |
| 自定义快捷命令 §6.2 | `GlobalSettings.customSlashCommands` + 设置编辑 |
| 大纲展开骨架 §6.6 | 大纲项右键「展开为章节骨架」→ AI 生成多章标题+摘要 → 批量 create |
| 附件上传 §6.10 | AI 上下文增加 `attachments[]`;文件存 userData/attachments/{bookId}/ |
### 8.5 其他补全
- 引用面板点击展开详情抽屉
- 章节列表悬停预览(首段 + summary)
- 分卷/章字数阈值提醒
### 8.6 Wave 4 验收
- [ ] E2E-TL-01~03
- [ ] 合规词下划线 UT
- [ ] 弧线 Tab 有数据时展示节点
---
## 9. Wave 5 — 平台基建(v2.0.0
### 9.1 撤销堆栈
- TipTap `editor.on('update')` 防抖写入 `undo_stack`JSON step
- 启动章时加载最近 50 条;与内存 history 合并策略:**仅持久化 manual 断点或每 N 步**
- 超出 50 删最旧
### 9.2 云同步
- 设置页「备份与同步」Tab`SyncSettingsTab`
- WebDAVPUT/GET 加密 `.novel`;本地目录:复制加密文件
- 定时:`node-cron``setInterval`hourly/daily/weekly
- 状态栏同步图标;冲突时模态三选一(本地/远程/取消)
- 首次配置同步密码(≥8 位),密钥不落盘明文
### 9.3 崩溃报告与日志
- `electron-log``{userData}/logs/bilin.log`,保留 7 天
- `app.getLastCrashReport` 或标记异常退出;下次启动提示
- 「发送报告」→ stub 记录日志
### 9.4 自动更新
- `electron-updater``feedURL` 来自环境或 package.json publish 配置
- IPC`update:check` / `update:download` / 事件推送
- 顶栏或状态栏「新版本可用」;开发模式 `BILIN_E2E` 禁用
### 9.5 扩展 API 骨架
- `src/shared/extension-api.ts``BilinExtension` 类型(同 design §15.3
- `src/main/services/extension-registry.ts`:空注册表 + `registerImporter` 等 stub
- `docs/extensions/README.md`:贡献者文档
- 设置页「扩展」:列出已声明扩展(dev 模式可从文件夹加载 manifest 校验,**不执行**第三方代码)
### 9.6 Skill / MCP 占位
- 设置 → AI → 内置 Skill 列表(静态 JSON
- MCP 连接器 URL 配置字段 + 测试连接 stub
### 9.7 无障碍与 i18n
- 关键模态 `aria-*` 复查;键盘 Tab 顺序
- 首次启动语言默认:读 `app.getLocale()`,非 zh/en 则 zh-CN
### 9.8 Wave 5 验收
- [ ] IT-SYNC-01WebDAV mock server 或 local-folder
- [ ] UT-LOG-01
- [ ] IT-UPDATE-01mock feed
---
## 10. IPC 通道(新增汇总)
```typescript
// pack.*
PACK_EXPORT_BOOK / PACK_IMPORT_BOOK / PACK_EXPORT_ALL / PACK_IMPORT_ALL / PACK_PROGRESS
// export matrix
EXPORT_CHAPTER_TXT / EXPORT_VOLUME / EXPORT_BOOK / EXPORT_PDF / EXPORT_DOCX
// read mode
READ_GET_VOLUME_CONTENT
// timeline
TIMELINE_LIST / TIMELINE_UPSERT / TIMELINE_DELETE / TIMELINE_CONFLICTS
// compliance
COMPLIANCE_CHECK_TEXT / COMPLIANCE_GET_WORDS / COMPLIANCE_SET_WORDS
// character arc
ARC_GET_FOR_CHARACTER
// style memory
STYLE_ANALYZE / STYLE_GET_PRESETS / STYLE_SAVE_PRESET
// undo
UNDO_PUSH / UNDO_LIST / UNDO_APPLY
// sync
SYNC_CONFIGURE / SYNC_RUN / SYNC_STATUS / SYNC_PROGRESS
// update
UPDATE_CHECK / UPDATE_DOWNLOAD / UPDATE_INSTALL
// attachments
ATTACHMENT_UPLOAD / ATTACHMENT_LIST / ATTACHMENT_DELETE
```
所有通道使用现有 `wrap()` + `IpcResult<T>` 信封。
---
## 11. 测试策略
| 波次 | 新增测试约计 |
|------|-------------|
| W1 | 8 E2E + 6 UT |
| W2 | 4 IT + 3 E2E |
| W3 | 6 E2E + 4 UT |
| W4 | 10 E2E + 8 UT |
| W5 | 4 IT + 3 E2E + 4 UT |
**原则**
- 每波合并前 `npm run build && npm run test && npm run test:e2e`(该波新增 spec
- Puppeteer/Updater/WebDAV 测试使用 mock 或 `vitest` 桩,不依赖外网
- 延续 `BILIN_E2E` / `BILIN_E2E_USER_DATA` 模式
---
## 12. 版本与文档
| 波次 | 版本 | CHANGELOG 要点 |
|------|------|----------------|
| W1 | v1.2.0 | 书籍设置、删改、模板变量、会话管理、专注/TTS |
| W2 | v1.3.0 | .novel / .novel-all |
| W3 | v1.4.0 | 导出矩阵、连续阅读 |
| W4 | v1.5.0 | 时间线、弧线、合规、AI 文风 |
| W5 | v2.0.0 | 同步、日志、更新、扩展 API |
每波更新:`package.json``README.md`、Settings 关于页、`design/readme.md` 实施状态表(可选)。
---
## 13. 实施计划文档
Master Program 拆为 5 份 implementation planbrainstorming 下一阶段):
1. `docs/superpowers/plans/2026-07-08-bilin-w1-foundation.md`
2. `docs/superpowers/plans/2026-07-08-bilin-w2-project-pack.md`
3. `docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md`
4. `docs/superpowers/plans/2026-07-08-bilin-w4-creative-depth.md`
5. `docs/superpowers/plans/2026-07-08-bilin-w5-platform.md`
---
## 14. Spec 自检(2026-07-08
| 检查项 | 结果 |
|--------|------|
| TBD/占位段 | 无 |
| 内部矛盾 | Wave 依赖与 v1.1 推迟项已对齐 |
| 范围 | 大但已拆 5 波;单 plan 可执行 |
| 歧义 | PDF 用 PuppeteerSQLCipher 明确排除;插件不执行第三方代码 |
| YAGNI | 平台投稿 API、SQLCipher、远端崩溃上传已排除 |
@@ -0,0 +1,444 @@
# 笔临 P7 角色关系图谱 + P9 个人写作分析 设计规格
**版本**1.0
**日期**2026-07-08
**范围**:§7 角色关系图谱 MVP + §9 个人写作分析 MVP(含 `writing_sessions` 精确采集)
**前置**P5.3 + P6.1 已交付(v0.9.0`d4122c8`);P5.2 写作日志 / 热力图 / 番茄(v0.8.0)
**参考**`design/readme.md` v1.1 §7 / §9、`design/ui_pc.html` `#graphModal` / `#dashboardModal`
**目标版本**v1.0.0
---
## 1. 背景与目标
### 1.1 阶段定位
v0.9.0 已完成作家工作流核心链路(驾驶舱、日志、番茄、知识库、AI 上下文、注入历史)。`design/readme.md` §7 **角色关系图谱** 与 §9 **个人写作分析** 仍为概念/原型状态(`#graphModal` 为静态 demo,分析卡片无真实数据源)。
本阶段(v1.0.0)统一 MVP 交付:
- **P7(§7**:基于 `settings` 角色设定 + `properties.relationships` 的可视化关系图谱
- **P9(§9**:基于 `writing_sessions` + 现有 `writing_logs` / `pomodoro_daily` 的驾驶舱分析区
### 1.2 已确认决策(Brainstorming 2026-07-08
| 维度 | 决策 |
|------|------|
| 交付策略 | **方案 3**C + D 统一 MVP,单版 v1.0.0 |
| 分析数据采集 | **方案 A**:新增 `writing_sessions` 表,编辑活跃时精确记录 |
| 关系存储 | **`settings.properties.relationships[]` JSON**,不新增每书关系表 |
| 每书 schema | **保持 v8**,不迁移;`chapters.pov_character_id` 已有,补 UI |
| 全局 sqlite | **扩展 `writing_logs.sqlite`**(与 `writing_logs` / `pomodoro_daily` 同库) |
| 图谱库 | **Cytoscape.js + cose-bilkent**(渲染进程) |
| Session 合并 | 同一书籍连续写作,间隔 **≤ 5 分钟** 合并为同一 session |
| 图谱上限 | **100 角色节点**;超出 toast 提示,仍可按「仅有关系的节点」筛选展示 |
| 边去重 | 单向存储;渲染时 A↔B 去重,取较高 `intimacy` |
| 分析入口 | **仅驾驶舱内嵌卡片**,不做独立全屏分析页 |
| POV | 章节元数据下拉绑定 `pov_character_id` → 分析 POV 字数分布 |
### 1.3 验收标准
用户能完成:
1. 在设定面板为角色添加/编辑/删除关系(目标角色、描述、亲密度 1–5),保存后在关系图谱中可见对应边
2. 打开 **角色关系图谱** 全屏模态,看到力导向布局下的角色节点与关系边;可切换 grid / circle 布局
3. 点击节点 → 侧栏显示角色名与「编辑设定」跳转;点击边 → 侧栏编辑关系描述与亲密度
4. 在章节编辑区设置 **POV 角色**,驾驶舱 POV 分布反映该角色章节字数
5. 在章节编辑器写作并保存后,驾驶舱 **今日写作速度**(字/分钟)> 0;**24 小时时段热力**有数据
6. 驾驶舱展示:7 日字数趋势、番茄今日次数与均字/次、按卷字数占比(与现有字数聚合一致)
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| AI 章节分析后自动关系建议(§7.2-3) | v1.1+ |
| 图谱中右键拖拽连线创建关系 | v1.1+ |
| 知识抽取面板「关系建议」一键添加 | v1.1+ |
| 预计完本日期 | §9 后续 |
| 角色弧线简报三节时间线(§20.3) | v1.1+ |
| 独立全屏「数据分析」页、书籍设置分析 Tab | 后续 |
| 导入导出、章节模板(§10 / §11) | 其他阶段 |
| 故事时间线模态(§5.7) | 其他阶段 |
| 设定条目(非 character)进入关系图谱 | 排除 |
---
## 2. 架构
### 2.1 进程模型
```
┌─────────────────────────────────────────────────────────────────────┐
│ 主进程(userData writing_logs.sqlite 扩展) │
│ · WritingSessionRepository / WritingSessionService │
│ · WritingAnalyticsService(聚合 sessions + logs + pomodoro + 章节) │
│ · ChapterWritingTracker.recordDelta → session.recordActivity │
└──────────────────────────────┬──────────────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────────────┐
│ 主进程(每书 .sqliteschema v8
│ · CharacterGraphService(读 settings,写 properties.relationships
│ · SettingRepository(已有) │
│ · ChapterRepository.pov_character_id(已有) │
└──────────────────────────────┬──────────────────────────────────────┘
│ IPC
┌──────────────────────────────▼──────────────────────────────────────┐
│ 渲染进程 │
│ · CharacterGraphModalCytoscape + 侧栏) │
│ · SettingRelationshipsEditor(角色设定页关系 CRUD
│ · CockpitModal 扩展 analytics 区块 │
│ · ChapterHeader / ChapterListPOV 下拉 │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.2 模块边界
| 模块 | 职责 | 依赖 |
|------|------|------|
| `WritingSessionService` | 活跃 session 状态机;5 分钟 idle 切分;`recordActivity` | `WritingSessionRepository` |
| `WritingAnalyticsService` | `getSummary(bookId)` 聚合分析指标 | sessions, logs, pomodoro, chapters, settings |
| `CharacterGraphService` | 构建 nodes/edgesCRUD 关系;边去重 | `SettingRepository` |
| `ChapterWritingTracker` | 已有字数 delta → **扩展**调用 session | `WritingLogService`, `WritingSessionService` |
| `CharacterGraphModal` | Cytoscape 渲染与布局切换 | preload `graph.*` |
| `CockpitModal` | 展示 `WritingAnalyticsSummary` | preload `analytics.getSummary` |
**原则**
- Session 写入与 `writing_logs` 共用 `recordDelta` 路径,**不重复计字**
- 图谱数据以 `settings` 为唯一真相源;Cytoscape 元素由 IPC 返回的 DTO 构建
- 分析服务只读;失败时驾驶舱分析区显示占位/空态,不阻断驾驶舱其他卡片
---
## 3. 数据模型
### 3.1 全局 userData:扩展 `writing_logs.sqlite`
`WritingLogRepository.migrate()` 追加:
```sql
CREATE TABLE IF NOT EXISTS writing_sessions (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
chapter_id TEXT,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
word_delta INTEGER NOT NULL DEFAULT 0,
date TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_book_date
ON writing_sessions (book_id, date);
CREATE INDEX IF NOT EXISTS idx_sessions_book_started
ON writing_sessions (book_id, started_at);
```
**Session 状态机**`WritingSessionService`):
| 事件 | 行为 |
|------|------|
| `recordActivity(bookId, chapterId, delta)` | 若无活跃 session 或距上次 `ended_at` > 5min → `INSERT` 新 session;否则 `UPDATE ended_at, word_delta += delta` |
| `endActive(bookId)` | 书籍切换 / 应用退出前结束活跃 session |
| `delta === 0` | 忽略(不创建空 session |
活跃 session 可在主进程内存缓存 `{ bookId, sessionId, lastEndedAt }`DB 为持久真相。
### 3.2 角色关系 JSON(每书 `settings.properties`
```typescript
interface CharacterRelationship {
id: string
targetId: string
label: string
intimacy: 1 | 2 | 3 | 4 | 5
}
// SettingEntry.properties.relationships?: CharacterRelationship[]
// 仅 type === 'character' 时读写
```
**边去重规则**(渲染):对无序对 `{A,B}` 仅输出一条边;若 A→B 与 B→A 均存在,取 `max(intimacy)``label` 优先取较高亲密度源的 label。
**CRUD**
- `upsertRelationship(sourceId, { id?, targetId, label, intimacy })` — 更新源角色 `properties.relationships`
- `deleteRelationship(sourceId, relationshipId)`
- 禁止 `targetId === sourceId`;禁止指向非 character 设定
### 3.3 POV(已有)
- DB 列:`chapters.pov_character_id`schema v8 已有 `tryAlter`
- UI:章节编辑器工具栏或章节列表元数据区,`select` 绑定 `type='character'` 设定
- IPC:复用 `chapter.update` patch `{ povCharacterId }`
### 3.4 类型扩展(`src/shared/types.ts`
```typescript
export interface CharacterGraphNode {
id: string
label: string
}
export interface CharacterGraphEdge {
id: string
source: string
target: string
label: string
intimacy: 1 | 2 | 3 | 4 | 5
}
export interface CharacterGraphData {
nodes: CharacterGraphNode[]
edges: CharacterGraphEdge[]
truncated: boolean
nodeCount: number
}
export interface WritingAnalyticsSummary {
todayWords: number
todaySpeedWpm: number | null
weekTrend: { date: string; words: number }[]
hourlyHeatmap: number[] // length 24
pomodoroToday: number
pomodoroAvgWords: number | null
povDistribution: { characterId: string; name: string; words: number }[]
volumeDistribution: { volumeId: string; title: string; words: number }[]
}
```
---
## 4. 服务层设计
### 4.1 WritingAnalyticsService
```typescript
getSummary(bookId: string): WritingAnalyticsSummary
```
| 字段 | 计算方式 |
|------|----------|
| `todayWords` | `WritingLogService.getStats(bookId).todayWords` |
| `todaySpeedWpm` | 今日 sessions`sum(word_delta) / sum(minutes(ended_at - started_at))`;无 session 或零时长 → `null` |
| `weekTrend` | 近 7 天 `writing_logs` 按 date |
| `hourlyHeatmap` | 近 30 天 sessions,按 `started_at` 小时 bucket 累加 `word_delta`24 槽) |
| `pomodoroToday` | `PomodoroDailyRepository.getTodayCount` |
| `pomodoroAvgWords` | 今日 `total_words / completed_count`count=0 → `null` |
| `povDistribution` | chapters GROUP BY `pov_character_id` SUM `word_count`JOIN character name |
| `volumeDistribution` | chapters GROUP BY `volume_id` SUM `word_count` |
未设置 POV 的章节计入 `{ characterId: '__none__', name: i18n未指定 }` 或单独过滤(实现时选 **单独「未指定」桶**)。
### 4.2 CharacterGraphService
```typescript
getData(bookId: string, filter?: 'all' | 'connected'): CharacterGraphData
upsertRelationship(bookId, sourceId, input): CharacterRelationship
deleteRelationship(bookId, sourceId, relationshipId): void
```
- `filter: 'connected'`:仅包含有至少一条边的节点(用于超 100 节点时的降级展示)
- `truncated: true` 当 character 总数 > 100
### 4.3 ChapterWritingTracker 扩展
```typescript
recordDelta(chapterId: string, newWordCount: number): void {
// 现有 writing_logs delta 逻辑不变
if (delta !== 0) {
this.writingLogs.addToday(this.bookId, delta)
this.sessions.recordActivity(this.bookId, chapterId, delta)
}
// snapshot upsert 不变
}
```
---
## 5. IPC 与 preload
```typescript
// ipc-channels.ts
GRAPH_GET_DATA: 'graph:getData',
GRAPH_UPSERT_RELATIONSHIP: 'graph:upsertRelationship',
GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship',
ANALYTICS_SUMMARY: 'analytics:summary',
```
| 通道 | 参数 | 返回 |
|------|------|------|
| `graph:getData` | `{ bookId, filter? }` | `IpcResult<CharacterGraphData>` |
| `graph:upsertRelationship` | `{ bookId, sourceId, relationship }` | `IpcResult<CharacterRelationship>` |
| `graph:deleteRelationship` | `{ bookId, sourceId, relationshipId }` | `IpcResult<void>` |
| `analytics:summary` | `{ bookId }` | `IpcResult<WritingAnalyticsSummary>` |
`cockpit:getSummary` **不合并** analytics(保持单一职责);驾驶舱 UI 并行请求 `cockpit.getSummary` + `analytics.getSummary`,或在 renderer 层 `Promise.all`
---
## 6. UI 规格
### 6.1 角色关系图谱(`CharacterGraphModal`
参考 `design/ui_pc.html` `#graphModal`
| 元素 | testid | 说明 |
|------|--------|------|
| 模态 | `character-graph-modal` | 宽屏 `modal-content--wide` |
| Cytoscape 容器 | `character-graph-cy` | 最小高度 480px |
| 布局按钮 | `graph-layout-cose` / `grid` / `circle` | |
| 侧栏 | `character-graph-sidebar` | 节点/边详情 |
| 设定入口按钮 | `setting-open-graph` | 设定面板工具栏 |
**亲密度配色**(边 `line-color`):
| intimacy | 颜色 |
|----------|------|
| 1 | `#4ea8de` 蓝 |
| 2 | `#45b7aa` 青 |
| 3 | `#3fb950` 绿 |
| 4 | `#f0883e` 橙 |
| 5 | `#e0556a` 红 |
**Cytoscape 依赖**(渲染进程):`cytoscape``cytoscape-cose-bilkent`destroy 于 modal 关闭。
### 6.2 设定面板关系编辑(`SettingRelationshipsEditor`
- 嵌入角色设定编辑区(TipTap 下方或侧栏,当 `type === 'character'`
- 列表:目标角色名、label、亲密度、删除
- 添加:目标角色下拉(排除自身)、label 输入、intimacy range 15
- testid`setting-relationships``setting-relationship-add`
### 6.3 驾驶舱分析区(`CockpitModal` 扩展)
| 卡片 | testid | 内容 |
|------|--------|------|
| 分析区容器 | `cockpit-analytics` | |
| 今日速度 | `cockpit-speed-today` | `{{wpm}} 字/分钟``—` |
| 时段热力 | `cockpit-hourly-heatmap` | 24 格横条 |
| POV 分布 | `cockpit-pov-chart` | Top 5 横向条 |
| 按卷占比 | `cockpit-volume-chart` | 卷名字 + 字数 |
| 7 日趋势 | `cockpit-week-trend` | 7 柱 mini chart |
驾驶舱底部增加「打开关系图谱」按钮:`cockpit-open-graph`
### 6.4 章节 POV
- 位置:章节编辑器标题栏或 `#editor-statusbar` 附近
- testid`chapter-pov-select`
- 选项:空(未指定)+ 所有 character 设定
- 变更时 debounce 调用 `chapter.update`
---
## 7. i18n 键(节选)
```json
"graph.title": "角色关系图谱",
"graph.layout.cose": "力导向",
"graph.layout.grid": "网格",
"graph.layout.circle": "圆形",
"graph.truncated": "角色过多,仅显示前 {{count}} 个",
"graph.sidebar.node": "角色:{{name}}",
"graph.sidebar.edge": "关系:{{label}}",
"graph.editSetting": "编辑设定",
"relationship.title": "角色关系",
"relationship.add": "添加关系",
"relationship.target": "目标角色",
"relationship.label": "关系描述",
"relationship.intimacy": "亲密度",
"analytics.title": "写作分析",
"analytics.speedToday": "今日 {{wpm}} 字/分钟",
"analytics.speedUnknown": "暂无速度数据",
"analytics.hourlyHeatmap": "写作时段(近30天)",
"analytics.povDistribution": "视角字数分布",
"analytics.volumeDistribution": "按卷字数",
"analytics.weekTrend": "近 7 日字数",
"analytics.povUnassigned": "未指定视角",
"chapter.pov": "视角角色",
"cockpit.openGraph": "角色关系图谱"
```
---
## 8. 测试要求
### 8.1 单元 / 集成
| 用例ID | 层级 | 场景 | 预期 |
|--------|------|------|------|
| UT-SESSION-01 | 单元 | 5min 内两次 recordActivity | 同一 sessionword_delta 累加 |
| UT-SESSION-02 | 单元 | 间隔 6min 两次 recordActivity | 两个 session |
| UT-SESSION-03 | 单元 | delta=0 | 不创建 session |
| UT-GRAPH-01 | 单元 | 3 角色 2 关系 getData | nodes=3, edges=2 |
| UT-GRAPH-02 | 单元 | A→B 与 B→A 双向存储 | 渲染 1 条边,intimacy 取 max |
| UT-GRAPH-03 | 单元 | 101 个 character | truncated=true |
| UT-ANALYTICS-01 | 单元 | 2 个 session 共 100 字 / 10 分钟 | todaySpeedWpm=10 |
| UT-ANALYTICS-02 | 单元 | hourlyHeatmap | 24 长度,对应小时有值 |
| IT-ANALYTICS-01 | 集成 | chapter update → recordDelta → getSummary | todaySpeedWpm > 0 |
| IT-GRAPH-01 | 集成 | upsertRelationship → getData | 边可见 |
### 8.2 E2E
| 用例ID | 场景 | 预期 |
|--------|------|------|
| E2E-GRAPH-01 | 打开关系图谱 | 力导向图可见节点 |
| E2E-GRAPH-02 | 点击节点 | 侧栏显示角色名 |
| E2E-GRAPH-03 | 切换圆形布局 | 布局变化 |
| E2E-GRAPH-04 | 设定面板添加关系 | 图谱出现新边 |
| E2E-ANALYTICS-01 | 写作保存后打开驾驶舱 | `cockpit-speed-today` 有数值 |
| E2E-ANALYTICS-02 | 设置 POV 后刷新驾驶舱 | POV chart 含该角色 |
**E2E 图谱**:不依赖 AI / LM Studio。
---
## 9. 文件清单(实现阶段参考)
| 路径 | 说明 |
|------|------|
| `src/main/db/repositories/writing-session.repo.ts` | sessions CRUD |
| `src/main/services/writing-session.service.ts` | 状态机 |
| `src/main/services/writing-analytics.service.ts` | 分析聚合 |
| `src/main/services/character-graph.service.ts` | 图谱 DTO + 关系 CRUD |
| `src/main/ipc/handlers/graph.handler.ts` | 图谱 IPC |
| `src/main/ipc/handlers/analytics.handler.ts` | 分析 IPC |
| `src/renderer/components/graph/CharacterGraphModal.tsx` | Cytoscape UI |
| `src/renderer/components/setting/SettingRelationshipsEditor.tsx` | 关系 CRUD |
| `src/renderer/components/cockpit/CockpitAnalytics.tsx` | 分析卡片 |
| `src/renderer/components/cockpit/HourlyHeatmap.tsx` | 24 槽热力 |
| `tests/main/writing-session.test.ts` | UT-SESSION-* |
| `tests/main/character-graph.test.ts` | UT-GRAPH-* |
| `tests/main/writing-analytics.test.ts` | UT-ANALYTICS-* |
| `e2e/character-graph.spec.ts` | E2E-GRAPH-* |
| `e2e/writing-analytics.spec.ts` | E2E-ANALYTICS-* |
---
## 10. Definition of Done
- [ ] `writing_sessions` 表与 Session 状态机落地
- [ ] 角色关系 CRUD + Cytoscape 图谱模态
- [ ] 驾驶舱分析区(速度 / 时段 / POV / 卷 / 7 日)
- [ ] 章节 POV 选择 UI
- [ ] UT / IT / E2E 用例通过
- [ ] i18n zh-CN / en 键齐全
- [ ] `npm run build` + `npm run test` 全绿
- [ ] 版本号 **1.0.0**`package.json` / README / Settings 关于页)
---
## 11. Spec Coveragedesign/readme.md
| 设计章节 | 本 spec 覆盖 |
|----------|-------------|
| §7.1 力导向图、节点/边、布局 | ✅ MVP |
| §7.2-1 设定面板手动添加 | ✅ |
| §7.2-2 图谱拖拽连线 | ❌ v1.1 |
| §7.2-3 AI 关系建议 | ❌ v1.1 |
| §7.3 E2E-GRAPH-01~04 | ✅(04 改为设定面板添加) |
| §9 写作速度仪表盘 | ✅ 今日速度 + 时段热力 |
| §9 习惯追踪 | 部分(连更/番茄已有;session 时长留待后续展示) |
| §9 POV 字数分布 | ✅ |
| §9 角色弧线简报 | ❌ v1.1 |
| §20.3 角色弧线 | ❌ v1.1 |
@@ -0,0 +1,399 @@
# 笔临 v1.1 作家日更工具包 设计规格
**版本**1.0
**日期**2026-07-08
**范围**:§10 章节模板 + §11.1 书籍导入(txt/md/docx+ §20.2 投稿导出预设 + §20.6 全局灵感捕获增强
**前置**v1.0.0 已交付(`4adafa1`:P7 角色关系图谱 + P9 写作分析)
**参考**`design/readme.md` v1.1 §10 / §11.1 / §20.2 / §20.6、`design/ui_pc.html` `#templateModal` / `#importModal` / `#exportModal` / `#inspirationModal`
**目标版本**v1.1.0
---
## 1. 背景与目标
### 1.1 阶段定位
v1.0.0 已完成「看数据」能力(关系图谱、写作分析、POV)。`design/readme.md` §20.7 审查清单仍有多项 **有 UI 原型、无真实实现** 的作家刚需:
- 主页「导入书籍」为 `comingSoon`
- 章节模板 / 快速新建章仅有 `#templateModal` 静态原型
- 导出模态「复制到剪贴板(网文投稿格式)」无后端
- 灵感捕获 `Ctrl+Shift+I` 仅编辑器内可用,保存后会跳转侧栏打断心流
本阶段(v1.1.0)交付 **作家日更工具包**,形成「写得更快 → 发得更顺 → 灵感不漏」闭环。
### 1.2 已确认决策(Brainstorming 2026-07-08
| 维度 | 决策 |
|------|------|
| 方向 | **方案 B**:作家日更工具包 |
| MVP 边界 | **方案 A(完整四模块)**:模板 + txt/md/docx 导入 + 投稿预设 + 灵感增强 |
| 模板存储 | `GlobalSettings.chapterTemplates[]`;内置模板代码常量合并展示 |
| 模板解析 | **渲染进程** `chapter-template.ts` 替换变量后 `chapter.create` + `chapter.update` |
| docx 解析 | **mammoth**docx → HTML,保留 bold/italic |
| md 解析 | **marked**md → HTML |
| 导入分章 | 自动正则 / 双换行 / 自定义正则 三档 |
| 大文件 | >20MB 导入前二次确认;进度条 IPC 事件 |
| 投稿导出 | **单章**复制剪贴板 + 另存 `.txt`;内置「通用网文」预设 |
| 投稿预设存储 | `GlobalSettings.submissionPresets[]` |
| 灵感增强 | 快捷键全局可用;未开书时模态内选书;保存后仅 toast、不跳转 |
| 置顶 | 灵感模态最高 z-index;不新建独立 BrowserWindow |
### 1.3 验收标准
用户能完成:
1. 在章节列表点击「快速新建」,选择模板并输入标题,创建章节且编辑器自动填入模板正文(含变量替换)
2. 在设置页创建/编辑/删除自定义章节模板;内置模板只读展示
3. 主页「导入书籍」选择 `.txt` / `.md` / `.docx`,预览分章结果,确认后创建新书并导入全部章节
4. 导入 >20MB 文件前看到确认提示;导入过程显示进度
5. 编辑器内 `Ctrl+Shift+E` 打开导出模态,选择「通用网文」预设,**复制到剪贴板** 得到段首空两格 + `# 第N章 标题` 格式纯文本
6. 设置页自定义投稿预设(标题格式、段首缩进、段落间距),导出时应用
7. 在主页按 `Ctrl+Shift+I` 唤起灵感捕获,选择书籍后保存,仅 toast 确认、焦点不打断原界面
8. 编辑器内灵感捕获行为与 7 一致(不再自动跳转灵感面板)
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| `.novel` / `.novel-all` 项目包导入导出 | v1.2+ |
| PDF / Word 全书导出、分卷批量投稿复制 | v1.2+ |
| §11.2 完整导出矩阵(docx 目录、PDF 排版) | v1.2+ |
| §20.5 敏感词合规、预设内敏感词替换规则 | v1.2+ |
| §20.3 角色弧线简报 | v1.2+(图谱深化线) |
| §5.7 故事时间线、§5.8 连续阅读模式 | v1.2+ |
| 导入 `.novel` 包 | v1.2+ |
| 平台 API 直连投稿 | 永久 YAGNI |
| 模板变量 `{outlineItem}` 自动关联当前章大纲 | v1.1 留空字符串(无关联时);手动关联后续 |
---
## 2. 架构
### 2.1 进程模型
```
┌─────────────────────────────────────────────────────────────────────┐
│ 渲染进程 │
│ · TemplateQuickCreateModal / TemplateSettingsTab │
│ · ImportBookModal │
│ · ExportModal + SubmissionPresetSettingsTab │
│ · InspirationModal(增强:全局唤起、选书、无跳转) │
│ · chapter-template.ts / submission-formatter.ts(纯函数) │
└──────────────────────────────┬──────────────────────────────────────┘
│ IPC
┌────────────────────────────▼────────────────────────────────────────┐
│ 主进程 │
│ · ImportService(读文件、分章、创建书+卷+章批量写入) │
│ · ExportService(HTML→纯文本、应用投稿预设、写剪贴板/文件) │
│ · BookRegistryService(已有 create/open
│ · GlobalSettingsService(扩展 chapterTemplates / submissionPresets)│
│ · dialog.showOpenDialog / clipboard │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.2 模块边界
| 模块 | 职责 | 依赖 |
|------|------|------|
| `chapter-template.ts` | 变量替换、章节号计算 | `GlobalSettings`, 书籍 volumes/chapters/settings |
| `ImportService` | 解析文件、分章、创建书 | `BookRegistry`, `ChapterRepository`, mammoth, marked |
| `ExportService` | 单章格式化、剪贴板、存盘 | `ChapterRepository`, `GlobalSettings` |
| `SubmissionFormatter` | HTML 剥离、段首缩进、标题行 | 纯函数,主/渲染均可调用 |
| `TemplateQuickCreateModal` | 选模板 → IPC 创建章 | `chapter.create/update` |
| `ImportBookModal` | 选文件 → preview → execute | `import.*` |
| `ExportModal` | 选预设 → 复制/存盘 | `export.*` |
**原则**
- 导入/导出重 IO 与解析放主进程;模板变量替换可在渲染进程(减少 IPC 往返)
- 导入失败整事务回滚:书未创建或创建后写章失败则 `registry.delete(bookId)`
- 剪贴板写入仅主进程(Electron `clipboard` API
---
## 3. 数据模型
### 3.1 GlobalSettings 扩展
```typescript
export interface ChapterTemplate {
id: string
name: string
body: string
defaultStatus?: ChapterStatus
builtin?: boolean
}
export interface SubmissionPreset {
id: string
name: string
titleFormat: string // 默认 "# 第{chapterNumber}章 {chapterTitle}"
indentParagraphs: boolean // 段首两全角空格
includeAuthorsNote: boolean // 保留「作者的话」段(检测 —— 分隔)
paragraphGap: 'single' | 'double'
}
// GlobalSettings 追加:
chapterTemplates?: ChapterTemplate[]
submissionPresets?: SubmissionPreset[]
defaultSubmissionPresetId?: string
```
**内置模板**`src/shared/builtin-templates.ts`,不写入 JSON):
| id | name | body 摘要 |
|----|------|-----------|
| `builtin-generic` | 通用模板 | `## 第{chapterNumber}章 {chapterTitle}\n\n` |
| `builtin-webnovel` | 网文常用 | 正文 + `\n\n——\n作者的话:\n` |
**内置投稿预设**
| id | name | 规则 |
|----|------|------|
| `builtin-webnovel` | 通用网文 | `titleFormat: "# 第{chapterNumber}章 {chapterTitle}"`, `indentParagraphs: true`, `paragraphGap: 'double'` |
`GlobalSettingsService` 读取时合并内置项;用户不可删除内置项。
### 3.2 模板变量
| 变量 | 来源 |
|------|------|
| `{chapterNumber}` | 目标卷 `chapters.length + 1` |
| `{chapterTitle}` | 用户输入 |
| `{penName}` | `settings.penName` |
| `{date}` | `localDateString()` |
| `{volumeName}` | 当前卷 `name` |
| `{previousChapterTitle}` | 同卷上一章 `title`,首章 `""` |
| `{outlineItem}` | v1.1 固定 `""`(章节创建时无 outline 关联) |
| `{characterList}` | 当前卷内 `settings.type=character``chapterIds` 与卷内任一章有交集的 `name`,逗号连接 |
### 3.3 导入分章
```typescript
export type ImportSplitMode = 'auto' | 'paragraph' | 'regex'
export interface ImportChapterPreview {
title: string
contentHtml: string
wordCount: number
}
export interface ImportPreviewResult {
fileName: string
fileSizeBytes: number
chapters: ImportChapterPreview[]
warnings: string[]
}
export interface ImportExecuteParams {
filePath: string
bookName: string
category: string
splitMode: ImportSplitMode
customRegex?: string
}
```
**自动分章正则**(依次尝试,命中即用):
1. `/^#\s*第.+章[^\n]*/gm`Markdown 标题)
2. `/^第[一二三四五六七八九十百千零〇\d]+章[^\n]*/gm`(纯文本章标题行)
**paragraph 模式**:按 `\n\s*\n` 分段;标题取段首非空行,截断 50 字。
**首章前导内容**:合并入第一章 `warnings` 提示,不丢弃。
---
## 4. IPC
### 4.1 通道
```typescript
IMPORT_PICK_FILE: 'import:pickFile'
IMPORT_PREVIEW: 'import:preview'
IMPORT_EXECUTE: 'import:execute'
IMPORT_PROGRESS: 'import:progress' // main → renderer event
EXPORT_FORMAT_CHAPTER: 'export:formatChapter'
EXPORT_COPY_CLIPBOARD: 'export:copyClipboard'
EXPORT_SAVE_TXT: 'export:saveTxt'
```
### 4.2 载荷
```typescript
// import:pickFile → { canceled, filePath? }
// import:preview → { filePath, splitMode, customRegex? } → ImportPreviewResult
// import:execute → ImportExecuteParams → { bookId }
// import:progress → { phase: 'parsing'|'writing', current, total }
// export:formatChapter → { bookId, chapterId, presetId } → { text }
// export:copyClipboard → { text } → void
// export:saveTxt → { text, defaultName } → { savedPath? }
```
---
## 5. UI 规格
### 5.1 TemplateQuickCreateModal
- 入口:章节列表底部 `data-testid="chapter-quick-new"`
- 布局:参考 `#templateModal` 卡片网格 + 标题输入
- 流程:选模板 → 输入标题 → 创建 → `switchTarget` 到新章
### 5.2 设置页 — 章节模板 Tab
- `data-testid="settings-chapter-templates"`
- 列表:内置 + 自定义;自定义可编辑 body(textarea)、删除
- 新建:`setting-template-add`
### 5.3 ImportBookModal
- 入口:主页 `home.importBook`(替换 comingSoon
- `data-testid="import-book-modal"`
- 字段:文件路径(浏览)、书名、分类、分章规则下拉、自定义正则输入(regex 模式显示)
- 预览区:章数 + 前 5 章标题列表
- >20MB`import-large-file-confirm` 对话框
- 进度:`import-progress-bar`
### 5.4 ExportModal
- 入口:编辑器工具栏 `data-testid="export-chapter"` + 快捷键 `exportBook`
- `data-testid="export-modal"`
- 折叠区「投稿预设」:`export-preset-select`
- 操作:`export-copy-clipboard` / `export-save-txt`
- 预览:格式化后前 500 字纯文本
### 5.5 设置页 — 投稿预设 Tab
- `data-testid="settings-submission-presets"`
- 字段:名称、标题格式、段首缩进 checkbox、段落间距 select
### 5.6 InspirationModal 增强
- `App.tsx``captureInspiration` 移除 `view !== 'editor'` 限制
- 未开书:显示 `inspiration-book-select`(书籍列表下拉,默认 `lastOpenedAt` 最新)
- 保存后:删除 `switchTarget` + `setSidebarPanel('inspiration')`;仅 `showToast` + `setOpen(false)`
- 样式:`inspiration-modal--capture` 提升 z-index(高于 TopBar
---
## 6. 依赖
| 包 | 用途 |
|----|------|
| `mammoth` | docx → HTML |
| `marked` | md → HTML |
均在主进程 `ImportService` 使用;electron-vite 打包时 external 或 bundle 按现有惯例。
---
## 7. 测试
| 用例 ID | 层级 | 场景 | 预期 |
|---------|------|------|------|
| UT-TEMPL-01 | 单元 | 解析含 `{chapterNumber}` `{chapterTitle}` | 正确替换 |
| UT-TEMPL-02 | 单元 | `{volumeName}` 替换 | 卷名正确 |
| UT-TEMPL-03 | 单元 | `{characterList}` 卷内角色 | 逗号列表 |
| IT-TEMPL-01 | 集成 | 模板快速新建写入 DB | content 含替换后正文 |
| UT-IMPORT-01 | 单元 | txt 自动分章 3 章 | `chapters.length === 3` |
| UT-IMPORT-02 | 单元 | md 转 HTML | 含 `<p>` |
| UT-IMPORT-03 | 单元 | docx mammoth | 非空 HTML |
| IT-IMPORT-01 | 集成 | execute 创建书+章 | `registry.open` 章数匹配 |
| UT-EXPORT-01 | 单元 | 段首空两格 | 每段 `  ` 前缀 |
| UT-EXPORT-02 | 单元 | 标题格式 | `# 第1章 标题` |
| IT-EXPORT-01 | 集成 | copyClipboard | 剪贴板非空 |
| E2E-TEMPL-01 | E2E | 快速新建章 | 编辑器有模板内容 |
| E2E-IMPORT-01 | E2E | 导入 txt | 新书可见多章 |
| E2E-EXPORT-01 | E2E | 复制投稿格式 | 剪贴板含章标题 |
| E2E-INSP-01 | E2E | 主页 Ctrl+Shift+I 保存 | toast,不跳编辑器 |
---
## 8. i18n 键(节选)
```json
"template.title": "章节模板",
"template.quickNew": "模板新建章",
"template.builtin": "内置",
"template.custom": "自定义",
"import.title": "导入书籍",
"import.pickFile": "浏览文件",
"import.splitAuto": "自动识别章节",
"import.splitParagraph": "双换行分段",
"import.splitRegex": "自定义正则",
"import.largeFileConfirm": "文件较大({{size}}),导入可能需要 30 秒至 2 分钟,是否继续?",
"import.progress": "正在导入 {{current}}/{{total}}",
"export.title": "导出章节",
"export.submissionPresets": "投稿预设",
"export.copyClipboard": "复制到剪贴板",
"export.saveTxt": "另存为文本",
"export.copied": "已复制到剪贴板",
"submission.title": "投稿预设",
"submission.titleFormat": "标题格式",
"submission.indent": "段首空两格",
"inspiration.selectBook": "关联书籍",
"inspiration.saved": "灵感已保存"
```
---
## 9. 文件清单
| 文件 | 职责 |
|------|------|
| `src/shared/types.ts` | `ChapterTemplate`, `SubmissionPreset`, Import/Export DTO |
| `src/shared/builtin-templates.ts` | 内置模板与投稿预设 |
| `src/shared/ipc-channels.ts` | `import.*` / `export.*` |
| `src/main/services/import.service.ts` | 解析 + 分章 + 创建书 |
| `src/main/services/export.service.ts` | 格式化 + 剪贴板/存盘 |
| `src/main/lib/submission-formatter.ts` | HTML → 投稿纯文本 |
| `src/main/lib/chapter-splitter.ts` | 分章逻辑 |
| `src/main/ipc/handlers/import.handler.ts` | 导入 IPC |
| `src/main/ipc/handlers/export.handler.ts` | 导出 IPC |
| `src/renderer/lib/chapter-template.ts` | 模板变量替换 |
| `src/renderer/components/chapter/TemplateQuickCreateModal.tsx` | 快速新建 |
| `src/renderer/components/import/ImportBookModal.tsx` | 导入 UI |
| `src/renderer/components/export/ExportModal.tsx` | 导出 UI |
| `src/renderer/components/settings/TemplateSettingsTab.tsx` | 模板管理 |
| `src/renderer/components/settings/SubmissionPresetTab.tsx` | 投稿预设管理 |
| `src/renderer/components/inspiration/InspirationModal.tsx` | 增强 |
| `src/renderer/components/home/HomePage.tsx` | 接入导入 |
| `src/renderer/components/layout/EditorLayout.tsx` | 导出入口 |
| `tests/main/chapter-template.test.ts` | UT-TEMPL-* |
| `tests/main/chapter-splitter.test.ts` | UT-IMPORT-01 |
| `tests/main/submission-formatter.test.ts` | UT-EXPORT-* |
| `tests/main/import.service.test.ts` | IT-IMPORT-01 |
| `e2e/chapter-template.spec.ts` | E2E-TEMPL-01 |
| `e2e/import-book.spec.ts` | E2E-IMPORT-01 |
| `e2e/export-submission.spec.ts` | E2E-EXPORT-01 |
| `e2e/inspiration-capture.spec.ts` | E2E-INSP-01 |
---
## 10. 实现顺序建议
1. 类型 + 内置常量 + `chapter-template.ts` + 快速新建 UI
2. `SubmissionFormatter` + ExportService + ExportModal + 设置 Tab
3. InspirationModal 增强(独立、改动小)
4. ImportService + ImportBookModal(依赖 mammoth/marked,放最后)
**目标版本号**:实现完成后 `package.json` / README / 关于页 → **v1.1.0**
---
## 11. Spec 自检
| 检查项 | 状态 |
|--------|------|
| 无 TBD/TODO 占位 | ✅ |
| 与方案 A 四模块一致 | ✅ |
| `.novel` 明确排除 | ✅ |
| 单章投稿导出范围明确 | ✅ |
| IPC/数据模型无矛盾 | ✅ |
| 测试用例覆盖四模块 | ✅ |
@@ -0,0 +1,482 @@
# 笔临 P4.1 自动写作 + 向导式写作设计规格
**版本**1.0
**日期**2026-07-09
**范围**P4.1 — 实装 §6.5.2 自动写作 + §6.5.3 向导式写作(统一写作流引擎)
**前置**:P4 交互式写作已交付(v0.4.0,`a8e0ba9`
**参考**`design/readme.md` v1.1 §6.5.2–§6.5.3、`design/ui_pc.html` AI 面板模式 Tab
**目标版本**v0.5.0
---
## 1. 背景与目标
### 1.1 阶段定位
v0.4.0 已交付交互式写作(§6.5.1);「自动写作」「向导」Tab 仍为 `feature.comingSoon` 占位。本阶段在同一 AI 会话体系下实装:
- **自动写作**:用户设定章节目标与字数范围 → AI 规划场景顺序 → 连续流式生成 → 达标或手动停止 → 成章落盘
- **向导式写作**:5 步引导收集参数 → AI 策略预览 → 自动生成 → **handoff 至交互面板**做场景微调
两模式共享 P4 的场景存储、命名暂停、流式推送与成章逻辑,并通过 **handoff** 与交互写作衔接。
### 1.2 已确认决策(Brainstorming 2026-07-09
| 维度 | 决策 |
|------|------|
| 范围 | **P4.1**:§6.5.2 + §6.5.3 同时交付;§6.3 / §6.6 / §6.7 仍排除 |
| 架构 | **方案 1**:统一写作流引擎,扩展 P4 表与 Service,不重写 |
| 向导第 4 步 | **选项 A**:复用 `ContextEditorModal` 确认上下文(含大纲/设定中的伏笔线索),不引入知识库表 |
| 向导风格 | 节奏三选一(舒缓/紧张/混合)+ 可选自由文本 `styleNote`**不做**文风滑杆 |
| 向导生成后 | 自动切入 **InteractivePanel**toast「已进入交互微调」) |
| 自动暂停 | 可「继续自动」或「转入交互微调」(handoff,保留 scenes |
| 成章落盘 | 活跃卷新建章节;`origin=auto``origin=wizard`;合并全部场景 HTML |
| AI 快照 | 成章时创建 `type='ai'` 快照(标签「自动写作成章」/「向导写作成章」;**顺带补齐** P4 interactive 成章快照) |
| 状态持久化 | **schema v5**`interactive_flows``flow_mode` / `mode_config_json``interactive_scenes` 不变 |
| 启动门槛 | **硬拦截**:复用 P4 `checkInteractiveGate` 逻辑(无正文且无大纲/设定时 Tab 禁用) |
| 同会话 flow | 每 `session_id` 仅一个 `status=in_progress` flow;按 `flow_mode` 区分模式 |
| AI 后端 | **LM Studio**(延续 P3/P4),`http://127.0.0.1:1234``gemma-4-e4b-it` |
| 测试原则 | **禁止 Mock**;集成 / E2E 打真实 LM Studio |
| 测试超时 | Vitest AI 用例 **240s**Playwright 全局 **180s**auto/wizard E2E **300s** |
### 1.3 验收标准
用户能完成:
1. 在有足够上下文的书籍中进入「自动写作」或「向导」Tab(门槛不满足时 Tab 禁用并 tooltip 提示)
2. **自动写作**:设定本章目标(大纲选取和/或手写)+ 目标字数范围 → AI 返回场景规划 → 连续流式生成场景(支持命名暂停)→ 字数达范围或点击「结束本章」→ 新建章节 `origin=auto`,编辑器自动打开
3. **自动写作干预**:生成中点击「暂停」→ flow 进入 `paused`;可「继续自动」或「转入交互微调」→ 交互面板可见已生成场景并可微调/续写
4. **向导写作**:依次完成 5 步(目标 → 节奏/风格 → 视角人物 → 上下文确认 → 策略预览)→ 确认后开始生成 → 生成完成后自动显示交互面板,可微调场景
5. 向导成章后章节 `origin=wizard`;自动成章 `origin=auto`
6. 关闭应用并重启后,未完成的 auto/wizard flow 恢复到上次步骤
7. 切换回「对话」模式时同会话聊天历史仍可见;再次进入 auto/wizard/interactive 时 **恢复已有 flow**(不无条件 reset
8. 所有成章路径均创建 `type='ai'` 快照
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| §6.3 文风学习与风格滑杆 | P4.2+ |
| §6.6 大纲生成章节骨架 | P4.2+(可独立 spec |
| §6.7 知识自动抽取与审核工作流 | P4.2+ |
| §6.4 Skill / MCP | P4+ |
| 知识库伏笔追踪 UI | P4.2+ / P5 |
| 导入导出、驾驶舱等 | P5+ |
| 命名结果自动写入设定库 | 本阶段仅写入正文 |
---
## 2. 架构
### 2.1 进程模型
```
┌──────────────────────────────────────────────────────────────────┐
│ 主进程 │
│ · schema v5interactive_flows.flow_mode / mode_config_json │
│ · chapters.originmanual | interactive | auto | wizard │
│ · SceneGenerationCore(从 P4 抽取:流式、命名暂停、HTML 规范化) │
│ · AutoWritingService(目标→规划→连续生成→暂停/成章/handoff) │
│ · WizardWritingService5 步→策略预览→委托 Auto→handoff
│ · InteractiveWritingServiceP4 不变;handoff 目标) │
│ · checkWritingGate(复用 interactive-gate 逻辑,三模式共用) │
│ · AiClientService / AiContextBuilder(复用) │
│ · IPCauto:* / wizard:* + interactive:streamChunk(增 flowMode
└────────────────────────────┬─────────────────────────────────────┘
│ contextBridge
┌────────────────────────────▼─────────────────────────────────────┐
│ 渲染进程 │
│ · AiPanelwritingMode=auto|wizard 时挂载 AutoPanel/WizardPanel │
│ · handoff 后 writingMode 保持或切 interactive,显示 InteractivePanel│
│ · useAutoStore / useWizardStore + useInteractiveStore │
│ · ContextEditorModal(向导步骤 4 + 自动可选编辑上下文) │
└────────────────────────────────────────────────────────────────────┘
```
**约束**(延续 P0P4):
- 渲染进程禁止直接 `fetch` AI API
- 流式通过 `webContents.send` 推送,不阻塞 IPC invoke
- 写作 flow 绑定 `ai_session_id`;关键轮次可写入 `ai_messages` 作审计(可选,非阻塞)
### 2.2 Handoff 规则
`auto``wizard` 转入交互微调时:
1. `interactive_scenes` 列表 **不变**
2. `flow_mode` 更新为 `'interactive'`
3. `step` 设为:
-`scene_draft_html` 非空 → `'scene_review'`
- 否则若已有 scenes → `'plot_suggest'`(进入下一场景循环)
- 否则 → `'context_confirm'`
4. `mode_config_json` 保留(只读参考),不删除
5. 渲染进程 `setWritingMode('interactive')` + toast
自动模式 **暂停**(非 handoff)时:`step='paused'``flow_mode` 仍为 `'auto'`
### 2.3 步骤机
#### 2.3.1 interactiveP4 不变)
| Step | 名称 |
|------|------|
| `context_confirm` | 上下文确认 |
| `plot_suggest` | 剧情走向 |
| `plot_select` | 用户选择 |
| `scene_generate` | 场景生成 |
| `naming_pause` | 命名暂停 |
| `scene_review` | 场景确认/微调 |
| `completed` | 成章完成 |
#### 2.3.2 auto
| Step | 名称 | AI |
|------|------|-----|
| `goal_setup` | 目标/字数/大纲 | 无 |
| `scene_plan` | 等待用户触发规划 | JSON 场景列表 |
| `auto_generate` | 连续生成(含 naming_pause 子状态) | 流式 |
| `paused` | 用户暂停 | 无 |
| `completed` | 成章完成 | 无 |
生成循环:按 `scenePlan` 顺序逐场景生成;每场景完成后追加 `interactive_scenes`;累计字数(stripHtml 计)≥ `wordMin` 且 AI 判定目标达成或用户点击「结束本章」时停止。
#### 2.3.3 wizard
| Step | 名称 | AI |
|------|------|-----|
| `wizard_goal` | 步骤 1:本章目标 | 无 |
| `wizard_rhythm` | 步骤 2:节奏 + styleNote | 无 |
| `wizard_pov` | 步骤 3:视角人物(设定 character | 无 |
| `wizard_context` | 步骤 4ContextEditorModal | 无 |
| `wizard_preview` | 步骤 5:策略预览 | 非流式摘要 |
| `wizard_generating` | 委托 Auto 引擎生成 | 流式 |
| → handoff | 见 §2.2 | — |
---
## 3. Schema v5
### 3.1 DDL(每本书 `.sqlite`
```sql
-- schema-v5.sql
ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive';
ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}';
-- chapters.origin 在应用层扩展为 manual | interactive | auto | wizard
-- v4 已有 origin 列,v5 迁移不 ALTER(字符串枚举扩展)
```
`migrate.ts``CURRENT_VERSION=5`v4→v5 应用上述 ALTER。
### 3.2 mode_config_json
```typescript
export type WritingFlowMode = 'interactive' | 'auto' | 'wizard'
export type AutoRhythm = 'relaxed' | 'tense' | 'mixed'
export interface ScenePlanItem {
label: string
summary: string
}
export interface AutoWritingConfig {
chapterGoal: string
outlineItemIds?: string[]
wordMin: number // 默认 2000
wordMax: number // 默认 4000
scenePlan?: ScenePlanItem[]
currentPlanIndex?: number // auto_generate 进度
}
export interface WizardWritingConfig extends AutoWritingConfig {
rhythm: AutoRhythm
styleNote?: string
povSettingId?: string
strategyPreview?: string // HTML
contextBinding?: AiContextBinding // 步骤 4 确认结果
}
```
### 3.3 共享类型扩展
```typescript
export type ChapterOrigin = 'manual' | 'interactive' | 'auto' | 'wizard'
export type AutoStep =
| 'goal_setup'
| 'scene_plan'
| 'auto_generate'
| 'paused'
| 'completed'
export type WizardStep =
| 'wizard_goal'
| 'wizard_rhythm'
| 'wizard_pov'
| 'wizard_context'
| 'wizard_preview'
| 'wizard_generating'
// InteractiveFlow 扩展:
// flowMode: WritingFlowMode
// modeConfig: AutoWritingConfig | WizardWritingConfig | Record<string, never>
// step: InteractiveStep | AutoStep | WizardStep
```
---
## 4. 服务层
### 4.1 SceneGenerationCore
`InteractiveWritingService` 抽取(或内部组合):
- `generateSceneStream(flowId, planItem, config, onChunk, signal)`
- `resolveNaming(flowId, chosenName, ...)`
- `normalizeSceneHtml(raw: string): string`
- 命名暂停检测:复用 `tryParseNamingFromBuffer`
### 4.2 AutoWritingService
| 方法 | 说明 |
|------|------|
| `start(sessionId)` | 创建 `flow_mode=auto`, `step=goal_setup` |
| `getFlow(sessionId)` | 按 session 查 in_progress |
| `setGoal(flowId, config: Partial<AutoWritingConfig>)` | 写入 mode_config`step=scene_plan` |
| `planScenes(flowId)` | AI JSON → `scenePlan``step=auto_generate` |
| `generateNext(flowId, onChunk, signal)` | 生成当前 plan 项;处理 naming;追加 scene;更新 index |
| `pause(flowId)` | `step=paused`abort 进行中的生成 |
| `resume(flowId)` | `step=auto_generate`,继续下一项 |
| `handoffToInteractive(flowId)` | §2.2 |
| `finishChapter(flowId, volumeId, title?)` | 合并 scenes → 新建章 `origin=auto` + AI 快照 |
| `shouldStop(flowId)` | 字数 ≥ wordMin 且(最后一项 plan 完成 或 AI 返回 stop 标记) |
### 4.3 WizardWritingService
| 方法 | 说明 |
|------|------|
| `start(sessionId)` | `flow_mode=wizard`, `step=wizard_goal` |
| `setGoal` / `setRhythm` / `setPov` | 逐步写入 mode_config,推进 step |
| `confirmContext(flowId, binding)` | 步骤 4`step=wizard_preview` |
| `buildPreview(flowId)` | AI 生成 strategyPreview HTML |
| `startGenerate(flowId, onChunk, signal)` | `step=wizard_generating`;构建 AutoWritingConfig 并调用 AutoWritingService 全量生成 |
| `handoffToInteractive(flowId)` | 生成完成后自动调用 §2.2 |
### 4.4 成章与快照(三模式统一)
`finishChapter` 路径均:
1. 合并 `interactive_scenes.content_html`
2. `chapters.create(..., origin)`
3. `snapshots.create(chapterId, merged, type='ai', label=...)`
4. `flow.status=completed`, `step=completed`
---
## 5. IPC
### 5.1 通道(`ipc-channels.ts` 追加)
```typescript
// auto
AUTO_CHECK_GATE: 'auto:checkGate' // 实际复用 gate service
AUTO_GET_FLOW: 'auto:getFlow'
AUTO_START: 'auto:start'
AUTO_SET_GOAL: 'auto:setGoal'
AUTO_PLAN_SCENES: 'auto:planScenes'
AUTO_GENERATE: 'auto:generate' // 生成下一场景或批量至 pause/stop
AUTO_PAUSE: 'auto:pause'
AUTO_RESUME: 'auto:resume'
AUTO_HANDOFF_INTERACTIVE: 'auto:handoffInteractive'
AUTO_FINISH_CHAPTER: 'auto:finishChapter'
AUTO_GET_SCENE_DRAFT: 'auto:getSceneDraft'
AUTO_ABORT: 'auto:abort'
// wizard
WIZARD_GET_FLOW: 'wizard:getFlow'
WIZARD_START: 'wizard:start'
WIZARD_SET_GOAL: 'wizard:setGoal'
WIZARD_SET_RHYTHM: 'wizard:setRhythm'
WIZARD_SET_POV: 'wizard:setPov'
WIZARD_CONFIRM_CONTEXT: 'wizard:confirmContext'
WIZARD_BUILD_PREVIEW: 'wizard:buildPreview'
WIZARD_START_GENERATE: 'wizard:startGenerate'
WIZARD_HANDOFF_INTERACTIVE: 'wizard:handoffInteractive'
WIZARD_ABORT: 'wizard:abort'
```
流式事件 `interactive:streamChunk` payload 扩展:
```typescript
{ flowId, flowMode: WritingFlowMode, phase: 'scene' | 'naming' | 'refine', delta: string, done: boolean }
```
### 5.2 Preload / electron-api.d.ts
暴露 `window.electronAPI.auto.*``window.electronAPI.wizard.*` 命名空间。
---
## 6. Prompt 设计
### 6.1 场景规划(auto planScenes
输入:章节目标、大纲条目摘要、已有上下文摘要、wordMin/wordMax。
输出 JSON
```json
{
"scenes": [
{ "label": "场景1", "summary": "…≥80字…" },
{ "label": "场景2", "summary": "…" }
],
"estimatedWords": 3200
}
```
约束:3–6 个场景;总预估字数在 [wordMin, wordMax] 内。
### 6.2 自动场景生成
输入:当前 plan item、累计 scenes 摘要、AutoWritingConfig。
输出:HTML 段落(600–1500 字/场景);命名暂停格式同 P4。
### 6.3 向导策略预览(wizard buildPreview
输入:WizardWritingConfig 全量 + AiContextBuilder 摘要。
输出 HTML 一段,模板语义:
> 我将以 **[视角人物]** 视角,用 **[节奏]** 节奏,写一段达成 **[目标]** 的剧情。风格说明:…。重点上下文:…。
---
## 7. 渲染进程 UI
### 7.1 AiPanel
- 移除 `auto` / `wizard``comingSoon`
- `handleModeClick('auto'|'wizard')`checkGate → loadFlow → 无 flow 则 start**不**无条件 reset 已有 flow
- `writingMode === 'auto'``<AutoPanel />`
- `writingMode === 'wizard'``<WizardPanel />`handoff 后 `writingMode='interactive'``<InteractivePanel />`
### 7.2 AutoPanel
| data-testid | 元素 |
|-------------|------|
| `auto-panel` | 根容器 |
| `auto-goal-input` | 本章目标 |
| `auto-outline-picker` | 大纲多选 |
| `auto-word-min` / `auto-word-max` | 字数范围 |
| `auto-plan-scenes` | 触发规划 |
| `auto-start-generate` | 开始/继续生成 |
| `auto-scene-preview` | 流式预览 |
| `auto-pause` | 暂停 |
| `auto-handoff-interactive` | 转入交互 |
| `auto-finish-chapter` | 结束本章 |
步骤条:`目标 → 规划 → 生成 → 完成`
### 7.3 WizardPanel
| data-testid | 元素 |
|-------------|------|
| `wizard-panel` | 根容器 |
| `wizard-step-indicator` | 15 步指示 |
| `wizard-goal-input` | 步骤 1 |
| `wizard-rhythm-*` | 步骤 2 节奏选项 |
| `wizard-style-note` | 步骤 2 风格说明 |
| `wizard-pov-select` | 步骤 3 |
| `wizard-context-edit` | 步骤 4 打开 ContextEditorModal |
| `wizard-preview` | 步骤 5 策略摘要 |
| `wizard-start-generate` | 开始生成 |
生成完成后隐藏 WizardPanel,显示 InteractivePanel(同 session)。
### 7.4 i18n 键(zh-CN / en
`auto.gateBlocked``auto.gateTooltip``auto.step.*``auto.pause``auto.resume``auto.handoffInteractive``auto.finishChapter``auto.chapterCreated`
`wizard.step.*``wizard.rhythm.*``wizard.previewTitle``wizard.startGenerate``wizard.handoffToast`
及步骤条、placeholder、错误 toast 文案。
---
## 8. 错误处理
| 场景 | 行为 |
|------|------|
| planScenes JSON 解析失败 | 重试一次(追加「仅返回 JSON」);仍失败 → toast + 保持 `scene_plan` |
| 生成中用户 pause | AbortController abort`step=paused`;已生成 scenes 保留 |
| 无活跃卷 finishChapter | IPC 错误码;toast「请先创建分卷」 |
| 离线 / 非 lmstudio 且无网络 | 同 P3/P4disabled + offline banner |
| wizard 某步必填缺失 | 下一步按钮 disabled |
| handoff 时无 scenes 且无 draft | 仍 handoff 到 `context_confirm`toast 提示可手动续写 |
---
## 9. 测试
### 9.1 单元 / 集成(Vitest,真实 LM Studio
| ID | 场景 | 超时 |
|----|------|------|
| UT-MIG-05 | v4 DB 迁移到 v5flow_mode 列存在 | — |
| IT-AUTO-01 | planScenes 返回 ≥2 个 scene | 240s |
| IT-AUTO-02 | generateNext 写入非空 scene | 240s |
| IT-AUTO-03 | pause 后 step=pausedresume 继续 | 240s |
| IT-AUTO-04 | handoff 后 flow_mode=interactive | — |
| IT-WIZ-01 | buildPreview 非空 HTML | 240s |
| IT-WIZ-02 | startGenerate 完成后 scenes.length ≥ 1 | 300s |
| IT-FIN-01 | auto finishChapter → origin=auto + ai 快照 | — |
| IT-FIN-02 | interactive finishChapter → ai 快照(P4 补齐) | — |
### 9.2 E2EPlaywright300s
| ID | 场景 |
|----|------|
| E2E-AUTO-01 | 自动:设目标 → 规划 → 生成 → 成章 → 编辑器非空 |
| E2E-AUTO-02 | 自动:暂停 → 转交互 → accept scene 可见 |
| E2E-WIZ-01 | 向导 5 步 → 生成 → InteractivePanel 可见 |
| E2E-AUTO-03 | 空书 auto tab disabled(门槛,无 AI |
---
## 10. 文件清单(实现参考)
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v5.sql` | v5 DDL |
| `src/main/db/migrate.ts` | v4→v5 |
| `src/main/db/repositories/interactive.repo.ts` | flow_mode / mode_config CRUD |
| `src/main/services/scene-generation-core.service.ts` | 共享生成逻辑 |
| `src/main/services/auto-writing.service.ts` | 自动写作步骤机 |
| `src/main/services/wizard-writing.service.ts` | 向导步骤机 |
| `src/main/services/auto-prompt-builder.service.ts` | plan / scene / preview prompt |
| `src/main/ipc/handlers/auto.handler.ts` | auto IPC |
| `src/main/ipc/handlers/wizard.handler.ts` | wizard IPC |
| `src/renderer/components/ai/AutoPanel.tsx` | 自动 UI |
| `src/renderer/components/ai/WizardPanel.tsx` | 向导 UI |
| `src/renderer/stores/useAutoStore.ts` | auto 状态 |
| `src/renderer/stores/useWizardStore.ts` | wizard 状态 |
| `tests/main/migrate-v5.test.ts` | 迁移 |
| `tests/main/auto-*.test.ts` | 集成 |
| `tests/main/wizard-*.test.ts` | 集成 |
| `e2e/auto-writing.spec.ts` | E2E |
| `e2e/wizard-writing.spec.ts` | E2E |
---
## 11. Spec 自检
| 检查项 | 结果 |
|--------|------|
| Placeholder 扫描 | 无 TBD/TODO;排除项已列 §1.4 |
| 内部一致性 | handoff / 步骤机 / IPC / UI testid 对齐 |
| 范围 | 单阶段可交付 v0.5.0;§6.7 已通过 ContextEditor 降级 |
| 歧义 | 成章 origin 三分;同 session 单 in_progress flow;恢复 flow 不 reset |
---
*本规格为笔临 P4.1 子项目。知识抽取、大纲骨架等各自在后续 spec 中增量修订。*
+13 -6
View File
@@ -25,15 +25,22 @@ async function createAndOpenBook(page: Page, name = 'AI测试书'): Promise<void
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function openGlobalSettings(page: Page): Promise<void> {
const topSettings = page.locator('#topbar button.top-btn').filter({ hasText: '⚙' })
if (await topSettings.isVisible()) {
await topSettings.click()
} else {
await page.getByRole('button', { name: /系统设置/ }).click()
}
await page.getByTestId('topbar-settings').click()
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
}
+139
View File
@@ -0,0 +1,139 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: {
...process.env,
BILIN_E2E: '1',
BILIN_E2E_USER_DATA: userDataDir
}
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
const items = page.locator('.chapter-item')
if ((await items.count()) === 0) {
await page.getByRole('button', { name: '+ 新章' }).click()
} else {
await items.first().click()
}
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
async function addApprovedKnowledge(page: Page, title: string): Promise<void> {
await page.locator('[data-testid="panel-tab-knowledge"]').click()
await page.locator('[data-testid="knowledge-new"]').click()
await page.locator('[data-testid="knowledge-title-input"]').fill(title)
await page.locator('[data-testid="knowledge-save"]').click()
await page.locator('[data-testid="knowledge-tab-pending"]').click()
await page.locator('[data-testid^="knowledge-approve-"]').first().click()
}
test.describe('AI knowledge context P6', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-ctx-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-CTX-01: context editor knowledge tab shows suggestions', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '上下文书')
await ensureChapterEditor(page)
await addApprovedKnowledge(page, '测灵石异常')
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await page.getByTestId('ai-context-preview').click()
await expect(page.getByTestId('context-editor-modal')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('context-tab-knowledge').click()
await expect(page.getByTestId('context-knowledge-suggest')).toBeVisible({ timeout: 10_000 })
await app.close()
})
test('E2E-CTX-02: interactive context_confirm shows knowledge suggest', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '交互知识书')
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('林远站在演武场中央,众人目光汇聚。')
await page.keyboard.press('Control+s')
await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 })
await addApprovedKnowledge(page, '测灵石异常')
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await expect(page.getByTestId('ai-mode-tab-interactive')).toBeEnabled({ timeout: 10_000 })
await page.getByTestId('ai-mode-tab-interactive').click()
await expect(page.getByTestId('interactive-knowledge-suggest')).toBeVisible({ timeout: 15_000 })
await app.close()
})
test('E2E-CTX-03: chat with knowledge context gets AI reply', async () => {
test.setTimeout(300_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '对话知识书')
await ensureChapterEditor(page)
await addApprovedKnowledge(page, '测灵石异常')
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await page.getByTestId('ai-context-preview').click()
await page.getByTestId('context-tab-knowledge').click()
await page.getByTestId('context-save').click()
await page.getByTestId('ai-chat-input').fill('根据知识库,本章可能发生什么?')
await page.getByTestId('ai-send').click()
await expect(page.getByTestId('chat-message-assistant').last()).not.toBeEmpty({ timeout: 240_000 })
await app.close()
})
})
+154
View File
@@ -0,0 +1,154 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir }
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
async function openBookWithContent(page: Page): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('自动测试书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('林远站在演武场中央,众人目光汇聚。')
await page.keyboard.press('Control+s')
await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 })
}
async function openAutoPanel(page: Page): Promise<void> {
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await expect(page.getByTestId('ai-mode-tab-auto')).toBeEnabled({ timeout: 10_000 })
await page.getByTestId('ai-mode-tab-auto').click()
await expect(page.getByTestId('auto-panel')).toBeVisible({ timeout: 10_000 })
}
async function planAutoScenes(page: Page): Promise<void> {
await page.getByTestId('auto-goal-input').fill('林远通过入门考核,展现天赋震惊众人')
const wordInputs = page.locator('.auto-word-range input')
await wordInputs.nth(0).fill('180')
await wordInputs.nth(1).fill('220')
await page.getByTestId('auto-plan-scenes').click()
await expect(page.getByTestId('auto-start-generate')).toBeVisible({ timeout: 360_000 })
}
async function generateOneAutoScene(page: Page): Promise<void> {
await page.getByTestId('auto-start-generate').click()
const naming0 = page.getByTestId('naming-option-0')
const finishBtn = page.getByTestId('auto-finish-chapter')
await expect(naming0.or(finishBtn).or(page.getByTestId('auto-start-generate'))).toBeVisible({
timeout: 240_000
})
if (await naming0.isVisible()) {
await naming0.click()
await expect(finishBtn.or(page.getByTestId('auto-start-generate'))).toBeVisible({
timeout: 240_000
})
}
}
test.describe('Auto writing', () => {
test.describe.configure({ retries: 1 })
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-auto-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-AUTO-03: auto tab disabled on empty book', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('空书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByTestId('panel-tab-ai').click()
await expect(page.getByTestId('ai-mode-tab-auto')).toBeDisabled({ timeout: 10_000 })
await app.close()
})
test('E2E-AUTO-01: auto flow creates chapter', async () => {
test.setTimeout(600_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await openBookWithContent(page)
await openAutoPanel(page)
await planAutoScenes(page)
await generateOneAutoScene(page)
await expect(page.getByTestId('auto-finish-chapter')).toBeVisible({ timeout: 30_000 })
await page.getByTestId('auto-finish-chapter').click()
await expect(page.locator('.ProseMirror')).not.toBeEmpty({ timeout: 30_000 })
await app.close()
})
test('E2E-AUTO-02: pause and handoff to interactive', async () => {
test.setTimeout(600_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await openBookWithContent(page)
await openAutoPanel(page)
await planAutoScenes(page)
await page.getByTestId('auto-start-generate').click()
await expect(page.getByTestId('auto-pause')).toBeEnabled({ timeout: 15_000 })
await page.waitForTimeout(2_000)
await page.getByTestId('auto-pause').click()
await expect(page.getByTestId('auto-handoff-interactive')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('auto-handoff-interactive').click()
await expect(page.getByTestId('interactive-panel')).toBeVisible({ timeout: 15_000 })
const acceptBtn = page.getByTestId('interactive-accept-scene')
const plotA = page.getByTestId('plot-option-A')
const startBtn = page.getByTestId('interactive-start')
await expect(acceptBtn.or(plotA).or(startBtn)).toBeVisible({ timeout: 120_000 })
await app.close()
})
})
+43
View File
@@ -0,0 +1,43 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, createBookAndOpenEditor, openBookSettings } from './helpers'
test.describe('Book settings', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-book-settings-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-BOOK-01: rename book, always cockpit, delete book', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '原始书名')
await openBookSettings(page)
await page.getByTestId('book-settings-name').fill('改后书名')
await page.getByTestId('book-settings-always-cockpit').check()
await page.getByTestId('book-settings-save').click()
await expect(page.locator('.sidebar-header')).toContainText('改后书名', { timeout: 10_000 })
page.once('dialog', (dialog) => dialog.accept())
await page.getByTestId('book-settings-delete').click()
await expect(page.locator('#home-page')).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('改后书名')).toHaveCount(0)
await app.close()
})
})
+55
View File
@@ -0,0 +1,55 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, createBookAndOpenEditor, ensureChapterEditor } from './helpers'
test.describe('Chapter management', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-chapter-mgmt-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-CHAP-01: double-click rename and delete chapter', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '章节管理书')
await ensureChapterEditor(page)
await page.getByTestId('sidebar-tab-chapters').click()
const firstChapter = page.locator('.chapter-item').first()
await expect(firstChapter).toBeVisible({ timeout: 10_000 })
const chapterTestId = await firstChapter.getAttribute('data-testid')
const chapterId = chapterTestId?.replace('chapter-item-', '') ?? ''
expect(chapterId).toBeTruthy()
await firstChapter.dblclick()
const renameInput = page.getByTestId(`chapter-rename-${chapterId}`)
await expect(renameInput).toBeVisible({ timeout: 5000 })
await renameInput.fill('重命名章节')
await renameInput.blur()
await expect(firstChapter).toContainText('重命名章节', { timeout: 10_000 })
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.chapter-item')).toHaveCount(2, { timeout: 10_000 })
page.once('dialog', (dialog) => dialog.accept())
await firstChapter.hover()
await page.getByTestId(`chapter-delete-${chapterId}`).click()
await expect(page.locator('.chapter-item')).toHaveCount(1, { timeout: 10_000 })
await app.close()
})
})
+75
View File
@@ -0,0 +1,75 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, createBookAndOpenEditor, ensureChapterEditor, addCharacterSetting, dismissCockpitIfOpen } from './helpers'
test.describe('Chapter template', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-templ-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-TEMPL-01: quick create from template fills editor', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '模板测试书')
await page.getByTestId('chapter-quick-new').click()
await expect(page.getByTestId('template-quick-create-modal')).toBeVisible()
await page.getByTestId('template-chapter-title').fill('觉醒之路')
await page.getByTestId('template-create-btn').click()
await expect(page.getByTestId('template-quick-create-modal')).toBeHidden({ timeout: 10_000 })
const editor = page.locator('.ProseMirror')
await expect(editor).toBeVisible({ timeout: 10_000 })
await expect(editor).toContainText('觉醒之路', { timeout: 10_000 })
await expect(editor).toContainText('第')
await app.close()
})
test('E2E-TEMPL-05: characterList from settings in quick create', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '角色模板书')
await addCharacterSetting(page, '林远')
await addCharacterSetting(page, '苏晴')
await page.locator('#topbar .top-btn').filter({ hasText: '⚙' }).click()
await page.getByTestId('settings-nav-templates').click()
await page.getByTestId('setting-template-add').click()
const customCard = page.locator('.template-settings-item').filter({ hasText: '新模板' }).last()
await customCard.getByRole('button', { name: '编辑' }).click()
await page.locator('.template-edit-panel textarea').fill('出场角色:{characterList}\n\n正文')
await page.locator('#topbar .tab').filter({ hasText: '角色模板书' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 10_000 })
await dismissCockpitIfOpen(page)
await page.getByTestId('sidebar-tab-chapters').click()
await page.getByTestId('chapter-quick-new').click()
await expect(page.getByTestId('template-quick-create-modal')).toBeVisible()
await page.locator('.template-card').filter({ hasText: '新模板' }).click()
await page.getByTestId('template-chapter-title').fill('角色章')
await page.getByTestId('template-create-btn').click()
const editor = page.locator('.ProseMirror')
await expect(editor).toContainText('林远', { timeout: 10_000 })
await expect(editor).toContainText('苏晴')
await app.close()
})
})
+137
View File
@@ -0,0 +1,137 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: {
...process.env,
BILIN_E2E: '1',
BILIN_E2E_USER_DATA: userDataDir
}
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 })
await page.getByRole('button', { name: '跳过' }).click()
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
async function createCharacterSetting(page: Page, name: string): Promise<void> {
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId('setting-new').click()
await page.getByTestId('setting-name-input').fill(name)
await page.locator('.dialog-content select').selectOption('character')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.getByTestId(/setting-item-/).filter({ hasText: name })).toBeVisible({
timeout: 10_000
})
}
test.describe('Character graph P7', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-graph-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-GRAPH-01: relationship editor and graph modal', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '图谱测试书')
await createCharacterSetting(page, '角色甲')
await createCharacterSetting(page, '角色乙')
await page.getByTestId(/setting-item-/).filter({ hasText: '角色甲' }).click()
await expect(page.getByTestId('setting-relationships')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('setting-relationship-add').click()
await expect(page.locator('.setting-relationship-form select')).toBeVisible({ timeout: 10_000 })
await page.locator('.setting-relationship-form select').selectOption({ index: 1 })
await expect(page.locator('.setting-relationship-form input.form-control')).toBeVisible({
timeout: 10_000
})
await page.locator('.setting-relationship-form input.form-control').fill('挚友')
await page.locator('.setting-relationship-form .btn-primary').click()
await expect(page.getByTestId('setting-relationships')).toContainText('挚友')
await page.getByTestId('setting-open-graph').click()
await expect(page.getByTestId('character-graph-cy')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('graph-layout-circle').click()
await expect(page.getByTestId('character-graph-sidebar')).toBeVisible()
await app.close()
})
test('E2E-GRAPH-02: tap node shows sidebar with character name', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '图谱节点书')
await createCharacterSetting(page, '节点A')
await createCharacterSetting(page, '节点B')
await page.getByTestId(/setting-item-/).filter({ hasText: '节点A' }).click()
await page.getByTestId('setting-relationship-add').click()
await expect(page.locator('.setting-relationship-form select')).toBeVisible({ timeout: 10_000 })
await page.locator('.setting-relationship-form select').selectOption({ index: 1 })
await expect(page.locator('.setting-relationship-form input.form-control')).toBeVisible({
timeout: 10_000
})
await page.locator('.setting-relationship-form input.form-control').fill('同门')
await page.locator('.setting-relationship-form .btn-primary').click()
await page.getByTestId('setting-open-graph').click()
await expect(page.getByTestId('character-graph-cy')).toBeVisible({ timeout: 10_000 })
await page.evaluate(() => {
const el = document.querySelector('[data-testid="character-graph-cy"]') as HTMLElement & {
__bilinCy?: { nodes: () => { trigger: (e: string) => void }[] }
}
const cy = el?.__bilinCy
if (!cy) throw new Error('cytoscape not ready')
const nodes = cy.nodes()
if (!nodes.length) throw new Error('no graph nodes')
nodes[0]!.trigger('tap')
})
await expect(page.getByTestId('character-graph-sidebar')).toContainText(/节点A|节点B/, {
timeout: 10_000
})
await app.close()
})
})
+200
View File
@@ -0,0 +1,200 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: {
...process.env,
BILIN_E2E: '1',
BILIN_E2E_USER_DATA: userDataDir
}
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 })
await page.getByRole('button', { name: '跳过' }).click()
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function ensureChapterEditor(page: Page): Promise<void> {
const items = page.locator('.chapter-item')
if ((await items.count()) === 0) {
await page.getByRole('button', { name: '+ 新章' }).click()
} else {
await items.first().click()
}
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
test.describe('Cockpit & Knowledge P5', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-p5-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-COCK-01: cockpit modal shows stats', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '驾驶舱书')
await page.locator('[data-testid="topbar-cockpit"]').click()
await expect(page.locator('[data-testid="cockpit-modal"]')).toBeVisible()
await expect(page.locator('[data-testid="cockpit-total-words"]')).toBeVisible()
await app.close()
})
test('E2E-COCK-02: first open shows cockpit once', async () => {
const app1 = await launchApp(userDataDir)
const page1 = await app1.firstWindow()
await skipOnboarding(page1)
await page1.getByRole('button', { name: /新建书籍/ }).first().click()
await page1.locator('.dialog-content input').first().fill('首次驾驶舱')
await page1.getByRole('button', { name: '创建' }).click()
await expect(page1.locator('[data-testid="cockpit-modal"]')).toBeVisible({ timeout: 15_000 })
await page1.locator('[data-testid="cockpit-modal"] .modal-close').click()
await app1.close()
const app2 = await launchApp(userDataDir)
const page2 = await app2.firstWindow()
await expect(page2.getByText('欢迎使用笔临')).toBeHidden({ timeout: 15_000 })
await page2.getByText('首次驾驶舱').click()
await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await expect(page2.locator('[data-testid="cockpit-modal"]')).toBeHidden({ timeout: 3000 })
await app2.close()
})
test('E2E-PUB-01: publish ready increases stock count', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '发布书')
await ensureChapterEditor(page)
const chapterItem = page.locator('.chapter-item').first()
await expect(chapterItem).toBeVisible()
const chapterId = await chapterItem.getAttribute('data-testid')
const id = chapterId?.replace('chapter-item-', '') ?? ''
await page.locator(`[data-testid="chapter-publish-${id}"]`).click()
await page.locator('[data-testid="topbar-cockpit"]').click()
await expect(page.locator('[data-testid="cockpit-stock-count"]')).toContainText('1')
await app.close()
})
test('E2E-KNOW-01: create approve knowledge entry', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '知识库书')
await page.locator('[data-testid="panel-tab-knowledge"]').click()
await page.locator('[data-testid="knowledge-new"]').click()
await page.locator('[data-testid="knowledge-title-input"]').fill('测灵石异常')
await page.locator('[data-testid="knowledge-save"]').click()
await page.locator('[data-testid="knowledge-tab-pending"]').click()
await expect(page.locator('.kb-item')).toContainText('测灵石异常')
const approveBtn = page.locator('[data-testid^="knowledge-approve-"]').first()
await approveBtn.click()
await page.locator('[data-testid="knowledge-tab-all"]').click()
await expect(page.locator('.kb-item')).toContainText('测灵石异常')
await app.close()
})
test('E2E-BRIDGE-01: bridge modal opens from cockpit manually', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '衔接书')
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('上一章结尾内容,林远站在演武场中央。')
await page.keyboard.press('Control+s')
await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click()
await dismissCockpitIfOpen(page)
await expect(page.locator('[data-testid="bridge-modal"]')).toBeHidden({ timeout: 3000 })
await page.locator('[data-testid="topbar-cockpit"]').click()
await page.locator('[data-testid="cockpit-bridge-check"]').click()
await expect(page.locator('[data-testid="bridge-modal"]')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('[data-testid="bridge-previous-tail"]')).toBeVisible({ timeout: 60_000 })
await expect(page.locator('[data-testid="bridge-previous-tail"]')).not.toHaveText('(无)')
await app.close()
})
test('E2E-STOCK-01: low stock warning in statusbar', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '存稿书')
await page.locator('.top-btn').filter({ hasText: '⚙' }).click()
await page.locator('[data-testid="settings-update-schedule"]').selectOption('daily')
await page.locator('[data-testid="settings-stock-threshold"]').fill('3')
await page.locator('[data-testid="settings-stock-threshold"]').blur()
await page.waitForTimeout(500)
await page.getByText('存稿书').click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('[data-testid="status-stock-warning"]')).toBeVisible({ timeout: 10_000 })
await app.close()
})
test('E2E-BRIDGE-02: bridge shows AI suggestion', async () => {
test.setTimeout(300_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, 'AI衔接书')
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('林远站在演武场中央,众人目光汇聚,他知道从这一刻起一切都将不同。')
await page.keyboard.press('Control+s')
await page.waitForTimeout(1000)
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click()
await dismissCockpitIfOpen(page)
await expect(page.locator('[data-testid="bridge-modal"]')).toBeHidden({ timeout: 3000 })
await page.locator('[data-testid="topbar-cockpit"]').click()
await page.locator('[data-testid="cockpit-bridge-check"]').click()
await expect(page.locator('[data-testid="bridge-modal"]')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('[data-testid="bridge-ai-suggestion"]')).not.toBeEmpty({ timeout: 240_000 })
await app.close()
})
})
+58
View File
@@ -0,0 +1,58 @@
import { mkdtempSync, rmSync, existsSync, statSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
openExportModal,
dismissCockpitIfOpen
} from './helpers'
test.describe('Export matrix', () => {
let userDataDir: string
let pdfPath: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-matrix-'))
pdfPath = join(userDataDir, 'chapter.pdf')
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-PDF-01: export chapter as PDF', async () => {
const app = await launchApp(userDataDir, { BILIN_E2E_PDF_SAVE: pdfPath })
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, 'PDF导出测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('PDF导出测试正文。')
await page.keyboard.press('Control+S')
await openExportModal(page)
await page.getByTestId('export-kind-select').selectOption('matrix')
await page.getByTestId('export-matrix-format').selectOption('pdf')
await page.getByTestId('export-matrix-scope').selectOption('chapter')
await page.getByTestId('export-matrix-btn').click()
await expect(page.getByText(/已保存|Saved to/)).toBeVisible({ timeout: 30_000 })
expect(existsSync(pdfPath)).toBe(true)
expect(statSync(pdfPath).size).toBeGreaterThan(500)
await app.close()
})
})
+50
View File
@@ -0,0 +1,50 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
openExportModal
} from './helpers'
test.describe('Export submission', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-export-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-EXPORT-01: copy submission format includes chapter heading', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '导出测试书')
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('这是投稿导出测试正文。')
await page.keyboard.press('Control+S')
await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 })
await openExportModal(page)
await expect(page.getByTestId('export-preview')).toContainText(/# 第\d+章/, { timeout: 10_000 })
await page.getByTestId('export-copy-clipboard').click()
await expect(page.getByText('已复制到剪贴板')).toBeVisible({ timeout: 10_000 })
await app.close()
})
})
+42
View File
@@ -0,0 +1,42 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, createBookAndOpenEditor, ensureChapterEditor } from './helpers'
test.describe('Focus mode', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-focus-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-FOCUS-01: Ctrl+Alt+F enters focus mode, Esc exits', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '专注模式书')
await ensureChapterEditor(page)
await page.getByTestId('topbar-focus-mode').click()
await expect(page.getByTestId('app-root')).toHaveClass(/focus-mode/, { timeout: 5000 })
await expect(page.locator('#left-sidebar')).toBeHidden()
await expect(page.getByTestId('focus-mode-exit')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByTestId('app-root')).not.toHaveClass(/focus-mode/)
await expect(page.locator('#left-sidebar')).toBeVisible()
await app.close()
})
})
+104
View File
@@ -0,0 +1,104 @@
import path from 'path'
import { _electron as electron, type ElectronApplication, type Page, expect } from '@playwright/test'
export const ROOT = path.join(import.meta.dirname, '..')
export async function launchApp(
userDataDir: string,
extraEnv: Record<string, string> = {}
): Promise<ElectronApplication> {
return electron.launch({
args: [ROOT],
env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir, ...extraEnv }
})
}
export async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
}
}
export async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
export async function openGlobalSettings(page: Page): Promise<void> {
await page.getByTestId('topbar-settings').click()
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
}
export async function createBookAndOpenEditor(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
export async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
const editor = page.locator('.ProseMirror')
if (await editor.isVisible().catch(() => false)) return
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(editor).toBeVisible({ timeout: 10_000 })
}
export async function openInspirationModal(page: Page): Promise<void> {
await page.keyboard.press('Control+Shift+I')
const modal = page.getByTestId('inspiration-modal')
if (!(await modal.isVisible().catch(() => false))) {
await page.evaluate(() => window.dispatchEvent(new Event('bilin:e2e-capture-inspiration')))
}
await expect(modal).toBeVisible({ timeout: 10_000 })
}
export async function openExportModal(page: Page): Promise<void> {
await page.keyboard.press('Control+Shift+E')
const modal = page.getByTestId('export-modal')
if (!(await modal.isVisible().catch(() => false))) {
await page.getByTestId('open-export').click()
}
await expect(modal).toBeVisible({ timeout: 10_000 })
}
export async function openExportAllModal(page: Page): Promise<void> {
await page.getByTestId('home-export-all').click()
await expect(page.getByTestId('export-all-modal')).toBeVisible({ timeout: 10_000 })
}
export async function openImportBookModal(page: Page): Promise<void> {
await page.getByTestId('home-import-book').click()
await expect(page.getByTestId('import-book-modal')).toBeVisible({ timeout: 10_000 })
}
export async function openBookSettings(page: Page): Promise<void> {
await page.getByTestId('panel-tab-book-settings').click()
await expect(page.getByTestId('book-settings-tab')).toBeVisible({ timeout: 10_000 })
}
export async function openCharacterSetting(page: Page, name: string): Promise<void> {
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId(/^setting-item-/).filter({ hasText: name }).first().click()
await expect(page.getByTestId('setting-relationships')).toBeVisible({ timeout: 15_000 })
}
export async function addCharacterSetting(page: Page, name: string): Promise<void> {
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId('setting-new').click()
await page.getByTestId('setting-name-input').fill(name)
await page.locator('.dialog-content select').selectOption('character')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.getByTestId(/setting-item-/).filter({ hasText: name })).toBeVisible({
timeout: 10_000
})
}
+45
View File
@@ -0,0 +1,45 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, ROOT, dismissCockpitIfOpen } from './helpers'
const FIXTURE = join(ROOT, 'tests/fixtures/import-three-chapters.txt')
test.describe('Import book', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-import-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-IMPORT-01: import txt creates book with multiple chapters', async () => {
const app = await launchApp(userDataDir, { BILIN_E2E_IMPORT_FILE: FIXTURE })
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByTestId('home-import-book').click()
await expect(page.getByTestId('import-book-modal')).toBeVisible()
await page.getByTestId('import-pick-file').click()
await expect(page.getByTestId('import-file-path')).not.toHaveValue(/未选择|No file/)
await page.getByTestId('import-book-name').fill('导入E2E书')
await page.getByTestId('import-preview-btn').click()
await expect(page.getByTestId('import-preview')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('import-execute-btn').click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 30_000 })
await dismissCockpitIfOpen(page)
await expect(page.locator('.chapter-item')).toHaveCount(3, { timeout: 15_000 })
await app.close()
})
})
+45
View File
@@ -0,0 +1,45 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
openInspirationModal
} from './helpers'
test.describe('Inspiration capture', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-insp-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-INSP-01: home Ctrl+Shift+I saves idea with toast, stays on home', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '灵感测试书')
await page.locator('.tab.active').locator('span').last().click()
await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
await openInspirationModal(page)
await page.getByTestId('inspiration-content-input').fill('主页捕获的灵感内容')
await page.getByTestId('inspiration-save-btn').click()
await expect(page.getByText('灵感已保存')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('#home-page')).toBeVisible()
await app.close()
})
})
+21 -5
View File
@@ -20,7 +20,19 @@ async function skipOnboarding(page: Page): Promise<void> {
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
@@ -30,6 +42,7 @@ async function openBookWithContent(page: Page): Promise<void> {
await page.locator('.dialog-content input').first().fill('交互测试书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
@@ -39,6 +52,7 @@ async function openBookWithContent(page: Page): Promise<void> {
}
test.describe('Interactive writing', () => {
test.describe.configure({ retries: 1 })
let userDataDir: string
test.beforeEach(() => {
@@ -63,13 +77,14 @@ test.describe('Interactive writing', () => {
await page.locator('.dialog-content input').first().fill('空书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByTestId('panel-tab-ai').click()
await expect(page.getByTestId('ai-mode-tab-interactive')).toBeDisabled({ timeout: 10_000 })
await app.close()
})
test('E2E-INT-01: interactive flow creates chapter', async () => {
test.setTimeout(300_000)
test.setTimeout(600_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
@@ -85,21 +100,21 @@ test.describe('Interactive writing', () => {
await page.getByTestId('interactive-confirm-plot').click()
const naming0 = page.getByTestId('naming-option-0')
const acceptBtn = page.getByTestId('interactive-accept-scene')
await expect(naming0.or(acceptBtn)).toBeVisible({ timeout: 120_000 })
await expect(naming0.or(acceptBtn)).toBeVisible({ timeout: 240_000 })
if (await naming0.isVisible()) {
await naming0.click()
await expect(acceptBtn).toBeVisible({ timeout: 120_000 })
await expect(acceptBtn).toBeVisible({ timeout: 240_000 })
}
await acceptBtn.click()
const finishBtn = page.getByTestId('interactive-finish-chapter')
await expect(finishBtn).toBeVisible({ timeout: 120_000 })
await expect(finishBtn).toBeEnabled({ timeout: 240_000 })
await finishBtn.click()
await expect(page.locator('.ProseMirror')).not.toBeEmpty({ timeout: 30_000 })
await app.close()
})
test('E2E-INT-03: restores flow after restart', async () => {
test.setTimeout(300_000)
test.setTimeout(600_000)
const app1 = await launchApp(userDataDir)
const page1 = await app1.firstWindow()
await skipOnboarding(page1)
@@ -129,6 +144,7 @@ test.describe('Interactive writing', () => {
await skipOnboarding(page2)
await page2.getByText('交互测试书').click()
await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page2)
await page2.getByTestId('panel-tab-ai').click()
await expect(page2.getByTestId('ai-session-select')).not.toHaveValue('', { timeout: 10_000 })
await page2.getByTestId('ai-session-select').selectOption(sessionValue)
+97
View File
@@ -0,0 +1,97 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: {
...process.env,
BILIN_E2E: '1',
BILIN_E2E_USER_DATA: userDataDir
}
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 })
await page.getByRole('button', { name: '跳过' }).click()
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
test.describe('Knowledge extraction P5.2', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-ext-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-EXT-01: knowledge panel shows extract controls', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '抽取测试书')
const items = page.locator('.chapter-item')
if ((await items.count()) === 0) {
await page.getByRole('button', { name: '+ 新章' }).click()
} else {
await items.first().click()
}
await page.locator('[data-testid="panel-tab-knowledge"]').click()
await expect(page.locator('[data-testid="knowledge-panel"]')).toBeVisible()
await page.locator('[data-testid="knowledge-tab-pending"]').click()
await expect(page.locator('[data-testid="knowledge-extract-current"]')).toBeVisible()
await expect(page.locator('[data-testid="knowledge-batch-high-confidence"]')).toBeVisible()
await app.close()
})
test('E2E-EXT-02: settings show auto-extract toggle', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '全局设置测试')
await page.getByTestId('topbar-settings').click()
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('[data-testid="settings-knowledge-auto-extract"]')).toBeVisible()
await expect(page.locator('[data-testid="settings-extract-threshold"]')).toBeVisible()
await app.close()
})
})
+117
View File
@@ -0,0 +1,117 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir }
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function openBookWithContent(page: Page): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('注入历史书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click()
const editor = page.locator('.ProseMirror')
await expect(editor).toBeVisible({ timeout: 10_000 })
await editor.click()
await editor.pressSequentially('林远站在演武场中央,众人目光汇聚。')
await page.keyboard.press('Control+s')
await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 })
}
async function addApprovedKnowledge(page: Page, title: string): Promise<string> {
await page.locator('[data-testid="panel-tab-knowledge"]').click()
await page.locator('[data-testid="knowledge-new"]').click()
await page.locator('[data-testid="knowledge-title-input"]').fill(title)
await page.locator('[data-testid="knowledge-save"]').click()
await page.locator('[data-testid="knowledge-tab-pending"]').click()
const approveBtn = page.locator('[data-testid^="knowledge-approve-"]').first()
const testId = await approveBtn.getAttribute('data-testid')
await approveBtn.click()
const entryId = testId?.replace('knowledge-approve-', '') ?? ''
return entryId
}
async function openWizardToContext(page: Page): Promise<void> {
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await expect(page.getByTestId('ai-mode-tab-wizard')).toBeEnabled({ timeout: 10_000 })
await page.getByTestId('ai-mode-tab-wizard').click()
await expect(page.getByTestId('wizard-panel')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('wizard-goal-input').fill('林远通过入门考核')
await page.getByRole('button', { name: '下一步' }).click()
await page.getByTestId('wizard-rhythm-mixed').click()
await page.getByRole('button', { name: '下一步' }).click()
await page.getByRole('button', { name: '下一步' }).click()
await expect(page.getByTestId('wizard-context-edit')).toBeVisible({ timeout: 10_000 })
}
test.describe('Knowledge injection history P6.1', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-inj-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-INJ-01: wizard confirmContext shows injection preview', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await openBookWithContent(page)
const entryId = await addApprovedKnowledge(page, '测灵石异常')
expect(entryId).toBeTruthy()
await openWizardToContext(page)
await page.getByTestId('wizard-context-edit').click()
await expect(page.getByTestId('context-editor-modal')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('context-tab-knowledge').click()
await page.locator(`[data-testid="context-knowledge-item-${entryId}"] input`).check()
await page.getByTestId('context-save').click()
await expect(page.getByTestId('context-editor-modal')).toBeHidden({ timeout: 10_000 })
await page.locator('[data-testid="wizard-panel"] button.btn.primary').click()
await page.locator('[data-testid="panel-tab-knowledge"]').click()
await page.locator('[data-testid="knowledge-tab-all"]').click()
await expect(page.locator(`[data-testid="knowledge-injection-preview-${entryId}"]`)).toBeVisible({
timeout: 10_000
})
await app.close()
})
})
+12
View File
@@ -20,11 +20,23 @@ async function skipOnboarding(page: Page): Promise<void> {
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('覆盖度测试')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
test.describe('Outline coverage', () => {
+12
View File
@@ -26,11 +26,23 @@ async function skipOnboarding(page: Page): Promise<void> {
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
test.describe('Outline persistence', () => {
+13
View File
@@ -20,14 +20,27 @@ async function skipOnboarding(page: Page): Promise<void> {
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page, name = 'P2测试书'): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
+14
View File
@@ -20,14 +20,27 @@ async function skipOnboarding(page: Page): Promise<void> {
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('拖拽测试')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
@@ -150,6 +163,7 @@ test.describe('P2.1 features', () => {
await page.getByTestId('inspiration-save-btn').click()
await expect(page.getByTestId('inspiration-modal')).toBeHidden({ timeout: 10_000 })
await page.getByTestId('sidebar-tab-inspiration').click()
await expect(page.getByTestId('inspiration-list')).toBeVisible()
await expect(page.getByTestId('inspiration-list').getByText(idea)).toBeVisible({ timeout: 10_000 })
await app.close()
+49
View File
@@ -0,0 +1,49 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, dismissCockpitIfOpen } from './helpers'
test.describe('Pack export all', () => {
let userDataDir: string
let packAllPath: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-pack-all-'))
packAllPath = join(userDataDir, 'backup.novel-all')
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-ALL-03: home export all books', async () => {
const app = await launchApp(userDataDir, { BILIN_E2E_PACK_SAVE: packAllPath })
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('批量导出书A')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: /笔临|Bilin/i }).first().click()
await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('home-export-all').click()
await expect(page.getByTestId('export-all-modal')).toBeVisible()
await page.getByTestId('export-all-pick').click()
await expect(page.getByTestId('export-all-dest')).not.toHaveValue(/未选择|No destination/)
await page.getByTestId('export-all-start').click()
await expect(page.getByText(/已导出|exported/i)).toBeVisible({ timeout: 30_000 })
await app.close()
})
})
+74
View File
@@ -0,0 +1,74 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
openExportModal,
dismissCockpitIfOpen
} from './helpers'
test.describe('Pack export/import', () => {
let userDataDir: string
let packPath: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-pack-'))
packPath = join(userDataDir, 'roundtrip.novel')
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-PACK-01: export .novel and re-import via UI', async () => {
const app = await launchApp(userDataDir, {
BILIN_E2E_PACK_SAVE: packPath,
BILIN_E2E_PACK_IMPORT_FILE: packPath
})
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, 'Pack Roundtrip')
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('项目包导出导入测试正文。')
await page.keyboard.press('Control+S')
await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 })
await openExportModal(page)
await page.getByTestId('export-kind-select').selectOption('novel')
await page.getByTestId('export-novel-btn').click()
await expect(page.getByText(/已导出/)).toBeVisible({ timeout: 15_000 })
await page.evaluate(async (dest) => {
const list = await window.electronAPI.book.list()
if (!list.ok || !list.data?.length) throw new Error('no books')
await window.electronAPI.book.delete(list.data[0].id)
}, packPath)
await page.getByRole('button', { name: /笔临|Bilin/i }).first().click()
await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('home-import-book').click()
await expect(page.getByTestId('import-book-modal')).toBeVisible()
await page.getByTestId('import-pick-file').click()
await expect(page.getByTestId('import-pack-hint')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('import-execute-btn').click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 30_000 })
await dismissCockpitIfOpen(page)
await expect(page.locator('.chapter-item')).toHaveCount(1, { timeout: 15_000 })
await app.close()
})
})
+85
View File
@@ -0,0 +1,85 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
dismissCockpitIfOpen
} from './helpers'
test.describe('Read mode', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-read-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-READ-01: open read mode shows merged chapter content', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '阅读模式测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('这是连续阅读模式测试正文。')
await page.keyboard.press('Control+S')
await page.getByTestId('chapter-read-mode').click()
await expect(page.getByTestId('read-mode-overlay')).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('read-mode-content')).toContainText('连续阅读模式测试正文')
await app.close()
})
test('E2E-READ-02: switch device width to phone', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '阅读宽度测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await page.getByTestId('chapter-read-mode').click()
await expect(page.getByTestId('read-mode-overlay')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('read-device-width').selectOption('phone')
await expect(page.getByTestId('read-mode-content')).toHaveCSS('max-width', '360px')
await app.close()
})
test('E2E-READ-03: font theme persists after exit and re-enter', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '阅读主题测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await page.getByTestId('chapter-read-mode').click()
await expect(page.getByTestId('read-mode-overlay')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('read-font-theme').selectOption('eye-care')
await expect(page.locator('.read-mode-font-eye-care')).toBeVisible()
await page.getByTestId('read-mode-close').click()
await expect(page.getByTestId('read-mode-overlay')).toBeHidden()
await page.getByTestId('chapter-read-mode').click()
await expect(page.locator('.read-mode-font-eye-care')).toBeVisible()
await app.close()
})
})
+55
View File
@@ -0,0 +1,55 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding } from './helpers'
const ROOT = path.join(import.meta.dirname, '..')
test.describe('Release smoke', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-release-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-RELEASE-01: app launches and shows home after onboarding skip', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
await expect(page.getByRole('button', { name: /新建书籍/ }).first()).toBeVisible({
timeout: 10_000
})
await app.close()
})
test('E2E-RELEASE-02: create book opens editor without cockpit blocking', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('发布冒烟书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
const cockpit = page.locator('[data-testid="cockpit-modal"]')
if (await cockpit.isVisible({ timeout: 5000 }).catch(() => false)) {
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
}
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
await app.close()
})
})
+12
View File
@@ -26,11 +26,23 @@ async function skipOnboarding(page: Page): Promise<void> {
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createBookAndOpenEditor(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
+51
View File
@@ -0,0 +1,51 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
dismissCockpitIfOpen
} from './helpers'
test.describe('Cloud sync', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-sync-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-SYNC-01: configure local sync and run', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByRole('button', { name: /系统设置/ }).click()
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('settings-nav-backup').click()
await expect(page.getByTestId('sync-settings-tab')).toBeVisible({ timeout: 10_000 })
const syncDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-sync-dir-'))
await page.getByTestId('sync-local-path').fill(syncDir)
await page.getByTestId('sync-password').fill('e2e-sync-password')
await page.getByTestId('sync-save-config').click()
await page.getByTestId('sync-run-now').click()
await expect(page.getByTestId('sync-progress').or(page.getByTestId('sync-last-status'))).toBeVisible({
timeout: 60_000
})
await app.close()
rmSync(syncDir, { recursive: true, force: true })
})
})
+127
View File
@@ -0,0 +1,127 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
dismissCockpitIfOpen,
addCharacterSetting,
openCharacterSetting
} from './helpers'
async function openChapterByIndex(page: import('@playwright/test').Page, index = 0): Promise<void> {
await page.getByTestId('sidebar-tab-chapters').click()
await page.getByTestId(/^chapter-item-/).nth(index).click()
await expect(page.getByTestId('chapter-meta-open')).toBeVisible({ timeout: 10_000 })
}
async function setChapterStoryTime(
page: import('@playwright/test').Page,
storyTime: string,
chapterIndex = 0
): Promise<void> {
await openChapterByIndex(page, chapterIndex)
await page.getByTestId('chapter-meta-open').click()
await expect(page.getByTestId('chapter-meta-panel')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('chapter-meta-story-time').fill(storyTime)
await page.getByTestId('chapter-meta-save').click()
await expect(page.getByTestId('chapter-meta-panel')).toBeHidden({ timeout: 10_000 })
}
test.describe('Story timeline', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-timeline-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-TL-01: open story timeline lists chapters', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '时间线测试书')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await setChapterStoryTime(page, '730')
await page.getByTestId('open-timeline').click()
await expect(page.getByTestId('timeline-modal')).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('timeline-list')).toContainText('730')
await app.close()
})
test('E2E-TL-02: character birth year and chapter times show in timeline', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '角色年龄测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await addCharacterSetting(page, '林远')
await openCharacterSetting(page, '林远')
await expect(page.getByTestId('character-arc-panel')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('character-birth-date').fill('700')
await page.getByRole('button', { name: '保存' }).click()
await page.getByTestId('sidebar-tab-chapters').click()
await openChapterByIndex(page, 0)
await setChapterStoryTime(page, '710', 0)
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
await setChapterStoryTime(page, '730', 1)
await page.getByTestId('open-timeline').click()
await expect(page.getByTestId('timeline-modal')).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('timeline-list')).toContainText('710')
await expect(page.getByTestId('timeline-list')).toContainText('730')
await expect(page.locator('.timeline-item--conflict')).toHaveCount(0)
await app.close()
})
test('E2E-TL-03: timeline conflict shows red warning', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '时间冲突测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await addCharacterSetting(page, '主角')
await openCharacterSetting(page, '主角')
await expect(page.getByTestId('character-arc-panel')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('character-birth-date').fill('700')
await page.getByRole('button', { name: '保存' }).click()
await page.getByTestId('sidebar-tab-chapters').click()
await openChapterByIndex(page, 0)
await setChapterStoryTime(page, '730', 0)
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
await setChapterStoryTime(page, '720', 1)
await page.getByTestId('open-timeline').click()
await expect(page.getByTestId('timeline-modal')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.timeline-item--conflict')).toHaveCount(1, { timeout: 10_000 })
await expect(page.getByTestId('timeline-conflict-msg')).toBeVisible()
await app.close()
})
})
+59
View File
@@ -0,0 +1,59 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
dismissCockpitIfOpen
} from './helpers'
test.describe('Persistent undo', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-undo-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-UNDO-01: ctrl+z after reopen restores previous content', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '撤销测试书')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('持久化撤销测试')
await page.keyboard.press('Control+S')
await page.waitForTimeout(2000)
await page.getByRole('button', { name: /笔临/ }).click()
await expect(page.locator('#home-page')).toBeVisible({ timeout: 15_000 })
await page.locator('.book-name', { hasText: '撤销测试书' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await expect(editor).toContainText('持久化撤销测试')
await editor.click()
await page.keyboard.press('Control+Z')
await expect(editor).not.toContainText('持久化撤销测试', { timeout: 10_000 })
await app.close()
})
})
+36
View File
@@ -0,0 +1,36 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding } from './helpers'
test.describe('Auto update', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-update-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-UPDATE-02: mock update shows banner and download flow', async () => {
const app = await launchApp(userDataDir, {
BILIN_MOCK_UPDATE: '1',
BILIN_MOCK_UPDATE_VERSION: '9.9.9'
})
const page = await app.firstWindow()
await skipOnboarding(page)
await expect(page.getByTestId('update-banner')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('update-download').click()
await expect(page.getByTestId('update-install')).toBeVisible({ timeout: 15_000 })
await app.close()
})
})
+143
View File
@@ -0,0 +1,143 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir }
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
async function openBookWithContent(page: Page): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('向导测试书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('林远站在演武场中央,众人目光汇聚。')
await page.keyboard.press('Control+s')
await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 })
}
async function openWizardPanel(page: Page): Promise<void> {
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await expect(page.getByTestId('ai-mode-tab-wizard')).toBeEnabled({ timeout: 10_000 })
await page.getByTestId('ai-mode-tab-wizard').click()
await expect(page.getByTestId('wizard-panel')).toBeVisible({ timeout: 10_000 })
}
async function confirmWizardContext(page: Page): Promise<void> {
await page.getByTestId('wizard-context-edit').click()
await expect(page.getByTestId('context-editor-modal')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('context-tab-chapter').click()
await page.locator('[data-testid^="context-item-"]').first().check()
await page.getByTestId('context-refresh').click()
await page.getByTestId('context-save').click()
await expect(page.getByTestId('wizard-preview')).toBeVisible({ timeout: 15_000 })
}
test.describe('Wizard writing', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-wiz-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-WIZ-GATE: wizard tab disabled on empty book', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('空书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByTestId('panel-tab-ai').click()
await expect(page.getByTestId('ai-mode-tab-wizard')).toBeDisabled({ timeout: 10_000 })
await app.close()
})
test('E2E-WIZ-01: wizard flow reaches interactive panel', async () => {
test.setTimeout(600_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await openBookWithContent(page)
await openWizardPanel(page)
await page.getByTestId('wizard-goal-input').fill('林远通过入门考核,展现天赋震惊众人')
const wordInputs = page.locator('.auto-word-range input')
await wordInputs.nth(0).fill('180')
await wordInputs.nth(1).fill('220')
await page.getByRole('button', { name: '下一步' }).click()
await page.getByTestId('wizard-rhythm-mixed').click()
await page.getByRole('button', { name: '下一步' }).click()
await page.getByRole('button', { name: '下一步' }).click()
await confirmWizardContext(page)
await page.getByRole('button', { name: '刷新预览' }).click()
await expect(page.getByTestId('wizard-preview')).not.toContainText('…', { timeout: 240_000 })
await page.getByTestId('wizard-start-generate').click()
await expect(page.getByTestId('ai-mode-tab-interactive')).toHaveClass(/active/, { timeout: 360_000 })
const acceptBtn = page.getByTestId('interactive-accept-scene')
const plotA = page.getByTestId('plot-option-A')
const naming0 = page.getByTestId('naming-option-0')
await expect(acceptBtn.or(plotA).or(naming0)).toBeVisible({ timeout: 120_000 })
if (await naming0.isVisible()) {
await naming0.click()
}
await expect(page.getByTestId('interactive-panel')).toBeVisible({ timeout: 30_000 })
await expect(acceptBtn.or(plotA)).toBeVisible({ timeout: 120_000 })
await app.close()
})
})
+125
View File
@@ -0,0 +1,125 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: {
...process.env,
BILIN_E2E: '1',
BILIN_E2E_USER_DATA: userDataDir
}
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
const items = page.locator('.chapter-item')
if ((await items.count()) === 0) {
await page.getByRole('button', { name: '+ 新章' }).click()
} else {
await items.first().click()
}
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
test.describe('Writing analytics P9', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-analytics-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-ANALYTICS-01: writing updates cockpit speed card', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '分析测试书')
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially(
'这是一段用于测试写作分析速度的示例文字,需要足够长度才能写入写作会话并计算今日字速。继续添加一些汉字以确保字数统计生效。'
)
await page.waitForTimeout(1500)
await page.locator('[data-testid="topbar-cockpit"]').click()
await expect(page.locator('[data-testid="cockpit-modal"]')).toBeVisible()
await expect(page.locator('[data-testid="cockpit-analytics"]')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('[data-testid="cockpit-speed-today"]')).not.toHaveText('', {
timeout: 10_000
})
await app.close()
})
test('E2E-ANALYTICS-02: chapter POV appears in cockpit chart', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '视角分析书')
await ensureChapterEditor(page)
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId('setting-new').click()
await page.getByTestId('setting-name-input').fill('主角视角')
await page.locator('.dialog-content select').selectOption('character')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.getByTestId(/setting-item-/).filter({ hasText: '主角视角' })).toBeVisible({
timeout: 10_000
})
await page.getByTestId('sidebar-tab-chapters').click()
await ensureChapterEditor(page)
await page.getByTestId('chapter-pov-select').selectOption({ label: '主角视角' })
await page.locator('[data-testid="topbar-cockpit"]').click()
await expect(page.locator('[data-testid="cockpit-pov-chart"]')).toContainText('主角视角', {
timeout: 10_000
})
await app.close()
})
})
+157
View File
@@ -0,0 +1,157 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { DatabaseSync } from 'node:sqlite'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string, extraEnv: Record<string, string> = {}) {
return electron.launch({
args: [ROOT],
env: {
...process.env,
BILIN_E2E: '1',
BILIN_E2E_USER_DATA: userDataDir,
...extraEnv
}
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
const items = page.locator('.chapter-item')
if ((await items.count()) === 0) {
await page.getByRole('button', { name: '+ 新章' }).click()
} else {
await items.first().click()
}
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
function seedMilestone(userDataDir: string, bookId: string, milestone: string): void {
const dbPath = join(userDataDir, 'writing_logs.sqlite')
const db = new DatabaseSync(dbPath)
db.prepare('INSERT INTO writing_milestones (book_id, milestone) VALUES (?, ?)').run(
bookId,
milestone
)
db.close()
}
test.describe('Writing goals P5.3', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-goal-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-GOAL-01: daily goal met shows progress and toast', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '目标测试书')
await ensureChapterEditor(page)
await page.getByTestId('topbar-settings').click()
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
await page.locator('[data-testid="settings-daily-word-goal"]').fill('30')
await page.getByRole('button', { name: '目标测试书' }).click()
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially(
'这是一段用于测试每日写作目标达标的示例文字,需要足够长度才能触发字数统计与达标提醒通知。继续书写更多内容以确保超过三十字的目标阈值。'
)
await page.waitForTimeout(2500)
await expect(page.locator('[data-testid="status-daily-goal"].goal-met')).toBeVisible({
timeout: 15_000
})
await expect(page.locator('.toast')).toContainText(/目标|goal/i, { timeout: 10_000 })
await app.close()
})
test('E2E-GOAL-02: cockpit shows unlocked achievement badge', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '成就测试书')
const bookId = await page.evaluate(async () => {
const res = await window.electronAPI.book.list()
return res.ok ? res.data[0]?.id : null
})
expect(bookId).toBeTruthy()
await app.close()
seedMilestone(userDataDir, bookId!, 'streak_7')
const app2 = await launchApp(userDataDir)
const page2 = await app2.firstWindow()
await skipOnboarding(page2)
await page2.locator('.book-card').filter({ hasText: '成就测试书' }).click()
await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page2)
await page2.getByTestId('topbar-cockpit').click()
await expect(page2.locator('[data-testid="cockpit-modal"]')).toBeVisible({ timeout: 10_000 })
const badge = page2.locator('[data-testid="cockpit-achievements"] .cockpit-achievement-badge.unlocked')
await expect(badge).toHaveCount(1, { timeout: 10_000 })
await app2.close()
})
test('E2E-GOAL-03: pomodoro completes with toast', async () => {
const app = await launchApp(userDataDir, { POMODORO_TEST_SEC: '3' })
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '番茄测试书')
await ensureChapterEditor(page)
await page.locator('[data-testid="pomodoro-control"] button').first().click()
await expect(page.locator('.pomodoro-time')).toBeVisible()
await page.waitForTimeout(4500)
await expect(page.locator('.toast')).toContainText(/番茄|Pomodoro|完成|complete/i, {
timeout: 10_000
})
await app.close()
})
})
+113
View File
@@ -0,0 +1,113 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: {
...process.env,
BILIN_E2E: '1',
BILIN_E2E_USER_DATA: userDataDir
}
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 })
await page.getByRole('button', { name: '跳过' }).click()
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
test.describe('Writing logs P5.2', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-log-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-LOG-01: typing updates today progress in cockpit', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '日志测试书')
await page.getByTestId('topbar-settings').click()
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
await page.locator('[data-testid="settings-daily-word-goal"]').fill('100')
await page.getByRole('button', { name: '日志测试书' }).click()
const items = page.locator('.chapter-item')
if ((await items.count()) === 0) {
await page.getByRole('button', { name: '+ 新章' }).click()
} else {
await items.first().click()
}
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
await page.locator('.ProseMirror').click()
await page.keyboard.type('这是一段用于测试写作日志累计的示例文字内容,需要足够长才能触发字数统计与日志记录。')
await page.waitForTimeout(1500)
await page.locator('[data-testid="topbar-cockpit"]').click()
await expect(page.locator('[data-testid="cockpit-modal"]')).toBeVisible()
await expect(page.locator('[data-testid="cockpit-today-progress"]')).toBeVisible()
await expect(page.locator('[data-testid="cockpit-heatmap"]')).toBeVisible()
await app.close()
})
test('E2E-LOG-02: status bar shows daily goal when configured', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '状态栏测试')
await page.getByTestId('topbar-settings').click()
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
await page.locator('[data-testid="settings-daily-word-goal"]').fill('500')
await page.getByRole('button', { name: '状态栏测试' }).click()
const items = page.locator('.chapter-item')
if ((await items.count()) === 0) {
await page.getByRole('button', { name: '+ 新章' }).click()
} else {
await items.first().click()
}
await expect(page.locator('[data-testid="status-daily-goal"]')).toBeVisible({ timeout: 10_000 })
await app.close()
})
})
+13
View File
@@ -25,11 +25,23 @@ async function skipOnboarding(page: Page): Promise<void> {
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
try {
await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
test.describe('Writing', () => {
@@ -54,6 +66,7 @@ test.describe('Writing', () => {
const page1 = await app1.firstWindow()
await skipOnboarding(page1)
await createAndOpenBook(page1, '测试书')
await dismissCockpitIfOpen(page1)
await page1.getByRole('button', { name: '+ 新章' }).click()
const editor = page1.locator('.ProseMirror')
+7 -2
View File
@@ -8,8 +8,13 @@ export default defineConfig({
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts')
}
index: resolve(__dirname, 'src/main/index.ts'),
'import-bootstrap': resolve(__dirname, 'src/main/import-bootstrap.ts'),
'pack-bootstrap': resolve(__dirname, 'src/main/pack-bootstrap.ts'),
'export-matrix-bootstrap': resolve(__dirname, 'src/main/export-matrix-bootstrap.ts'),
'sync-bootstrap': resolve(__dirname, 'src/main/sync-bootstrap.ts')
},
external: ['mammoth', 'marked', 'archiver', 'extract-zip', 'docx', 'electron-log', 'electron-updater']
}
}
},
+1067 -201
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bilin",
"version": "0.4.0",
"version": "2.0.0",
"description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js",
"type": "module",
@@ -10,6 +10,8 @@
"preview": "npx electron-vite preview",
"test": "node node_modules/vitest/vitest.mjs run",
"test:watch": "node node_modules/vitest/vitest.mjs",
"test:release": "npm run build && node node_modules/vitest/vitest.mjs run tests/release && npx playwright test e2e/release-smoke.spec.ts",
"test:ci": "npm run build && npm run test && npm run test:e2e",
"test:e2e": "playwright test"
},
"keywords": [],
@@ -28,10 +30,19 @@
"@tiptap/extension-underline": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"archiver": "^8.0.0",
"cytoscape": "^3.34.0",
"cytoscape-cose-bilkent": "^4.1.0",
"diff-match-patch": "^1.0.5",
"docx": "^9.7.1",
"electron": "^36.9.0",
"electron-log": "^5.4.4",
"electron-updater": "^6.8.9",
"extract-zip": "^2.0.1",
"i18next": "^24.2.3",
"jotai": "^2.12.1",
"mammoth": "^1.12.0",
"marked": "^15.0.12",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
@@ -40,7 +51,11 @@
},
"devDependencies": {
"@playwright/test": "^1.51.0",
"@types/archiver": "^8.0.0",
"@types/cytoscape": "^3.21.9",
"@types/diff-match-patch": "^1.0.36",
"@types/extract-zip": "^2.0.0",
"@types/marked": "^6.0.0",
"@types/node": "^22.13.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
+407 -2
View File
@@ -13,6 +13,7 @@
"dialog.newBook.category": "Category",
"dialog.newBook.target": "Target word count (optional)",
"dialog.cancel": "Cancel",
"dialog.save": "Save",
"dialog.create": "Create",
"settings.title": "Settings",
"settings.general": "General",
@@ -71,12 +72,17 @@
"ai.untitledSession": "New chat",
"ai.contextPlaceholder": "📎 Context: not configured yet",
"ai.contextEmpty": "📎 Context: nothing selected",
"ai.contextSummary": "📎 Context: {{chapters}} ch. · {{outlines}} outline · {{settings}} settings · {{inspirations}} ideas",
"ai.contextSummary": "📎 Context: {{chapters}} ch. · {{outlines}} outline · {{settings}} settings · {{inspirations}} ideas · {{knowledge}} knowledge",
"ai.contextEditorTitle": "Edit AI context",
"ai.contextTab.chapter": "Chapters",
"ai.contextTab.outline": "Outline",
"ai.contextTab.setting": "Settings",
"ai.contextTab.inspiration": "Ideas",
"ai.contextTab.knowledge": "Knowledge",
"ai.contextRefreshSuggest": "Refresh suggestions",
"ai.contextKnowledgeSuggestTitle": "Suggested knowledge",
"ai.contextKnowledgeAllTitle": "All approved knowledge",
"ai.contextKnowledgeEmpty": "No approved knowledge entries",
"ai.contextEmptyList": "No items",
"ai.contextRefresh": "Refresh context",
"ai.contextSave": "Save",
@@ -99,6 +105,7 @@
"interactive.step.scene": "Scene",
"interactive.step.review": "Review",
"interactive.start": "Start interactive",
"interactive.confirmKnowledge": "Confirm knowledge context",
"interactive.loadingPlots": "Generating plot options…",
"interactive.plotNotePlaceholder": "Additional instructions (optional)",
"interactive.generateScene": "Generate scene",
@@ -112,6 +119,41 @@
"interactive.chapterTitlePlaceholder": "Chapter title (optional)",
"interactive.chapterCreated": "Interactive draft chapter created",
"interactive.sceneCount": "{{count}} scene(s) confirmed",
"auto.gateBlocked": "Add chapter content, outline, or settings before auto writing",
"auto.gateTooltip": "Requires chapter text or outline/settings",
"auto.step.goal": "Goal",
"auto.step.plan": "Plan",
"auto.step.generate": "Generate",
"auto.step.done": "Done",
"auto.goalPlaceholder": "Plot points for this chapter…",
"auto.planScenes": "Plan scenes",
"auto.startGenerate": "Generate next scene",
"auto.sceneProgress": "Scene {{current}} / {{total}}",
"auto.pause": "Pause",
"auto.resume": "Resume auto",
"auto.handoffInteractive": "Switch to interactive",
"auto.finishChapter": "Finish chapter",
"auto.chapterCreated": "Auto-written chapter created",
"wizard.gateBlocked": "Add chapter content, outline, or settings before wizard mode",
"wizard.gateTooltip": "Requires chapter text or outline/settings",
"wizard.step.goal": "Goal",
"wizard.step.rhythm": "Rhythm",
"wizard.step.pov": "POV",
"wizard.step.context": "Context",
"wizard.step.preview": "Preview",
"wizard.rhythm.relaxed": "Relaxed",
"wizard.rhythm.tense": "Tense",
"wizard.rhythm.mixed": "Mixed",
"wizard.styleNotePlaceholder": "Optional style note",
"wizard.contextHint": "Confirm context including outline/settings clues",
"wizard.knowledgeList": "Knowledge to inject",
"wizard.previewTitle": "Strategy preview",
"wizard.refreshPreview": "Refresh preview",
"wizard.startGenerate": "Start generating",
"wizard.handoffToast": "Switched to interactive refinement",
"wizard.next": "Next",
"wizard.povDefault": "Default POV",
"wizard.generating": "Generating chapter…",
"snapshot.ai": "Before AI insert",
"naming.open": "🔍 Naming consistency check",
"naming.title": "Naming consistency check",
@@ -125,6 +167,160 @@
"naming.ignored": "Added to ignore list",
"naming.replaced": "Replaced {{count}} occurrence(s)",
"knowledge.placeholder": "Knowledge base entries will appear here",
"knowledge.new": "New entry",
"knowledge.edit": "Edit entry",
"knowledge.type": "Type",
"knowledge.type.character": "Character",
"knowledge.type.foreshadow": "Foreshadow",
"knowledge.type.location": "Location",
"knowledge.type.relationship": "Relationship",
"knowledge.type.fact": "Fact",
"knowledge.reason.highImportance": "High importance",
"knowledge.reason.chapterRelevant": "Chapter relevant",
"knowledge.reason.foreshadowBuried": "Foreshadow pending",
"knowledge.reason.goalMatch": "Goal match",
"knowledge.titleLabel": "Title",
"knowledge.contentLabel": "Content",
"knowledge.importance": "Importance",
"knowledge.foreshadowStatus": "Foreshadow status",
"knowledge.foreshadow.buried": "Buried",
"knowledge.foreshadow.partial": "Partial",
"knowledge.foreshadow.resolved": "Resolved",
"knowledge.sourceChapter": "Source chapter",
"knowledge.noChapter": "(none)",
"knowledge.approveNow": "Approve immediately",
"knowledge.tab.pending": "Pending",
"knowledge.tab.all": "All",
"knowledge.tab.foreshadow": "Foreshadow",
"knowledge.approve": "Approve",
"knowledge.reject": "Reject",
"knowledge.delete": "Delete",
"knowledge.batchApprove": "Approve selected ({{count}})",
"knowledge.empty": "No entries yet",
"knowledge.status.pending": "Pending",
"knowledge.status.approved": "Approved",
"knowledge.status.rejected": "Rejected",
"knowledge.statsSummary": "Buried {{buried}} · Resolved {{resolved}} · Forgotten {{forgotten}}",
"knowledge.syncFromLandmark": "Sync this foreshadow to knowledge base?",
"knowledge.syncedFromLandmark": "Synced to knowledge base (pending review)",
"knowledge.extractChapter": "Extract chapter knowledge",
"knowledge.extractFromCurrent": "Extract from current chapter",
"knowledge.batchHighConfidence": "Approve all high-confidence",
"knowledge.mergeSuggest": "Suggested update: {{title}}",
"knowledge.approveMerge": "Confirm update",
"knowledge.saveAsNew": "Save as new entry",
"knowledge.extracting": "Extracting knowledge…",
"knowledge.extractFailed": "Extraction failed, try again later",
"cockpit.title": "Writing cockpit",
"cockpit.loading": "Loading…",
"cockpit.totalWords": "Book words",
"cockpit.volumeWords": "Volume words",
"cockpit.stockReady": "Stock buffer",
"cockpit.foreshadow": "Foreshadow",
"cockpit.foreshadowDetail": "Buried {{buried}} / Resolved {{resolved}} / Forgotten {{forgotten}}",
"cockpit.resumeEdit": "Resume writing",
"cockpit.bridgeCheck": "Chapter bridge check",
"cockpit.forgottenForeshadow": "Forgotten foreshadows",
"cockpit.todayOutlineTasks": "Today's outline tasks",
"cockpit.outlineTargetWords": "Target {{count}} words",
"cockpit.todayProgress": "Today {{current}} / {{goal}} words",
"cockpit.streak": "{{days}}-day streak",
"cockpit.heatmap": "Writing heatmap",
"cockpit.achievements": "Writing achievements",
"cockpit.pomodoroToday": "{{count}} pomodoros today",
"cockpit.openGraph": "Character relationship graph",
"graph.title": "Character relationship graph",
"graph.layout.cose": "Force-directed",
"graph.layout.grid": "Grid",
"graph.layout.circle": "Circle",
"graph.truncated": "Too many characters; showing first {{count}} only",
"graph.sidebar.node": "Character: {{name}}",
"graph.sidebar.edge": "Relationship: {{label}}",
"graph.sidebar.hint": "Tap a node or edge for details",
"graph.editSetting": "Edit setting",
"graph.filter.all": "All characters",
"graph.filter.connected": "Connected only",
"relationship.title": "Character relationships",
"relationship.add": "Add relationship",
"relationship.target": "Target character",
"relationship.label": "Relationship label",
"relationship.intimacy": "Intimacy",
"analytics.title": "Writing analytics",
"analytics.speedToday": "Today {{wpm}} words/min",
"analytics.speedUnknown": "No speed data yet",
"analytics.hourlyHeatmap": "Writing hours (last 30 days)",
"analytics.povDistribution": "POV word distribution",
"analytics.volumeDistribution": "Words by volume",
"analytics.weekTrend": "Last 7 days",
"analytics.povUnassigned": "Unassigned POV",
"chapter.pov": "POV character",
"pomodoro.start": "Start pomodoro",
"pomodoro.pause": "Pause",
"pomodoro.resume": "Resume",
"pomodoro.cancel": "Cancel",
"pomodoro.complete": "Pomodoro complete! {{words}} words written",
"pomodoro.switchedBook": "Book switched — pomodoro cancelled",
"goal.dailyMet": "Daily goal met: {{current}} / {{goal}} words",
"achievement.streak7": "7-day streak",
"achievement.streak30": "30-day streak",
"achievement.streak100": "100-day streak",
"achievement.unlocked": "Achievement unlocked: {{name}}",
"knowledge.injectionHistory": "Injection history",
"knowledge.injectionPreview": "Recent injections",
"knowledge.injectionEntry": "{{date}} · {{mode}} · {{chapter}}",
"knowledge.injectionMode.interactive": "Interactive",
"knowledge.injectionMode.auto": "Auto writing",
"knowledge.injectionMode.wizard": "Wizard",
"settings.pomodoroDuration": "Pomodoro duration (minutes)",
"settings.systemNotifications": "Enable system notifications",
"settings.goalNotifications": "Enable goal notifications",
"settings.focusAmbientSound": "Focus mode ambient sound",
"settings.complianceCheck": "Enable sensitive word compliance check",
"settings.complianceWords": "Custom sensitive words",
"settings.complianceWordsHint": "One word per line; merged with built-in list",
"timeline.open": "Story timeline",
"timeline.title": "Story timeline",
"timeline.empty": "No timeline events yet",
"timeline.noTime": "No time set",
"timeline.manual": "Manual event",
"timeline.addEvent": "Add event",
"timeline.jumpChapter": "Go to chapter",
"timeline.eventTitlePrompt": "Event title",
"timeline.storyTimePrompt": "Story time (e.g. 732 or +10y)",
"timeline.characterHint": "Set character birth year in the arc panel for conflict detection",
"arc.title": "Character arc",
"arc.birthDate": "Birth year",
"arc.empty": "No arc nodes yet; knowledge entries will appear as you write",
"arc.jumpChapter": "Go to chapter",
"reference.openInEditor": "Open in editor",
"reference.detailTitle": "Reference detail",
"ai.settings.customSlashCommands": "Custom slash commands",
"ai.settings.customSlashName": "Command (e.g. /outline)",
"ai.settings.customSlashTemplate": "Expansion template",
"ai.settings.customSlashAdd": "Add command",
"bridge.title": "Chapter bridge",
"bridge.loading": "Analyzing…",
"bridge.previousTail": "Previous chapter ending",
"bridge.currentHead": "Current chapter opening",
"bridge.aiSuggestion": "AI suggestion",
"bridge.none": "(none)",
"bridge.empty": "Could not load bridge info",
"bridge.dismissLabel": "Do not prompt for this chapter",
"bridge.startWriting": "Start writing",
"publish.status.draft": "Draft",
"publish.status.ready": "Ready",
"publish.status.published": "Published",
"settings.updateSchedule": "Update schedule",
"settings.updateSchedule.none": "No reminder",
"settings.updateSchedule.daily": "Daily",
"settings.updateSchedule.alternate": "Every other day",
"settings.updateSchedule.weekly": "Weekly",
"settings.stockBufferThreshold": "Stock buffer threshold (chapters)",
"settings.dailyWordGoal": "Daily word goal",
"settings.knowledgeAutoExtract": "Auto-extract knowledge on chapter finish",
"settings.extractThreshold": "High-confidence threshold",
"stock.warning": "Low stock: {{count}}/{{threshold}} chapters ready",
"landmark.foreshadowConfirm": "Mark as foreshadow landmark?",
"ai.settings.backend": "Backend",
"ai.settings.backend.lmstudio": "LM Studio (local)",
"ai.settings.backend.openai": "OpenAI",
@@ -140,6 +336,7 @@
"status.chapter": "Chapter",
"status.volume": "Volume",
"status.book": "Book",
"status.dailyGoal": "Today {{current}} / {{goal}}",
"toast.bookCreated": "Book created",
"toast.saveFailed": "Save failed, please retry",
"toast.enterBookName": "Please enter a book title",
@@ -229,5 +426,213 @@
"landmark.created": "Landmark inserted",
"landmark.chapterOnly": "Insert landmarks in a chapter",
"wordfreq.hint": "Top 100 words in current scope",
"wordfreq.aiNaming": "AI naming suggestions"
"wordfreq.aiNaming": "AI naming suggestions",
"settings.templates": "Chapter Templates",
"settings.submission": "Submission Presets",
"dialog.edit": "Edit",
"dialog.delete": "Delete",
"template.title": "Chapter Templates",
"template.quickNew": "New from Template",
"template.builtin": "Built-in",
"template.custom": "Custom",
"template.selectTemplate": "Select template",
"template.chapterTitle": "Chapter title",
"template.chapterTitlePlaceholder": "Enter chapter title",
"template.createChapter": "Create chapter",
"template.add": "Add template",
"template.newTemplate": "New template",
"template.name": "Template name",
"template.body": "Template body",
"import.title": "Import Book",
"import.pickFile": "Browse file",
"import.noFile": "No file selected",
"import.splitMode": "Split mode",
"import.splitAuto": "Auto-detect chapters",
"import.splitParagraph": "Split by blank lines",
"import.splitRegex": "Custom regex",
"import.customRegex": "Regex pattern",
"import.preview": "Preview chapters",
"import.chapterCount": "{{count}} chapter(s)",
"import.largeFileConfirm": "Large file ({{size}}). Import may take 30s2min. Continue?",
"import.progress": "Importing {{current}}/{{total}}",
"import.start": "Start import",
"import.importing": "Importing…",
"import.success": "Import complete",
"import.failed": "Import failed",
"export.title": "Export Chapter",
"export.submissionPresets": "Submission preset",
"export.copyClipboard": "Copy to clipboard",
"export.saveTxt": "Save as text",
"export.copied": "Copied to clipboard",
"export.saved": "Saved to {{path}}",
"export.preview": "Preview",
"export.loading": "Loading…",
"export.noPreview": "(no preview)",
"export.kind": "Export type",
"export.kindSubmission": "Submission format (current chapter)",
"export.kindNovel": "Bilin project pack (.novel)",
"export.novelDesc": "Export the full book database, metadata, and attachments. Restore via Import Book.",
"export.exportNovel": "Export project pack",
"export.kindMatrix": "Standard export (txt/md/docx/pdf)",
"export.matrixFormat": "File format",
"export.matrixScope": "Export scope",
"export.matrixOptions": "Options",
"export.scopeChapter": "Current chapter",
"export.scopeVolume": "Current volume",
"export.scopeBook": "Entire book",
"export.optionAnnotations": "Include annotations/landmarks",
"export.optionAiMark": "Mark AI-generated content",
"export.matrixStart": "Export file",
"export.matrixSaved": "Saved to {{path}}",
"export.matrixFailed": "Export failed",
"export.exporting": "Exporting…",
"import.novelHint": "Imports the entire book from a .novel pack (chapters, settings, knowledge).",
"import.novelAllHint": "Imports all books from a .novel-all pack. Choose a conflict strategy.",
"pack.exportAllTitle": "Export all books",
"pack.exportAllDesc": "Package all books and global settings into a .novel-all file.",
"pack.estimating": "Estimating size…",
"pack.estimatedSize": "Estimated size: {{size}}",
"pack.noDest": "No destination selected",
"pack.pickDest": "Choose location",
"pack.largeExportConfirm": "Export is large ({{size}}). Continue?",
"pack.exportAllStart": "Start export",
"pack.exportAllSuccess": "All books exported",
"pack.exportAllFailed": "Export failed",
"pack.exportBookSuccess": "\"{{name}}\" exported as project pack",
"pack.exportBookFailed": "Project pack export failed",
"pack.exporting": "Exporting…",
"pack.cancel": "Cancel",
"pack.progress": "Progress {{current}}/{{total}}",
"pack.importStrategy": "Import strategy",
"pack.strategyNew": "Import all as new books",
"pack.strategySkip": "Skip existing books",
"pack.strategyOverwrite": "Overwrite matching books",
"pack.importAllSuccess": "Import done: {{imported}} new, {{skipped}} skipped, {{overwritten}} overwritten",
"backup.desc": "Export books as Bilin project packs for backup, migration, or restore.",
"backup.exportCurrentBook": "Export current book",
"backup.exportBookBtn": "Export as .novel",
"backup.exportAllBooks": "Export all books",
"backup.exportAllBtn": "Open bulk export",
"backup.noBook": "Open a book in the editor first",
"backup.openBookHint": "Open a book in the editor to export a single project pack",
"backup.exportBookSuccess": "\"{{name}}\" exported",
"backup.exportBookFailed": "Export failed",
"backup.formatHint": "Bulk exports over 500 MB require extra confirmation.",
"submission.title": "Submission Presets",
"submission.titleFormat": "Title format",
"submission.indent": "Indent paragraphs",
"submission.indentOn": "Indent: on",
"submission.indentOff": "Indent: off",
"submission.includeAuthorsNote": "Include author's note",
"submission.paragraphGap": "Paragraph spacing",
"submission.gapSingle": "Single spacing",
"submission.gapDouble": "Double spacing",
"submission.add": "Add preset",
"submission.newPreset": "New preset",
"submission.name": "Preset name",
"submission.setDefault": "Set as default",
"inspiration.selectBook": "Link to book",
"inspiration.saved": "Idea saved",
"inspiration.noBooks": "Create a book first to save ideas",
"bookSettings.title": "Book settings",
"bookSettings.synopsis": "Synopsis",
"bookSettings.cover": "Cover",
"bookSettings.pickCover": "Choose cover image",
"bookSettings.noCover": "Not set",
"bookSettings.alwaysShowCockpit": "Show cockpit on every open",
"bookSettings.dangerZone": "Danger zone",
"bookSettings.deleteBook": "Delete this book",
"bookSettings.deleteConfirm": "Delete \"{{name}}\"? This cannot be undone.",
"volume.rename": "Rename volume",
"volume.renamePrompt": "Volume name",
"volume.editDescription": "Edit description",
"volume.descriptionPrompt": "Volume description",
"volume.delete": "Delete volume",
"volume.deleteConfirm": "Delete volume \"{{name}}\"?",
"volume.deleteHasChapters": "Cannot delete a volume that still has chapters",
"chapter.delete": "Delete chapter",
"chapter.deleteConfirm": "Delete chapter \"{{title}}\"?",
"chapterMeta.title": "Chapter metadata",
"chapterMeta.open": "Meta",
"chapterMeta.summary": "Summary",
"chapterMeta.storyTime": "Story time",
"chapterMeta.wordThreshold": "Word count threshold",
"chapterMeta.tags": "Tags",
"chapterMeta.tagPlaceholder": "Enter tag",
"chapterMeta.addTag": "Add",
"chapterMeta.publishedAt": "Published {{date}}",
"ai.session.rename": "Rename session",
"ai.session.renamePrompt": "Session title",
"ai.session.archive": "Archive",
"ai.session.unarchive": "Unarchive",
"ai.session.export": "Export",
"ai.session.exportMd": "Markdown (.md)",
"ai.session.exportTxt": "Plain text (.txt)",
"ai.session.delete": "Delete session",
"ai.session.deleteConfirm": "Delete session \"{{title}}\"?",
"ai.session.active": "Active",
"ai.session.archived": "Archived",
"focus.toggle": "Focus mode",
"focus.exit": "Exit focus",
"tts.started": "Reading aloud",
"tts.stopped": "Stopped reading",
"tts.noText": "Nothing to read",
"home.exportAllWave2": "Bulk export coming in a later release",
"readMode.title": "Read mode",
"readMode.open": "Read mode",
"readMode.exit": "Exit reading",
"readMode.deviceWidth": "Device width",
"readMode.devicePhone": "Phone (360px)",
"readMode.deviceTablet": "Tablet (768px)",
"readMode.deviceDesktop": "Desktop (100%)",
"readMode.fontTheme": "Font theme",
"readMode.fontSerif": "Serif",
"readMode.fontSans": "Sans-serif",
"readMode.fontEyeCare": "Eye care",
"readMode.tts": "Read aloud",
"settings.extensions": "Extensions",
"sync.title": "Cloud sync",
"sync.desc": "Sync encrypted backup of all books to WebDAV or a local folder",
"sync.mode": "Sync mode",
"sync.modeLocal": "Local folder",
"sync.modeWebdav": "WebDAV",
"sync.localPathPlaceholder": "Sync folder path",
"sync.pickFolder": "Pick folder",
"sync.webdavUrl": "WebDAV URL",
"sync.webdavUser": "WebDAV username",
"sync.schedule": "Schedule",
"sync.scheduleManual": "Manual",
"sync.scheduleHourly": "Hourly",
"sync.scheduleDaily": "Daily",
"sync.scheduleWeekly": "Weekly",
"sync.passwordPlaceholder": "Sync password (min 8 chars)",
"sync.passwordHint": "Set a sync password with at least 8 characters",
"sync.saveConfig": "Save sync config",
"sync.runNow": "Sync now",
"sync.running": "Syncing…",
"sync.done": "Sync complete",
"sync.configSaved": "Sync config saved",
"sync.lastSync": "Last sync: {{time}} ({{status}})",
"sync.conflictTitle": "Sync conflict",
"sync.conflictDesc": "Both local and remote changed. Choose which copy to keep.",
"sync.useRemote": "Use remote",
"sync.useLocal": "Use local",
"extensions.title": "Extensions",
"extensions.desc": "Extension API skeleton (no third-party code execution)",
"extensions.devMode": "Developer mode (manifest validation only)",
"extensions.stubHint": "Full extension marketplace coming in a future release.",
"update.available": "Update {{version}} available",
"update.download": "Download update",
"update.downloading": "Downloading {{percent}}%",
"update.install": "Restart to install",
"crash.title": "Unclean shutdown detected",
"crash.desc": "The app may have exited unexpectedly last time. You can send a local log report (stub).",
"crash.send": "Send report",
"crash.dismiss": "Dismiss",
"ai.settings.builtinSkills": "Built-in skills",
"ai.settings.skillContinue": "Continue writing",
"ai.settings.skillPolish": "Polish",
"ai.settings.skillProofread": "Proofread",
"ai.settings.mcpConnector": "MCP connector URL",
"ai.settings.mcpTest": "Test connection"
}
+407 -2
View File
@@ -13,6 +13,7 @@
"dialog.newBook.category": "分类",
"dialog.newBook.target": "目标总字数(可选)",
"dialog.cancel": "取消",
"dialog.save": "保存",
"dialog.create": "创建",
"settings.title": "系统设置",
"settings.general": "通用",
@@ -71,12 +72,17 @@
"ai.untitledSession": "新对话",
"ai.contextPlaceholder": "📎 上下文:尚未配置",
"ai.contextEmpty": "📎 上下文:尚未勾选",
"ai.contextSummary": "📎 上下文:{{chapters}}章 · 大纲{{outlines}}条 · 设定{{settings}}个 · 灵感{{inspirations}}条",
"ai.contextSummary": "📎 上下文:{{chapters}}章 · 大纲{{outlines}}条 · 设定{{settings}}个 · 灵感{{inspirations}}条 · 知识{{knowledge}}条",
"ai.contextEditorTitle": "编辑 AI 上下文",
"ai.contextTab.chapter": "章节",
"ai.contextTab.outline": "大纲",
"ai.contextTab.setting": "设定",
"ai.contextTab.inspiration": "灵感",
"ai.contextTab.knowledge": "知识库",
"ai.contextRefreshSuggest": "刷新推荐",
"ai.contextKnowledgeSuggestTitle": "推荐知识",
"ai.contextKnowledgeAllTitle": "全部已审核知识",
"ai.contextKnowledgeEmpty": "暂无已审核知识条目",
"ai.contextEmptyList": "暂无可选项",
"ai.contextRefresh": "更新上下文",
"ai.contextSave": "保存",
@@ -99,6 +105,7 @@
"interactive.step.scene": "场景",
"interactive.step.review": "确认",
"interactive.start": "开始交互",
"interactive.confirmKnowledge": "确认知识上下文",
"interactive.loadingPlots": "正在生成剧情走向…",
"interactive.plotNotePlaceholder": "补充指令(可选)",
"interactive.generateScene": "生成场景",
@@ -112,6 +119,41 @@
"interactive.chapterTitlePlaceholder": "章节标题(可选)",
"interactive.chapterCreated": "已生成交互初稿章节",
"interactive.sceneCount": "已确认 {{count}} 个场景",
"auto.gateBlocked": "请先创建章节正文、大纲或设定后再使用自动写作",
"auto.gateTooltip": "需要至少一章正文,或存在大纲/设定",
"auto.step.goal": "目标",
"auto.step.plan": "规划",
"auto.step.generate": "生成",
"auto.step.done": "完成",
"auto.goalPlaceholder": "本章需达成的剧情点…",
"auto.planScenes": "规划场景",
"auto.startGenerate": "生成下一场景",
"auto.sceneProgress": "场景 {{current}} / {{total}}",
"auto.pause": "暂停",
"auto.resume": "继续自动",
"auto.handoffInteractive": "转入交互微调",
"auto.finishChapter": "结束本章",
"auto.chapterCreated": "已生成自动写作章节",
"wizard.gateBlocked": "请先创建章节正文、大纲或设定后再使用向导写作",
"wizard.gateTooltip": "需要至少一章正文,或存在大纲/设定",
"wizard.step.goal": "目标",
"wizard.step.rhythm": "节奏",
"wizard.step.pov": "视角",
"wizard.step.context": "上下文",
"wizard.step.preview": "预览",
"wizard.rhythm.relaxed": "舒缓",
"wizard.rhythm.tense": "紧张",
"wizard.rhythm.mixed": "混合",
"wizard.styleNotePlaceholder": "可选:本章风格说明",
"wizard.contextHint": "确认与本章相关的上下文(含大纲/设定中的伏笔线索)",
"wizard.knowledgeList": "将注入知识",
"wizard.previewTitle": "生成策略预览",
"wizard.refreshPreview": "刷新预览",
"wizard.startGenerate": "开始生成",
"wizard.handoffToast": "已进入交互微调",
"wizard.next": "下一步",
"wizard.povDefault": "默认视角",
"wizard.generating": "正在生成章节…",
"snapshot.ai": "AI 生成前",
"naming.open": "🔍 命名一致性检查",
"naming.title": "命名一致性检查",
@@ -125,6 +167,160 @@
"naming.ignored": "已加入忽略词库",
"naming.replaced": "已替换 {{count}} 处",
"knowledge.placeholder": "知识库条目将在此展示(伏笔、角色状态等)",
"knowledge.new": "新建条目",
"knowledge.edit": "编辑条目",
"knowledge.type": "类型",
"knowledge.type.character": "角色",
"knowledge.type.foreshadow": "伏笔",
"knowledge.type.location": "地点",
"knowledge.type.relationship": "关系",
"knowledge.type.fact": "事实",
"knowledge.reason.highImportance": "高重要度",
"knowledge.reason.chapterRelevant": "与当前章相关",
"knowledge.reason.foreshadowBuried": "伏笔待回收",
"knowledge.reason.goalMatch": "匹配章节目标",
"knowledge.titleLabel": "标题",
"knowledge.contentLabel": "内容",
"knowledge.importance": "重要度",
"knowledge.foreshadowStatus": "伏笔状态",
"knowledge.foreshadow.buried": "已埋设",
"knowledge.foreshadow.partial": "部分回收",
"knowledge.foreshadow.resolved": "已回收",
"knowledge.sourceChapter": "来源章节",
"knowledge.noChapter": "(无)",
"knowledge.approveNow": "直接采纳",
"knowledge.tab.pending": "待审核",
"knowledge.tab.all": "全部",
"knowledge.tab.foreshadow": "伏笔",
"knowledge.approve": "采纳",
"knowledge.reject": "拒绝",
"knowledge.delete": "删除",
"knowledge.batchApprove": "批量采纳 ({{count}})",
"knowledge.empty": "暂无条目",
"knowledge.status.pending": "待审核",
"knowledge.status.approved": "已采纳",
"knowledge.status.rejected": "已拒绝",
"knowledge.statsSummary": "埋设 {{buried}} · 回收 {{resolved}} · 遗忘 {{forgotten}}",
"knowledge.syncFromLandmark": "是否同步此伏笔到知识库?",
"knowledge.syncedFromLandmark": "已同步到知识库(待审核)",
"knowledge.extractChapter": "抽取本章知识",
"knowledge.extractFromCurrent": "从当前章抽取",
"knowledge.batchHighConfidence": "全部采纳高置信度",
"knowledge.mergeSuggest": "建议更新:{{title}}",
"knowledge.approveMerge": "确认更新",
"knowledge.saveAsNew": "作为新条目",
"knowledge.extracting": "正在抽取知识…",
"knowledge.extractFailed": "知识抽取失败,请稍后重试",
"cockpit.title": "写作驾驶舱",
"cockpit.loading": "加载中…",
"cockpit.totalWords": "全书字数",
"cockpit.volumeWords": "本卷字数",
"cockpit.stockReady": "存稿缓冲",
"cockpit.foreshadow": "伏笔概览",
"cockpit.foreshadowDetail": "埋设 {{buried}} / 回收 {{resolved}} / 遗忘 {{forgotten}}",
"cockpit.resumeEdit": "继续写作",
"cockpit.bridgeCheck": "章间衔接检查",
"cockpit.forgottenForeshadow": "查看遗忘伏笔",
"cockpit.todayOutlineTasks": "今日大纲任务",
"cockpit.outlineTargetWords": "目标 {{count}} 字",
"cockpit.todayProgress": "今日 {{current}} / {{goal}} 字",
"cockpit.streak": "连更 {{days}} 天",
"cockpit.heatmap": "写作热力图",
"cockpit.achievements": "写作成就",
"cockpit.pomodoroToday": "今日番茄 {{count}} 次",
"cockpit.openGraph": "角色关系图谱",
"graph.title": "角色关系图谱",
"graph.layout.cose": "力导向",
"graph.layout.grid": "网格",
"graph.layout.circle": "圆形",
"graph.truncated": "角色过多,仅显示前 {{count}} 个",
"graph.sidebar.node": "角色:{{name}}",
"graph.sidebar.edge": "关系:{{label}}",
"graph.sidebar.hint": "点击节点或边查看详情",
"graph.editSetting": "编辑设定",
"graph.filter.all": "全部角色",
"graph.filter.connected": "仅有关系",
"relationship.title": "角色关系",
"relationship.add": "添加关系",
"relationship.target": "目标角色",
"relationship.label": "关系描述",
"relationship.intimacy": "亲密度",
"analytics.title": "写作分析",
"analytics.speedToday": "今日 {{wpm}} 字/分钟",
"analytics.speedUnknown": "暂无速度数据",
"analytics.hourlyHeatmap": "写作时段(近30天)",
"analytics.povDistribution": "视角字数分布",
"analytics.volumeDistribution": "按卷字数",
"analytics.weekTrend": "近 7 日字数",
"analytics.povUnassigned": "未指定视角",
"chapter.pov": "视角角色",
"pomodoro.start": "开始番茄钟",
"pomodoro.pause": "暂停",
"pomodoro.resume": "继续",
"pomodoro.cancel": "取消",
"pomodoro.complete": "番茄钟完成!本次写作 {{words}} 字",
"pomodoro.switchedBook": "已切换书籍,番茄钟已取消",
"goal.dailyMet": "今日目标已达成 {{current}} / {{goal}} 字",
"achievement.streak7": "连更 7 天",
"achievement.streak30": "连更 30 天",
"achievement.streak100": "连更 100 天",
"achievement.unlocked": "解锁成就:{{name}}",
"knowledge.injectionHistory": "注入历史",
"knowledge.injectionPreview": "最近注入",
"knowledge.injectionEntry": "{{date}} · {{mode}} · {{chapter}}",
"knowledge.injectionMode.interactive": "交互写作",
"knowledge.injectionMode.auto": "自动续写",
"knowledge.injectionMode.wizard": "向导模式",
"settings.pomodoroDuration": "番茄钟时长(分钟)",
"settings.systemNotifications": "启用系统通知",
"settings.goalNotifications": "启用目标达成提醒",
"settings.focusAmbientSound": "专注模式环境音效",
"settings.complianceCheck": "启用敏感词合规检查",
"settings.complianceWords": "自定义敏感词库",
"settings.complianceWordsHint": "每行一个词,将与内置词库合并扫描",
"timeline.open": "故事时间线",
"timeline.title": "故事时间线",
"timeline.empty": "暂无时间线事件",
"timeline.noTime": "未标记时间",
"timeline.manual": "手动事件",
"timeline.addEvent": "添加事件",
"timeline.jumpChapter": "跳转章节",
"timeline.eventTitlePrompt": "事件标题",
"timeline.storyTimePrompt": "故事时间(如 732 或 +10y",
"timeline.characterHint": "角色出生日期可在角色设定弧线面板中配置,用于冲突检测",
"arc.title": "角色弧线",
"arc.birthDate": "出生年份",
"arc.empty": "暂无弧线节点,写作后知识库会自动聚合",
"arc.jumpChapter": "跳转章节",
"reference.openInEditor": "在编辑器中打开",
"reference.detailTitle": "引用详情",
"ai.settings.customSlashCommands": "自定义快捷命令",
"ai.settings.customSlashName": "命令名(如 /大纲)",
"ai.settings.customSlashTemplate": "展开模板",
"ai.settings.customSlashAdd": "添加命令",
"bridge.title": "章间衔接",
"bridge.loading": "分析中…",
"bridge.previousTail": "上一章末尾",
"bridge.currentHead": "本章开头",
"bridge.aiSuggestion": "AI 建议",
"bridge.none": "(无)",
"bridge.empty": "无法获取衔接信息",
"bridge.dismissLabel": "不再提示本章",
"bridge.startWriting": "开始写作",
"publish.status.draft": "草稿",
"publish.status.ready": "待发布",
"publish.status.published": "已发布",
"settings.updateSchedule": "更新计划",
"settings.updateSchedule.none": "不提醒",
"settings.updateSchedule.daily": "日更",
"settings.updateSchedule.alternate": "隔日更",
"settings.updateSchedule.weekly": "周更",
"settings.stockBufferThreshold": "存稿缓冲阈值(章)",
"settings.dailyWordGoal": "每日写作目标(字)",
"settings.knowledgeAutoExtract": "成章后自动抽取知识",
"settings.extractThreshold": "高置信度阈值",
"stock.warning": "存稿不足:{{count}}/{{threshold}} 章待发布",
"landmark.foreshadowConfirm": "标记为伏笔地标?",
"ai.settings.backend": "后端类型",
"ai.settings.backend.lmstudio": "LM Studio(本地)",
"ai.settings.backend.openai": "OpenAI",
@@ -140,6 +336,7 @@
"status.chapter": "本章",
"status.volume": "本卷",
"status.book": "全书",
"status.dailyGoal": "今日 {{current}} / {{goal}}",
"toast.bookCreated": "书籍已创建",
"toast.saveFailed": "保存失败,请重试",
"toast.enterBookName": "请输入书名",
@@ -229,5 +426,213 @@
"landmark.created": "地标已插入",
"landmark.chapterOnly": "请在章节中插入地标",
"wordfreq.hint": "当前范围词频 Top 100",
"wordfreq.aiNaming": "AI 命名建议"
"wordfreq.aiNaming": "AI 命名建议",
"settings.templates": "章节模板",
"settings.submission": "投稿预设",
"dialog.edit": "编辑",
"dialog.delete": "删除",
"template.title": "章节模板",
"template.quickNew": "模板新建章",
"template.builtin": "内置",
"template.custom": "自定义",
"template.selectTemplate": "选择模板",
"template.chapterTitle": "章节标题",
"template.chapterTitlePlaceholder": "输入本章标题",
"template.createChapter": "创建章节",
"template.add": "新建模板",
"template.newTemplate": "新模板",
"template.name": "模板名称",
"template.body": "模板正文",
"import.title": "导入书籍",
"import.pickFile": "浏览文件",
"import.noFile": "未选择文件",
"import.splitMode": "分章规则",
"import.splitAuto": "自动识别章节",
"import.splitParagraph": "双换行分段",
"import.splitRegex": "自定义正则",
"import.customRegex": "正则表达式",
"import.preview": "预览分章",
"import.chapterCount": "共 {{count}} 章",
"import.largeFileConfirm": "文件较大({{size}}),导入可能需要 30 秒至 2 分钟,是否继续?",
"import.progress": "正在导入 {{current}}/{{total}}",
"import.start": "开始导入",
"import.importing": "导入中…",
"import.success": "导入成功",
"import.failed": "导入失败",
"export.title": "导出章节",
"export.submissionPresets": "投稿预设",
"export.copyClipboard": "复制到剪贴板",
"export.saveTxt": "另存为文本",
"export.copied": "已复制到剪贴板",
"export.saved": "已保存至 {{path}}",
"export.preview": "预览",
"export.loading": "加载中…",
"export.noPreview": "(无预览)",
"export.kind": "导出类型",
"export.kindSubmission": "投稿格式(当前章节)",
"export.kindNovel": "笔临项目包 (.novel)",
"export.novelDesc": "导出整本书的数据库、元数据与附件,可在其他设备或备份后通过「导入书籍」恢复。",
"export.exportNovel": "导出项目包",
"export.kindMatrix": "常规导出(txt/md/docx/pdf",
"export.matrixFormat": "文件格式",
"export.matrixScope": "导出范围",
"export.matrixOptions": "导出选项",
"export.scopeChapter": "当前章节",
"export.scopeVolume": "当前分卷",
"export.scopeBook": "全书",
"export.optionAnnotations": "包含批注/地标",
"export.optionAiMark": "标注 AI 生成内容",
"export.matrixStart": "导出文件",
"export.matrixSaved": "已保存至 {{path}}",
"export.matrixFailed": "导出失败",
"export.exporting": "导出中…",
"import.novelHint": "将导入 .novel 项目包中的整本书(含章节、设定与知识库)。",
"import.novelAllHint": "将批量导入 .novel-all 包中的所有书籍,请选择冲突处理策略。",
"pack.exportAllTitle": "导出所有书籍",
"pack.exportAllDesc": "将所有书籍与全局设置打包为 .novel-all 文件。",
"pack.estimating": "正在估算大小…",
"pack.estimatedSize": "预计大小:{{size}}",
"pack.noDest": "未选择保存路径",
"pack.pickDest": "选择保存位置",
"pack.largeExportConfirm": "导出体积较大({{size}}),确认继续?",
"pack.exportAllStart": "开始导出",
"pack.exportAllSuccess": "全部书籍已导出",
"pack.exportAllFailed": "导出失败",
"pack.exportBookSuccess": "「{{name}}」已导出为项目包",
"pack.exportBookFailed": "项目包导出失败",
"pack.exporting": "导出中…",
"pack.cancel": "取消",
"pack.progress": "进度 {{current}}/{{total}}",
"pack.importStrategy": "导入策略",
"pack.strategyNew": "全部作为新书导入",
"pack.strategySkip": "跳过已存在的书",
"pack.strategyOverwrite": "覆盖同名书籍",
"pack.importAllSuccess": "导入完成:新增 {{imported}},跳过 {{skipped}},覆盖 {{overwritten}}",
"backup.desc": "将书籍导出为笔临项目包,便于备份、迁移或在其他设备恢复。",
"backup.exportCurrentBook": "导出当前书籍",
"backup.exportBookBtn": "导出为 .novel",
"backup.exportAllBooks": "导出全部书籍",
"backup.exportAllBtn": "打开批量导出",
"backup.noBook": "请先在编辑器中打开一本书",
"backup.openBookHint": "在编辑器中打开书籍后可导出单书项目包",
"backup.exportBookSuccess": "「{{name}}」已导出",
"backup.exportBookFailed": "导出失败",
"backup.formatHint": "超过 500 MB 的批量导出需要额外确认。",
"submission.title": "投稿预设",
"submission.titleFormat": "标题格式",
"submission.indent": "段首空两格",
"submission.indentOn": "段首缩进:开",
"submission.indentOff": "段首缩进:关",
"submission.includeAuthorsNote": "包含作者的话",
"submission.paragraphGap": "段落间距",
"submission.gapSingle": "单倍行距",
"submission.gapDouble": "双倍行距",
"submission.add": "新建预设",
"submission.newPreset": "新投稿预设",
"submission.name": "预设名称",
"submission.setDefault": "设为默认预设",
"inspiration.selectBook": "关联书籍",
"inspiration.saved": "灵感已保存",
"inspiration.noBooks": "请先创建一本书再保存灵感",
"bookSettings.title": "书籍设置",
"bookSettings.synopsis": "简介",
"bookSettings.cover": "封面",
"bookSettings.pickCover": "选择封面图片",
"bookSettings.noCover": "未设置",
"bookSettings.alwaysShowCockpit": "每次打开显示驾驶舱",
"bookSettings.dangerZone": "危险操作",
"bookSettings.deleteBook": "删除此书",
"bookSettings.deleteConfirm": "确定删除「{{name}}」?此操作不可恢复。",
"volume.rename": "重命名分卷",
"volume.renamePrompt": "分卷名称",
"volume.editDescription": "编辑简述",
"volume.descriptionPrompt": "分卷简述",
"volume.delete": "删除分卷",
"volume.deleteConfirm": "确定删除分卷「{{name}}」?",
"volume.deleteHasChapters": "分卷内仍有章节,无法删除",
"chapter.delete": "删除章节",
"chapter.deleteConfirm": "确定删除章节「{{title}}」?",
"chapterMeta.title": "章节元数据",
"chapterMeta.open": "元数据",
"chapterMeta.summary": "章节摘要",
"chapterMeta.storyTime": "故事时间",
"chapterMeta.wordThreshold": "字数阈值",
"chapterMeta.tags": "标签",
"chapterMeta.tagPlaceholder": "输入标签",
"chapterMeta.addTag": "添加",
"chapterMeta.publishedAt": "发布于 {{date}}",
"ai.session.rename": "重命名会话",
"ai.session.renamePrompt": "会话标题",
"ai.session.archive": "归档",
"ai.session.unarchive": "取消归档",
"ai.session.export": "导出",
"ai.session.exportMd": "Markdown (.md)",
"ai.session.exportTxt": "纯文本 (.txt)",
"ai.session.delete": "删除会话",
"ai.session.deleteConfirm": "确定删除会话「{{title}}」?",
"ai.session.active": "活跃",
"ai.session.archived": "已归档",
"focus.toggle": "专注模式",
"focus.exit": "退出专注",
"tts.started": "开始朗读",
"tts.stopped": "已停止朗读",
"tts.noText": "没有可朗读的内容",
"home.exportAllWave2": "批量导出将在后续版本提供",
"readMode.title": "阅读模式",
"readMode.open": "阅读模式",
"readMode.exit": "退出阅读",
"readMode.deviceWidth": "设备宽度",
"readMode.devicePhone": "手机 (360px)",
"readMode.deviceTablet": "平板 (768px)",
"readMode.deviceDesktop": "桌面 (100%)",
"readMode.fontTheme": "字体主题",
"readMode.fontSerif": "衬线",
"readMode.fontSans": "无衬线",
"readMode.fontEyeCare": "护眼",
"readMode.tts": "朗读",
"settings.extensions": "扩展",
"sync.title": "云同步",
"sync.desc": "将加密后的全部书籍备份同步到 WebDAV 或本地云盘目录",
"sync.mode": "同步方式",
"sync.modeLocal": "本地目录",
"sync.modeWebdav": "WebDAV",
"sync.localPathPlaceholder": "选择或输入同步目录路径",
"sync.pickFolder": "选择目录",
"sync.webdavUrl": "WebDAV 地址",
"sync.webdavUser": "WebDAV 用户名",
"sync.schedule": "同步计划",
"sync.scheduleManual": "手动",
"sync.scheduleHourly": "每小时",
"sync.scheduleDaily": "每天",
"sync.scheduleWeekly": "每周",
"sync.passwordPlaceholder": "同步密码(至少 8 位)",
"sync.passwordHint": "请设置至少 8 位的同步密码",
"sync.saveConfig": "保存同步配置",
"sync.runNow": "立即同步",
"sync.running": "同步中…",
"sync.done": "同步完成",
"sync.configSaved": "同步配置已保存",
"sync.lastSync": "上次同步:{{time}}{{status}}",
"sync.conflictTitle": "同步冲突",
"sync.conflictDesc": "本地与远程均有更新,请选择保留哪一侧的数据。",
"sync.useRemote": "使用远程",
"sync.useLocal": "使用本地",
"extensions.title": "扩展",
"extensions.desc": "扩展 API 骨架(不执行第三方代码)",
"extensions.devMode": "开发者模式(仅校验 manifest",
"extensions.stubHint": "完整插件市场将在后续版本提供。",
"update.available": "新版本 {{version}} 可用",
"update.download": "下载更新",
"update.downloading": "下载中 {{percent}}%",
"update.install": "重启并安装",
"crash.title": "检测到异常退出",
"crash.desc": "上次使用时应用可能异常退出。可发送本地日志报告(当前为 stub)。",
"crash.send": "发送报告",
"crash.dismiss": "忽略",
"ai.settings.builtinSkills": "内置 Skill",
"ai.settings.skillContinue": "续写助手",
"ai.settings.skillPolish": "润色助手",
"ai.settings.skillProofread": "纠错助手",
"ai.settings.mcpConnector": "MCP 连接器 URL",
"ai.settings.mcpTest": "测试连接"
}
+55 -1
View File
@@ -3,8 +3,16 @@ import schemaV1 from './schema-v1.sql?raw'
import schemaV2 from './schema-v2.sql?raw'
import schemaV3 from './schema-v3.sql?raw'
import schemaV4 from './schema-v4.sql?raw'
import schemaV5 from './schema-v5.sql?raw'
import schemaV6 from './schema-v6.sql?raw'
import schemaV7 from './schema-v7.sql?raw'
import schemaV8 from './schema-v8.sql?raw'
import schemaV9 from './schema-v9.sql?raw'
const CURRENT_VERSION = 4
import schemaV10 from './schema-v10.sql?raw'
import schemaV11 from './schema-v11.sql?raw'
const CURRENT_VERSION = 11
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -56,5 +64,51 @@ export function migrate(db: SqliteDb): void {
tryAlter(db, "ALTER TABLE chapters ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'")
db.exec(schemaV4)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(4, 'P4 interactive schema')
current = 4
}
if (current < 5) {
tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive'")
tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}'")
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(5, 'P4.1 auto/wizard schema')
current = 5
}
if (current < 6) {
db.exec(schemaV6)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(6, 'P5 knowledge/cockpit schema')
current = 6
}
if (current < 7) {
db.exec(schemaV7)
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN extraction_confidence REAL')
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN merge_target_id TEXT')
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN extraction_batch_id TEXT')
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(7, 'P5.2 writing logs extraction')
current = 7
}
if (current < 8) {
db.exec(schemaV8)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection')
current = 8
}
if (current < 9) {
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN published_at TEXT DEFAULT NULL')
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN word_count_threshold INTEGER DEFAULT NULL')
db.exec(schemaV9)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(9, 'W1 foundation')
}
if (current < 10) {
db.exec(schemaV10)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(10, 'W4 timeline')
}
if (current < 11) {
db.exec(schemaV11)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(11, 'W5 undo stack')
}
}
+4 -2
View File
@@ -6,7 +6,8 @@ const EMPTY_CONTEXT: AiContextBinding = {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: []
inspirationIds: [],
knowledgeIds: []
}
function parseContext(json: string): AiContextBinding {
@@ -16,7 +17,8 @@ function parseContext(json: string): AiContextBinding {
chapterIds: parsed.chapterIds ?? [],
outlineIds: parsed.outlineIds ?? [],
settingIds: parsed.settingIds ?? [],
inspirationIds: parsed.inspirationIds ?? []
inspirationIds: parsed.inspirationIds ?? [],
knowledgeIds: parsed.knowledgeIds ?? []
}
} catch {
return { ...EMPTY_CONTEXT }
@@ -0,0 +1,21 @@
import type { SqliteDb } from '../types'
export class BookPrefsRepository {
constructor(private db: SqliteDb) {}
get(key: string): string | null {
const row = this.db.prepare('SELECT value FROM book_preferences WHERE key = ?').get(key) as
| { value: string }
| undefined
return row?.value ?? null
}
set(key: string, value: string): void {
this.db
.prepare(
`INSERT INTO book_preferences (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
)
.run(key, value)
}
}
@@ -0,0 +1,21 @@
import type { SqliteDb } from '../types'
export class BridgeDismissRepository {
constructor(private db: SqliteDb) {}
isDismissed(chapterId: string): boolean {
const row = this.db
.prepare('SELECT chapter_id FROM chapter_bridge_dismiss WHERE chapter_id = ?')
.get(chapterId)
return Boolean(row)
}
dismiss(chapterId: string): void {
this.db
.prepare(
`INSERT INTO chapter_bridge_dismiss (chapter_id) VALUES (?)
ON CONFLICT(chapter_id) DO UPDATE SET dismissed_at = datetime('now')`
)
.run(chapterId)
}
}
@@ -0,0 +1,44 @@
import type { ChapterTag } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): ChapterTag {
return {
chapterId: row.chapter_id as string,
tag: row.tag as string,
color: (row.color as string) ?? ''
}
}
export class ChapterTagRepository {
constructor(private db: SqliteDb) {}
list(chapterId: string): ChapterTag[] {
return (
this.db
.prepare('SELECT * FROM chapter_tags WHERE chapter_id = ? ORDER BY tag')
.all(chapterId) as Record<string, unknown>[]
).map(mapRow)
}
listAll(): ChapterTag[] {
return (
this.db.prepare('SELECT * FROM chapter_tags ORDER BY chapter_id, tag').all() as Record<
string,
unknown
>[]
).map(mapRow)
}
set(chapterId: string, tag: string, color = ''): void {
this.db
.prepare(
`INSERT INTO chapter_tags (chapter_id, tag, color) VALUES (?, ?, ?)
ON CONFLICT(chapter_id, tag) DO UPDATE SET color = excluded.color`
)
.run(chapterId, tag, color)
}
remove(chapterId: string, tag: string): void {
this.db.prepare('DELETE FROM chapter_tags WHERE chapter_id = ? AND tag = ?').run(chapterId, tag)
}
}
@@ -0,0 +1,35 @@
import type { SqliteDb } from '../types'
export interface ChapterWritingSnapshot {
loggedWordCount: number
finishSupplemented: boolean
}
export class ChapterWritingSnapshotRepository {
constructor(private db: SqliteDb) {}
get(chapterId: string): ChapterWritingSnapshot | null {
const row = this.db
.prepare(
'SELECT logged_word_count, finish_supplemented FROM chapter_writing_snapshots WHERE chapter_id = ?'
)
.get(chapterId) as { logged_word_count: number; finish_supplemented: number } | undefined
if (!row) return null
return {
loggedWordCount: row.logged_word_count,
finishSupplemented: row.finish_supplemented === 1
}
}
upsert(chapterId: string, loggedWordCount: number, finishSupplemented = false): void {
this.db
.prepare(
`INSERT INTO chapter_writing_snapshots (chapter_id, logged_word_count, finish_supplemented)
VALUES (?, ?, ?)
ON CONFLICT(chapter_id) DO UPDATE SET
logged_word_count = excluded.logged_word_count,
finish_supplemented = excluded.finish_supplemented`
)
.run(chapterId, loggedWordCount, finishSupplemented ? 1 : 0)
}
}
+61 -4
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'crypto'
import { countWords } from '../../services/word-count'
import type { Chapter, ChapterOrigin, ChapterStatus } from '../../../shared/types'
import type { Chapter, ChapterOrigin, ChapterStatus, PublishStatus } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): Chapter {
return {
@@ -12,7 +12,13 @@ function mapRow(row: Record<string, unknown>): Chapter {
wordCount: row.word_count as number,
sortOrder: row.sort_order as number,
cursorOffset: (row.cursor_offset as number) ?? 0,
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus,
povCharacterId: (row.pov_character_id as string) || null,
summary: (row.summary as string) ?? '',
storyTime: (row.story_time as string) ?? null,
publishedAt: (row.published_at as string) ?? null,
wordCountThreshold: (row.word_count_threshold as number) ?? null
}
}
@@ -59,19 +65,34 @@ export class ChapterRepository {
update(
id: string,
patch: Partial<{ title: string; content: string; status: ChapterStatus; cursorOffset: number }>
patch: Partial<{
title: string
content: string
status: ChapterStatus
cursorOffset: number
publishStatus: PublishStatus
povCharacterId: string | null
summary: string
storyTime: string | null
publishedAt: string | null
wordCountThreshold: number | null
}>
): Chapter {
const existing = this.get(id)
if (!existing) throw new Error('Chapter not found')
const content = patch.content ?? existing.content
const wordCount = countWords(content)
const povCharacterId =
patch.povCharacterId !== undefined ? patch.povCharacterId : existing.povCharacterId ?? null
this.db
.prepare(
`UPDATE chapters SET
title = ?, content = ?, status = ?, cursor_offset = ?,
word_count = ?, updated_at = datetime('now')
publish_status = ?, pov_character_id = ?, word_count = ?,
summary = ?, story_time = ?, published_at = ?, word_count_threshold = ?,
updated_at = datetime('now')
WHERE id = ?`
)
.run(
@@ -79,12 +100,48 @@ export class ChapterRepository {
content,
patch.status ?? existing.status,
patch.cursorOffset ?? existing.cursorOffset,
patch.publishStatus ?? existing.publishStatus ?? 'draft',
povCharacterId,
wordCount,
patch.summary ?? existing.summary ?? '',
patch.storyTime !== undefined ? patch.storyTime : (existing.storyTime ?? null),
patch.publishedAt !== undefined ? patch.publishedAt : (existing.publishedAt ?? null),
patch.wordCountThreshold !== undefined
? patch.wordCountThreshold
: (existing.wordCountThreshold ?? null),
id
)
return this.get(id)!
}
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
const patch: Parameters<ChapterRepository['update']>[1] = { publishStatus }
if (publishStatus === 'published') {
patch.publishedAt = new Date().toISOString()
}
return this.update(id, patch)
}
countByPublishStatus(status: PublishStatus): number {
const row = this.db
.prepare('SELECT COUNT(*) AS c FROM chapters WHERE publish_status = ?')
.get(status) as { c: number }
return row.c
}
getPreviousChapter(chapterId: string): Chapter | null {
const current = this.get(chapterId)
if (!current) return null
const row = this.db
.prepare(
`SELECT * FROM chapters
WHERE sort_order < ?
ORDER BY sort_order DESC LIMIT 1`
)
.get(current.sortOrder) as Record<string, unknown> | undefined
return row ? mapRow(row) : null
}
delete(id: string): void {
this.db.prepare('DELETE FROM chapters WHERE id = ?').run(id)
}
+63 -9
View File
@@ -1,10 +1,13 @@
import { randomUUID } from 'crypto'
import type {
AutoWritingConfig,
InteractiveFlow,
InteractiveFlowStatus,
InteractiveScene,
InteractiveStep,
NamingPause
NamingPause,
WizardWritingConfig,
WritingFlowMode,
WritingStep
} from '../../../shared/types'
import type { SqliteDb } from '../types'
@@ -27,33 +30,47 @@ export class InteractiveRepository {
return row ? this.mapFlow(row) : null
}
create(sessionId: string): InteractiveFlow {
create(
sessionId: string,
flowMode: WritingFlowMode = 'interactive',
initialStep?: WritingStep
): InteractiveFlow {
const id = randomUUID()
const step =
initialStep ??
(flowMode === 'auto'
? 'goal_setup'
: flowMode === 'wizard'
? 'wizard_goal'
: 'context_confirm')
this.db
.prepare(
`INSERT INTO interactive_flows (id, session_id, step, status, context_json) VALUES (?, ?, 'context_confirm', 'in_progress', '{}')`
`INSERT INTO interactive_flows (id, session_id, step, status, context_json, flow_mode, mode_config_json)
VALUES (?, ?, ?, 'in_progress', '{}', ?, '{}')`
)
.run(id, sessionId)
.run(id, sessionId, step, flowMode)
return this.get(id)!
}
resetInProgress(sessionId: string): InteractiveFlow {
resetInProgress(sessionId: string, flowMode: WritingFlowMode = 'interactive'): InteractiveFlow {
this.db
.prepare(
`UPDATE interactive_flows SET status = 'abandoned', updated_at = datetime('now') WHERE session_id = ? AND status = 'in_progress'`
)
.run(sessionId)
return this.create(sessionId)
return this.create(sessionId, flowMode)
}
updateStep(
flowId: string,
step: InteractiveStep,
step: WritingStep,
patch: {
contextJson?: string
plotSelectionJson?: string | null
namingPendingJson?: string | null
sceneDraftHtml?: string | null
modeConfigJson?: string
flowMode?: WritingFlowMode
status?: InteractiveFlowStatus
} = {}
): InteractiveFlow {
@@ -75,6 +92,14 @@ export class InteractiveRepository {
sets.push('scene_draft_html = ?')
vals.push(patch.sceneDraftHtml)
}
if (patch.modeConfigJson !== undefined) {
sets.push('mode_config_json = ?')
vals.push(patch.modeConfigJson)
}
if (patch.flowMode !== undefined) {
sets.push('flow_mode = ?')
vals.push(patch.flowMode)
}
if (patch.status !== undefined) {
sets.push('status = ?')
vals.push(patch.status)
@@ -84,6 +109,27 @@ export class InteractiveRepository {
return this.get(flowId)!
}
getModeConfig<T extends AutoWritingConfig | WizardWritingConfig>(
flowId: string
): T {
const row = this.db
.prepare('SELECT mode_config_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { mode_config_json: string } | undefined
try {
return JSON.parse(row?.mode_config_json ?? '{}') as T
} catch {
return {} as T
}
}
setModeConfig(flowId: string, config: AutoWritingConfig | WizardWritingConfig): void {
this.db
.prepare(
`UPDATE interactive_flows SET mode_config_json = ?, updated_at = datetime('now') WHERE id = ?`
)
.run(JSON.stringify(config), flowId)
}
listScenes(flowId: string): InteractiveScene[] {
const rows = this.db
.prepare('SELECT * FROM interactive_scenes WHERE flow_id = ? ORDER BY sort_order')
@@ -156,11 +202,19 @@ export class InteractiveRepository {
namingPending = null
}
}
let modeConfig: AutoWritingConfig | WizardWritingConfig | Record<string, never> = {}
try {
modeConfig = JSON.parse((row.mode_config_json as string) ?? '{}')
} catch {
modeConfig = {}
}
return {
id: flowId,
sessionId: row.session_id as string,
step: row.step as InteractiveStep,
flowMode: (row.flow_mode as WritingFlowMode) ?? 'interactive',
step: row.step as WritingStep,
status: row.status as InteractiveFlowStatus,
modeConfig,
scenes: this.listScenes(flowId),
namingPending,
contextSummary: undefined
@@ -0,0 +1,53 @@
import { randomUUID } from 'crypto'
import type { InjectionFlowMode, KnowledgeInjectionLog } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): KnowledgeInjectionLog {
return {
id: row.id as string,
knowledgeEntryId: row.knowledge_entry_id as string,
flowMode: row.flow_mode as InjectionFlowMode,
flowId: row.flow_id as string,
chapterId: (row.chapter_id as string) || undefined,
injectedAt: row.injected_at as string
}
}
export class KnowledgeInjectionRepository {
constructor(private db: SqliteDb) {}
insertBatch(
rows: Array<{
knowledgeEntryId: string
flowMode: InjectionFlowMode
flowId: string
chapterId?: string
}>
): void {
const stmt = this.db.prepare(
`INSERT INTO knowledge_injection_logs
(id, knowledge_entry_id, flow_mode, flow_id, chapter_id)
VALUES (?, ?, ?, ?, ?)`
)
for (const row of rows) {
stmt.run(
randomUUID(),
row.knowledgeEntryId,
row.flowMode,
row.flowId,
row.chapterId ?? null
)
}
}
listForEntry(entryId: string, limit = 50): KnowledgeInjectionLog[] {
const rows = this.db
.prepare(
`SELECT * FROM knowledge_injection_logs
WHERE knowledge_entry_id = ?
ORDER BY injected_at DESC LIMIT ?`
)
.all(entryId, limit) as Record<string, unknown>[]
return rows.map(mapRow)
}
}
+177
View File
@@ -0,0 +1,177 @@
import { randomUUID } from 'crypto'
import type {
CreateKnowledgeInput,
ForeshadowStatus,
KnowledgeEntry,
KnowledgeStatus,
KnowledgeType
} from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): KnowledgeEntry {
return {
id: row.id as string,
type: row.type as KnowledgeType,
title: row.title as string,
content: row.content as string,
importance: row.importance as number,
status: row.status as KnowledgeStatus,
foreshadowStatus: (row.foreshadow_status as ForeshadowStatus) || undefined,
sourceChapterId: (row.source_chapter_id as string) || undefined,
lastMentionChapterId: (row.last_mention_chapter_id as string) || undefined,
linkedBookmarkId: (row.linked_bookmark_id as string) || undefined,
extractionConfidence: (row.extraction_confidence as number) ?? undefined,
mergeTargetId: (row.merge_target_id as string) || undefined,
extractionBatchId: (row.extraction_batch_id as string) || undefined,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string
}
}
export class KnowledgeRepository {
constructor(private db: SqliteDb) {}
create(input: CreateKnowledgeInput): KnowledgeEntry {
const id = randomUUID()
const status = input.status ?? 'pending'
this.db
.prepare(
`INSERT INTO knowledge_entries (
id, type, title, content, importance, status, foreshadow_status,
source_chapter_id, last_mention_chapter_id, linked_bookmark_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
input.type,
input.title,
input.content ?? '',
input.importance ?? 3,
status,
input.foreshadowStatus ?? null,
input.sourceChapterId ?? null,
input.lastMentionChapterId ?? null,
input.linkedBookmarkId ?? null
)
return this.get(id)!
}
createExtracted(
input: CreateKnowledgeInput & {
extractionConfidence?: number
mergeTargetId?: string
extractionBatchId?: string
}
): KnowledgeEntry {
const id = randomUUID()
const status = input.status ?? 'pending'
this.db
.prepare(
`INSERT INTO knowledge_entries (
id, type, title, content, importance, status, foreshadow_status,
source_chapter_id, last_mention_chapter_id, linked_bookmark_id,
extraction_confidence, merge_target_id, extraction_batch_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
input.type,
input.title,
input.content ?? '',
input.importance ?? 3,
status,
input.foreshadowStatus ?? null,
input.sourceChapterId ?? null,
input.lastMentionChapterId ?? null,
input.linkedBookmarkId ?? null,
input.extractionConfidence ?? null,
input.mergeTargetId ?? null,
input.extractionBatchId ?? null
)
return this.get(id)!
}
get(id: string): KnowledgeEntry | null {
const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
list(filter?: { status?: KnowledgeStatus; type?: KnowledgeType }): KnowledgeEntry[] {
let sql = 'SELECT * FROM knowledge_entries WHERE 1=1'
const params: unknown[] = []
if (filter?.status) {
sql += ' AND status = ?'
params.push(filter.status)
}
if (filter?.type) {
sql += ' AND type = ?'
params.push(filter.type)
}
sql += ' ORDER BY updated_at DESC'
return (this.db.prepare(sql).all(...params) as Record<string, unknown>[]).map(mapRow)
}
update(id: string, patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>): KnowledgeEntry {
const existing = this.get(id)
if (!existing) throw new Error('Knowledge entry not found')
this.db
.prepare(
`UPDATE knowledge_entries SET
type = ?, title = ?, content = ?, importance = ?, status = ?,
foreshadow_status = ?, source_chapter_id = ?, last_mention_chapter_id = ?,
linked_bookmark_id = ?, updated_at = datetime('now')
WHERE id = ?`
)
.run(
patch.type ?? existing.type,
patch.title ?? existing.title,
patch.content ?? existing.content,
patch.importance ?? existing.importance,
patch.status ?? existing.status,
patch.foreshadowStatus ?? existing.foreshadowStatus ?? null,
patch.sourceChapterId ?? existing.sourceChapterId ?? null,
patch.lastMentionChapterId ?? existing.lastMentionChapterId ?? null,
patch.linkedBookmarkId ?? existing.linkedBookmarkId ?? null,
id
)
return this.get(id)!
}
clearMergeTarget(id: string): KnowledgeEntry {
this.db
.prepare(
`UPDATE knowledge_entries SET merge_target_id = NULL, updated_at = datetime('now') WHERE id = ?`
)
.run(id)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id)
}
countByStatus(status: KnowledgeStatus): number {
const row = this.db
.prepare('SELECT COUNT(*) AS c FROM knowledge_entries WHERE status = ?')
.get(status) as { c: number }
return row.c
}
batchApproveHighConfidence(threshold: number): number {
const rows = this.db
.prepare(
`SELECT id FROM knowledge_entries
WHERE status = 'pending' AND merge_target_id IS NULL
AND extraction_confidence IS NOT NULL AND extraction_confidence >= ?`
)
.all(threshold) as { id: string }[]
let count = 0
for (const { id } of rows) {
this.update(id, { status: 'approved' })
count++
}
return count
}
}
@@ -0,0 +1,40 @@
import type { DatabaseSync } from 'node:sqlite'
import { localDateString } from '../../services/writing-date.util'
export class PomodoroDailyRepository {
constructor(private db: DatabaseSync) {}
recordComplete(bookId: string, wordDelta: number, date = localDateString()): void {
this.db
.prepare(
`INSERT INTO pomodoro_daily (date, book_id, completed_count, total_words)
VALUES (?, ?, 1, ?)
ON CONFLICT(date, book_id) DO UPDATE SET
completed_count = completed_count + 1,
total_words = total_words + excluded.total_words`
)
.run(date, bookId, wordDelta)
}
getTodayCount(bookId: string, date = localDateString()): number {
const row = this.db
.prepare('SELECT completed_count FROM pomodoro_daily WHERE date = ? AND book_id = ?')
.get(date, bookId) as { completed_count: number } | undefined
return row?.completed_count ?? 0
}
getTodayStats(
bookId: string,
date = localDateString()
): { completedCount: number; totalWords: number } {
const row = this.db
.prepare(
'SELECT completed_count, total_words FROM pomodoro_daily WHERE date = ? AND book_id = ?'
)
.get(date, bookId) as { completed_count: number; total_words: number } | undefined
return {
completedCount: row?.completed_count ?? 0,
totalWords: row?.total_words ?? 0
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import { randomUUID } from 'crypto'
import type { TimelineEntry } from '../../../shared/timeline'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): TimelineEntry {
return {
id: row.id as string,
kind: 'manual',
title: row.title as string,
storyTime: (row.story_time as string) ?? null,
sortOrder: row.sort_order as number,
notes: (row.notes as string) ?? ''
}
}
export class TimelineRepository {
constructor(private db: SqliteDb) {}
listManual(): TimelineEntry[] {
return (
this.db
.prepare('SELECT * FROM timeline_events ORDER BY sort_order ASC, created_at ASC')
.all() as Record<string, unknown>[]
).map(mapRow)
}
upsert(input: {
id?: string
title: string
storyTime?: string | null
sortOrder?: number
notes?: string
}): TimelineEntry {
const id = input.id ?? randomUUID()
const existing = this.db.prepare('SELECT id FROM timeline_events WHERE id = ?').get(id)
if (existing) {
this.db
.prepare(
`UPDATE timeline_events SET title = ?, story_time = ?, sort_order = ?, notes = ? WHERE id = ?`
)
.run(
input.title,
input.storyTime ?? null,
input.sortOrder ?? 0,
input.notes ?? '',
id
)
} else {
this.db
.prepare(
`INSERT INTO timeline_events (id, title, story_time, sort_order, notes) VALUES (?, ?, ?, ?, ?)`
)
.run(id, input.title, input.storyTime ?? null, input.sortOrder ?? 0, input.notes ?? '')
}
return mapRow(this.db.prepare('SELECT * FROM timeline_events WHERE id = ?').get(id) as Record<string, unknown>)
}
delete(id: string): void {
this.db.prepare('DELETE FROM timeline_events WHERE id = ?').run(id)
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { UndoOperation, UndoStackEntry } from '../../../shared/undo'
import type { SqliteDb } from '../types'
const MAX_ENTRIES = 50
function mapRow(row: Record<string, unknown>): UndoStackEntry {
let operation: UndoOperation
try {
operation = JSON.parse(row.operation as string) as UndoOperation
} catch {
operation = { type: 'doc', content: '' }
}
return {
id: row.id as number,
chapterId: row.chapter_id as string,
operation,
timestamp: row.timestamp as string
}
}
export class UndoStackRepository {
constructor(private db: SqliteDb) {}
push(chapterId: string, operation: UndoOperation): UndoStackEntry {
const json = JSON.stringify(operation)
const result = this.db
.prepare('INSERT INTO undo_stack (chapter_id, operation) VALUES (?, ?)')
.run(chapterId, json)
const id = Number(result.lastInsertRowid)
this.trim(chapterId)
return this.get(id)!
}
list(chapterId: string, limit = MAX_ENTRIES): UndoStackEntry[] {
return (
this.db
.prepare(
'SELECT * FROM undo_stack WHERE chapter_id = ? ORDER BY id DESC LIMIT ?'
)
.all(chapterId, limit) as Record<string, unknown>[]
).map(mapRow)
}
get(id: number): UndoStackEntry | null {
const row = this.db.prepare('SELECT * FROM undo_stack WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
private trim(chapterId: string): void {
const rows = this.db
.prepare(
'SELECT id FROM undo_stack WHERE chapter_id = ? ORDER BY id DESC LIMIT -1 OFFSET ?'
)
.all(chapterId, MAX_ENTRIES) as Array<{ id: number }>
for (const row of rows) {
this.db.prepare('DELETE FROM undo_stack WHERE id = ?').run(row.id)
}
}
}
@@ -0,0 +1,106 @@
import { existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import { DatabaseSync } from 'node:sqlite'
import type { WritingDayLog } from '../../../shared/types'
import { addDays, localDateString } from '../../services/writing-date.util'
export class WritingLogRepository {
private db: DatabaseSync
constructor(userDataDir: string) {
if (!existsSync(userDataDir)) mkdirSync(userDataDir, { recursive: true })
this.db = new DatabaseSync(join(userDataDir, 'writing_logs.sqlite'))
this.migrate()
}
private migrate(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS writing_logs (
date TEXT NOT NULL,
book_id TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, book_id)
);
CREATE TABLE IF NOT EXISTS writing_milestones (
book_id TEXT NOT NULL,
milestone TEXT NOT NULL,
unlocked_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (book_id, milestone)
);
CREATE TABLE IF NOT EXISTS pomodoro_daily (
date TEXT NOT NULL,
book_id TEXT NOT NULL,
completed_count INTEGER NOT NULL DEFAULT 0,
total_words INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, book_id)
);
CREATE TABLE IF NOT EXISTS writing_sessions (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
chapter_id TEXT,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
word_delta INTEGER NOT NULL DEFAULT 0,
date TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_book_date ON writing_sessions (book_id, date);
CREATE INDEX IF NOT EXISTS idx_sessions_book_started ON writing_sessions (book_id, started_at)
`)
}
getDb(): DatabaseSync {
return this.db
}
addToday(bookId: string, delta: number, date = localDateString()): number {
const row = this.db
.prepare('SELECT word_count FROM writing_logs WHERE date = ? AND book_id = ?')
.get(date, bookId) as { word_count: number } | undefined
const next = Math.max(0, (row?.word_count ?? 0) + delta)
this.db
.prepare(
`INSERT INTO writing_logs (date, book_id, word_count) VALUES (?, ?, ?)
ON CONFLICT(date, book_id) DO UPDATE SET word_count = excluded.word_count`
)
.run(date, bookId, next)
return next
}
getToday(bookId: string, date = localDateString()): number {
const row = this.db
.prepare('SELECT word_count FROM writing_logs WHERE date = ? AND book_id = ?')
.get(date, bookId) as { word_count: number } | undefined
return row?.word_count ?? 0
}
getDay(bookId: string, date: string): number {
return this.getToday(bookId, date)
}
listRange(bookId: string, fromDate: string, toDate: string): WritingDayLog[] {
const rows = this.db
.prepare(
`SELECT date, book_id, word_count FROM writing_logs
WHERE book_id = ? AND date >= ? AND date <= ?
ORDER BY date ASC`
)
.all(bookId, fromDate, toDate) as Array<{ date: string; book_id: string; word_count: number }>
return rows.map((r) => ({ date: r.date, bookId: r.book_id, wordCount: r.word_count }))
}
buildHeatmap(bookId: string, days: number): WritingDayLog[] {
const today = localDateString()
const from = addDays(today, -(days - 1))
const existing = new Map(this.listRange(bookId, from, today).map((l) => [l.date, l.wordCount]))
const result: WritingDayLog[] = []
for (let i = 0; i < days; i++) {
const date = addDays(from, i)
result.push({ date, bookId, wordCount: existing.get(date) ?? 0 })
}
return result
}
close(): void {
this.db.close()
}
}
@@ -0,0 +1,37 @@
import type { DatabaseSync } from 'node:sqlite'
import type { WritingAchievement, WritingMilestone } from '../../../shared/types'
const ORDER: WritingMilestone[] = ['streak_7', 'streak_30', 'streak_100']
export class WritingMilestoneRepository {
constructor(private db: DatabaseSync) {}
list(bookId: string): WritingAchievement[] {
const rows = this.db
.prepare('SELECT milestone, unlocked_at FROM writing_milestones WHERE book_id = ?')
.all(bookId) as Array<{ milestone: string; unlocked_at: string }>
return rows
.map((r) => ({ milestone: r.milestone as WritingMilestone, unlockedAt: r.unlocked_at }))
.sort((a, b) => ORDER.indexOf(a.milestone) - ORDER.indexOf(b.milestone))
}
has(bookId: string, milestone: WritingMilestone): boolean {
const row = this.db
.prepare('SELECT 1 FROM writing_milestones WHERE book_id = ? AND milestone = ?')
.get(bookId, milestone)
return row != null
}
unlock(bookId: string, milestone: WritingMilestone): WritingAchievement | null {
if (this.has(bookId, milestone)) return null
this.db
.prepare(
`INSERT INTO writing_milestones (book_id, milestone) VALUES (?, ?)`
)
.run(bookId, milestone)
const row = this.db
.prepare('SELECT unlocked_at FROM writing_milestones WHERE book_id = ? AND milestone = ?')
.get(bookId, milestone) as { unlocked_at: string }
return { milestone, unlockedAt: row.unlocked_at }
}
}
@@ -0,0 +1,83 @@
import type { DatabaseSync } from 'node:sqlite'
export interface WritingSessionRow {
id: string
bookId: string
chapterId?: string
startedAt: string
endedAt: string
wordDelta: number
date: string
}
function mapRow(row: Record<string, unknown>): WritingSessionRow {
return {
id: row.id as string,
bookId: row.book_id as string,
chapterId: (row.chapter_id as string) || undefined,
startedAt: row.started_at as string,
endedAt: row.ended_at as string,
wordDelta: row.word_delta as number,
date: row.date as string
}
}
export class WritingSessionRepository {
constructor(private db: DatabaseSync) {}
insert(row: {
id: string
bookId: string
chapterId?: string
startedAt: string
endedAt: string
wordDelta: number
date: string
}): void {
this.db
.prepare(
`INSERT INTO writing_sessions (id, book_id, chapter_id, started_at, ended_at, word_delta, date)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
row.id,
row.bookId,
row.chapterId ?? null,
row.startedAt,
row.endedAt,
row.wordDelta,
row.date
)
}
updateActivity(id: string, endedAt: string, wordDelta: number): void {
this.db
.prepare(
`UPDATE writing_sessions SET ended_at = ?, word_delta = word_delta + ? WHERE id = ?`
)
.run(endedAt, wordDelta, id)
}
getById(id: string): WritingSessionRow | null {
const row = this.db.prepare('SELECT * FROM writing_sessions WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
listForBookDate(bookId: string, date: string): WritingSessionRow[] {
const rows = this.db
.prepare('SELECT * FROM writing_sessions WHERE book_id = ? AND date = ? ORDER BY started_at')
.all(bookId, date) as Record<string, unknown>[]
return rows.map(mapRow)
}
listSince(bookId: string, sinceIso: string): WritingSessionRow[] {
const rows = this.db
.prepare(
`SELECT * FROM writing_sessions WHERE book_id = ? AND started_at >= ? ORDER BY started_at`
)
.all(bookId, sinceIso) as Record<string, unknown>[]
return rows.map(mapRow)
}
}
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS timeline_events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
story_time TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS undo_stack (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chapter_id TEXT NOT NULL,
operation TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_undo_stack_chapter ON undo_stack(chapter_id, id DESC);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive';
ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}';
+30
View File
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS knowledge_entries (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
importance INTEGER NOT NULL DEFAULT 3,
status TEXT NOT NULL DEFAULT 'pending',
foreshadow_status TEXT,
source_chapter_id TEXT,
last_mention_chapter_id TEXT,
linked_bookmark_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (source_chapter_id) REFERENCES chapters(id) ON DELETE SET NULL,
FOREIGN KEY (last_mention_chapter_id) REFERENCES chapters(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_knowledge_status ON knowledge_entries(status);
CREATE INDEX IF NOT EXISTS idx_knowledge_type ON knowledge_entries(type);
CREATE TABLE IF NOT EXISTS book_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS chapter_bridge_dismiss (
chapter_id TEXT PRIMARY KEY,
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
+6
View File
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS chapter_writing_snapshots (
chapter_id TEXT PRIMARY KEY,
logged_word_count INTEGER NOT NULL DEFAULT 0,
finish_supplemented INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS knowledge_injection_logs (
id TEXT PRIMARY KEY,
knowledge_entry_id TEXT NOT NULL,
flow_mode TEXT NOT NULL,
flow_id TEXT NOT NULL,
chapter_id TEXT,
injected_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (knowledge_entry_id) REFERENCES knowledge_entries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_injection_entry_time
ON knowledge_injection_logs (knowledge_entry_id, injected_at DESC);
+7
View File
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS chapter_tags (
chapter_id TEXT NOT NULL,
tag TEXT NOT NULL,
color TEXT DEFAULT '',
PRIMARY KEY (chapter_id, tag),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
+10
View File
@@ -0,0 +1,10 @@
import { registerExportMatrixHandlers } from './ipc/handlers/export-matrix.handler'
import type { BookRegistryService } from './services/book-registry'
import type { GlobalSettingsService } from './services/global-settings'
export function bootstrapExportMatrixHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
registerExportMatrixHandlers(registry, settings)
}
+8
View File
@@ -0,0 +1,8 @@
import type { BookRegistryService } from './services/book-registry'
import { registerImportHandlers } from './ipc/handlers/import.handler'
export { registerImportHandlers }
export function bootstrapImportHandlers(registry: BookRegistryService): void {
registerImportHandlers(registry)
}
+10 -2
View File
@@ -50,8 +50,16 @@ function createWindow(): void { mainWindow = new BrowserWindow({
getNetworkMonitor()?.start(() => mainWindow)
}
app.whenReady().then(() => {
registerIpc()
app.whenReady().then(async () => {
const { books, settings } = registerIpc()
const { bootstrapImportHandlers } = await import('./import-bootstrap.js')
bootstrapImportHandlers(books)
const { bootstrapPackHandlers } = await import('./pack-bootstrap.js')
bootstrapPackHandlers(books, settings)
const { bootstrapExportMatrixHandlers } = await import('./export-matrix-bootstrap.js')
bootstrapExportMatrixHandlers(books, settings)
const { bootstrapSyncHandlers } = await import('./sync-bootstrap.js')
bootstrapSyncHandlers(books, settings)
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
@@ -0,0 +1,10 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AchievementService } from '../../services/achievement.service'
import { wrap } from '../result'
export function registerAchievementHandlers(achievement: AchievementService): void {
ipcMain.handle(IPC.ACHIEVEMENT_LIST, (_e, { bookId }: { bookId: string }) =>
wrap(() => achievement.listAchievements(bookId))
)
}
+44 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'crypto'
import { BrowserWindow, ipcMain } from 'electron'
import { writeFileSync } from 'fs'
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
@@ -19,6 +20,15 @@ import { wrap } from '../result'
const activeChats = new Map<string, AbortController>()
function formatSessionMarkdown(title: string, messages: { role: string; content: string; createdAt: string }[]): string {
const lines = [`# ${title || 'AI 会话'}`, '']
for (const msg of messages) {
const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role
lines.push(`## ${label}`, '', msg.content, '', `_${msg.createdAt}_`, '')
}
return lines.join('\n')
}
export function registerAiHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
@@ -67,6 +77,39 @@ export function registerAiHandlers(
wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId))
)
ipcMain.handle(
IPC.AI_SESSION_EXPORT,
async (
_e,
{
bookId,
sessionId,
format = 'markdown'
}: { bookId: string; sessionId: string; format?: 'markdown' | 'txt' }
) =>
wrap(async () => {
const repo = registry.getAiSessionRepo(bookId)
const session = repo.get(sessionId)
if (!session) throw new Error('AI session not found')
const messages = repo.listMessages(sessionId)
const text =
format === 'markdown'
? formatSessionMarkdown(session.title, messages)
: messages
.map((m) => `[${m.role}] ${m.createdAt}\n${m.content}`)
.join('\n\n---\n\n')
const ext = format === 'markdown' ? 'md' : 'txt'
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: `${session.title || 'ai-session'}.${ext}`,
filters: [{ name: format === 'markdown' ? 'Markdown' : 'Text', extensions: [ext] }]
})
if (canceled || !filePath) return null
writeFileSync(filePath, text, 'utf8')
return filePath
})
)
ipcMain.handle(
IPC.AI_BUILD_CONTEXT,
(_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) =>
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { PomodoroDailyRepository } from '../../db/repositories/pomodoro-daily.repo'
import { WritingSessionRepository } from '../../db/repositories/writing-session.repo'
import { WritingAnalyticsService } from '../../services/writing-analytics.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { WritingLogService } from '../../services/writing-log.service'
import { wrap } from '../result'
export function registerAnalyticsHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService,
sessionRepo: WritingSessionRepository,
pomodoroDaily: PomodoroDailyRepository
): void {
const svc = new WritingAnalyticsService(writingLogs, sessionRepo, pomodoroDaily, registry)
ipcMain.handle(IPC.ANALYTICS_SUMMARY, (_e, { bookId }: { bookId: string }) =>
wrap(() => svc.getSummary(bookId))
)
}
+13
View File
@@ -0,0 +1,13 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { ArcService } from '../../services/arc.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerArcHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.ARC_GET_FOR_CHARACTER,
(_e, { bookId, settingId }: { bookId: string; settingId: string }) =>
wrap(() => new ArcService(registry.getDb(bookId)).getForCharacter(settingId))
)
}
+243
View File
@@ -0,0 +1,243 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AiContextBinding, AutoWritingConfig } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings'
import { AiClientService } from '../../services/ai-client.service'
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
import { checkInteractiveGate } from '../../services/interactive-gate.service'
import { AutoWritingService } from '../../services/auto-writing.service'
import { scheduleChapterExtraction } from '../../services/knowledge-extraction-runner'
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result'
const activeAuto = new Map<string, AbortController>()
function serviceFor(registry: BookRegistryService, bookId: string, aiClient: AiClientService) {
return new AutoWritingService(registry.getDb(bookId), aiClient)
}
function enrich(registry: BookRegistryService, bookId: string, aiClient: AiClientService, flowId: string) {
const svc = serviceFor(registry, bookId, aiClient)
const flow = new InteractiveRepository(registry.getDb(bookId)).get(flowId)
return flow ? svc.enrichFlow(flow) : null
}
function readContextText(registry: BookRegistryService, bookId: string, flowId: string): string {
const row = registry
.getDb(bookId)
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { context_json: string } | undefined
return readFlowSystemPrompt(row?.context_json ?? '{}')
}
export function registerAutoHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService,
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
)
ipcMain.handle(
IPC.AUTO_GET_FLOW,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).getFlow(sessionId)
return flow ? serviceFor(registry, bookId, aiClient).enrichFlow(flow) : null
})
)
ipcMain.handle(
IPC.AUTO_START,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).start(sessionId)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
})
)
ipcMain.handle(
IPC.AUTO_SET_GOAL,
(
_e,
{ bookId, flowId, config }: { bookId: string; flowId: string; config: Partial<AutoWritingConfig> }
) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
const flow = svc.setGoal(flowId, config)
return svc.enrichFlow(flow)
})
)
ipcMain.handle(
IPC.AUTO_CONFIRM_CONTEXT,
(
_e,
{ bookId, flowId, binding }: { bookId: string; flowId: string; binding: AiContextBinding }
) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
const payload = buildFlowContextPayload(
binding,
registry,
bookId,
settings.get().penName
)
recordKnowledgeInjection(registry, bookId, {
knowledgeIds: binding.knowledgeIds,
flowMode: 'auto',
flowId,
chapterId: registry.getMeta(bookId)?.lastChapterId ?? undefined
})
return svc.enrichFlow(svc.confirmContext(flowId, payload))
})
)
ipcMain.handle(
IPC.AUTO_PLAN_SCENES,
(_e, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
wrap(async () => {
const svc = serviceFor(registry, bookId, aiClient)
if (contextSummary) svc.setContextSummary(flowId, contextSummary)
const text = contextSummary ?? readContextText(registry, bookId, flowId)
await svc.planScenes(flowId, text)
return enrich(registry, bookId, aiClient, flowId)
})
)
ipcMain.handle(
IPC.AUTO_GENERATE,
(event, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
wrap(async () => {
const controller = new AbortController()
activeAuto.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
const svc = serviceFor(registry, bookId, aiClient)
const text = contextSummary ?? readContextText(registry, bookId, flowId)
try {
const flow = await svc.generateNext(
flowId,
text,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
flowMode: 'auto',
phase: 'scene',
delta,
done
})
},
controller.signal
)
return svc.enrichFlow(flow)
} finally {
activeAuto.delete(flowId)
}
})
)
ipcMain.handle(
IPC.AUTO_RESOLVE_NAMING,
(
event,
{
bookId,
flowId,
chosenName,
contextSummary
}: { bookId: string; flowId: string; chosenName: string; contextSummary?: string }
) =>
wrap(async () => {
const controller = new AbortController()
activeAuto.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
const svc = serviceFor(registry, bookId, aiClient)
const text = contextSummary ?? readContextText(registry, bookId, flowId)
try {
const flow = await svc.resolveNaming(
flowId,
chosenName,
text,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
flowMode: 'auto',
phase: 'naming_resume',
delta,
done
})
},
controller.signal
)
return svc.enrichFlow(flow)
} finally {
activeAuto.delete(flowId)
}
})
)
ipcMain.handle(IPC.AUTO_PAUSE, (_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
activeAuto.get(flowId)?.abort()
activeAuto.delete(flowId)
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.pause(flowId))
})
)
ipcMain.handle(IPC.AUTO_RESUME, (_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.resume(flowId))
})
)
ipcMain.handle(
IPC.AUTO_HANDOFF_INTERACTIVE,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.handoffToInteractive(flowId))
})
)
ipcMain.handle(
IPC.AUTO_FINISH_CHAPTER,
(
_e,
{ bookId, flowId, volumeId, title }: { bookId: string; flowId: string; volumeId: string; title?: string }
) =>
wrap(() => {
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
const ch = registry.getChapterRepo(bookId).get(result.chapterId)!
trackerFor(registry, bookId, writingLogs, writingSessions).supplementOnFinish(
result.chapterId,
ch.wordCount
)
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result
})
)
ipcMain.handle(IPC.AUTO_ABORT, (_e, { flowId }: { flowId: string }) =>
wrap(() => {
activeAuto.get(flowId)?.abort()
activeAuto.delete(flowId)
})
)
ipcMain.handle(
IPC.AUTO_GET_SCENE_DRAFT,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => serviceFor(registry, bookId, aiClient).getSceneDraft(flowId))
)
}
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { BookPrefsRepository } from '../../db/repositories/book-prefs.repo'
import { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerBookPrefsHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.BOOK_PREFS_GET,
(_event, { bookId, key }: { bookId: string; key: string }) =>
wrap(() => new BookPrefsRepository(registry.getDb(bookId)).get(key))
)
ipcMain.handle(
IPC.BOOK_PREFS_SET,
(_event, { bookId, key, value }: { bookId: string; key: string; value: string }) =>
wrap(() => {
new BookPrefsRepository(registry.getDb(bookId)).set(key, value)
})
)
}
+37 -16
View File
@@ -1,10 +1,18 @@
import { ipcMain } from 'electron'
import { ipcMain, dialog, app } from 'electron'
import { copyFileSync, mkdirSync } from 'fs'
import { extname, join } from 'path'
import { IPC } from '../../../shared/ipc-channels'
import type { BookMeta, CreateBookParams } from '../../../shared/types'
import type { CreateBookParams, UpdateBookMetaParams } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import type { WritingSessionService } from '../../services/writing-session.service'
import { wrap } from '../result'
export function registerBookHandlers(registry: BookRegistryService): void {
let previousBookId: string | null = null
export function registerBookHandlers(
registry: BookRegistryService,
writingSessions?: WritingSessionService
): void {
ipcMain.handle(IPC.BOOK_LIST, () => wrap(() => registry.list()))
ipcMain.handle(IPC.BOOK_CREATE, (_event, params: CreateBookParams) =>
@@ -18,22 +26,35 @@ export function registerBookHandlers(registry: BookRegistryService): void {
)
ipcMain.handle(IPC.BOOK_OPEN, (_event, { bookId }: { bookId: string }) =>
wrap(() => registry.open(bookId))
wrap(() => {
if (writingSessions && previousBookId && previousBookId !== bookId) {
writingSessions.endActive(previousBookId)
}
previousBookId = bookId
return registry.open(bookId)
})
)
ipcMain.handle(
IPC.BOOK_UPDATE_META,
(
_event,
{
bookId,
...patch
}: {
bookId: string
lastChapterId?: string | null
lastOpenedAt?: string
status?: BookMeta['status']
}
) => wrap(() => registry.updateMeta(bookId, patch))
(_event, { bookId, ...patch }: { bookId: string } & UpdateBookMetaParams) =>
wrap(() => registry.updateMeta(bookId, patch))
)
ipcMain.handle(IPC.BOOK_PICK_COVER, (_event, { bookId }: { bookId: string }) =>
wrap(async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }],
properties: ['openFile']
})
if (canceled || !filePaths[0]) return null
const src = filePaths[0]
const ext = extname(src).toLowerCase() || '.jpg'
const coversDir = join(app.getPath('userData'), 'covers')
mkdirSync(coversDir, { recursive: true })
const dest = join(coversDir, `${bookId}${ext}`)
copyFileSync(src, dest)
return registry.updateMeta(bookId, { coverPath: dest })
})
)
}
+35
View File
@@ -0,0 +1,35 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { BookRegistryService } from '../../services/book-registry'
import { ChapterBridgeService } from '../../services/chapter-bridge.service'
import { AiClientService } from '../../services/ai-client.service'
import { wrap } from '../result'
export function registerBridgeHandlers(
registry: BookRegistryService,
aiClient: AiClientService
): void {
ipcMain.handle(
IPC.BRIDGE_GET,
(
_e,
{ bookId, chapterId, withAi }: { bookId: string; chapterId: string; withAi?: boolean }
) =>
wrap(() =>
new ChapterBridgeService(registry.getDb(bookId), aiClient).getBridge(
chapterId,
withAi ?? false
)
)
)
ipcMain.handle(IPC.BRIDGE_DISMISS, (_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => new ChapterBridgeService(registry.getDb(bookId)).dismiss(chapterId))
)
ipcMain.handle(
IPC.BRIDGE_SHOULD_PROMPT,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => new ChapterBridgeService(registry.getDb(bookId)).shouldPrompt(chapterId))
)
}
+71 -4
View File
@@ -1,11 +1,18 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ChapterStatus } from '../../../shared/types'
import type { ChapterStatus, PublishStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerChapterHandlers(registry: BookRegistryService): void {
export function registerChapterHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(
IPC.VOLUME_CREATE,
(_event, { bookId, name }: { bookId: string; name: string }) =>
@@ -67,7 +74,13 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
title,
content,
status,
cursorOffset
cursorOffset,
povCharacterId,
summary,
storyTime,
wordCountThreshold,
publishStatus,
publishedAt
}: {
bookId: string
chapterId: string
@@ -75,6 +88,12 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
content?: string
status?: ChapterStatus
cursorOffset?: number
povCharacterId?: string | null
summary?: string
storyTime?: string | null
wordCountThreshold?: number | null
publishStatus?: PublishStatus
publishedAt?: string | null
}
) =>
wrap(() => {
@@ -82,7 +101,13 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
title,
content,
status,
cursorOffset
cursorOffset,
povCharacterId,
summary,
storyTime,
wordCountThreshold,
publishStatus,
publishedAt
})
ftsSync.upsert(
registry.getDb(bookId),
@@ -91,6 +116,10 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
chapter.title,
chapter.content
)
trackerFor(registry, bookId, writingLogs, writingSessions).recordDelta(
chapterId,
chapter.wordCount
)
registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter
})
@@ -124,4 +153,42 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
) =>
wrap(() => registry.getChapterRepo(bookId).moveToVolume(chapterId, targetVolumeId, targetIndex))
)
ipcMain.handle(
IPC.CHAPTER_SET_PUBLISH_STATUS,
(
_event,
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, publishStatus))
)
ipcMain.handle(
IPC.CHAPTER_TAG_LIST,
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => registry.getChapterTagRepo(bookId).list(chapterId))
)
ipcMain.handle(
IPC.CHAPTER_TAG_SET,
(
_event,
{
bookId,
chapterId,
tag,
color
}: { bookId: string; chapterId: string; tag: string; color?: string }
) =>
wrap(() => {
registry.getChapterTagRepo(bookId).set(chapterId, tag, color ?? '')
})
)
ipcMain.handle(
IPC.CHAPTER_TAG_REMOVE,
(_event, { bookId, chapterId, tag }: { bookId: string; chapterId: string; tag: string }) =>
wrap(() => {
registry.getChapterTagRepo(bookId).remove(chapterId, tag)
})
)
}
+41
View File
@@ -0,0 +1,41 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { BookRegistryService } from '../../services/book-registry'
import { CockpitService } from '../../services/cockpit.service'
import type { GlobalSettingsService } from '../../services/global-settings'
import type { AchievementService } from '../../services/achievement.service'
import type { PomodoroDailyRepository } from '../../db/repositories/pomodoro-daily.repo'
import type { WritingLogService } from '../../services/writing-log.service'
import { wrap } from '../result'
export function registerCockpitHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
writingLogs: WritingLogService,
achievementService: AchievementService,
pomodoroDaily: PomodoroDailyRepository
): void {
ipcMain.handle(
IPC.COCKPIT_SUMMARY,
(_e, { bookId, volumeId }: { bookId: string; volumeId?: string }) =>
wrap(() =>
new CockpitService(
registry.getDb(bookId),
settings,
writingLogs,
achievementService,
pomodoroDaily
).getSummary(volumeId, bookId)
)
)
ipcMain.handle(IPC.COCKPIT_MARK_SEEN, (_e, { bookId }: { bookId: string }) =>
wrap(() => {
new CockpitService(registry.getDb(bookId), settings).markSeen()
})
)
ipcMain.handle(IPC.COCKPIT_SHOULD_SHOW, (_e, { bookId }: { bookId: string }) =>
wrap(() => new CockpitService(registry.getDb(bookId), settings).shouldShowOnOpen())
)
}
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { ComplianceService } from '../../services/compliance.service'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
export function registerComplianceHandlers(settings: GlobalSettingsService): void {
const svc = new ComplianceService(settings)
ipcMain.handle(IPC.COMPLIANCE_GET_WORDS, () => wrap(() => svc.getWords()))
ipcMain.handle(IPC.COMPLIANCE_SET_WORDS, (_e, { words }: { words: string[] }) =>
wrap(() => {
svc.setWords(words)
})
)
ipcMain.handle(IPC.COMPLIANCE_CHECK_TEXT, (_e, { text }: { text: string }) =>
wrap(() => svc.scanText(text))
)
}

Some files were not shown because too many files have changed in this diff Show More