# 笔临 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 │ └──────────────────────────────┬──────────────────────────────────────┘ │ ┌──────────────────────────────▼──────────────────────────────────────┐ │ 主进程(每书 .sqlite,schema v8) │ │ · CharacterGraphService(读 settings,写 properties.relationships) │ │ · SettingRepository(已有) │ │ · ChapterRepository.pov_character_id(已有) │ └──────────────────────────────┬──────────────────────────────────────┘ │ IPC ┌──────────────────────────────▼──────────────────────────────────────┐ │ 渲染进程 │ │ · CharacterGraphModal(Cytoscape + 侧栏) │ │ · SettingRelationshipsEditor(角色设定页关系 CRUD) │ │ · CockpitModal 扩展 analytics 区块 │ │ · ChapterHeader / ChapterList:POV 下拉 │ └─────────────────────────────────────────────────────────────────────┘ ``` ### 2.2 模块边界 | 模块 | 职责 | 依赖 | |------|------|------| | `WritingSessionService` | 活跃 session 状态机;5 分钟 idle 切分;`recordActivity` | `WritingSessionRepository` | | `WritingAnalyticsService` | `getSummary(bookId)` 聚合分析指标 | sessions, logs, pomodoro, chapters, settings | | `CharacterGraphService` | 构建 nodes/edges;CRUD 关系;边去重 | `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` | | `graph:upsertRelationship` | `{ bookId, sourceId, relationship }` | `IpcResult` | | `graph:deleteRelationship` | `{ bookId, sourceId, relationshipId }` | `IpcResult` | | `analytics:summary` | `{ bookId }` | `IpcResult` | `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 1–5 - 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 | 同一 session,word_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 Coverage(design/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 |