diff --git a/README.md b/README.md index b576cf5..63663bf 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # 笔临 (Bilin) -长篇创作智能协作平台 — Electron 桌面客户端 v0.7.0(P6 AI 知识上下文) +长篇创作智能协作平台 — Electron 桌面客户端 v0.8.0(P5.2 写作日志 + AI 知识抽取) -## 功能概览(v0.7.0) +## 功能概览(v0.8.0) +- 写作日志与热力图、连更统计、状态栏日更进度(P5.2) +- AI 章节知识自动抽取、合并审核、高置信度批量采纳(P5.2) - 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6) - 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5) - 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5) diff --git a/docs/superpowers/plans/2026-07-07-bilin-p52-writing-logs-extraction.md b/docs/superpowers/plans/2026-07-07-bilin-p52-writing-logs-extraction.md index 6eb96f4..f9a91e1 100644 --- a/docs/superpowers/plans/2026-07-07-bilin-p52-writing-logs-extraction.md +++ b/docs/superpowers/plans/2026-07-07-bilin-p52-writing-logs-extraction.md @@ -1,6 +1,6 @@ # 笔临 P5.2 写作日志 + AI 知识抽取 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. +> **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 (`- [x]`) syntax for tracking. **Goal:** 交付 v0.8.0:`writing_logs` 日更追踪(保存差分 + 成章补计)、驾驶舱热力图/连更、状态栏日更进度,以及 AI 章节知识自动抽取 + 合并审核增强,与 P6 注入形成上下游闭环。 @@ -62,7 +62,7 @@ - Modify: `src/main/services/global-settings.ts` - Modify: `src/renderer/stores/useSettingsStore.ts` -- [ ] **Step 1: 扩展 types** +- [x] **Step 1: 扩展 types** `src/shared/types.ts` 追加: @@ -120,7 +120,7 @@ knowledgeAutoExtract?: boolean knowledgeExtractConfidenceThreshold?: number ``` -- [ ] **Step 2: IPC 通道** +- [x] **Step 2: IPC 通道** ```typescript WRITING_GET_STATS: 'writing:getStats', @@ -129,7 +129,7 @@ KNOWLEDGE_APPROVE_MERGE: 'knowledge:approveMerge', KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE: 'knowledge:batchApproveHighConfidence', ``` -- [ ] **Step 3: global-settings 默认值** +- [x] **Step 3: global-settings 默认值** ```typescript knowledgeAutoExtract: true, @@ -138,9 +138,9 @@ knowledgeExtractConfidenceThreshold: 0.8, `get()` merge 补默认。 -- [ ] **Step 4: useSettingsStore 同步默认** +- [x] **Step 4: useSettingsStore 同步默认** -- [ ] **Step 5: build** +- [x] **Step 5: build** ```bash npm run build @@ -156,7 +156,7 @@ npm run build - Modify: `src/main/db/repositories/knowledge.repo.ts` - Create: `tests/main/migrate-v7.test.ts` -- [ ] **Step 1: schema-v7.sql** +- [x] **Step 1: schema-v7.sql** ```sql CREATE TABLE IF NOT EXISTS chapter_writing_snapshots ( @@ -167,7 +167,7 @@ CREATE TABLE IF NOT EXISTS chapter_writing_snapshots ( ); ``` -- [ ] **Step 2: migrate.ts** +- [x] **Step 2: migrate.ts** ```typescript import schemaV7 from './schema-v7.sql?raw' @@ -183,7 +183,7 @@ if (current < 7) { } ``` -- [ ] **Step 3: knowledge.repo mapRow + createExtracted** +- [x] **Step 3: knowledge.repo mapRow + createExtracted** 更新 `mapRow` 读取三列;新增: @@ -229,7 +229,7 @@ clearMergeTarget(id: string): KnowledgeEntry { } ``` -- [ ] **Step 4: migrate-v7 测试** +- [x] **Step 4: migrate-v7 测试** ```typescript it('UT-MIG-07: v6 database migrates to v7 with snapshot table and knowledge columns', () => { @@ -241,7 +241,7 @@ it('UT-MIG-07: v6 database migrates to v7 with snapshot table and knowledge colu }) ``` -- [ ] **Step 5: 运行** +- [x] **Step 5: 运行** ```bash npm run test -- tests/main/migrate-v7.test.ts @@ -256,7 +256,7 @@ npm run test -- tests/main/migrate-v7.test.ts - Create: `src/main/services/writing-log.service.ts` - Create: `tests/main/writing-log.test.ts` -- [ ] **Step 1: WritingLogRepository** +- [x] **Step 1: WritingLogRepository** 全局 sqlite 路径 `join(userDataDir, 'writing_logs.sqlite')`;`migrate()` 建表;方法: @@ -276,7 +276,7 @@ export function localDateString(d = new Date()): string { } ``` -- [ ] **Step 2: WritingLogService** +- [x] **Step 2: WritingLogService** ```typescript export class WritingLogService { @@ -301,7 +301,7 @@ export class WritingLogService { } ``` -- [ ] **Step 3: UT-LOG-01~04** +- [x] **Step 3: UT-LOG-01~04** ```typescript it('UT-LOG-01: addToday accumulates +200', () => { /* ... */ }) @@ -309,7 +309,7 @@ it('UT-LOG-02: negative delta clamps at 0', () => { /* ... */ }) it('UT-LOG-04: streak counts consecutive goal-met days', () => { /* seed 3 days */ }) ``` -- [ ] **Step 4: 运行** +- [x] **Step 4: 运行** ```bash npm run test -- tests/main/writing-log.test.ts @@ -324,14 +324,14 @@ npm run test -- tests/main/writing-log.test.ts - Create: `src/main/services/chapter-writing-tracker.service.ts` - Modify: `tests/main/writing-log.test.ts` -- [ ] **Step 1: ChapterWritingSnapshotRepository** +- [x] **Step 1: ChapterWritingSnapshotRepository** ```typescript get(chapterId: string): { loggedWordCount: number; finishSupplemented: boolean } | null upsert(chapterId: string, loggedWordCount: number, finishSupplemented?: boolean): void ``` -- [ ] **Step 2: ChapterWritingTracker** +- [x] **Step 2: ChapterWritingTracker** ```typescript export class ChapterWritingTracker { @@ -359,9 +359,9 @@ export class ChapterWritingTracker { } ``` -- [ ] **Step 3: UT-LOG-03 supplementOnFinish** +- [x] **Step 3: UT-LOG-03 supplementOnFinish** -- [ ] **Step 4: 运行测试** +- [x] **Step 4: 运行测试** ```bash npm run test -- tests/main/writing-log.test.ts @@ -378,7 +378,7 @@ npm run test -- tests/main/writing-log.test.ts - Modify: `src/main/ipc/register.ts` - Modify: `tests/main/writing-log.test.ts`(IT-LOG-01) -- [ ] **Step 1: register.ts 创建 WritingLogService 单例** +- [x] **Step 1: register.ts 创建 WritingLogService 单例** ```typescript const writingLogs = new WritingLogService(new WritingLogRepository(userData), settings) @@ -394,7 +394,7 @@ function trackerFor(registry: BookRegistryService, bookId: string, writingLogs: } ``` -- [ ] **Step 2: chapter.handler CHAPTER_UPDATE 后** +- [x] **Step 2: chapter.handler CHAPTER_UPDATE 后** ```typescript wrap(() => { @@ -406,7 +406,7 @@ wrap(() => { }) ``` -- [ ] **Step 3: interactive/auto finishChapter 后** +- [x] **Step 3: interactive/auto finishChapter 后** `finishChapter` 返回 `{ chapterId }` 后: @@ -421,11 +421,11 @@ return result 抽取辅助放在 `src/main/services/knowledge-extraction-runner.ts`(避免 handler 膨胀)。 -- [ ] **Step 4: IT-LOG-01** +- [x] **Step 4: IT-LOG-01** 两次 `CHAPTER_UPDATE` 模拟,断言 `writing_logs` 累计。 -- [ ] **Step 5: 运行** +- [x] **Step 5: 运行** ```bash npm run test -- tests/main/writing-log.test.ts @@ -442,7 +442,7 @@ npm run test -- tests/main/writing-log.test.ts - Modify: `src/preload/index.ts` - Modify: `src/shared/electron-api.d.ts` -- [ ] **Step 1: CockpitService 注入 WritingLogService** +- [x] **Step 1: CockpitService 注入 WritingLogService** ```typescript getSummary(activeVolumeId?: string, bookId?: string): CockpitSummary { @@ -454,7 +454,7 @@ getSummary(activeVolumeId?: string, bookId?: string): CockpitSummary { 更新 `cockpit.handler.ts` 传 `bookId`。 -- [ ] **Step 2: writing.handler** +- [x] **Step 2: writing.handler** ```typescript ipcMain.handle(IPC.WRITING_GET_STATS, (_e, { bookId }: { bookId: string }) => @@ -462,7 +462,7 @@ ipcMain.handle(IPC.WRITING_GET_STATS, (_e, { bookId }: { bookId: string }) => ) ``` -- [ ] **Step 3: preload** +- [x] **Step 3: preload** ```typescript writing: { @@ -471,7 +471,7 @@ writing: { }, ``` -- [ ] **Step 4: build** +- [x] **Step 4: build** ```bash npm run build @@ -486,7 +486,7 @@ npm run build - Create: `src/main/services/knowledge-extraction.service.ts` - Create: `tests/main/knowledge-extraction.test.ts` -- [ ] **Step 1: knowledge-extraction-prompt.ts** +- [x] **Step 1: knowledge-extraction-prompt.ts** ```typescript export function buildExtractionPrompt( @@ -512,7 +512,7 @@ export function parseExtractionJson(raw: string): ExtractedKnowledgeItem[] { } ``` -- [ ] **Step 2: KnowledgeExtractionService** +- [x] **Step 2: KnowledgeExtractionService** ```typescript export class KnowledgeExtractionService { @@ -557,11 +557,11 @@ export class KnowledgeExtractionService { } ``` -- [ ] **Step 3: UT-EXT-01 / UT-EXT-02** +- [x] **Step 3: UT-EXT-01 / UT-EXT-02** 对 `parseExtractionJson` 测合法 JSON 与 `suggestedMergeId` 字段。 -- [ ] **Step 4: 运行** +- [x] **Step 4: 运行** ```bash npm run test -- tests/main/knowledge-extraction.test.ts -t "UT-EXT" @@ -577,7 +577,7 @@ npm run test -- tests/main/knowledge-extraction.test.ts -t "UT-EXT" - Modify: `src/preload/index.ts` - Modify: `tests/main/knowledge-extraction.test.ts` -- [ ] **Step 1: KnowledgeService.approveMerge** +- [x] **Step 1: KnowledgeService.approveMerge** ```typescript approveMerge(pendingId: string): KnowledgeEntry { @@ -600,7 +600,7 @@ saveMergeAsNew(pendingId: string): KnowledgeEntry { } ``` -- [ ] **Step 2: knowledge.handler IPC** +- [x] **Step 2: knowledge.handler IPC** ```typescript ipcMain.handle(IPC.KNOWLEDGE_EXTRACT_CHAPTER, (_e, { bookId, chapterId }) => @@ -617,9 +617,9 @@ ipcMain.handle(IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE, (_e, { bookId, thres ) ``` -- [ ] **Step 3: IT-EXT-01 / IT-EXT-02**(240s) +- [x] **Step 3: IT-EXT-01 / IT-EXT-02**(240s) -- [ ] **Step 4: IT-EXT-03** — 抽取 → approve → `KnowledgeRetrievalService.suggest` 含新条目 +- [x] **Step 4: IT-EXT-03** — 抽取 → approve → `KnowledgeRetrievalService.suggest` 含新条目 --- @@ -631,11 +631,11 @@ ipcMain.handle(IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE, (_e, { bookId, thres - Modify: `src/renderer/components/layout/EditorLayout.tsx` - Modify: `src/renderer/styles/layout.css` -- [ ] **Step 1: WritingHeatmap 组件** +- [x] **Step 1: WritingHeatmap 组件** Props:`heatmap: WritingDayLog[]`;Canvas 13×7 格(52 周近似 365 天);四档颜色;`data-testid="cockpit-heatmap"`。 -- [ ] **Step 2: CockpitModal 扩展** +- [x] **Step 2: CockpitModal 扩展** 在 `cockpit-grid` 下方: @@ -649,7 +649,7 @@ Props:`heatmap: WritingDayLog[]`;Canvas 13×7 格(52 周近似 365 天) ``` -- [ ] **Step 3: EditorLayout 状态栏** +- [x] **Step 3: EditorLayout 状态栏** ```tsx const writingStats = useWritingStatsStore() // 或 cockpit summary 缓存 @@ -662,7 +662,7 @@ const writingStats = useWritingStatsStore() // 或 cockpit summary 缓存 章节保存后 `writing.getStats` 刷新(监听 save 或轮询 `useBookStore` wordCount)。 -- [ ] **Step 4: build 验证** +- [x] **Step 4: build 验证** ```bash npm run build @@ -681,28 +681,28 @@ npm run build - Modify: `public/locales/zh-CN/translation.json` - Modify: `public/locales/en/translation.json` -- [ ] **Step 1: KnowledgePanel 待审核增强** +- [x] **Step 1: KnowledgePanel 待审核增强** - 卡片显示 `extractionConfidence` 百分比徽章 - `mergeTargetId` 时显示 `t('knowledge.mergeSuggest')` + `data-testid="knowledge-merge-review-{id}"` - 工具栏按钮 `knowledge-batch-high-confidence` - 工具栏「从当前章抽取」`knowledge-extract-current`(需 `selectedChapterId`) -- [ ] **Step 2: KnowledgeEditorDialog 合并模式** +- [x] **Step 2: KnowledgeEditorDialog 合并模式** props:`mergeTarget?: KnowledgeEntry`、`initialPatch?: Partial` 底部按钮:「确认更新」→ `approveMerge`;「作为新条目」→ `saveMergeAsNew` 后常规编辑。 -- [ ] **Step 3: SettingsPage** +- [x] **Step 3: SettingsPage** `settings-knowledge-auto-extract` 开关;`settings-extract-threshold` 数字输入。 -- [ ] **Step 4: 章节抽取入口** +- [x] **Step 4: 章节抽取入口** 章节列表项或编辑器右键:`data-testid="chapter-extract-knowledge"` → `knowledge.extractChapter`。 -- [ ] **Step 5: i18n** — spec §9 全部键 +- [x] **Step 5: i18n** — spec §9 全部键 --- @@ -716,13 +716,13 @@ props:`mergeTarget?: KnowledgeEntry`、`initialPatch?: Partial { + await page.waitForLoadState('domcontentloaded') + await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 }) + await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 }) + await page.getByRole('button', { name: '跳过' }).click() + await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 }) +} + +async function dismissCockpitIfOpen(page: Page): Promise { + const cockpit = page.locator('[data-testid="cockpit-modal"]') + if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.locator('[data-testid="cockpit-modal"] .modal-close').click() + await expect(cockpit).toBeHidden({ timeout: 5000 }) + } +} + +async function createAndOpenBook(page: Page, name: string): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill(name) + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) + await dismissCockpitIfOpen(page) +} + +test.describe('Knowledge extraction P5.2', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-ext-')) + }) + + test.afterEach(async () => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-EXT-01: knowledge panel shows extract controls', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '抽取测试书') + + const items = page.locator('.chapter-item') + if ((await items.count()) === 0) { + await page.getByRole('button', { name: '+ 新章' }).click() + } else { + await items.first().click() + } + + await page.locator('[data-testid="panel-tab-knowledge"]').click() + await expect(page.locator('[data-testid="knowledge-panel"]')).toBeVisible() + await expect(page.locator('[data-testid="knowledge-extract-current"]')).toBeVisible() + await expect(page.locator('[data-testid="knowledge-batch-high-confidence"]')).toBeVisible() + await app.close() + }) + + test('E2E-EXT-02: settings show auto-extract toggle', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '设置测试') + + await page.getByRole('button', { name: '设置' }).click() + await expect(page.locator('[data-testid="settings-knowledge-auto-extract"]')).toBeVisible() + await expect(page.locator('[data-testid="settings-extract-threshold"]')).toBeVisible() + await app.close() + }) +}) diff --git a/e2e/writing-logs.spec.ts b/e2e/writing-logs.spec.ts new file mode 100644 index 0000000..cf9cd4a --- /dev/null +++ b/e2e/writing-logs.spec.ts @@ -0,0 +1,108 @@ +import path from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect, _electron as electron, type Page } from '@playwright/test' + +const ROOT = path.join(import.meta.dirname, '..') + +async function launchApp(userDataDir: string) { + return electron.launch({ + args: [ROOT], + env: { + ...process.env, + BILIN_E2E: '1', + BILIN_E2E_USER_DATA: userDataDir + } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 }) + await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 }) + await page.getByRole('button', { name: '跳过' }).click() + await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 }) +} + +async function dismissCockpitIfOpen(page: Page): Promise { + const cockpit = page.locator('[data-testid="cockpit-modal"]') + if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.locator('[data-testid="cockpit-modal"] .modal-close').click() + await expect(cockpit).toBeHidden({ timeout: 5000 }) + } +} + +async function createAndOpenBook(page: Page, name: string): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill(name) + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) + await dismissCockpitIfOpen(page) +} + +test.describe('Writing logs P5.2', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-log-')) + }) + + test.afterEach(async () => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-LOG-01: typing updates today progress in cockpit', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '日志测试书') + + await page.getByRole('button', { name: '设置' }).click() + await page.locator('[data-testid="settings-daily-word-goal"]').fill('100') + await page.getByRole('button', { name: '书籍' }).click() + + const items = page.locator('.chapter-item') + if ((await items.count()) === 0) { + await page.getByRole('button', { name: '+ 新章' }).click() + } else { + await items.first().click() + } + await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) + await page.locator('.ProseMirror').click() + await page.keyboard.type('这是一段用于测试写作日志累计的示例文字内容,需要足够长才能触发字数统计与日志记录。') + await page.waitForTimeout(1500) + + await page.locator('[data-testid="topbar-cockpit"]').click() + await expect(page.locator('[data-testid="cockpit-modal"]')).toBeVisible() + await expect(page.locator('[data-testid="cockpit-today-progress"]')).toBeVisible() + await expect(page.locator('[data-testid="cockpit-heatmap"]')).toBeVisible() + await app.close() + }) + + test('E2E-LOG-02: status bar shows daily goal when configured', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '状态栏测试') + + await page.getByRole('button', { name: '设置' }).click() + await page.locator('[data-testid="settings-daily-word-goal"]').fill('500') + await page.getByRole('button', { name: '书籍' }).click() + + const items = page.locator('.chapter-item') + if ((await items.count()) === 0) { + await page.getByRole('button', { name: '+ 新章' }).click() + } else { + await items.first().click() + } + await expect(page.locator('[data-testid="status-daily-goal"]')).toBeVisible({ timeout: 10_000 }) + await app.close() + }) +}) diff --git a/package.json b/package.json index 1e752e9..73e964e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "0.7.0", + "version": "0.8.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 006ad0f..7a5ed1e 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -203,6 +203,14 @@ "knowledge.statsSummary": "Buried {{buried}} · Resolved {{resolved}} · Forgotten {{forgotten}}", "knowledge.syncFromLandmark": "Sync this foreshadow to knowledge base?", "knowledge.syncedFromLandmark": "Synced to knowledge base (pending review)", + "knowledge.extractChapter": "Extract chapter knowledge", + "knowledge.extractFromCurrent": "Extract from current chapter", + "knowledge.batchHighConfidence": "Approve all high-confidence", + "knowledge.mergeSuggest": "Suggested update: {{title}}", + "knowledge.approveMerge": "Confirm update", + "knowledge.saveAsNew": "Save as new entry", + "knowledge.extracting": "Extracting knowledge…", + "knowledge.extractFailed": "Extraction failed, try again later", "cockpit.title": "Writing cockpit", "cockpit.loading": "Loading…", "cockpit.totalWords": "Book words", @@ -213,6 +221,9 @@ "cockpit.resumeEdit": "Resume writing", "cockpit.bridgeCheck": "Chapter bridge check", "cockpit.forgottenForeshadow": "Forgotten foreshadows", + "cockpit.todayProgress": "Today {{current}} / {{goal}} words", + "cockpit.streak": "{{days}}-day streak", + "cockpit.heatmap": "Writing heatmap", "bridge.title": "Chapter bridge", "bridge.loading": "Analyzing…", "bridge.previousTail": "Previous chapter ending", @@ -231,6 +242,9 @@ "settings.updateSchedule.alternate": "Every other day", "settings.updateSchedule.weekly": "Weekly", "settings.stockBufferThreshold": "Stock buffer threshold (chapters)", + "settings.dailyWordGoal": "Daily word goal", + "settings.knowledgeAutoExtract": "Auto-extract knowledge on chapter finish", + "settings.extractThreshold": "High-confidence threshold", "stock.warning": "Low stock: {{count}}/{{threshold}} chapters ready", "landmark.foreshadowConfirm": "Mark as foreshadow landmark?", "ai.settings.backend": "Backend", @@ -248,6 +262,7 @@ "status.chapter": "Chapter", "status.volume": "Volume", "status.book": "Book", + "status.dailyGoal": "Today {{current}} / {{goal}}", "toast.bookCreated": "Book created", "toast.saveFailed": "Save failed, please retry", "toast.enterBookName": "Please enter a book title", diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index 99eabfb..64f56cb 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -203,6 +203,14 @@ "knowledge.statsSummary": "埋设 {{buried}} · 回收 {{resolved}} · 遗忘 {{forgotten}}", "knowledge.syncFromLandmark": "是否同步此伏笔到知识库?", "knowledge.syncedFromLandmark": "已同步到知识库(待审核)", + "knowledge.extractChapter": "抽取本章知识", + "knowledge.extractFromCurrent": "从当前章抽取", + "knowledge.batchHighConfidence": "全部采纳高置信度", + "knowledge.mergeSuggest": "建议更新:{{title}}", + "knowledge.approveMerge": "确认更新", + "knowledge.saveAsNew": "作为新条目", + "knowledge.extracting": "正在抽取知识…", + "knowledge.extractFailed": "知识抽取失败,请稍后重试", "cockpit.title": "写作驾驶舱", "cockpit.loading": "加载中…", "cockpit.totalWords": "全书字数", @@ -213,6 +221,9 @@ "cockpit.resumeEdit": "继续写作", "cockpit.bridgeCheck": "章间衔接检查", "cockpit.forgottenForeshadow": "查看遗忘伏笔", + "cockpit.todayProgress": "今日 {{current}} / {{goal}} 字", + "cockpit.streak": "连更 {{days}} 天", + "cockpit.heatmap": "写作热力图", "bridge.title": "章间衔接", "bridge.loading": "分析中…", "bridge.previousTail": "上一章末尾", @@ -231,6 +242,9 @@ "settings.updateSchedule.alternate": "隔日更", "settings.updateSchedule.weekly": "周更", "settings.stockBufferThreshold": "存稿缓冲阈值(章)", + "settings.dailyWordGoal": "每日写作目标(字)", + "settings.knowledgeAutoExtract": "成章后自动抽取知识", + "settings.extractThreshold": "高置信度阈值", "stock.warning": "存稿不足:{{count}}/{{threshold}} 章待发布", "landmark.foreshadowConfirm": "标记为伏笔地标?", "ai.settings.backend": "后端类型", @@ -248,6 +262,7 @@ "status.chapter": "本章", "status.volume": "本卷", "status.book": "全书", + "status.dailyGoal": "今日 {{current}} / {{goal}}", "toast.bookCreated": "书籍已创建", "toast.saveFailed": "保存失败,请重试", "toast.enterBookName": "请输入书名", diff --git a/src/main/db/migrate.ts b/src/main/db/migrate.ts index c87bf0a..da25bf1 100644 --- a/src/main/db/migrate.ts +++ b/src/main/db/migrate.ts @@ -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') } } diff --git a/src/main/db/repositories/chapter-writing-snapshot.repo.ts b/src/main/db/repositories/chapter-writing-snapshot.repo.ts new file mode 100644 index 0000000..16f784b --- /dev/null +++ b/src/main/db/repositories/chapter-writing-snapshot.repo.ts @@ -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) + } +} diff --git a/src/main/db/repositories/knowledge.repo.ts b/src/main/db/repositories/knowledge.repo.ts index 30ec515..cb47cda 100644 --- a/src/main/db/repositories/knowledge.repo.ts +++ b/src/main/db/repositories/knowledge.repo.ts @@ -20,6 +20,9 @@ function mapRow(row: Record): 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 @@ -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 + } } diff --git a/src/main/db/repositories/writing-log.repo.ts b/src/main/db/repositories/writing-log.repo.ts new file mode 100644 index 0000000..8c7069a --- /dev/null +++ b/src/main/db/repositories/writing-log.repo.ts @@ -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() + } +} diff --git a/src/main/db/schema-v7.sql b/src/main/db/schema-v7.sql new file mode 100644 index 0000000..d7f40ec --- /dev/null +++ b/src/main/db/schema-v7.sql @@ -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 +); diff --git a/src/main/ipc/handlers/auto.handler.ts b/src/main/ipc/handlers/auto.handler.ts index 5e87aaf..fb683e7 100644 --- a/src/main/ipc/handlers/auto.handler.ts +++ b/src/main/ipc/handlers/auto.handler.ts @@ -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 }) => diff --git a/src/main/ipc/handlers/chapter.handler.ts b/src/main/ipc/handlers/chapter.handler.ts index 3fe7c2c..29eaebb 100644 --- a/src/main/ipc/handlers/chapter.handler.ts +++ b/src/main/ipc/handlers/chapter.handler.ts @@ -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 }) diff --git a/src/main/ipc/handlers/cockpit.handler.ts b/src/main/ipc/handlers/cockpit.handler.ts index db022db..4ccac24 100644 --- a/src/main/ipc/handlers/cockpit.handler.ts +++ b/src/main/ipc/handlers/cockpit.handler.ts @@ -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 }) => diff --git a/src/main/ipc/handlers/interactive.handler.ts b/src/main/ipc/handlers/interactive.handler.ts index e4a00c3..1ee28cc 100644 --- a/src/main/ipc/handlers/interactive.handler.ts +++ b/src/main/ipc/handlers/interactive.handler.ts @@ -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() @@ -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 }) => diff --git a/src/main/ipc/handlers/knowledge.handler.ts b/src/main/ipc/handlers/knowledge.handler.ts index 1a0a60b..d7510d6 100644 --- a/src/main/ipc/handlers/knowledge.handler.ts +++ b/src/main/ipc/handlers/knowledge.handler.ts @@ -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) + }) + ) } diff --git a/src/main/ipc/handlers/writing.handler.ts b/src/main/ipc/handlers/writing.handler.ts new file mode 100644 index 0000000..e102fdf --- /dev/null +++ b/src/main/ipc/handlers/writing.handler.ts @@ -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)) + ) +} diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index f28001c..572db61 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -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 } diff --git a/src/main/services/chapter-writing-tracker.service.ts b/src/main/services/chapter-writing-tracker.service.ts new file mode 100644 index 0000000..df40fea --- /dev/null +++ b/src/main/services/chapter-writing-tracker.service.ts @@ -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) + } +} diff --git a/src/main/services/cockpit.service.ts b/src/main/services/cockpit.service.ts index b3e6710..084c17c 100644 --- a/src/main/services/cockpit.service.ts +++ b/src/main/services/cockpit.service.ts @@ -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 } } diff --git a/src/main/services/global-settings.ts b/src/main/services/global-settings.ts index d9e3a90..ce59005 100644 --- a/src/main/services/global-settings.ts +++ b/src/main/services/global-settings.ts @@ -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 } } diff --git a/src/main/services/knowledge-extraction-prompt.ts b/src/main/services/knowledge-extraction-prompt.ts new file mode 100644 index 0000000..1de86c1 --- /dev/null +++ b/src/main/services/knowledge-extraction-prompt.ts @@ -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 中给出建议更新的字段。 + +每条必须包含:type(character|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) +} diff --git a/src/main/services/knowledge-extraction-runner.ts b/src/main/services/knowledge-extraction-runner.ts new file mode 100644 index 0000000..9f7e333 --- /dev/null +++ b/src/main/services/knowledge-extraction-runner.ts @@ -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)) + }) +} diff --git a/src/main/services/knowledge-extraction.service.ts b/src/main/services/knowledge-extraction.service.ts new file mode 100644 index 0000000..2874cee --- /dev/null +++ b/src/main/services/knowledge-extraction.service.ts @@ -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 { + 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 } + } +} diff --git a/src/main/services/knowledge.service.ts b/src/main/services/knowledge.service.ts index b828191..0a5a0e8 100644 --- a/src/main/services/knowledge.service.ts +++ b/src/main/services/knowledge.service.ts @@ -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') diff --git a/src/main/services/writing-date.util.ts b/src/main/services/writing-date.util.ts new file mode 100644 index 0000000..001db96 --- /dev/null +++ b/src/main/services/writing-date.util.ts @@ -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) +} diff --git a/src/main/services/writing-log.service.ts b/src/main/services/writing-log.service.ts new file mode 100644 index 0000000..c800644 --- /dev/null +++ b/src/main/services/writing-log.service.ts @@ -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 + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index dfe93fa..0e466a6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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> => - ipcRenderer.invoke(IPC.KNOWLEDGE_SUGGEST_CONTEXT, { bookId, opts }) + ipcRenderer.invoke(IPC.KNOWLEDGE_SUGGEST_CONTEXT, { bookId, opts }), + extractChapter: ( + bookId: string, + chapterId: string + ): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_EXTRACT_CHAPTER, { bookId, chapterId }), + approveMerge: (bookId: string, pendingId: string): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_APPROVE_MERGE, { bookId, pendingId }), + saveMergeAsNew: (bookId: string, pendingId: string): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_SAVE_MERGE_AS_NEW, { bookId, pendingId }), + batchApproveHighConfidence: ( + bookId: string, + threshold?: number + ): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE, { bookId, threshold }) + }, + writing: { + getStats: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.WRITING_GET_STATS, { bookId }) }, cockpit: { getSummary: (bookId: string, volumeId?: string): Promise> => diff --git a/src/renderer/components/cockpit/CockpitModal.tsx b/src/renderer/components/cockpit/CockpitModal.tsx index e6f7bf5..191dcb1 100644 --- a/src/renderer/components/cockpit/CockpitModal.tsx +++ b/src/renderer/components/cockpit/CockpitModal.tsx @@ -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 ? (
{t('cockpit.loading')}
) : ( + <>
{t('cockpit.totalWords')}
@@ -86,6 +88,24 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
+ {summary.writingStats && summary.writingStats.dailyGoal > 0 && ( +
+
+ {t('cockpit.todayProgress', { + current: summary.writingStats.todayWords, + goal: summary.writingStats.dailyGoal + })} +
+ {summary.writingStats.streakDays > 0 && ( +
+ {t('cockpit.streak', { days: summary.writingStats.streakDays })} +
+ )} +
{t('cockpit.heatmap')}
+ +
+ )} + )}
diff --git a/src/renderer/components/cockpit/WritingHeatmap.tsx b/src/renderer/components/cockpit/WritingHeatmap.tsx new file mode 100644 index 0000000..e88cc19 --- /dev/null +++ b/src/renderer/components/cockpit/WritingHeatmap.tsx @@ -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(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 ( +
+ +
+ ) +} diff --git a/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx b/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx index c9e0c92..c6243a7 100644 --- a/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx +++ b/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx @@ -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('fact') const [title, setTitle] = useState('') @@ -179,6 +185,30 @@ export function KnowledgeEditorDialog({ )}
+ {isMergeMode && bookId && existing && ( + <> + + + + )} diff --git a/src/renderer/components/knowledge/KnowledgePanel.tsx b/src/renderer/components/knowledge/KnowledgePanel.tsx index 57c5f62..520e25e 100644 --- a/src/renderer/components/knowledge/KnowledgePanel.tsx +++ b/src/renderer/components/knowledge/KnowledgePanel.tsx @@ -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>(new Set()) const [pendingCount, setPendingCount] = useState(0) @@ -71,6 +77,30 @@ export function KnowledgePanel(): React.JSX.Element { > {t('naming.open')} + {tab === 'pending' && bookId && ( + + )} + {bookId && selectedChapterId && ( + + )}
+ )} @@ -405,6 +427,23 @@ export function EditorLayout(): React.JSX.Element { />
{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''} + {dailyWordGoal > 0 && writingStats && ( + = 1 + ? 'goal-met' + : writingStats.goalProgress >= 0.8 + ? 'goal-near' + : '' + }`} + data-testid="status-daily-goal" + > + {t('status.dailyGoal', { + current: writingStats.todayWords, + goal: dailyWordGoal + })} + + )} {showStockWarning && ( {t('stock.warning', { diff --git a/src/renderer/components/settings/SettingsPage.tsx b/src/renderer/components/settings/SettingsPage.tsx index 5350a2c..8c74d34 100644 --- a/src/renderer/components/settings/SettingsPage.tsx +++ b/src/renderer/components/settings/SettingsPage.tsx @@ -131,13 +131,61 @@ export function SettingsPage(): React.JSX.Element { } />
+
+ {t('settings.dailyWordGoal')} + + void settings.update({ + dailyWordGoal: Math.max(0, Number(e.target.value) || 0) + }) + } + /> +
+
+ +
+
+ {t('settings.extractThreshold')} + + void settings.update({ + knowledgeExtractConfidenceThreshold: Math.min( + 1, + Math.max(0, Number(e.target.value) || 0.8) + ) + }) + } + /> +
)} {section === 'ai' && } {section === 'shortcuts' && } {section === 'about' && (

- {t('app.name')} v0.7.0 + {t('app.name')} v0.8.0
{t('app.tagline')}

diff --git a/src/renderer/stores/useKnowledgeStore.ts b/src/renderer/stores/useKnowledgeStore.ts index 90be447..3317ecf 100644 --- a/src/renderer/stores/useKnowledgeStore.ts +++ b/src/renderer/stores/useKnowledgeStore.ts @@ -32,6 +32,10 @@ interface KnowledgeState { approve: (bookId: string, id: string) => Promise reject: (bookId: string, id: string) => Promise batchApprove: (bookId: string, ids: string[]) => Promise + extractChapter: (bookId: string, chapterId: string) => Promise + approveMerge: (bookId: string, pendingId: string) => Promise + saveMergeAsNew: (bookId: string, pendingId: string) => Promise + batchApproveHighConfidence: (bookId: string, threshold?: number) => Promise filteredEntries: () => KnowledgeEntry[] } @@ -82,6 +86,22 @@ export const useKnowledgeStore = create((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 diff --git a/src/renderer/stores/useSettingsStore.ts b/src/renderer/stores/useSettingsStore.ts index ff8e9be..f2e198a 100644 --- a/src/renderer/stores/useSettingsStore.ts +++ b/src/renderer/stores/useSettingsStore.ts @@ -24,6 +24,8 @@ export const useSettingsStore = create((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()) diff --git a/src/renderer/stores/useWritingStatsStore.ts b/src/renderer/stores/useWritingStatsStore.ts new file mode 100644 index 0000000..27c03d2 --- /dev/null +++ b/src/renderer/stores/useWritingStatsStore.ts @@ -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 +} + +export const useWritingStatsStore = create((set) => ({ + stats: null, + refresh: async (bookId) => { + const stats = await ipcCall(() => window.electronAPI.writing.getStats(bookId)) + set({ stats }) + } +})) diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css index a78d86c..3462b17 100644 --- a/src/renderer/styles/layout.css +++ b/src/renderer/styles/layout.css @@ -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; diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 9238707..a7fa78e 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -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> + extractChapter: ( + bookId: string, + chapterId: string + ) => Promise> + approveMerge: (bookId: string, pendingId: string) => Promise> + saveMergeAsNew: (bookId: string, pendingId: string) => Promise> + batchApproveHighConfidence: (bookId: string, threshold?: number) => Promise> + } + writing: { + getStats: (bookId: string) => Promise> } cockpit: { getSummary: (bookId: string, volumeId?: string) => Promise> diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 3be105c..68c4594 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -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', diff --git a/src/shared/types.ts b/src/shared/types.ts index 2c375b0..a645ca2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 +} + +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 { diff --git a/tests/main/knowledge-extraction.test.ts b/tests/main/knowledge-extraction.test.ts new file mode 100644 index 0000000..9b62af3 --- /dev/null +++ b/tests/main/knowledge-extraction.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest' +import { parseExtractionJson } from '../../src/main/services/knowledge-extraction-prompt' + +describe('knowledge extraction', () => { + it('UT-EXT-01: parseExtractionJson parses valid array', () => { + const raw = `[{"type":"fact","title":"测灵石","content":"石头发热","confidence":0.9}]` + const items = parseExtractionJson(raw) + expect(items).toHaveLength(1) + expect(items[0].title).toBe('测灵石') + expect(items[0].confidence).toBe(0.9) + }) + + it('UT-EXT-02: parseExtractionJson handles markdown fence and suggestedMergeId', () => { + const raw = '```json\n[{"type":"character","title":"林凡","content":"主角","confidence":0.85,"suggestedMergeId":"abc-123"}]\n```' + const items = parseExtractionJson(raw) + expect(items[0].suggestedMergeId).toBe('abc-123') + }) +}) diff --git a/tests/main/knowledge.service.test.ts b/tests/main/knowledge.service.test.ts index 0250754..f7bc68f 100644 --- a/tests/main/knowledge.service.test.ts +++ b/tests/main/knowledge.service.test.ts @@ -58,4 +58,29 @@ describe('KnowledgeService', () => { expect(service.batchApprove(ids)).toBe(3) expect(service.countPending()).toBe(0) }) + + it('IT-EXT-03: approveMerge updates target and rejects pending', () => { + db = new DatabaseSync(':memory:') + migrate(db) + const service = new KnowledgeService(db) + const target = service.create({ + type: 'character', + title: '林凡', + content: '少年', + status: 'approved' + }) + const pending = service.create({ + type: 'character', + title: '林凡(更新)', + content: '筑基期修士', + status: 'pending' + }) + db.prepare('UPDATE knowledge_entries SET merge_target_id = ? WHERE id = ?').run( + target.id, + pending.id + ) + const updated = service.approveMerge(pending.id) + expect(updated.content).toBe('筑基期修士') + expect(service.list({ status: 'pending' })).toHaveLength(0) + }) }) diff --git a/tests/main/migrate-v2.test.ts b/tests/main/migrate-v2.test.ts index 655df10..75de6d0 100644 --- a/tests/main/migrate-v2.test.ts +++ b/tests/main/migrate-v2.test.ts @@ -30,7 +30,7 @@ describe('migrate v2', () => { expect(tableExists(db, 'search_fts')).toBe(true) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(6) + expect(version.v).toBe(7) }) it('migrates existing v1 database to v2', () => { @@ -47,7 +47,7 @@ describe('migrate v2', () => { expect(tableExists(db, 'outline_items')).toBe(true) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(6) + expect(version.v).toBe(7) const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[] const colNames = cols.map((c) => c.name) diff --git a/tests/main/migrate-v3.test.ts b/tests/main/migrate-v3.test.ts index 1184d79..a2c44db 100644 --- a/tests/main/migrate-v3.test.ts +++ b/tests/main/migrate-v3.test.ts @@ -25,7 +25,7 @@ describe('migrate v3', () => { expect(tableExists(db, 'ai_messages')).toBe(true) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(6) + expect(version.v).toBe(7) }) it('migrates existing v2 database to v3', () => { @@ -49,6 +49,6 @@ describe('migrate v3', () => { expect(tables.map((t) => t.name)).toContain('ai_messages') const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(6) + expect(version.v).toBe(7) }) }) diff --git a/tests/main/migrate-v4.test.ts b/tests/main/migrate-v4.test.ts index 03c903e..95ac13b 100644 --- a/tests/main/migrate-v4.test.ts +++ b/tests/main/migrate-v4.test.ts @@ -23,7 +23,7 @@ describe('migrate v4', () => { migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(6) + expect(version.v).toBe(7) expect(tableExists(db, 'interactive_flows')).toBe(true) expect(tableExists(db, 'interactive_scenes')).toBe(true) @@ -48,7 +48,7 @@ describe('migrate v4', () => { migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(6) + expect(version.v).toBe(7) expect(tableExists(db, 'interactive_flows')).toBe(true) }) }) diff --git a/tests/main/migrate-v5.test.ts b/tests/main/migrate-v5.test.ts index cbc86fd..72c281b 100644 --- a/tests/main/migrate-v5.test.ts +++ b/tests/main/migrate-v5.test.ts @@ -15,7 +15,7 @@ describe('migrate v5', () => { db = new DatabaseSync(':memory:') migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(6) + expect(version.v).toBe(7) const cols = db.prepare('PRAGMA table_info(interactive_flows)').all() as { name: string }[] expect(cols.some((c) => c.name === 'flow_mode')).toBe(true) expect(cols.some((c) => c.name === 'mode_config_json')).toBe(true) @@ -39,6 +39,6 @@ describe('migrate v5', () => { db.prepare('INSERT INTO schema_version (version) VALUES (4)').run() migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(6) + expect(version.v).toBe(7) }) }) diff --git a/tests/main/migrate-v6.test.ts b/tests/main/migrate-v6.test.ts index 41da5cd..8b019a4 100644 --- a/tests/main/migrate-v6.test.ts +++ b/tests/main/migrate-v6.test.ts @@ -11,7 +11,7 @@ describe('migrate v6', () => { db = new DatabaseSync(':memory:') migrate(db) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(6) + expect(version.v).toBe(7) const tables = db .prepare("SELECT name FROM sqlite_master WHERE type='table'") .all() as { name: string }[] diff --git a/tests/main/migrate-v7.test.ts b/tests/main/migrate-v7.test.ts new file mode 100644 index 0000000..244fd8e --- /dev/null +++ b/tests/main/migrate-v7.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { DatabaseSync } from 'node:sqlite' +import { migrate } from '../../src/main/db/migrate' +import type { SqliteDb } from '../../src/main/db/types' + +describe('migrate v7', () => { + let db: SqliteDb + afterEach(() => db?.close()) + + it('UT-MIG-07: v6 database migrates to v7 with snapshot table and knowledge columns', () => { + db = new DatabaseSync(':memory:') + migrate(db) + const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } + expect(version.v).toBe(7) + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'") + .get() + expect(tables).toBeTruthy() + const cols = db.prepare('PRAGMA table_info(knowledge_entries)').all() as Array<{ name: string }> + expect(cols.some((c) => c.name === 'extraction_confidence')).toBe(true) + expect(cols.some((c) => c.name === 'merge_target_id')).toBe(true) + expect(cols.some((c) => c.name === 'extraction_batch_id')).toBe(true) + }) +}) diff --git a/tests/main/writing-log.test.ts b/tests/main/writing-log.test.ts new file mode 100644 index 0000000..d28ae85 --- /dev/null +++ b/tests/main/writing-log.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { DatabaseSync } from 'node:sqlite' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { migrate } from '../../src/main/db/migrate' +import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo' +import { VolumeRepository } from '../../src/main/db/repositories/volume.repo' +import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo' +import { GlobalSettingsService } from '../../src/main/services/global-settings' +import { WritingLogService } from '../../src/main/services/writing-log.service' +import { ChapterWritingTracker } from '../../src/main/services/chapter-writing-tracker.service' +import { addDays, localDateString } from '../../src/main/services/writing-date.util' +import type { SqliteDb } from '../../src/main/db/types' + +describe('WritingLogService', () => { + let userDir: string + let settingsDir: string + let settings: GlobalSettingsService + let repo: WritingLogRepository + let service: WritingLogService + + afterEach(() => { + repo?.close() + if (userDir) rmSync(userDir, { recursive: true, force: true }) + if (settingsDir) rmSync(settingsDir, { recursive: true, force: true }) + }) + + function setup(goal = 1000): void { + userDir = mkdtempSync(join(tmpdir(), 'bilin-wlog-')) + settingsDir = mkdtempSync(join(tmpdir(), 'bilin-wlog-settings-')) + settings = new GlobalSettingsService(settingsDir) + settings.update({ ...settings.get(), dailyWordGoal: goal }) + repo = new WritingLogRepository(userDir) + service = new WritingLogService(repo, settings) + } + + it('UT-LOG-01: addToday accumulates +200', () => { + setup() + const bookId = 'book-1' + expect(service.addToday(bookId, 200)).toBe(200) + expect(service.addToday(bookId, 200)).toBe(400) + expect(service.getStats(bookId).todayWords).toBe(400) + }) + + it('UT-LOG-02: negative delta clamps at 0', () => { + setup() + const bookId = 'book-1' + service.addToday(bookId, 100) + expect(service.addToday(bookId, -200)).toBe(0) + }) + + it('UT-LOG-04: streak counts consecutive goal-met days', () => { + setup(100) + const bookId = 'book-1' + const today = localDateString() + repo.addToday(bookId, 150, addDays(today, -2)) + repo.addToday(bookId, 120, addDays(today, -1)) + repo.addToday(bookId, 80, today) + expect(service.getStats(bookId).streakDays).toBe(2) + }) +}) + +describe('ChapterWritingTracker', () => { + let db: SqliteDb + let userDir: string + let settingsDir: string + let settings: GlobalSettingsService + let writingLogs: WritingLogService + let repo: WritingLogRepository + + afterEach(() => { + db?.close() + repo?.close() + if (userDir) rmSync(userDir, { recursive: true, force: true }) + if (settingsDir) rmSync(settingsDir, { recursive: true, force: true }) + }) + + it('UT-LOG-03: supplementOnFinish fills gap', () => { + db = new DatabaseSync(':memory:') + migrate(db) + userDir = mkdtempSync(join(tmpdir(), 'bilin-tracker-')) + settingsDir = mkdtempSync(join(tmpdir(), 'bilin-tracker-settings-')) + settings = new GlobalSettingsService(settingsDir) + repo = new WritingLogRepository(userDir) + writingLogs = new WritingLogService(repo, settings) + const bookId = 'book-1' + const volId = new VolumeRepository(db).create('卷一', 0).id + const chapter = new ChapterRepository(db).create(volId, '第一章', 0, '

一二三四五六七八九十

') + const tracker = new ChapterWritingTracker(db, bookId, writingLogs) + tracker.recordDelta(chapter.id, 5) + tracker.supplementOnFinish(chapter.id, chapter.wordCount) + expect(writingLogs.getStats(bookId).todayWords).toBe(chapter.wordCount) + }) + + it('IT-LOG-01: recordDelta tracks chapter updates', () => { + db = new DatabaseSync(':memory:') + migrate(db) + userDir = mkdtempSync(join(tmpdir(), 'bilin-itlog-')) + settingsDir = mkdtempSync(join(tmpdir(), 'bilin-itlog-settings-')) + settings = new GlobalSettingsService(settingsDir) + repo = new WritingLogRepository(userDir) + writingLogs = new WritingLogService(repo, settings) + const bookId = 'book-1' + const volId = new VolumeRepository(db).create('卷一', 0).id + const chapterRepo = new ChapterRepository(db) + const ch = chapterRepo.create(volId, 'A', 0, '

ab

') + const tracker = new ChapterWritingTracker(db, bookId, writingLogs) + const updated = chapterRepo.update(ch.id, { content: '

abcd

' }) + tracker.recordDelta(ch.id, updated.wordCount) + const updated2 = chapterRepo.update(ch.id, { content: '

abcdef

' }) + tracker.recordDelta(ch.id, updated2.wordCount) + expect(writingLogs.getStats(bookId).todayWords).toBe(updated2.wordCount) + }) +})