Files
bilin/docs/superpowers/plans/2026-07-08-bilin-p7-graph-p9-analytics.md
T
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

23 KiB
Raw Blame History

笔临 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 (- [ ]) syntax for tracking.

Goal: 交付 v1.0.0:§7 角色关系图谱(Cytoscape + 设定关系 CRUD+ §9 个人写作分析(writing_sessions 驱动速度/时段热力 + POV/卷分布),驾驶舱内嵌展示。

Architecture: 全局 writing_logs.sqlite 扩展 writing_sessions 表;ChapterWritingTracker.recordDelta 同步写 sessionCharacterGraphServicesettings.properties.relationships 构建图 DTOWritingAnalyticsService 聚合 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 CharacterRelationshipCharacterGraph*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

  • Step 1: 扩展 types

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 注释即可)。

  • Step 2: IPC 通道
GRAPH_GET_DATA: 'graph:getData',
GRAPH_UPSERT_RELATIONSHIP: 'graph:upsertRelationship',
GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship',
ANALYTICS_SUMMARY: 'analytics:summary',
  • Step 3: build 验证
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.tsrepo 部分)

  • Step 1: writing-log.repo migrate 扩展

migrate()CREATE TABLE 块追加:

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);
  • Step 2: WritingSessionRepository
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
}
  • Step 3: UT repo smoke
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)
})
  • Step 4: 运行
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

  • Step 1: 实现

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)
  }
}
  • Step 2: UT-SESSION-01 / UT-SESSION-02

使用 vi.useFakeTimers()

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 */ })
  • Step 3: 运行
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.tsUpdateChapterParams 含 povCharacterId

  • Modify: src/shared/types.tsUpdateChapterParams

  • Modify: src/main/ipc/register.tstrackerFor 注入 sessions

  • Step 1: chapter.repo mapRow + update

// mapRow 增加
povCharacterId: (row.pov_character_id as string) || null,

// update patch 增加 povCharacterId?: string | null
// UPDATE SQL 增加 pov_character_id = ?
  • Step 2: trackerFor 签名
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)
  }
}
  • Step 3: register.ts
const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb())
const writingSessions = new WritingSessionService(writingSessionRepo)
// registerChapterHandlers(books, writingLogs, writingSessions)
  • Step 4: build
npm run build

Task 5: CharacterGraphService

Files:

  • Create: src/main/services/character-graph.service.ts

  • Create: tests/main/character-graph.test.ts

  • Step 1: getData 边去重

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
  }
}
  • Step 2: upsertRelationship / deleteRelationship
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
  • Step 3: UT-GRAPH-01 / UT-GRAPH-02 / UT-GRAPH-03

内存 DB + migrate + 创建 3 character settings + 2 relationships。

  • Step 4: 运行
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

  • Step 1: getSummary 实现

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__'
  }
}

辅助函数:

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)
}
  • Step 2: UT-ANALYTICS-01 / UT-ANALYTICS-02

  • Step 3: IT-ANALYTICS-01

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)
})
  • Step 4: 运行
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

  • Step 1: graph.handler

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) }))
}
  • Step 2: analytics.handler
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)))
}
  • Step 3: register.ts 串联
const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb())
const writingSessions = new WritingSessionService(writingSessionRepo)
registerGraphHandlers(books)
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
registerChapterHandlers(books, writingLogs, writingSessions)
  • Step 4: preload
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 })
}
  • Step 5: build
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.tsinit / layout / intimacy 颜色)

  • Modify: src/renderer/components/layout/EditorLayout.tsx(挂载模态)

  • Modify: src/renderer/styles/layout.css

  • Step 1: 安装依赖

npm install cytoscape cytoscape-cose-bilkent --legacy-peer-deps
npm install -D @types/cytoscape --legacy-peer-deps
  • Step 2: useGraphStore
interface GraphStore {
  open: boolean
  data: CharacterGraphData | null
  loading: boolean
  openModal: () => void
  close: () => void
  load: (bookId: string, filter?: 'all' | 'connected') => Promise<void>
}
  • Step 3: CharacterGraphModal 骨架
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。

  • Step 4: EditorLayout 挂载 <CharacterGraphModal />

  • Step 5: build

npm run build

Task 9: SettingRelationshipsEditor + 入口

Files:

  • Create: src/renderer/components/setting/SettingRelationshipsEditor.tsx

  • Modify: src/renderer/components/editor/TipTapEditor.tsxcharacter setting 下方嵌入)

  • Modify: src/renderer/components/setting/SettingList.tsxsetting-open-graph 按钮)

  • Step 1: SettingRelationshipsEditor

target.kind === 'setting' 且 entry.type === 'character'

<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

  • Step 2: SettingList 工具栏
<button data-testid="setting-open-graph" onClick={() => useGraphStore.getState().openModal()}>
  {t('graph.title')}
</button>
  • Step 3: build
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.tsxchapter-pov-select

  • Modify: src/renderer/stores/useCockpitStore.ts(可选:loadAnalytics

  • Step 1: CockpitModal 并行加载

useEffect(() => {
  if (!open || !bookId) return
  void Promise.all([
    loadSummary(bookId, activeVolumeId ?? undefined),
    ipcCall(() => window.electronAPI.analytics.getSummary(bookId)).then(setAnalytics)
  ])
}, [open, bookId, activeVolumeId])
  • Step 2: CockpitAnalytics 组件
<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>
  • Step 3: chapter-pov-select

#editor-statusbarisChapterTarget

<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>

updatePovchapter.update(bookId, chapterId, { povCharacterId }).

  • Step 4: build
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

  • Step 1: 添加 spec §7 全部 i18n 键(graph / relationship / analytics / chapter.pov / cockpit.openGraph

  • Step 2: 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; }
  • Step 3: build
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

  • Step 1: E2E-GRAPH-01~04

// 创建书 → 新建 character 设定 A/B → SettingRelationshipsEditor 添加关系
// → setting-open-graph → expect character-graph-cy visible
// → tap node → sidebar 含角色名
// → graph-layout-circle click
  • Step 2: E2E-ANALYTICS-01~02
// E2E-ANALYTICS-01: 写作保存 → topbar-cockpit → cockpit-speed-today 非空
// E2E-ANALYTICS-02: chapter-pov-select 选角色 → 刷新驾驶舱 → cockpit-pov-chart 含角色名
  • Step 3: 全量测试
npm run build
npm run test
npm run test:e2e -- e2e/character-graph.spec.ts e2e/writing-analytics.spec.ts
  • Step 4: 版本 1.0.0

package.json / README.md / Settings 关于页 → v1.0.0

  • 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

  • CharacterGraphServiceSettingRelationshipsEditor 共用 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