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>
This commit is contained in:
2026-07-08 10:04:56 +08:00
parent d4122c8f95
commit e64353f608
@@ -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 |