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>
This commit is contained in:
2026-07-07 18:26:29 +08:00
parent f331ddae86
commit a39e06ff07
51 changed files with 1436 additions and 89 deletions
+11 -1
View File
@@ -5,8 +5,9 @@ import schemaV3 from './schema-v3.sql?raw'
import schemaV4 from './schema-v4.sql?raw'
import schemaV5 from './schema-v5.sql?raw'
import schemaV6 from './schema-v6.sql?raw'
import schemaV7 from './schema-v7.sql?raw'
const CURRENT_VERSION = 6
const CURRENT_VERSION = 7
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -71,5 +72,14 @@ export function migrate(db: SqliteDb): void {
if (current < 6) {
db.exec(schemaV6)
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')
}
}
@@ -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)
}
}
@@ -20,6 +20,9 @@ function mapRow(row: Record<string, unknown>): KnowledgeEntry {
sourceChapterId: (row.source_chapter_id as string) || undefined,
lastMentionChapterId: (row.last_mention_chapter_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,
updatedAt: row.updated_at as string
}
@@ -53,6 +56,41 @@ export class KnowledgeRepository {
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 {
const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as
| Record<string, unknown>
@@ -101,6 +139,15 @@ export class KnowledgeRepository {
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 {
this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id)
}
@@ -111,4 +158,20 @@ export class KnowledgeRepository {
.get(status) as { c: number }
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,78 @@
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)
)
`)
}
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()
}
}
+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
);
+13 -3
View File
@@ -7,6 +7,9 @@ import { AiClientService } from '../../services/ai-client.service'
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
import { checkInteractiveGate } from '../../services/interactive-gate.service'
import { AutoWritingService } from '../../services/auto-writing.service'
import { scheduleChapterExtraction } from '../../services/knowledge-extraction-runner'
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import type { WritingLogService } from '../../services/writing-log.service'
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result'
@@ -33,9 +36,9 @@ function readContextText(registry: BookRegistryService, bookId: string, flowId:
export function registerAutoHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService
aiClient: AiClientService,
writingLogs: WritingLogService
): void {
void settings
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
@@ -203,7 +206,14 @@ export function registerAutoHandlers(
(
_e,
{ 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).supplementOnFinish(result.chapterId, ch.wordCount)
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result
})
)
ipcMain.handle(IPC.AUTO_ABORT, (_e, { flowId }: { flowId: string }) =>
+7 -1
View File
@@ -2,10 +2,15 @@ import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
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 { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerChapterHandlers(registry: BookRegistryService): void {
export function registerChapterHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService
): void {
ipcMain.handle(
IPC.VOLUME_CREATE,
(_event, { bookId, name }: { bookId: string; name: string }) =>
@@ -91,6 +96,7 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
chapter.title,
chapter.content
)
trackerFor(registry, bookId, writingLogs).recordDelta(chapterId, chapter.wordCount)
registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter
})
+6 -2
View File
@@ -3,16 +3,20 @@ import { IPC } from '../../../shared/ipc-channels'
import { BookRegistryService } from '../../services/book-registry'
import { CockpitService } from '../../services/cockpit.service'
import type { GlobalSettingsService } from '../../services/global-settings'
import type { WritingLogService } from '../../services/writing-log.service'
import { wrap } from '../result'
export function registerCockpitHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
settings: GlobalSettingsService,
writingLogs: WritingLogService
): void {
ipcMain.handle(
IPC.COCKPIT_SUMMARY,
(_e, { bookId, volumeId }: { bookId: string; volumeId?: string }) =>
wrap(() => new CockpitService(registry.getDb(bookId), settings).getSummary(volumeId))
wrap(() =>
new CockpitService(registry.getDb(bookId), settings, writingLogs).getSummary(volumeId, bookId)
)
)
ipcMain.handle(IPC.COCKPIT_MARK_SEEN, (_e, { bookId }: { bookId: string }) =>
+12 -2
View File
@@ -7,6 +7,9 @@ import { AiClientService } from '../../services/ai-client.service'
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
import { checkInteractiveGate } from '../../services/interactive-gate.service'
import { InteractiveWritingService } from '../../services/interactive-writing.service'
import { scheduleChapterExtraction } from '../../services/knowledge-extraction-runner'
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import type { WritingLogService } from '../../services/writing-log.service'
import { wrap } from '../result'
const activeInteractive = new Map<string, AbortController>()
@@ -24,7 +27,8 @@ function enrich(registry: BookRegistryService, bookId: string, aiClient: AiClien
export function registerInteractiveHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService
aiClient: AiClientService,
writingLogs: WritingLogService
): void {
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
@@ -194,7 +198,13 @@ export function registerInteractiveHandlers(
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).supplementOnFinish(result.chapterId, ch.wordCount)
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result
})
)
ipcMain.handle(IPC.INTERACTIVE_ABORT, (_e, { flowId }: { flowId: string }) =>
+41 -3
View File
@@ -1,15 +1,24 @@
import { ipcMain } from 'electron'
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 { 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 { KnowledgeRetrievalService } from '../../services/knowledge-retrieval.service'
import { KnowledgeService } from '../../services/knowledge.service'
import { wrap } from '../result'
export function registerKnowledgeHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
settings: GlobalSettingsService,
aiClient: AiClientService
): void {
ipcMain.handle(
IPC.KNOWLEDGE_LIST,
@@ -82,4 +91,33 @@ 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)
})
)
}
+10
View File
@@ -0,0 +1,10 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { WritingLogService } from '../../services/writing-log.service'
import { wrap } from '../result'
export function registerWritingHandlers(writingLogs: WritingLogService): void {
ipcMain.handle(IPC.WRITING_GET_STATS, (_e, { bookId }: { bookId: string }) =>
wrap(() => writingLogs.getStats(bookId))
)
}
+10 -5
View File
@@ -4,6 +4,8 @@ import { GlobalSettingsService } from '../services/global-settings'
import { BookRegistryService } from '../services/book-registry'
import { SnapshotService } from '../services/snapshot.service'
import { ShortcutManager } from '../shortcuts/manager'
import { WritingLogRepository } from '../db/repositories/writing-log.repo'
import { WritingLogService } from '../services/writing-log.service'
import { registerSettingsHandlers } from './handlers/settings.handler'
import { registerBookHandlers } from './handlers/book.handler'
import { registerChapterHandlers } from './handlers/chapter.handler'
@@ -20,6 +22,7 @@ import { registerAutoHandlers } from './handlers/auto.handler'
import { registerWizardHandlers } from './handlers/wizard.handler'
import { registerKnowledgeHandlers } from './handlers/knowledge.handler'
import { registerCockpitHandlers } from './handlers/cockpit.handler'
import { registerWritingHandlers } from './handlers/writing.handler'
import { registerBridgeHandlers } from './handlers/bridge.handler'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -33,13 +36,14 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
const settings = new GlobalSettingsService(userData)
const books = new BookRegistryService(userData)
const writingLogs = new WritingLogService(new WritingLogRepository(userData), settings)
shortcutManager = new ShortcutManager(settings)
snapshotService = new SnapshotService(() => settings.get())
networkMonitor = new NetworkMonitorService()
registerSettingsHandlers(settings)
registerBookHandlers(books)
registerChapterHandlers(books)
registerChapterHandlers(books, writingLogs)
registerShortcutHandlers(shortcutManager)
registerOutlineHandlers(books)
registerSettingHandlers(books)
@@ -49,11 +53,12 @@ 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)
registerAutoHandlers(books, settings, aiClient)
registerInteractiveHandlers(books, settings, aiClient, writingLogs)
registerAutoHandlers(books, settings, aiClient, writingLogs)
registerWizardHandlers(books, settings, aiClient)
registerKnowledgeHandlers(books, settings)
registerCockpitHandlers(books, settings)
registerKnowledgeHandlers(books, settings, aiClient)
registerCockpitHandlers(books, settings, writingLogs)
registerWritingHandlers(writingLogs)
registerBridgeHandlers(books, aiClient)
return { settings, books, shortcuts: shortcutManager }
@@ -0,0 +1,40 @@
import type { SqliteDb } from '../db/types'
import { ChapterWritingSnapshotRepository } from '../db/repositories/chapter-writing-snapshot.repo'
import type { BookRegistryService } from './book-registry'
import type { WritingLogService } from './writing-log.service'
export function trackerFor(
registry: BookRegistryService,
bookId: string,
writingLogs: WritingLogService
): ChapterWritingTracker {
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs)
}
export class ChapterWritingTracker {
private snapRepo: ChapterWritingSnapshotRepository
constructor(
bookDb: SqliteDb,
private bookId: string,
private writingLogs: WritingLogService
) {
this.snapRepo = new ChapterWritingSnapshotRepository(bookDb)
}
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)
}
}
+8 -3
View File
@@ -4,15 +4,17 @@ import { BookPrefsRepository } from '../db/repositories/book-prefs.repo'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import type { GlobalSettingsService } from './global-settings'
import { KnowledgeService } from './knowledge.service'
import type { WritingLogService } from './writing-log.service'
import { stripHtml } from './word-count'
export class CockpitService {
constructor(
private db: SqliteDb,
private settings: GlobalSettingsService
private settings: GlobalSettingsService,
private writingLogs?: WritingLogService
) {}
getSummary(activeVolumeId?: string): CockpitSummary {
getSummary(activeVolumeId?: string, bookId?: string): CockpitSummary {
const chapters = new ChapterRepository(this.db).list()
const totalWords = chapters.reduce((s, c) => s + stripHtml(c.content).length, 0)
const volumeWords = activeVolumeId
@@ -23,6 +25,8 @@ export class CockpitService {
const stockReadyCount = new ChapterRepository(this.db).countByPublishStatus('ready')
const stats = new KnowledgeService(this.db).getForeshadowStats()
const { stockBufferThreshold } = this.settings.get()
const writingStats =
bookId && this.writingLogs ? this.writingLogs.getStats(bookId) : undefined
return {
totalWords,
volumeWords,
@@ -30,7 +34,8 @@ export class CockpitService {
stockThreshold: stockBufferThreshold,
foreshadowBuried: stats.buried,
foreshadowResolved: stats.resolved,
foreshadowForgotten: stats.forgotten
foreshadowForgotten: stats.forgotten,
writingStats
}
}
+5 -1
View File
@@ -30,7 +30,9 @@ function defaults(): GlobalSettings {
aiConfig: defaultAiConfig(),
namingIgnoreWords: [],
knowledgeAutoSuggest: true,
knowledgeSuggestTopN: 8
knowledgeSuggestTopN: 8,
knowledgeAutoExtract: true,
knowledgeExtractConfidenceThreshold: 0.8
}
}
@@ -53,6 +55,8 @@ export class GlobalSettingsService {
stockBufferThreshold: raw.stockBufferThreshold ?? 3,
knowledgeAutoSuggest: raw.knowledgeAutoSuggest ?? true,
knowledgeSuggestTopN: raw.knowledgeSuggestTopN ?? 8,
knowledgeAutoExtract: raw.knowledgeAutoExtract ?? true,
knowledgeExtractConfidenceThreshold: raw.knowledgeExtractConfidenceThreshold ?? 0.8,
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
}
@@ -0,0 +1,45 @@
import type { ExtractedKnowledgeItem } from '../../shared/types'
export function buildExtractionPrompt(
chapterTitle: string,
plainText: string,
approvedTitles: Array<{ id: string; title: string }>
): string {
const catalog =
approvedTitles.length > 0
? approvedTitles.map((e) => `- ${e.id}: ${e.title}`).join('\n')
: '(无)'
return `你是小说知识库助手。请从以下章节正文中抽取知识条目,输出 JSON 数组。
章节标题:${chapterTitle}
已有 approved 知识(id: 标题):
${catalog}
若某条抽取内容是对已有知识的更新,请设置 suggestedMergeId 为对应 id,并在 proposedPatch 中给出建议更新的字段。
每条必须包含:typecharacter|foreshadow|relationship|location|fact)、title、content、importance(1-5)、confidence(0-1)、foreshadowStatus(仅 foreshadow 类型:buried|partial|resolved)。
正文:
${plainText}
仅返回 JSON 数组,不要 markdown。`
}
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)
}
@@ -0,0 +1,19 @@
import type { BookRegistryService } from './book-registry'
import type { AiClientService } from './ai-client.service'
import type { GlobalSettingsService } from './global-settings'
import { KnowledgeExtractionService } from './knowledge-extraction.service'
export function scheduleChapterExtraction(
registry: BookRegistryService,
bookId: string,
chapterId: string,
aiClient: AiClientService,
settings: GlobalSettingsService
): void {
if (settings.get().knowledgeAutoExtract === false) return
setImmediate(() => {
void new KnowledgeExtractionService(registry.getDb(bookId), aiClient)
.extractForChapter(chapterId)
.catch((err) => console.error('[knowledge-extract]', err))
})
}
@@ -0,0 +1,71 @@
import { randomUUID } from 'crypto'
import type { ExtractedKnowledgeItem, KnowledgeExtractionResult } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
import type { AiClientService } from './ai-client.service'
import { buildExtractionPrompt, parseExtractionJson } from './knowledge-extraction-prompt'
import { stripHtml } from './word-count'
export class KnowledgeExtractionService {
private chapterRepo: ChapterRepository
private knowledgeRepo: KnowledgeRepository
constructor(
private db: SqliteDb,
private aiClient: AiClientService
) {
this.chapterRepo = new ChapterRepository(db)
this.knowledgeRepo = new KnowledgeRepository(db)
}
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') : item.foreshadowStatus,
sourceChapterId: chapterId,
extractionConfidence: item.confidence,
mergeTargetId,
extractionBatchId: batchId
})
saved.push({ ...item, suggestedMergeId: mergeTargetId })
}
return { batchId, chapterId, items: saved }
}
}
+22
View File
@@ -69,6 +69,28 @@ export class KnowledgeService {
return count
}
batchApproveHighConfidence(threshold: number): number {
return this.repo.batchApproveHighConfidence(threshold)
}
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 updated = this.repo.update(target.id, {
content: pending.content,
importance: pending.importance,
lastMentionChapterId: pending.lastMentionChapterId ?? pending.sourceChapterId
})
this.repo.update(pendingId, { status: 'rejected' })
return updated
}
saveMergeAsNew(pendingId: string): KnowledgeEntry {
return this.repo.clearMergeTarget(pendingId)
}
createFromBookmark(bookmarkId: string): KnowledgeEntry {
const bm = this.bookmarkRepo.get(bookmarkId)
if (!bm) throw new Error('Bookmark not found')
+9
View File
@@ -0,0 +1,9 @@
export function localDateString(d = new Date()): string {
return d.toLocaleDateString('sv-SE')
}
export function addDays(dateStr: string, delta: number): string {
const d = new Date(`${dateStr}T12:00:00`)
d.setDate(d.getDate() + delta)
return localDateString(d)
}
+39
View File
@@ -0,0 +1,39 @@
import type { WritingStats } from '../../shared/types'
import { WritingLogRepository } from '../db/repositories/writing-log.repo'
import type { GlobalSettingsService } from './global-settings'
import { addDays, localDateString } from './writing-date.util'
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.repo.buildHeatmap(bookId, 365)
const streakDays = dailyGoal > 0 ? this.calcStreak(bookId, dailyGoal) : 0
return { todayWords, dailyGoal, goalProgress, streakDays, heatmap }
}
private calcStreak(bookId: string, goal: number): number {
let streak = 0
let date = addDays(localDateString(), -1)
for (let guard = 0; guard < 400; guard++) {
const words = this.repo.getDay(bookId, date)
if (words < goal) break
streak++
date = addDays(date, -1)
}
const todayWords = this.repo.getToday(bookId)
if (todayWords >= goal) streak++
return streak
}
}
+22 -2
View File
@@ -44,7 +44,9 @@ import type {
KnowledgeType,
CockpitSummary,
ChapterBridgeResult,
PublishStatus
PublishStatus,
WritingStats,
KnowledgeExtractionResult
} from '../shared/types'
const electronAPI = {
@@ -461,7 +463,25 @@ const electronAPI = {
bookId: string,
opts?: KnowledgeSuggestOptions
): Promise<IpcResult<ScoredKnowledge[]>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_SUGGEST_CONTEXT, { bookId, opts })
ipcRenderer.invoke(IPC.KNOWLEDGE_SUGGEST_CONTEXT, { bookId, opts }),
extractChapter: (
bookId: string,
chapterId: string
): Promise<IpcResult<KnowledgeExtractionResult>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_EXTRACT_CHAPTER, { bookId, chapterId }),
approveMerge: (bookId: string, pendingId: string): Promise<IpcResult<KnowledgeEntry>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_APPROVE_MERGE, { bookId, pendingId }),
saveMergeAsNew: (bookId: string, pendingId: string): Promise<IpcResult<KnowledgeEntry>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_SAVE_MERGE_AS_NEW, { bookId, pendingId }),
batchApproveHighConfidence: (
bookId: string,
threshold?: number
): Promise<IpcResult<number>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE, { bookId, threshold })
},
writing: {
getStats: (bookId: string): Promise<IpcResult<WritingStats>> =>
ipcRenderer.invoke(IPC.WRITING_GET_STATS, { bookId })
},
cockpit: {
getSummary: (bookId: string, volumeId?: string): Promise<IpcResult<CockpitSummary>> =>
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { WritingHeatmap } from '@renderer/components/cockpit/WritingHeatmap'
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
interface CockpitModalProps {
@@ -60,6 +61,7 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
{loading || !summary ? (
<div className="sidebar-empty">{t('cockpit.loading')}</div>
) : (
<>
<div className="cockpit-grid">
<div className="cockpit-card" data-testid="cockpit-total-words">
<div className="cockpit-card-label">{t('cockpit.totalWords')}</div>
@@ -86,6 +88,24 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
</div>
</div>
</div>
{summary.writingStats && summary.writingStats.dailyGoal > 0 && (
<div className="cockpit-writing-section">
<div className="cockpit-today-progress" data-testid="cockpit-today-progress">
{t('cockpit.todayProgress', {
current: summary.writingStats.todayWords,
goal: summary.writingStats.dailyGoal
})}
</div>
{summary.writingStats.streakDays > 0 && (
<div className="cockpit-streak" data-testid="cockpit-streak">
{t('cockpit.streak', { days: summary.writingStats.streakDays })}
</div>
)}
<div className="cockpit-heatmap-label">{t('cockpit.heatmap')}</div>
<WritingHeatmap heatmap={summary.writingStats.heatmap} />
</div>
)}
</>
)}
</div>
<div className="modal-footer cockpit-actions">
@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react'
import type { WritingDayLog } from '@shared/types'
interface WritingHeatmapProps {
heatmap: WritingDayLog[]
}
function levelFor(count: number, max: number): number {
if (count <= 0) return 0
if (max <= 0) return 1
const ratio = count / max
if (ratio < 0.25) return 1
if (ratio < 0.5) return 2
if (ratio < 0.75) return 3
return 4
}
export function WritingHeatmap({ heatmap }: WritingHeatmapProps): React.JSX.Element {
const canvasRef = useRef<HTMLCanvasElement>(null)
const recent = heatmap.slice(-91)
const max = Math.max(...recent.map((d) => d.wordCount), 1)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const cols = 13
const rows = 7
const cell = 12
const gap = 2
canvas.width = cols * (cell + gap)
canvas.height = rows * (cell + gap)
ctx.clearRect(0, 0, canvas.width, canvas.height)
const colors = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39']
for (let i = 0; i < recent.length; i++) {
const col = Math.floor(i / rows)
const row = i % rows
const lvl = levelFor(recent[i].wordCount, max)
ctx.fillStyle = colors[lvl]
ctx.fillRect(col * (cell + gap), row * (cell + gap), cell, cell)
}
}, [recent, max])
return (
<div className="cockpit-heatmap-wrap" data-testid="cockpit-heatmap">
<canvas ref={canvasRef} className="cockpit-heatmap-canvas" />
</div>
)
}
@@ -29,8 +29,14 @@ export function KnowledgeEditorDialog({
const entries = useKnowledgeStore((s) => s.entries)
const create = useKnowledgeStore((s) => s.create)
const update = useKnowledgeStore((s) => s.update)
const approveMerge = useKnowledgeStore((s) => s.approveMerge)
const saveMergeAsNew = useKnowledgeStore((s) => s.saveMergeAsNew)
const existing = entryId ? entries.find((e) => e.id === entryId) : null
const mergeTarget = existing?.mergeTargetId
? entries.find((e) => e.id === existing.mergeTargetId)
: undefined
const isMergeMode = Boolean(existing?.mergeTargetId && mergeTarget)
const [type, setType] = useState<KnowledgeType>('fact')
const [title, setTitle] = useState('')
@@ -179,6 +185,30 @@ export function KnowledgeEditorDialog({
)}
</div>
<div className="modal-footer">
{isMergeMode && bookId && existing && (
<>
<button
type="button"
className="btn btn-primary"
data-testid="knowledge-approve-merge"
onClick={() => {
void approveMerge(bookId, existing.id).then(onClose)
}}
>
{t('knowledge.approveMerge')}
</button>
<button
type="button"
className="btn"
data-testid="knowledge-save-as-new"
onClick={() => {
void saveMergeAsNew(bookId, existing.id).then(onClose)
}}
>
{t('knowledge.saveAsNew')}
</button>
</>
)}
<button type="button" className="btn" onClick={onClose}>
{t('dialog.cancel')}
</button>
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
import {
@@ -12,6 +13,8 @@ import {
export function KnowledgePanel(): React.JSX.Element {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
const extractThreshold = useSettingsStore((s) => s.knowledgeExtractConfidenceThreshold)
const {
entries,
stats,
@@ -25,8 +28,11 @@ export function KnowledgePanel(): React.JSX.Element {
approve,
reject,
deleteEntry,
batchApprove
batchApprove,
extractChapter,
batchApproveHighConfidence
} = useKnowledgeStore()
const [extracting, setExtracting] = useState(false)
const [namingOpen, setNamingOpen] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [pendingCount, setPendingCount] = useState(0)
@@ -71,6 +77,30 @@ export function KnowledgePanel(): React.JSX.Element {
>
{t('naming.open')}
</button>
{tab === 'pending' && bookId && (
<button
type="button"
className="btn"
data-testid="knowledge-batch-high-confidence"
onClick={() => void batchApproveHighConfidence(bookId, extractThreshold)}
>
{t('knowledge.batchHighConfidence')}
</button>
)}
{bookId && selectedChapterId && (
<button
type="button"
className="btn"
data-testid="knowledge-extract-current"
disabled={extracting}
onClick={() => {
setExtracting(true)
void extractChapter(bookId, selectedChapterId).finally(() => setExtracting(false))
}}
>
{extracting ? t('knowledge.extracting') : t('knowledge.extractFromCurrent')}
</button>
)}
</div>
<div className="knowledge-tabs">
<button
@@ -134,10 +164,29 @@ export function KnowledgePanel(): React.JSX.Element {
/>
)}
<div className="kb-item-main">
<div className="kb-item-title">{entry.title}</div>
<div className="kb-item-title">
{entry.title}
{entry.extractionConfidence != null && (
<span className="kb-confidence-badge">
{Math.round(entry.extractionConfidence * 100)}%
</span>
)}
</div>
<div className="kb-item-meta">
{t(`knowledge.type.${entry.type}`)} · {t(`knowledge.status.${entry.status}`)}
</div>
{entry.mergeTargetId && (
<div
className="kb-merge-hint"
data-testid={`knowledge-merge-review-${entry.id}`}
>
{t('knowledge.mergeSuggest', {
title:
entries.find((e) => e.id === entry.mergeTargetId)?.title ??
entry.mergeTargetId
})}
</div>
)}
{entry.content && <div className="kb-item-content">{entry.content}</div>}
</div>
<div className="kb-item-actions">
@@ -9,6 +9,7 @@ import { useOutlineStore } from '@renderer/stores/useOutlineStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { useWritingStatsStore } from '@renderer/stores/useWritingStatsStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
@@ -90,6 +91,9 @@ export function EditorLayout(): React.JSX.Element {
const switchTarget = useEditStore((s) => s.switchTarget)
const setTarget = useEditStore((s) => s.setTarget)
const updateSchedule = useSettingsStore((s) => s.updateSchedule)
const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal)
const writingStats = useWritingStatsStore((s) => s.stats)
const refreshWritingStats = useWritingStatsStore((s) => s.refresh)
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
@@ -122,6 +126,11 @@ export function EditorLayout(): React.JSX.Element {
}
}, [currentBookId, target?.kind, target?.id])
useEffect(() => {
if (!currentBookId) return
void refreshWritingStats(currentBookId)
}, [currentBookId, lastSaved, refreshWritingStats])
useEffect(() => {
if (!currentBookId) return
void ipcCall(() =>
@@ -344,6 +353,19 @@ export function EditorLayout(): React.JSX.Element {
))}
{activePanel === 'chapters' && (
<div className="sidebar-footer">
{selectedChapterId && currentBookId && (
<button
type="button"
data-testid="chapter-extract-knowledge"
onClick={() =>
void ipcCall(() =>
window.electronAPI.knowledge.extractChapter(currentBookId, selectedChapterId)
)
}
>
{t('knowledge.extractChapter')}
</button>
)}
<button type="button" onClick={() => void handleNewChapter()}>
+ {t('editor.newChapter')}
</button>
@@ -405,6 +427,23 @@ export function EditorLayout(): React.JSX.Element {
/>
<div id="editor-statusbar">
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
{dailyWordGoal > 0 && writingStats && (
<span
className={`status-daily-goal ${
writingStats.goalProgress >= 1
? 'goal-met'
: writingStats.goalProgress >= 0.8
? 'goal-near'
: ''
}`}
data-testid="status-daily-goal"
>
{t('status.dailyGoal', {
current: writingStats.todayWords,
goal: dailyWordGoal
})}
</span>
)}
{showStockWarning && (
<span className="status-stock-warning" data-testid="status-stock-warning">
{t('stock.warning', {
@@ -131,13 +131,61 @@ export function SettingsPage(): React.JSX.Element {
}
/>
</div>
<div className="setting-item">
<span>{t('settings.dailyWordGoal')}</span>
<input
type="number"
min={0}
data-testid="settings-daily-word-goal"
className="form-control form-control--narrow"
value={settings.dailyWordGoal}
onChange={(e) =>
void settings.update({
dailyWordGoal: Math.max(0, Number(e.target.value) || 0)
})
}
/>
</div>
<div className="setting-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
data-testid="settings-knowledge-auto-extract"
checked={settings.knowledgeAutoExtract !== false}
onChange={(e) =>
void settings.update({ knowledgeAutoExtract: e.target.checked })
}
/>
{t('settings.knowledgeAutoExtract')}
</label>
</div>
<div className="setting-item">
<span>{t('settings.extractThreshold')}</span>
<input
type="number"
min={0}
max={1}
step={0.05}
data-testid="settings-extract-threshold"
className="form-control form-control--narrow"
value={settings.knowledgeExtractConfidenceThreshold ?? 0.8}
onChange={(e) =>
void settings.update({
knowledgeExtractConfidenceThreshold: Math.min(
1,
Math.max(0, Number(e.target.value) || 0.8)
)
})
}
/>
</div>
</>
)}
{section === 'ai' && <AiSettingsPage />}
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v0.7.0
{t('app.name')} v0.8.0
<br />
{t('app.tagline')}
</p>
+20
View File
@@ -32,6 +32,10 @@ interface KnowledgeState {
approve: (bookId: string, id: string) => Promise<void>
reject: (bookId: string, id: string) => Promise<void>
batchApprove: (bookId: string, ids: string[]) => Promise<void>
extractChapter: (bookId: string, chapterId: string) => Promise<void>
approveMerge: (bookId: string, pendingId: string) => Promise<void>
saveMergeAsNew: (bookId: string, pendingId: string) => Promise<void>
batchApproveHighConfidence: (bookId: string, threshold?: number) => Promise<void>
filteredEntries: () => KnowledgeEntry[]
}
@@ -82,6 +86,22 @@ export const useKnowledgeStore = create<KnowledgeState>((set, get) => ({
await ipcCall(() => window.electronAPI.knowledge.batchApprove(bookId, ids))
await get().load(bookId)
},
extractChapter: async (bookId, chapterId) => {
await ipcCall(() => window.electronAPI.knowledge.extractChapter(bookId, chapterId))
await get().load(bookId)
},
approveMerge: async (bookId, pendingId) => {
await ipcCall(() => window.electronAPI.knowledge.approveMerge(bookId, pendingId))
await get().load(bookId)
},
saveMergeAsNew: async (bookId, pendingId) => {
await ipcCall(() => window.electronAPI.knowledge.saveMergeAsNew(bookId, pendingId))
await get().load(bookId)
},
batchApproveHighConfidence: async (bookId, threshold) => {
await ipcCall(() => window.electronAPI.knowledge.batchApproveHighConfidence(bookId, threshold))
await get().load(bookId)
},
filteredEntries: () => {
const { entries, tab, forgottenOnly } = get()
let list = entries
+2
View File
@@ -24,6 +24,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
namingIgnoreWords: [] as string[],
knowledgeAutoSuggest: true,
knowledgeSuggestTopN: 8,
knowledgeAutoExtract: true,
knowledgeExtractConfidenceThreshold: 0.8,
loaded: false,
load: async () => {
const data = await ipcCall(() => window.electronAPI.settings.get())
@@ -0,0 +1,16 @@
import { create } from 'zustand'
import type { WritingStats } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
interface WritingStatsState {
stats: WritingStats | null
refresh: (bookId: string) => Promise<void>
}
export const useWritingStatsStore = create<WritingStatsState>((set) => ({
stats: null,
refresh: async (bookId) => {
const stats = await ipcCall(() => window.electronAPI.writing.getStats(bookId))
set({ stats })
}
}))
+56
View File
@@ -868,6 +868,62 @@
gap: 8px;
}
.cockpit-writing-section {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.cockpit-today-progress {
font-size: 15px;
font-weight: 600;
}
.cockpit-streak {
color: var(--accent-light);
font-size: 13px;
}
.cockpit-heatmap-label {
font-size: 12px;
color: var(--text-secondary);
}
.cockpit-heatmap-wrap {
overflow-x: auto;
}
.status-daily-goal {
margin-left: 12px;
font-size: 12px;
color: var(--text-secondary);
}
.status-daily-goal.goal-near {
color: var(--accent-light);
}
.status-daily-goal.goal-met {
color: #3fb950;
font-weight: 600;
}
.kb-confidence-badge {
margin-left: 8px;
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.kb-merge-hint {
margin-top: 4px;
font-size: 12px;
color: var(--accent-light);
}
.bridge-body {
max-height: 420px;
overflow-y: auto;
+13 -1
View File
@@ -42,7 +42,9 @@ import type {
KnowledgeType,
CockpitSummary,
ChapterBridgeResult,
PublishStatus
PublishStatus,
WritingStats,
KnowledgeExtractionResult
} from './types'
export interface ElectronAPI {
@@ -349,6 +351,16 @@ export interface ElectronAPI {
bookId: string,
opts?: KnowledgeSuggestOptions
) => Promise<IpcResult<ScoredKnowledge[]>>
extractChapter: (
bookId: string,
chapterId: string
) => Promise<IpcResult<KnowledgeExtractionResult>>
approveMerge: (bookId: string, pendingId: string) => Promise<IpcResult<KnowledgeEntry>>
saveMergeAsNew: (bookId: string, pendingId: string) => Promise<IpcResult<KnowledgeEntry>>
batchApproveHighConfidence: (bookId: string, threshold?: number) => Promise<IpcResult<number>>
}
writing: {
getStats: (bookId: string) => Promise<IpcResult<WritingStats>>
}
cockpit: {
getSummary: (bookId: string, volumeId?: string) => Promise<IpcResult<CockpitSummary>>
+5
View File
@@ -112,6 +112,11 @@ export const IPC = {
KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark',
KNOWLEDGE_STATS: 'knowledge:stats',
KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext',
KNOWLEDGE_EXTRACT_CHAPTER: 'knowledge:extractChapter',
KNOWLEDGE_APPROVE_MERGE: 'knowledge:approveMerge',
KNOWLEDGE_SAVE_MERGE_AS_NEW: 'knowledge:saveMergeAsNew',
KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE: 'knowledge:batchApproveHighConfidence',
WRITING_GET_STATS: 'writing:getStats',
COCKPIT_SUMMARY: 'cockpit:summary',
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
+37
View File
@@ -66,6 +66,9 @@ export interface KnowledgeEntry {
sourceChapterId?: string
lastMentionChapterId?: string
linkedBookmarkId?: string
extractionConfidence?: number
mergeTargetId?: string
extractionBatchId?: string
createdAt: string
updatedAt: string
}
@@ -90,6 +93,38 @@ export interface CockpitSummary {
foreshadowBuried: number
foreshadowResolved: number
foreshadowForgotten: number
writingStats?: WritingStats
}
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[]
}
export interface ChapterBridgeResult {
@@ -134,6 +169,8 @@ export interface GlobalSettings {
namingIgnoreWords: string[]
knowledgeAutoSuggest?: boolean
knowledgeSuggestTopN?: number
knowledgeAutoExtract?: boolean
knowledgeExtractConfidenceThreshold?: number
}
export interface BookMeta {