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>
This commit is contained in:
2026-07-08 10:48:31 +08:00
parent 2c9b034a29
commit 4adafa1936
44 changed files with 2062 additions and 602 deletions
@@ -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))
)
}
+7 -2
View File
@@ -11,6 +11,7 @@ import { scheduleChapterExtraction } from '../../services/knowledge-extraction-r
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result'
@@ -38,7 +39,8 @@ export function registerAutoHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService,
writingLogs: WritingLogService
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
@@ -217,7 +219,10 @@ export function registerAutoHandlers(
wrap(() => {
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
const ch = registry.getChapterRepo(bookId).get(result.chapterId)!
trackerFor(registry, bookId, writingLogs).supplementOnFinish(result.chapterId, ch.wordCount)
trackerFor(registry, bookId, writingLogs, writingSessions).supplementOnFinish(
result.chapterId,
ch.wordCount
)
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result
})
+14 -2
View File
@@ -2,9 +2,15 @@ import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { BookMeta, CreateBookParams } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import type { WritingSessionService } from '../../services/writing-session.service'
import { wrap } from '../result'
export function registerBookHandlers(registry: BookRegistryService): void {
let previousBookId: string | null = null
export function registerBookHandlers(
registry: BookRegistryService,
writingSessions?: WritingSessionService
): void {
ipcMain.handle(IPC.BOOK_LIST, () => wrap(() => registry.list()))
ipcMain.handle(IPC.BOOK_CREATE, (_event, params: CreateBookParams) =>
@@ -18,7 +24,13 @@ export function registerBookHandlers(registry: BookRegistryService): void {
)
ipcMain.handle(IPC.BOOK_OPEN, (_event, { bookId }: { bookId: string }) =>
wrap(() => registry.open(bookId))
wrap(() => {
if (writingSessions && previousBookId && previousBookId !== bookId) {
writingSessions.endActive(previousBookId)
}
previousBookId = bookId
return registry.open(bookId)
})
)
ipcMain.handle(
+12 -4
View File
@@ -4,12 +4,14 @@ import type { ChapterStatus, PublishStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerChapterHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(
IPC.VOLUME_CREATE,
@@ -72,7 +74,8 @@ export function registerChapterHandlers(
title,
content,
status,
cursorOffset
cursorOffset,
povCharacterId
}: {
bookId: string
chapterId: string
@@ -80,6 +83,7 @@ export function registerChapterHandlers(
content?: string
status?: ChapterStatus
cursorOffset?: number
povCharacterId?: string | null
}
) =>
wrap(() => {
@@ -87,7 +91,8 @@ export function registerChapterHandlers(
title,
content,
status,
cursorOffset
cursorOffset,
povCharacterId
})
ftsSync.upsert(
registry.getDb(bookId),
@@ -96,7 +101,10 @@ export function registerChapterHandlers(
chapter.title,
chapter.content
)
trackerFor(registry, bookId, writingLogs).recordDelta(chapterId, chapter.wordCount)
trackerFor(registry, bookId, writingLogs, writingSessions).recordDelta(
chapterId,
chapter.wordCount
)
registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter
})
+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)
})
)
}
+7 -2
View File
@@ -11,6 +11,7 @@ import { scheduleChapterExtraction } from '../../services/knowledge-extraction-r
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'
const activeInteractive = new Map<string, AbortController>()
@@ -29,7 +30,8 @@ export function registerInteractiveHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService,
writingLogs: WritingLogService
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
@@ -208,7 +210,10 @@ export function registerInteractiveHandlers(
wrap(() => {
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
const ch = registry.getChapterRepo(bookId).get(result.chapterId)!
trackerFor(registry, bookId, writingLogs).supplementOnFinish(result.chapterId, ch.wordCount)
trackerFor(registry, bookId, writingLogs, writingSessions).supplementOnFinish(
result.chapterId,
ch.wordCount
)
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result
})
+12 -4
View File
@@ -10,6 +10,8 @@ import { WritingLogService } from '../services/writing-log.service'
import { GoalNotificationService } from '../services/goal-notification.service'
import { AchievementService } from '../services/achievement.service'
import { PomodoroService } from '../services/pomodoro.service'
import { WritingSessionRepository } from '../db/repositories/writing-session.repo'
import { WritingSessionService } from '../services/writing-session.service'
import { registerSettingsHandlers } from './handlers/settings.handler'
import { registerBookHandlers } from './handlers/book.handler'
import { registerChapterHandlers } from './handlers/chapter.handler'
@@ -29,6 +31,8 @@ import { registerCockpitHandlers } from './handlers/cockpit.handler'
import { registerWritingHandlers } from './handlers/writing.handler'
import { registerPomodoroHandlers } from './handlers/pomodoro.handler'
import { registerAchievementHandlers } from './handlers/achievement.handler'
import { registerGraphHandlers } from './handlers/graph.handler'
import { registerAnalyticsHandlers } from './handlers/analytics.handler'
import { registerBridgeHandlers } from './handlers/bridge.handler'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -46,6 +50,8 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
const writingLogs = new WritingLogService(writingLogRepo, settings)
const milestoneRepo = new WritingMilestoneRepository(writingLogRepo.getDb())
const pomodoroDailyRepo = new PomodoroDailyRepository(writingLogRepo.getDb())
const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb())
const writingSessions = new WritingSessionService(writingSessionRepo)
const goalNotify = new GoalNotificationService(settings)
const achievementService = new AchievementService(
milestoneRepo,
@@ -61,8 +67,8 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
networkMonitor = new NetworkMonitorService()
registerSettingsHandlers(settings)
registerBookHandlers(books)
registerChapterHandlers(books, writingLogs)
registerBookHandlers(books, writingSessions)
registerChapterHandlers(books, writingLogs, writingSessions)
registerShortcutHandlers(shortcutManager)
registerOutlineHandlers(books)
registerSettingHandlers(books)
@@ -72,14 +78,16 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerSearchHandlers(books, settings)
const aiClient = new AiClientService(() => settings.get().aiConfig)
registerAiHandlers(books, settings, aiClient)
registerInteractiveHandlers(books, settings, aiClient, writingLogs)
registerAutoHandlers(books, settings, aiClient, writingLogs)
registerInteractiveHandlers(books, settings, aiClient, writingLogs, writingSessions)
registerAutoHandlers(books, settings, aiClient, writingLogs, writingSessions)
registerWizardHandlers(books, settings, aiClient)
registerKnowledgeHandlers(books, settings, aiClient)
registerCockpitHandlers(books, settings, writingLogs, achievementService, pomodoroDailyRepo)
registerWritingHandlers(writingLogs)
registerPomodoroHandlers(pomodoro)
registerAchievementHandlers(achievementService)
registerGraphHandlers(books)
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
registerBridgeHandlers(books, aiClient)
return { settings, books, shortcuts: shortcutManager }