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:
@@ -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)
|
||||
|
||||
@@ -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 天)
|
||||
<WritingHeatmap heatmap={summary.writingStats?.heatmap ?? []} />
|
||||
```
|
||||
|
||||
- [ ] **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<CreateKnowledgeInput>`
|
||||
|
||||
底部按钮:「确认更新」→ `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<CreateKnowledge
|
||||
- Modify: `src/renderer/components/settings/SettingsPage.tsx`(about 版本)
|
||||
- Modify: `docs/superpowers/plans/2026-07-07-bilin-p52-writing-logs-extraction.md`(勾选完成项)
|
||||
|
||||
- [ ] **Step 1: E2E-LOG-01 / E2E-LOG-02**
|
||||
- [x] **Step 1: E2E-LOG-01 / E2E-LOG-02**
|
||||
|
||||
写 100 字保存 → 打开驾驶舱 → `cockpit-today-progress` 更新;`cockpit-heatmap` 可见。
|
||||
|
||||
- [ ] **Step 2: E2E-EXT-01 / E2E-EXT-02 / E2E-EXT-03**(300s timeout on AI tests)
|
||||
- [x] **Step 2: E2E-EXT-01 / E2E-EXT-02 / E2E-EXT-03**(300s timeout on AI tests)
|
||||
|
||||
- [ ] **Step 3: 全量测试**
|
||||
- [x] **Step 3: 全量测试**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -730,7 +730,7 @@ npm run test
|
||||
npm run test:e2e -- e2e/writing-logs.spec.ts e2e/knowledge-extraction.spec.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 版本 0.8.0**
|
||||
- [x] **Step 4: 版本 0.8.0**
|
||||
|
||||
`package.json`、`README.md` 追加:
|
||||
|
||||
@@ -738,7 +738,7 @@ npm run test:e2e -- e2e/writing-logs.spec.ts e2e/knowledge-extraction.spec.ts
|
||||
- 写作日志与热力图、AI 章节知识自动抽取(P5.2)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: ship v0.8.0 with writing logs and AI knowledge extraction"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bilin",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"description": "笔临 - 长篇创作智能协作平台",
|
||||
"main": "./out/main/index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "请输入书名",
|
||||
|
||||
+11
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 中给出建议更新的字段。
|
||||
|
||||
每条必须包含: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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}))
|
||||
@@ -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;
|
||||
|
||||
Vendored
+13
-1
@@ -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>>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }[]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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, '<p>一二三四五六七八九十</p>')
|
||||
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, '<p>ab</p>')
|
||||
const tracker = new ChapterWritingTracker(db, bookId, writingLogs)
|
||||
const updated = chapterRepo.update(ch.id, { content: '<p>abcd</p>' })
|
||||
tracker.recordDelta(ch.id, updated.wordCount)
|
||||
const updated2 = chapterRepo.update(ch.id, { content: '<p>abcdef</p>' })
|
||||
tracker.recordDelta(ch.id, updated2.wordCount)
|
||||
expect(writingLogs.getStats(bookId).todayWords).toBe(updated2.wordCount)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user