Compare commits

..

24 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
252 changed files with 22609 additions and 385 deletions
+2
View File
@@ -11,6 +11,8 @@ logs/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
release/ release/
!tests/release/
!tests/release/**
*.novel *.novel
*.novel-all *.novel-all
test-results/ test-results/
+53 -4
View File
@@ -1,9 +1,56 @@
# 笔临 (Bilin) # 笔临 (Bilin)
长篇创作智能协作平台 — Electron 桌面客户端 v0.7.0P6 AI 知识上下文 长篇创作智能协作平台 — Electron 桌面客户端 v2.0.0(平台基建
## 功能概览(v0.7.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) - 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5) - 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5) - 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5)
@@ -29,8 +76,10 @@ npm run dev
```bash ```bash
npm run build npm run build
npm run test # 单元 / 集成测试 npm run test:release # 发布前快速门禁:build + 导入/产物检查 + 启动冒烟 E2E
npm run test:e2e # E2E 测试(需先 build 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` 覆盖)。 **AI 相关测试前置条件**:本地 [LM Studio](http://127.0.0.1:1234) 需运行,模型默认 `gemma-4-e4b-it`(可通过环境变量 `BILIN_AI_MODEL` / `BILIN_AI_BASE_URL` 覆盖)。
+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,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,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**
@@ -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,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/数据模型无矛盾 | ✅ |
| 测试用例覆盖四模块 | ✅ |
+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.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) 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> { async function openGlobalSettings(page: Page): Promise<void> {
const topSettings = page.locator('#topbar button.top-btn').filter({ hasText: '⚙' }) await page.getByTestId('topbar-settings').click()
if (await topSettings.isVisible()) {
await topSettings.click()
} else {
await page.getByRole('button', { name: /系统设置/ }).click()
}
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 }) await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
} }
+6 -2
View File
@@ -28,9 +28,12 @@ async function skipOnboarding(page: Page): Promise<void> {
async function dismissCockpitIfOpen(page: Page): Promise<void> { async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]') const cockpit = page.locator('[data-testid="cockpit-modal"]')
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) { try {
await page.locator('[data-testid="cockpit-modal"] .modal-close').click() await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 }) await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
} }
} }
@@ -43,6 +46,7 @@ async function createAndOpenBook(page: Page, name: string): Promise<void> {
} }
async function ensureChapterEditor(page: Page): Promise<void> { async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
const items = page.locator('.chapter-item') const items = page.locator('.chapter-item')
if ((await items.count()) === 0) { if ((await items.count()) === 0) {
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
+21 -3
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> { async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) 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.locator('.dialog-content input').first().fill('自动测试书')
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page) await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror') const editor = page.locator('.ProseMirror')
await editor.click() await editor.click()
@@ -48,8 +61,11 @@ async function openAutoPanel(page: Page): Promise<void> {
async function planAutoScenes(page: Page): Promise<void> { async function planAutoScenes(page: Page): Promise<void> {
await page.getByTestId('auto-goal-input').fill('林远通过入门考核,展现天赋震惊众人') 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 page.getByTestId('auto-plan-scenes').click()
await expect(page.getByTestId('auto-start-generate')).toBeVisible({ timeout: 240_000 }) await expect(page.getByTestId('auto-start-generate')).toBeVisible({ timeout: 360_000 })
} }
async function generateOneAutoScene(page: Page): Promise<void> { async function generateOneAutoScene(page: Page): Promise<void> {
@@ -68,6 +84,7 @@ async function generateOneAutoScene(page: Page): Promise<void> {
} }
test.describe('Auto writing', () => { test.describe('Auto writing', () => {
test.describe.configure({ retries: 1 })
let userDataDir: string let userDataDir: string
test.beforeEach(() => { test.beforeEach(() => {
@@ -92,13 +109,14 @@ test.describe('Auto writing', () => {
await page.locator('.dialog-content input').first().fill('空书') await page.locator('.dialog-content input').first().fill('空书')
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByTestId('panel-tab-ai').click() await page.getByTestId('panel-tab-ai').click()
await expect(page.getByTestId('ai-mode-tab-auto')).toBeDisabled({ timeout: 10_000 }) await expect(page.getByTestId('ai-mode-tab-auto')).toBeDisabled({ timeout: 10_000 })
await app.close() await app.close()
}) })
test('E2E-AUTO-01: auto flow creates chapter', async () => { test('E2E-AUTO-01: auto flow creates chapter', async () => {
test.setTimeout(300_000) test.setTimeout(600_000)
const app = await launchApp(userDataDir) const app = await launchApp(userDataDir)
const page = await app.firstWindow() const page = await app.firstWindow()
await skipOnboarding(page) await skipOnboarding(page)
@@ -113,7 +131,7 @@ test.describe('Auto writing', () => {
}) })
test('E2E-AUTO-02: pause and handoff to interactive', async () => { test('E2E-AUTO-02: pause and handoff to interactive', async () => {
test.setTimeout(300_000) test.setTimeout(600_000)
const app = await launchApp(userDataDir) const app = await launchApp(userDataDir)
const page = await app.firstWindow() const page = await app.firstWindow()
await skipOnboarding(page) await skipOnboarding(page)
+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()
})
})
+12 -3
View File
@@ -27,9 +27,12 @@ async function skipOnboarding(page: Page): Promise<void> {
async function dismissCockpitIfOpen(page: Page): Promise<void> { async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]') const cockpit = page.locator('[data-testid="cockpit-modal"]')
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) { try {
await page.locator('[data-testid="cockpit-modal"] .modal-close').click() await cockpit.waitFor({ state: 'visible', timeout: 15_000 })
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 }) await expect(cockpit).toBeHidden({ timeout: 5000 })
} catch {
/* cockpit did not open */
} }
} }
@@ -133,7 +136,7 @@ test.describe('Cockpit & Knowledge P5', () => {
await app.close() await app.close()
}) })
test('E2E-BRIDGE-01: new chapter triggers bridge modal', async () => { test('E2E-BRIDGE-01: bridge modal opens from cockpit manually', async () => {
const app = await launchApp(userDataDir) const app = await launchApp(userDataDir)
const page = await app.firstWindow() const page = await app.firstWindow()
await skipOnboarding(page) await skipOnboarding(page)
@@ -147,6 +150,9 @@ test.describe('Cockpit & Knowledge P5', () => {
await dismissCockpitIfOpen(page) await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
await dismissCockpitIfOpen(page) 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-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"]')).toBeVisible({ timeout: 60_000 })
await expect(page.locator('[data-testid="bridge-previous-tail"]')).not.toHaveText('(无)') await expect(page.locator('[data-testid="bridge-previous-tail"]')).not.toHaveText('(无)')
@@ -184,6 +190,9 @@ test.describe('Cockpit & Knowledge P5', () => {
await dismissCockpitIfOpen(page) await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
await dismissCockpitIfOpen(page) 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-modal"]')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('[data-testid="bridge-ai-suggestion"]')).not.toBeEmpty({ timeout: 240_000 }) await expect(page.locator('[data-testid="bridge-ai-suggestion"]')).not.toBeEmpty({ timeout: 240_000 })
await app.close() 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> { async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) 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.locator('.dialog-content input').first().fill('交互测试书')
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page) await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror') const editor = page.locator('.ProseMirror')
await editor.click() await editor.click()
@@ -39,6 +52,7 @@ async function openBookWithContent(page: Page): Promise<void> {
} }
test.describe('Interactive writing', () => { test.describe('Interactive writing', () => {
test.describe.configure({ retries: 1 })
let userDataDir: string let userDataDir: string
test.beforeEach(() => { test.beforeEach(() => {
@@ -63,13 +77,14 @@ test.describe('Interactive writing', () => {
await page.locator('.dialog-content input').first().fill('空书') await page.locator('.dialog-content input').first().fill('空书')
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByTestId('panel-tab-ai').click() await page.getByTestId('panel-tab-ai').click()
await expect(page.getByTestId('ai-mode-tab-interactive')).toBeDisabled({ timeout: 10_000 }) await expect(page.getByTestId('ai-mode-tab-interactive')).toBeDisabled({ timeout: 10_000 })
await app.close() await app.close()
}) })
test('E2E-INT-01: interactive flow creates chapter', async () => { test('E2E-INT-01: interactive flow creates chapter', async () => {
test.setTimeout(300_000) test.setTimeout(600_000)
const app = await launchApp(userDataDir) const app = await launchApp(userDataDir)
const page = await app.firstWindow() const page = await app.firstWindow()
await skipOnboarding(page) await skipOnboarding(page)
@@ -85,21 +100,21 @@ test.describe('Interactive writing', () => {
await page.getByTestId('interactive-confirm-plot').click() await page.getByTestId('interactive-confirm-plot').click()
const naming0 = page.getByTestId('naming-option-0') const naming0 = page.getByTestId('naming-option-0')
const acceptBtn = page.getByTestId('interactive-accept-scene') 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()) { if (await naming0.isVisible()) {
await naming0.click() await naming0.click()
await expect(acceptBtn).toBeVisible({ timeout: 120_000 }) await expect(acceptBtn).toBeVisible({ timeout: 240_000 })
} }
await acceptBtn.click() await acceptBtn.click()
const finishBtn = page.getByTestId('interactive-finish-chapter') 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 finishBtn.click()
await expect(page.locator('.ProseMirror')).not.toBeEmpty({ timeout: 30_000 }) await expect(page.locator('.ProseMirror')).not.toBeEmpty({ timeout: 30_000 })
await app.close() await app.close()
}) })
test('E2E-INT-03: restores flow after restart', async () => { test('E2E-INT-03: restores flow after restart', async () => {
test.setTimeout(300_000) test.setTimeout(600_000)
const app1 = await launchApp(userDataDir) const app1 = await launchApp(userDataDir)
const page1 = await app1.firstWindow() const page1 = await app1.firstWindow()
await skipOnboarding(page1) await skipOnboarding(page1)
@@ -129,6 +144,7 @@ test.describe('Interactive writing', () => {
await skipOnboarding(page2) await skipOnboarding(page2)
await page2.getByText('交互测试书').click() await page2.getByText('交互测试书').click()
await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page2)
await page2.getByTestId('panel-tab-ai').click() await page2.getByTestId('panel-tab-ai').click()
await expect(page2.getByTestId('ai-session-select')).not.toHaveValue('', { timeout: 10_000 }) await expect(page2.getByTestId('ai-session-select')).not.toHaveValue('', { timeout: 10_000 })
await page2.getByTestId('ai-session-select').selectOption(sessionValue) 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> { async function createAndOpenBook(page: Page): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click() await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('覆盖度测试') await page.locator('.dialog-content input').first().fill('覆盖度测试')
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
} }
test.describe('Outline coverage', () => { 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> { async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click() await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name) await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
} }
test.describe('Outline persistence', () => { 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> { async function createAndOpenBook(page: Page, name = 'P2测试书'): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click() await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name) await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
} }
async function ensureChapterEditor(page: Page): Promise<void> { async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) 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> { async function createAndOpenBook(page: Page): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click() await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('拖拽测试') await page.locator('.dialog-content input').first().fill('拖拽测试')
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
} }
async function ensureChapterEditor(page: Page): Promise<void> { async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) 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 page.getByTestId('inspiration-save-btn').click()
await expect(page.getByTestId('inspiration-modal')).toBeHidden({ timeout: 10_000 }) 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')).toBeVisible()
await expect(page.getByTestId('inspiration-list').getByText(idea)).toBeVisible({ timeout: 10_000 }) await expect(page.getByTestId('inspiration-list').getByText(idea)).toBeVisible({ timeout: 10_000 })
await app.close() 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> { async function createBookAndOpenEditor(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click() await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name) await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) 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()
})
})
+17
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> { async function ensureChapterEditor(page: Page): Promise<void> {
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) 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.locator('.dialog-content input').first().fill('向导测试书')
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page) await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror') const editor = page.locator('.ProseMirror')
await editor.click() await editor.click()
@@ -81,6 +94,7 @@ test.describe('Wizard writing', () => {
await page.locator('.dialog-content input').first().fill('空书') await page.locator('.dialog-content input').first().fill('空书')
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByTestId('panel-tab-ai').click() await page.getByTestId('panel-tab-ai').click()
await expect(page.getByTestId('ai-mode-tab-wizard')).toBeDisabled({ timeout: 10_000 }) await expect(page.getByTestId('ai-mode-tab-wizard')).toBeDisabled({ timeout: 10_000 })
await app.close() await app.close()
@@ -95,6 +109,9 @@ test.describe('Wizard writing', () => {
await openWizardPanel(page) await openWizardPanel(page)
await page.getByTestId('wizard-goal-input').fill('林远通过入门考核,展现天赋震惊众人') 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.getByRole('button', { name: '下一步' }).click()
await page.getByTestId('wizard-rhythm-mixed').click() await page.getByTestId('wizard-rhythm-mixed').click()
+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 }) 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> { async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click() await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name) await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click() await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
} }
test.describe('Writing', () => { test.describe('Writing', () => {
@@ -54,6 +66,7 @@ test.describe('Writing', () => {
const page1 = await app1.firstWindow() const page1 = await app1.firstWindow()
await skipOnboarding(page1) await skipOnboarding(page1)
await createAndOpenBook(page1, '测试书') await createAndOpenBook(page1, '测试书')
await dismissCockpitIfOpen(page1)
await page1.getByRole('button', { name: '+ 新章' }).click() await page1.getByRole('button', { name: '+ 新章' }).click()
const editor = page1.locator('.ProseMirror') const editor = page1.locator('.ProseMirror')
+7 -2
View File
@@ -8,8 +8,13 @@ export default defineConfig({
build: { build: {
rollupOptions: { rollupOptions: {
input: { 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", "name": "bilin",
"version": "0.7.0", "version": "2.0.0",
"description": "笔临 - 长篇创作智能协作平台", "description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"type": "module", "type": "module",
@@ -10,6 +10,8 @@
"preview": "npx electron-vite preview", "preview": "npx electron-vite preview",
"test": "node node_modules/vitest/vitest.mjs run", "test": "node node_modules/vitest/vitest.mjs run",
"test:watch": "node node_modules/vitest/vitest.mjs", "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" "test:e2e": "playwright test"
}, },
"keywords": [], "keywords": [],
@@ -28,10 +30,19 @@
"@tiptap/extension-underline": "^3.27.1", "@tiptap/extension-underline": "^3.27.1",
"@tiptap/react": "^3.27.1", "@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^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", "diff-match-patch": "^1.0.5",
"docx": "^9.7.1",
"electron": "^36.9.0", "electron": "^36.9.0",
"electron-log": "^5.4.4",
"electron-updater": "^6.8.9",
"extract-zip": "^2.0.1",
"i18next": "^24.2.3", "i18next": "^24.2.3",
"jotai": "^2.12.1", "jotai": "^2.12.1",
"mammoth": "^1.12.0",
"marked": "^15.0.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
@@ -40,7 +51,11 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.51.0", "@playwright/test": "^1.51.0",
"@types/archiver": "^8.0.0",
"@types/cytoscape": "^3.21.9",
"@types/diff-match-patch": "^1.0.36", "@types/diff-match-patch": "^1.0.36",
"@types/extract-zip": "^2.0.0",
"@types/marked": "^6.0.0",
"@types/node": "^22.13.0", "@types/node": "^22.13.0",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
+298 -1
View File
@@ -203,6 +203,14 @@
"knowledge.statsSummary": "Buried {{buried}} · Resolved {{resolved}} · Forgotten {{forgotten}}", "knowledge.statsSummary": "Buried {{buried}} · Resolved {{resolved}} · Forgotten {{forgotten}}",
"knowledge.syncFromLandmark": "Sync this foreshadow to knowledge base?", "knowledge.syncFromLandmark": "Sync this foreshadow to knowledge base?",
"knowledge.syncedFromLandmark": "Synced to knowledge base (pending review)", "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.title": "Writing cockpit",
"cockpit.loading": "Loading…", "cockpit.loading": "Loading…",
"cockpit.totalWords": "Book words", "cockpit.totalWords": "Book words",
@@ -213,6 +221,83 @@
"cockpit.resumeEdit": "Resume writing", "cockpit.resumeEdit": "Resume writing",
"cockpit.bridgeCheck": "Chapter bridge check", "cockpit.bridgeCheck": "Chapter bridge check",
"cockpit.forgottenForeshadow": "Forgotten foreshadows", "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.title": "Chapter bridge",
"bridge.loading": "Analyzing…", "bridge.loading": "Analyzing…",
"bridge.previousTail": "Previous chapter ending", "bridge.previousTail": "Previous chapter ending",
@@ -231,6 +316,9 @@
"settings.updateSchedule.alternate": "Every other day", "settings.updateSchedule.alternate": "Every other day",
"settings.updateSchedule.weekly": "Weekly", "settings.updateSchedule.weekly": "Weekly",
"settings.stockBufferThreshold": "Stock buffer threshold (chapters)", "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", "stock.warning": "Low stock: {{count}}/{{threshold}} chapters ready",
"landmark.foreshadowConfirm": "Mark as foreshadow landmark?", "landmark.foreshadowConfirm": "Mark as foreshadow landmark?",
"ai.settings.backend": "Backend", "ai.settings.backend": "Backend",
@@ -248,6 +336,7 @@
"status.chapter": "Chapter", "status.chapter": "Chapter",
"status.volume": "Volume", "status.volume": "Volume",
"status.book": "Book", "status.book": "Book",
"status.dailyGoal": "Today {{current}} / {{goal}}",
"toast.bookCreated": "Book created", "toast.bookCreated": "Book created",
"toast.saveFailed": "Save failed, please retry", "toast.saveFailed": "Save failed, please retry",
"toast.enterBookName": "Please enter a book title", "toast.enterBookName": "Please enter a book title",
@@ -337,5 +426,213 @@
"landmark.created": "Landmark inserted", "landmark.created": "Landmark inserted",
"landmark.chapterOnly": "Insert landmarks in a chapter", "landmark.chapterOnly": "Insert landmarks in a chapter",
"wordfreq.hint": "Top 100 words in current scope", "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"
} }
+298 -1
View File
@@ -203,6 +203,14 @@
"knowledge.statsSummary": "埋设 {{buried}} · 回收 {{resolved}} · 遗忘 {{forgotten}}", "knowledge.statsSummary": "埋设 {{buried}} · 回收 {{resolved}} · 遗忘 {{forgotten}}",
"knowledge.syncFromLandmark": "是否同步此伏笔到知识库?", "knowledge.syncFromLandmark": "是否同步此伏笔到知识库?",
"knowledge.syncedFromLandmark": "已同步到知识库(待审核)", "knowledge.syncedFromLandmark": "已同步到知识库(待审核)",
"knowledge.extractChapter": "抽取本章知识",
"knowledge.extractFromCurrent": "从当前章抽取",
"knowledge.batchHighConfidence": "全部采纳高置信度",
"knowledge.mergeSuggest": "建议更新:{{title}}",
"knowledge.approveMerge": "确认更新",
"knowledge.saveAsNew": "作为新条目",
"knowledge.extracting": "正在抽取知识…",
"knowledge.extractFailed": "知识抽取失败,请稍后重试",
"cockpit.title": "写作驾驶舱", "cockpit.title": "写作驾驶舱",
"cockpit.loading": "加载中…", "cockpit.loading": "加载中…",
"cockpit.totalWords": "全书字数", "cockpit.totalWords": "全书字数",
@@ -213,6 +221,83 @@
"cockpit.resumeEdit": "继续写作", "cockpit.resumeEdit": "继续写作",
"cockpit.bridgeCheck": "章间衔接检查", "cockpit.bridgeCheck": "章间衔接检查",
"cockpit.forgottenForeshadow": "查看遗忘伏笔", "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.title": "章间衔接",
"bridge.loading": "分析中…", "bridge.loading": "分析中…",
"bridge.previousTail": "上一章末尾", "bridge.previousTail": "上一章末尾",
@@ -231,6 +316,9 @@
"settings.updateSchedule.alternate": "隔日更", "settings.updateSchedule.alternate": "隔日更",
"settings.updateSchedule.weekly": "周更", "settings.updateSchedule.weekly": "周更",
"settings.stockBufferThreshold": "存稿缓冲阈值(章)", "settings.stockBufferThreshold": "存稿缓冲阈值(章)",
"settings.dailyWordGoal": "每日写作目标(字)",
"settings.knowledgeAutoExtract": "成章后自动抽取知识",
"settings.extractThreshold": "高置信度阈值",
"stock.warning": "存稿不足:{{count}}/{{threshold}} 章待发布", "stock.warning": "存稿不足:{{count}}/{{threshold}} 章待发布",
"landmark.foreshadowConfirm": "标记为伏笔地标?", "landmark.foreshadowConfirm": "标记为伏笔地标?",
"ai.settings.backend": "后端类型", "ai.settings.backend": "后端类型",
@@ -248,6 +336,7 @@
"status.chapter": "本章", "status.chapter": "本章",
"status.volume": "本卷", "status.volume": "本卷",
"status.book": "全书", "status.book": "全书",
"status.dailyGoal": "今日 {{current}} / {{goal}}",
"toast.bookCreated": "书籍已创建", "toast.bookCreated": "书籍已创建",
"toast.saveFailed": "保存失败,请重试", "toast.saveFailed": "保存失败,请重试",
"toast.enterBookName": "请输入书名", "toast.enterBookName": "请输入书名",
@@ -337,5 +426,213 @@
"landmark.created": "地标已插入", "landmark.created": "地标已插入",
"landmark.chapterOnly": "请在章节中插入地标", "landmark.chapterOnly": "请在章节中插入地标",
"wordfreq.hint": "当前范围词频 Top 100", "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": "测试连接"
} }
+40 -1
View File
@@ -5,8 +5,14 @@ import schemaV3 from './schema-v3.sql?raw'
import schemaV4 from './schema-v4.sql?raw' import schemaV4 from './schema-v4.sql?raw'
import schemaV5 from './schema-v5.sql?raw' import schemaV5 from './schema-v5.sql?raw'
import schemaV6 from './schema-v6.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 = 6 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 { function tryAlter(db: SqliteDb, sql: string): void {
try { try {
@@ -71,5 +77,38 @@ export function migrate(db: SqliteDb): void {
if (current < 6) { if (current < 6) {
db.exec(schemaV6) db.exec(schemaV6)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(6, 'P5 knowledge/cockpit schema') 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')
} }
} }
@@ -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)
}
}
+28 -3
View File
@@ -13,7 +13,12 @@ function mapRow(row: Record<string, unknown>): Chapter {
sortOrder: row.sort_order as number, sortOrder: row.sort_order as number,
cursorOffset: (row.cursor_offset as number) ?? 0, 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 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
} }
} }
@@ -66,6 +71,11 @@ export class ChapterRepository {
status: ChapterStatus status: ChapterStatus
cursorOffset: number cursorOffset: number
publishStatus: PublishStatus publishStatus: PublishStatus
povCharacterId: string | null
summary: string
storyTime: string | null
publishedAt: string | null
wordCountThreshold: number | null
}> }>
): Chapter { ): Chapter {
const existing = this.get(id) const existing = this.get(id)
@@ -73,12 +83,16 @@ export class ChapterRepository {
const content = patch.content ?? existing.content const content = patch.content ?? existing.content
const wordCount = countWords(content) const wordCount = countWords(content)
const povCharacterId =
patch.povCharacterId !== undefined ? patch.povCharacterId : existing.povCharacterId ?? null
this.db this.db
.prepare( .prepare(
`UPDATE chapters SET `UPDATE chapters SET
title = ?, content = ?, status = ?, cursor_offset = ?, title = ?, content = ?, status = ?, cursor_offset = ?,
publish_status = ?, 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 = ?` WHERE id = ?`
) )
.run( .run(
@@ -87,14 +101,25 @@ export class ChapterRepository {
patch.status ?? existing.status, patch.status ?? existing.status,
patch.cursorOffset ?? existing.cursorOffset, patch.cursorOffset ?? existing.cursorOffset,
patch.publishStatus ?? existing.publishStatus ?? 'draft', patch.publishStatus ?? existing.publishStatus ?? 'draft',
povCharacterId,
wordCount, 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 id
) )
return this.get(id)! return this.get(id)!
} }
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter { setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
return this.update(id, { publishStatus }) const patch: Parameters<ChapterRepository['update']>[1] = { publishStatus }
if (publishStatus === 'published') {
patch.publishedAt = new Date().toISOString()
}
return this.update(id, patch)
} }
countByPublishStatus(status: PublishStatus): number { countByPublishStatus(status: PublishStatus): number {
@@ -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)
}
}
@@ -20,6 +20,9 @@ function mapRow(row: Record<string, unknown>): KnowledgeEntry {
sourceChapterId: (row.source_chapter_id as string) || undefined, sourceChapterId: (row.source_chapter_id as string) || undefined,
lastMentionChapterId: (row.last_mention_chapter_id as string) || undefined, lastMentionChapterId: (row.last_mention_chapter_id as string) || undefined,
linkedBookmarkId: (row.linked_bookmark_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, createdAt: row.created_at as string,
updatedAt: row.updated_at as string updatedAt: row.updated_at as string
} }
@@ -53,6 +56,41 @@ export class KnowledgeRepository {
return this.get(id)! 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 { get(id: string): KnowledgeEntry | null {
const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as
| Record<string, unknown> | Record<string, unknown>
@@ -101,6 +139,15 @@ export class KnowledgeRepository {
return this.get(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 { delete(id: string): void {
this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id) this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id)
} }
@@ -111,4 +158,20 @@ export class KnowledgeRepository {
.get(status) as { c: number } .get(status) as { c: number }
return row.c 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);
+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) getNetworkMonitor()?.start(() => mainWindow)
} }
app.whenReady().then(() => { app.whenReady().then(async () => {
registerIpc() 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) => { ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize() 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 { 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 { IPC } from '../../../shared/ipc-channels'
import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types' import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry' import { BookRegistryService } from '../../services/book-registry'
@@ -19,6 +20,15 @@ import { wrap } from '../result'
const activeChats = new Map<string, AbortController>() 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( export function registerAiHandlers(
registry: BookRegistryService, registry: BookRegistryService,
settings: GlobalSettingsService, settings: GlobalSettingsService,
@@ -67,6 +77,39 @@ export function registerAiHandlers(
wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId)) 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( ipcMain.handle(
IPC.AI_BUILD_CONTEXT, IPC.AI_BUILD_CONTEXT,
(_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) => (_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))
)
}
+25 -3
View File
@@ -7,6 +7,11 @@ import { AiClientService } from '../../services/ai-client.service'
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util' import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
import { checkInteractiveGate } from '../../services/interactive-gate.service' import { checkInteractiveGate } from '../../services/interactive-gate.service'
import { AutoWritingService } from '../../services/auto-writing.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 { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result' import { wrap } from '../result'
@@ -33,9 +38,10 @@ function readContextText(registry: BookRegistryService, bookId: string, flowId:
export function registerAutoHandlers( export function registerAutoHandlers(
registry: BookRegistryService, registry: BookRegistryService,
settings: GlobalSettingsService, settings: GlobalSettingsService,
aiClient: AiClientService aiClient: AiClientService,
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void { ): void {
void settings
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) => ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId))) wrap(() => checkInteractiveGate(registry.getDb(bookId)))
@@ -86,6 +92,12 @@ export function registerAutoHandlers(
bookId, bookId,
settings.get().penName 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)) return svc.enrichFlow(svc.confirmContext(flowId, payload))
}) })
) )
@@ -203,7 +215,17 @@ export function registerAutoHandlers(
( (
_e, _e,
{ bookId, flowId, volumeId, title }: { bookId: string; flowId: string; volumeId: string; title?: string } { bookId, flowId, volumeId, title }: { bookId: string; flowId: string; volumeId: string; title?: string }
) => wrap(() => serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)) ) =>
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 }) => ipcMain.handle(IPC.AUTO_ABORT, (_e, { flowId }: { flowId: string }) =>
@@ -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 { 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 { BookRegistryService } from '../../services/book-registry'
import type { WritingSessionService } from '../../services/writing-session.service'
import { wrap } from '../result' 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_LIST, () => wrap(() => registry.list()))
ipcMain.handle(IPC.BOOK_CREATE, (_event, params: CreateBookParams) => 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 }) => 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( ipcMain.handle(
IPC.BOOK_UPDATE_META, IPC.BOOK_UPDATE_META,
( (_event, { bookId, ...patch }: { bookId: string } & UpdateBookMetaParams) =>
_event, wrap(() => registry.updateMeta(bookId, patch))
{ )
bookId,
...patch ipcMain.handle(IPC.BOOK_PICK_COVER, (_event, { bookId }: { bookId: string }) =>
}: { wrap(async () => {
bookId: string const { canceled, filePaths } = await dialog.showOpenDialog({
lastChapterId?: string | null filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }],
lastOpenedAt?: string properties: ['openFile']
status?: BookMeta['status'] })
} if (canceled || !filePaths[0]) return null
) => wrap(() => registry.updateMeta(bookId, patch)) 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 })
})
) )
} }
+62 -3
View File
@@ -2,10 +2,17 @@ import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels' import { IPC } from '../../../shared/ipc-channels'
import type { ChapterStatus, PublishStatus } from '../../../shared/types' import type { ChapterStatus, PublishStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry' 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 { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result' import { wrap } from '../result'
export function registerChapterHandlers(registry: BookRegistryService): void { export function registerChapterHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle( ipcMain.handle(
IPC.VOLUME_CREATE, IPC.VOLUME_CREATE,
(_event, { bookId, name }: { bookId: string; name: string }) => (_event, { bookId, name }: { bookId: string; name: string }) =>
@@ -67,7 +74,13 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
title, title,
content, content,
status, status,
cursorOffset cursorOffset,
povCharacterId,
summary,
storyTime,
wordCountThreshold,
publishStatus,
publishedAt
}: { }: {
bookId: string bookId: string
chapterId: string chapterId: string
@@ -75,6 +88,12 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
content?: string content?: string
status?: ChapterStatus status?: ChapterStatus
cursorOffset?: number cursorOffset?: number
povCharacterId?: string | null
summary?: string
storyTime?: string | null
wordCountThreshold?: number | null
publishStatus?: PublishStatus
publishedAt?: string | null
} }
) => ) =>
wrap(() => { wrap(() => {
@@ -82,7 +101,13 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
title, title,
content, content,
status, status,
cursorOffset cursorOffset,
povCharacterId,
summary,
storyTime,
wordCountThreshold,
publishStatus,
publishedAt
}) })
ftsSync.upsert( ftsSync.upsert(
registry.getDb(bookId), registry.getDb(bookId),
@@ -91,6 +116,10 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
chapter.title, chapter.title,
chapter.content chapter.content
) )
trackerFor(registry, bookId, writingLogs, writingSessions).recordDelta(
chapterId,
chapter.wordCount
)
registry.updateMeta(bookId, { lastChapterId: chapterId }) registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter return chapter
}) })
@@ -132,4 +161,34 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus } { bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, 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)
})
)
} }
+16 -2
View File
@@ -3,16 +3,30 @@ import { IPC } from '../../../shared/ipc-channels'
import { BookRegistryService } from '../../services/book-registry' import { BookRegistryService } from '../../services/book-registry'
import { CockpitService } from '../../services/cockpit.service' import { CockpitService } from '../../services/cockpit.service'
import type { GlobalSettingsService } from '../../services/global-settings' 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' import { wrap } from '../result'
export function registerCockpitHandlers( export function registerCockpitHandlers(
registry: BookRegistryService, registry: BookRegistryService,
settings: GlobalSettingsService settings: GlobalSettingsService,
writingLogs: WritingLogService,
achievementService: AchievementService,
pomodoroDaily: PomodoroDailyRepository
): void { ): void {
ipcMain.handle( ipcMain.handle(
IPC.COCKPIT_SUMMARY, IPC.COCKPIT_SUMMARY,
(_e, { bookId, volumeId }: { bookId: string; volumeId?: string }) => (_e, { bookId, volumeId }: { bookId: string; volumeId?: string }) =>
wrap(() => new CockpitService(registry.getDb(bookId), settings).getSummary(volumeId)) wrap(() =>
new CockpitService(
registry.getDb(bookId),
settings,
writingLogs,
achievementService,
pomodoroDaily
).getSummary(volumeId, bookId)
)
) )
ipcMain.handle(IPC.COCKPIT_MARK_SEEN, (_e, { bookId }: { bookId: string }) => ipcMain.handle(IPC.COCKPIT_MARK_SEEN, (_e, { bookId }: { bookId: string }) =>
@@ -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))
)
}
@@ -0,0 +1,18 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ExportMatrixParams } from '../../../shared/export-matrix'
import { ExportMatrixService } from '../../services/export-matrix.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
export function registerExportMatrixHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const svc = new ExportMatrixService(registry, settings)
ipcMain.handle(IPC.EXPORT_MATRIX_EXPORT, (_e, params: ExportMatrixParams) =>
wrap(() => svc.pickAndExport(params))
)
}
+38
View File
@@ -0,0 +1,38 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ImportExecuteParams, ImportSplitMode } from '../../../shared/types'
import { ExportService } from '../../services/export.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
export function registerExportHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const svc = new ExportService(registry, settings)
ipcMain.handle(
IPC.EXPORT_FORMAT_CHAPTER,
(
_e,
{
bookId,
chapterId,
presetId
}: { bookId: string; chapterId: string; presetId: string }
) => wrap(() => svc.formatChapter(bookId, chapterId, presetId))
)
ipcMain.handle(IPC.EXPORT_COPY_CLIPBOARD, (_e, { text }: { text: string }) =>
wrap(() => {
svc.copyToClipboard(text)
})
)
ipcMain.handle(
IPC.EXPORT_SAVE_TXT,
(_e, { text, defaultName }: { text: string; defaultName: string }) =>
wrap(() => svc.saveTxt(text, defaultName))
)
}
+51
View File
@@ -0,0 +1,51 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { CharacterRelationship } from '../../../shared/types'
import { CharacterGraphService } from '../../services/character-graph.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerGraphHandlers(registry: BookRegistryService): void {
const svc = () => new CharacterGraphService(registry)
ipcMain.handle(
IPC.GRAPH_GET_DATA,
(_e, { bookId, filter }: { bookId: string; filter?: 'all' | 'connected' }) =>
wrap(() => svc().getData(bookId, filter ?? 'all'))
)
ipcMain.handle(
IPC.GRAPH_UPSERT_RELATIONSHIP,
(
_e,
{
bookId,
sourceId,
relationship
}: {
bookId: string
sourceId: string
relationship: Partial<CharacterRelationship> & {
targetId: string
label: string
intimacy: number
}
}
) => wrap(() => svc().upsertRelationship(bookId, sourceId, relationship))
)
ipcMain.handle(
IPC.GRAPH_DELETE_RELATIONSHIP,
(
_e,
{
bookId,
sourceId,
relationshipId
}: { bookId: string; sourceId: string; relationshipId: string }
) =>
wrap(() => {
svc().deleteRelationship(bookId, sourceId, relationshipId)
})
)
}
+35
View File
@@ -0,0 +1,35 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ImportExecuteParams, ImportSplitMode } from '../../../shared/types'
import { ImportService } from '../../services/import.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerImportHandlers(registry: BookRegistryService): void {
const svc = new ImportService(registry)
ipcMain.handle(IPC.IMPORT_PICK_FILE, () => wrap(() => svc.pickFile()))
ipcMain.handle(
IPC.IMPORT_PREVIEW,
(
_e,
{
filePath,
splitMode,
customRegex
}: { filePath: string; splitMode: ImportSplitMode; customRegex?: string }
) => wrap(() => svc.preview(filePath, splitMode, customRegex))
)
ipcMain.handle(IPC.IMPORT_EXECUTE, (_e, params: ImportExecuteParams) =>
wrap(async () => {
const bookId = await svc.execute(params, (current, total) => {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.IMPORT_PROGRESS, { current, total })
}
})
return bookId
})
)
}
+24 -2
View File
@@ -7,6 +7,11 @@ import { AiClientService } from '../../services/ai-client.service'
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util' import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
import { checkInteractiveGate } from '../../services/interactive-gate.service' import { checkInteractiveGate } from '../../services/interactive-gate.service'
import { InteractiveWritingService } from '../../services/interactive-writing.service' import { InteractiveWritingService } from '../../services/interactive-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 { wrap } from '../result' import { wrap } from '../result'
const activeInteractive = new Map<string, AbortController>() const activeInteractive = new Map<string, AbortController>()
@@ -24,7 +29,9 @@ function enrich(registry: BookRegistryService, bookId: string, aiClient: AiClien
export function registerInteractiveHandlers( export function registerInteractiveHandlers(
registry: BookRegistryService, registry: BookRegistryService,
settings: GlobalSettingsService, settings: GlobalSettingsService,
aiClient: AiClientService aiClient: AiClientService,
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void { ): void {
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) => ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId))) wrap(() => checkInteractiveGate(registry.getDb(bookId)))
@@ -62,6 +69,12 @@ export function registerInteractiveHandlers(
bookId, bookId,
settings.get().penName settings.get().penName
) )
recordKnowledgeInjection(registry, bookId, {
knowledgeIds: binding.knowledgeIds,
flowMode: 'interactive',
flowId,
chapterId: registry.getMeta(bookId)?.lastChapterId ?? undefined
})
const flow = serviceFor(registry, bookId, aiClient).confirmContext(flowId, payload) const flow = serviceFor(registry, bookId, aiClient).confirmContext(flowId, payload)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow) return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
}) })
@@ -194,7 +207,16 @@ export function registerInteractiveHandlers(
title title
}: { bookId: string; flowId: string; volumeId: string; title?: string } }: { bookId: string; flowId: string; volumeId: string; title?: string }
) => ) =>
wrap(() => serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)) 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.INTERACTIVE_ABORT, (_e, { flowId }: { flowId: string }) => ipcMain.handle(IPC.INTERACTIVE_ABORT, (_e, { flowId }: { flowId: string }) =>
+53 -3
View File
@@ -1,15 +1,25 @@
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels' import { IPC } from '../../../shared/ipc-channels'
import type { CreateKnowledgeInput, KnowledgeStatus, KnowledgeType, KnowledgeSuggestOptions } from '../../../shared/types' import type {
CreateKnowledgeInput,
KnowledgeStatus,
KnowledgeType,
KnowledgeSuggestOptions
} from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry' import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings' import type { AiClientService } from '../../services/ai-client.service'
import type { GlobalSettingsService } from '../../services/global-settings'
import { KnowledgeRepository } from '../../db/repositories/knowledge.repo'
import { KnowledgeExtractionService } from '../../services/knowledge-extraction.service'
import { KnowledgeInjectionService } from '../../services/knowledge-injection.service'
import { KnowledgeRetrievalService } from '../../services/knowledge-retrieval.service' import { KnowledgeRetrievalService } from '../../services/knowledge-retrieval.service'
import { KnowledgeService } from '../../services/knowledge.service' import { KnowledgeService } from '../../services/knowledge.service'
import { wrap } from '../result' import { wrap } from '../result'
export function registerKnowledgeHandlers( export function registerKnowledgeHandlers(
registry: BookRegistryService, registry: BookRegistryService,
settings: GlobalSettingsService settings: GlobalSettingsService,
aiClient: AiClientService
): void { ): void {
ipcMain.handle( ipcMain.handle(
IPC.KNOWLEDGE_LIST, IPC.KNOWLEDGE_LIST,
@@ -82,4 +92,44 @@ export function registerKnowledgeHandlers(
}) })
}) })
) )
ipcMain.handle(
IPC.KNOWLEDGE_EXTRACT_CHAPTER,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() =>
new KnowledgeExtractionService(registry.getDb(bookId), aiClient).extractForChapter(chapterId)
)
)
ipcMain.handle(
IPC.KNOWLEDGE_APPROVE_MERGE,
(_e, { bookId, pendingId }: { bookId: string; pendingId: string }) =>
wrap(() => new KnowledgeService(registry.getDb(bookId)).approveMerge(pendingId))
)
ipcMain.handle(
IPC.KNOWLEDGE_SAVE_MERGE_AS_NEW,
(_e, { bookId, pendingId }: { bookId: string; pendingId: string }) =>
wrap(() => new KnowledgeService(registry.getDb(bookId)).saveMergeAsNew(pendingId))
)
ipcMain.handle(
IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE,
(_e, { bookId, threshold }: { bookId: string; threshold?: number }) =>
wrap(() => {
const t = threshold ?? settings.get().knowledgeExtractConfidenceThreshold ?? 0.8
return new KnowledgeService(registry.getDb(bookId)).batchApproveHighConfidence(t)
})
)
ipcMain.handle(
IPC.KNOWLEDGE_LIST_INJECTIONS,
(
_e,
{ bookId, entryId, limit }: { bookId: string; entryId: string; limit?: number }
) =>
wrap(() =>
new KnowledgeInjectionService(registry.getDb(bookId)).listForEntry(entryId, limit ?? 50)
)
)
} }
+33
View File
@@ -0,0 +1,33 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { LogService } from '../../services/log.service'
import { extensionRegistry } from '../../services/extension-registry'
import { wrap } from '../result'
export function registerLogHandlers(logs: LogService): void {
ipcMain.handle(IPC.LOG_HAD_CRASH, () => wrap(() => logs.hadCrashOnLastRun()))
ipcMain.handle(IPC.LOG_CLEAR_CRASH, () =>
wrap(() => {
logs.clearCrashFlag()
})
)
ipcMain.handle(IPC.LOG_SEND_REPORT, (_e, { note }: { note?: string }) =>
wrap(() => logs.sendReportStub(note))
)
ipcMain.handle(IPC.LOG_WRITE_ERROR, (_e, { message }: { message: string }) =>
wrap(() => {
logs.logManualError(message)
})
)
}
export function registerExtensionHandlers(): void {
ipcMain.handle(IPC.EXTENSION_LIST, () => wrap(() => extensionRegistry.list()))
ipcMain.handle(IPC.EXTENSION_VALIDATE, (_e, { manifest }: { manifest: unknown }) =>
wrap(() => extensionRegistry.validateManifest(manifest))
)
ipcMain.handle(IPC.MCP_TEST_CONNECTION, (_e, { url }: { url: string }) =>
wrap(() => ({ ok: true, message: `MCP stub: ${url || 'no url'}` }))
)
}
+103
View File
@@ -0,0 +1,103 @@
import { dialog, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { PackImportStrategy } from '../../../shared/novel-pack'
import { PackService } from '../../services/pack.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
let packService: PackService | null = null
function svc(registry: BookRegistryService, settings: GlobalSettingsService): PackService {
if (!packService) {
packService = new PackService(registry, settings, registry.getUserDataDir())
}
return packService
}
export function registerPackHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const service = () => svc(registry, settings)
ipcMain.handle(
IPC.PACK_EXPORT_BOOK,
(_e, { bookId, destPath }: { bookId: string; destPath: string }) =>
wrap(() => service().exportBook(bookId, destPath))
)
ipcMain.handle(IPC.PACK_IMPORT_BOOK, (_e, { filePath }: { filePath: string }) =>
wrap(() => service().importBook(filePath))
)
ipcMain.handle(IPC.PACK_EXPORT_ALL, (_e, { destPath }: { destPath: string }) =>
wrap(() => service().exportAll(destPath))
)
ipcMain.handle(
IPC.PACK_IMPORT_ALL,
(_e, { filePath, strategy }: { filePath: string; strategy: PackImportStrategy }) =>
wrap(() => service().importAll(filePath, strategy))
)
ipcMain.handle(IPC.PACK_ESTIMATE_EXPORT_ALL, () =>
wrap(() => ({
bytes: service().estimateExportAllBytes(),
needsConfirm: service().needsExportAllConfirm()
}))
)
ipcMain.handle(IPC.PACK_NEEDS_CONFIRM, () =>
wrap(() => service().needsExportAllConfirm())
)
ipcMain.handle(IPC.PACK_CANCEL, () =>
wrap(() => {
service().cancel()
})
)
ipcMain.handle(IPC.PACK_PICK_FILE, (_e, { mode }: { mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import' }) =>
wrap(async () => {
if (process.env.BILIN_E2E === '1') {
if (
(mode === 'save-novel' || mode === 'save-all') &&
process.env.BILIN_E2E_PACK_SAVE
) {
return process.env.BILIN_E2E_PACK_SAVE
}
if (
(mode === 'import' || mode === 'novel' || mode === 'novel-all') &&
process.env.BILIN_E2E_PACK_IMPORT_FILE
) {
return process.env.BILIN_E2E_PACK_IMPORT_FILE
}
if (mode === 'import' && process.env.BILIN_E2E_IMPORT_FILE) {
return process.env.BILIN_E2E_IMPORT_FILE
}
}
if (mode === 'save-novel' || mode === 'save-all') {
const ext = mode === 'save-all' ? 'novel-all' : 'novel'
const { canceled, filePath } = await dialog.showSaveDialog({
filters: [{ name: 'Bilin Pack', extensions: [ext] }],
defaultPath: mode === 'save-all' ? 'bilin-backup.novel-all' : 'book.novel'
})
if (canceled || !filePath) return null
return filePath
}
const extensions =
mode === 'import'
? ['txt', 'md', 'docx', 'novel', 'novel-all']
: mode === 'novel-all'
? ['novel-all']
: ['novel', 'novel-all']
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Bilin Pack', extensions }]
})
if (canceled || filePaths.length === 0) return null
return filePaths[0]
})
)
}
+14
View File
@@ -0,0 +1,14 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { PomodoroService } from '../../services/pomodoro.service'
import { wrap } from '../result'
export function registerPomodoroHandlers(pomodoro: PomodoroService): void {
ipcMain.handle(IPC.POMODORO_START, (_e, { bookId }: { bookId: string }) =>
wrap(() => pomodoro.start(bookId))
)
ipcMain.handle(IPC.POMODORO_PAUSE, () => wrap(() => pomodoro.pause()))
ipcMain.handle(IPC.POMODORO_RESUME, () => wrap(() => pomodoro.resume()))
ipcMain.handle(IPC.POMODORO_CANCEL, () => wrap(() => pomodoro.cancel()))
ipcMain.handle(IPC.POMODORO_GET_STATE, () => wrap(() => pomodoro.getState()))
}
+41
View File
@@ -0,0 +1,41 @@
import { dialog, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SyncConfigureInput, SyncRunInput } from '../../../shared/sync'
import { SyncService } from '../../services/sync.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
let syncService: SyncService | null = null
function svc(registry: BookRegistryService, settings: GlobalSettingsService): SyncService {
if (!syncService) {
syncService = new SyncService(registry, settings, registry.getUserDataDir())
}
return syncService
}
export function registerSyncHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const service = () => svc(registry, settings)
ipcMain.handle(IPC.SYNC_STATUS, () => wrap(() => service().getStatus()))
ipcMain.handle(IPC.SYNC_CONFIGURE, (_e, input: SyncConfigureInput) =>
wrap(() => service().configure(input))
)
ipcMain.handle(IPC.SYNC_RUN, (_e, input: SyncRunInput) =>
wrap(() => service().run(input.password, input.conflictResolution))
)
ipcMain.handle(IPC.SYNC_PICK_FOLDER, async () =>
wrap(async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
if (result.canceled || !result.filePaths[0]) return null
return result.filePaths[0]
})
)
}
+28
View File
@@ -0,0 +1,28 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { TimelineUpsertManualInput } from '../../../shared/timeline'
import { TimelineService } from '../../services/timeline.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerTimelineHandlers(registry: BookRegistryService): void {
ipcMain.handle(IPC.TIMELINE_LIST, (_e, { bookId }: { bookId: string }) =>
wrap(() => new TimelineService(registry.getDb(bookId)).list())
)
ipcMain.handle(
IPC.TIMELINE_UPSERT,
(_e, { bookId, input }: { bookId: string; input: TimelineUpsertManualInput }) =>
wrap(() => new TimelineService(registry.getDb(bookId)).upsertManual(input))
)
ipcMain.handle(IPC.TIMELINE_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
new TimelineService(registry.getDb(bookId)).deleteManual(id)
})
)
ipcMain.handle(IPC.TIMELINE_CONFLICTS, (_e, { bookId }: { bookId: string }) =>
wrap(() => new TimelineService(registry.getDb(bookId)).detectConflicts())
)
}
+36
View File
@@ -0,0 +1,36 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { UndoOperation } from '../../../shared/undo'
import { UndoStackService } from '../../services/undo-stack.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerUndoHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.UNDO_PUSH,
(
_e,
{
bookId,
chapterId,
operation
}: { bookId: string; chapterId: string; operation: UndoOperation }
) => wrap(() => new UndoStackService(registry.getDb(bookId)).push(chapterId, operation))
)
ipcMain.handle(
IPC.UNDO_LIST,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => new UndoStackService(registry.getDb(bookId)).list(chapterId))
)
ipcMain.handle(
IPC.UNDO_APPLY,
(_e, { bookId, entryId }: { bookId: string; entryId: number }) =>
wrap(() => {
const op = new UndoStackService(registry.getDb(bookId)).apply(entryId)
if (!op) throw new Error('Undo entry not found')
return op
})
)
}
+21
View File
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { UpdateService } from '../../services/update.service'
import { wrap } from '../result'
let updateService: UpdateService | null = null
function service(): UpdateService {
if (!updateService) updateService = new UpdateService()
return updateService
}
export function registerUpdateHandlers(): void {
ipcMain.handle(IPC.UPDATE_CHECK, () => wrap(() => service().check()))
ipcMain.handle(IPC.UPDATE_DOWNLOAD, () => wrap(() => service().download()))
ipcMain.handle(IPC.UPDATE_INSTALL, () =>
wrap(() => {
service().install()
})
)
}

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