Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4122c8f95 | |||
| 255f1a99a0 | |||
| 1099cb2b0e | |||
| a39e06ff07 | |||
| f331ddae86 | |||
| 72c8e13210 |
@@ -1,9 +1,11 @@
|
|||||||
# 笔临 (Bilin)
|
# 笔临 (Bilin)
|
||||||
|
|
||||||
长篇创作智能协作平台 — Electron 桌面客户端 v0.7.0(P6 AI 知识上下文)
|
长篇创作智能协作平台 — Electron 桌面客户端 v0.9.0(P5.3 番茄钟/成就 + P6.1 知识注入历史)
|
||||||
|
|
||||||
## 功能概览(v0.7.0)
|
## 功能概览(v0.9.0)
|
||||||
|
|
||||||
|
- 写作日志与热力图、连更统计、状态栏日更进度(P5.2)
|
||||||
|
- AI 章节知识自动抽取、合并审核、高置信度批量采纳(P5.2)
|
||||||
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
|
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
|
||||||
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
|
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
|
||||||
- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5)
|
- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5)
|
||||||
|
|||||||
@@ -0,0 +1,764 @@
|
|||||||
|
# 笔临 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 (`- [x]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 交付 v0.8.0:`writing_logs` 日更追踪(保存差分 + 成章补计)、驾驶舱热力图/连更、状态栏日更进度,以及 AI 章节知识自动抽取 + 合并审核增强,与 P6 注入形成上下游闭环。
|
||||||
|
|
||||||
|
**Architecture:** 全局 `userData/writing_logs.sqlite` 存 `(date, bookId)` 日字数;每书 schema v7 增加 `chapter_writing_snapshots` 与 `knowledge_entries` 抽取元数据列;`ChapterWritingTracker` 挂 `CHAPTER_UPDATE` / `finishChapter`;`KnowledgeExtractionService` 异步调用 `AiClientService` 落库 `pending`。测试 **禁止 Mock** AI 集成/E2E。
|
||||||
|
|
||||||
|
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、i18next、Vitest、Playwright
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-07-bilin-p52-writing-logs-extraction-design.md`
|
||||||
|
|
||||||
|
**LM Studio 默认:** `baseUrl=http://127.0.0.1:1234`,`model=gemma-4-e4b-it`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/shared/types.ts` | `WritingStats`、`ExtractedKnowledgeItem`、`KnowledgeExtractionResult`;扩展 `KnowledgeEntry`、`CockpitSummary`、`GlobalSettings` |
|
||||||
|
| `src/shared/ipc-channels.ts` | `WRITING_GET_STATS`、`KNOWLEDGE_EXTRACT_CHAPTER`、`KNOWLEDGE_APPROVE_MERGE`、`KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE` |
|
||||||
|
| `src/main/db/schema-v7.sql` | `chapter_writing_snapshots` + knowledge ALTER |
|
||||||
|
| `src/main/db/migrate.ts` | v7 迁移 |
|
||||||
|
| `src/main/db/repositories/writing-log.repo.ts` | 全局 writing_logs |
|
||||||
|
| `src/main/db/repositories/chapter-writing-snapshot.repo.ts` | 每书 snapshot |
|
||||||
|
| `src/main/db/repositories/knowledge.repo.ts` | 抽取列读写、`createExtracted`、`batchApproveHighConfidence` |
|
||||||
|
| `src/main/services/writing-log.service.ts` | 日更、连更、热力图 |
|
||||||
|
| `src/main/services/chapter-writing-tracker.service.ts` | `recordDelta` / `supplementOnFinish` |
|
||||||
|
| `src/main/services/knowledge-extraction-prompt.ts` | prompt + `parseExtractionJson` |
|
||||||
|
| `src/main/services/knowledge-extraction.service.ts` | `extractForChapter` |
|
||||||
|
| `src/main/services/knowledge.service.ts` | `approveMerge`、`clearMergeTarget` |
|
||||||
|
| `src/main/services/cockpit.service.ts` | 附加 `writingStats` |
|
||||||
|
| `src/main/services/global-settings.ts` | 新设置默认值 |
|
||||||
|
| `src/main/ipc/handlers/writing.handler.ts` | `writing:getStats` |
|
||||||
|
| `src/main/ipc/handlers/knowledge.handler.ts` | 抽取/合并 IPC |
|
||||||
|
| `src/main/ipc/handlers/chapter.handler.ts` | 挂钩 tracker |
|
||||||
|
| `src/main/ipc/handlers/interactive.handler.ts` | finish 后 supplement + 异步抽取 |
|
||||||
|
| `src/main/ipc/handlers/auto.handler.ts` | 同上 |
|
||||||
|
| `src/main/ipc/register.ts` | 注入 `WritingLogService` |
|
||||||
|
| `src/preload/index.ts` + `electron-api.d.ts` | 新 API |
|
||||||
|
| `src/renderer/components/cockpit/WritingHeatmap.tsx` | Canvas 热力图 |
|
||||||
|
| `src/renderer/components/cockpit/CockpitModal.tsx` | 今日进度/连更/热力图 |
|
||||||
|
| `src/renderer/components/layout/EditorLayout.tsx` | 状态栏日更进度 |
|
||||||
|
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 批次/合并/批量采纳 |
|
||||||
|
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 合并预填模式 |
|
||||||
|
| `src/renderer/components/settings/SettingsPage.tsx` | 抽取设置 |
|
||||||
|
| `src/renderer/components/layout/ChapterList.tsx` 或编辑器菜单 | 抽取本章入口 |
|
||||||
|
| `public/locales/*/translation.json` | P5.2 文案 |
|
||||||
|
| `tests/main/writing-log.test.ts` | UT-LOG-* / IT-LOG-01 |
|
||||||
|
| `tests/main/knowledge-extraction.test.ts` | UT-EXT-* / IT-EXT-* |
|
||||||
|
| `e2e/writing-logs.spec.ts` | E2E-LOG-* |
|
||||||
|
| `e2e/knowledge-extraction.spec.ts` | E2E-EXT-* |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 类型 + IPC 通道 + GlobalSettings
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/shared/types.ts`
|
||||||
|
- Modify: `src/shared/ipc-channels.ts`
|
||||||
|
- Modify: `src/main/services/global-settings.ts`
|
||||||
|
- Modify: `src/renderer/stores/useSettingsStore.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: 扩展 types**
|
||||||
|
|
||||||
|
`src/shared/types.ts` 追加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `KnowledgeEntry`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
extractionConfidence?: number
|
||||||
|
mergeTargetId?: string
|
||||||
|
extractionBatchId?: string
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `CockpitSummary`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
writingStats?: WritingStats
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `GlobalSettings`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
knowledgeAutoExtract?: boolean
|
||||||
|
knowledgeExtractConfidenceThreshold?: number
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: IPC 通道**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
WRITING_GET_STATS: 'writing:getStats',
|
||||||
|
KNOWLEDGE_EXTRACT_CHAPTER: 'knowledge:extractChapter',
|
||||||
|
KNOWLEDGE_APPROVE_MERGE: 'knowledge:approveMerge',
|
||||||
|
KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE: 'knowledge:batchApproveHighConfidence',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: global-settings 默认值**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
knowledgeAutoExtract: true,
|
||||||
|
knowledgeExtractConfidenceThreshold: 0.8,
|
||||||
|
```
|
||||||
|
|
||||||
|
`get()` merge 补默认。
|
||||||
|
|
||||||
|
- [x] **Step 4: useSettingsStore 同步默认**
|
||||||
|
|
||||||
|
- [x] **Step 5: build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: schema v7 + KnowledgeRepository 扩展
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/db/schema-v7.sql`
|
||||||
|
- Modify: `src/main/db/migrate.ts`
|
||||||
|
- Modify: `src/main/db/repositories/knowledge.repo.ts`
|
||||||
|
- Create: `tests/main/migrate-v7.test.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: schema-v7.sql**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: migrate.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import schemaV7 from './schema-v7.sql?raw'
|
||||||
|
const CURRENT_VERSION = 7
|
||||||
|
|
||||||
|
// in migrate(), after v6:
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: knowledge.repo mapRow + createExtracted**
|
||||||
|
|
||||||
|
更新 `mapRow` 读取三列;新增:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
createExtracted(input: CreateKnowledgeInput & {
|
||||||
|
extractionConfidence?: number
|
||||||
|
mergeTargetId?: string
|
||||||
|
extractionBatchId?: string
|
||||||
|
}): KnowledgeEntry {
|
||||||
|
const id = randomUUID()
|
||||||
|
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,
|
||||||
|
input.status ?? 'pending', 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)!
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: migrate-v7 测试**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('UT-MIG-07: v6 database migrates to v7 with snapshot table and knowledge columns', () => {
|
||||||
|
const db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db) // fresh → v7
|
||||||
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'").get()
|
||||||
|
expect(tables).toBeTruthy()
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/migrate-v7.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: WritingLogService(全局)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/db/repositories/writing-log.repo.ts`
|
||||||
|
- Create: `src/main/services/writing-log.service.ts`
|
||||||
|
- Create: `tests/main/writing-log.test.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: WritingLogRepository**
|
||||||
|
|
||||||
|
全局 sqlite 路径 `join(userDataDir, 'writing_logs.sqlite')`;`migrate()` 建表;方法:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
addToday(bookId: string, delta: number, date?: string): number
|
||||||
|
getToday(bookId: string, date?: string): number
|
||||||
|
listRange(bookId: string, fromDate: string, toDate: string): WritingDayLog[]
|
||||||
|
```
|
||||||
|
|
||||||
|
`addToday`:`INSERT ... ON CONFLICT DO UPDATE SET word_count = MAX(0, word_count + excluded.word_count)`。
|
||||||
|
|
||||||
|
日期工具:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function localDateString(d = new Date()): string {
|
||||||
|
return d.toLocaleDateString('sv-SE') // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: WritingLogService**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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.buildHeatmap(bookId, 365)
|
||||||
|
const streakDays = dailyGoal > 0 ? this.calcStreak(bookId, dailyGoal) : 0
|
||||||
|
return { todayWords, dailyGoal, goalProgress, streakDays, heatmap }
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHeatmap(bookId: string, days: number): WritingDayLog[] { /* 填充 0 */ }
|
||||||
|
private calcStreak(bookId: string, goal: number): number { /* 从昨日向前 */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: UT-LOG-01~04**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('UT-LOG-01: addToday accumulates +200', () => { /* ... */ })
|
||||||
|
it('UT-LOG-02: negative delta clamps at 0', () => { /* ... */ })
|
||||||
|
it('UT-LOG-04: streak counts consecutive goal-met days', () => { /* seed 3 days */ })
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/writing-log.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: ChapterWritingTracker + Snapshot Repo
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/db/repositories/chapter-writing-snapshot.repo.ts`
|
||||||
|
- Create: `src/main/services/chapter-writing-tracker.service.ts`
|
||||||
|
- Modify: `tests/main/writing-log.test.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: ChapterWritingSnapshotRepository**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
get(chapterId: string): { loggedWordCount: number; finishSupplemented: boolean } | null
|
||||||
|
upsert(chapterId: string, loggedWordCount: number, finishSupplemented?: boolean): void
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: ChapterWritingTracker**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ChapterWritingTracker {
|
||||||
|
constructor(
|
||||||
|
private bookDb: SqliteDb,
|
||||||
|
private bookId: string,
|
||||||
|
private writingLogs: WritingLogService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: UT-LOG-03 supplementOnFinish**
|
||||||
|
|
||||||
|
- [x] **Step 4: 运行测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/writing-log.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 挂钩 CHAPTER_UPDATE + finishChapter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/ipc/handlers/chapter.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/handlers/interactive.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/handlers/auto.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/register.ts`
|
||||||
|
- Modify: `tests/main/writing-log.test.ts`(IT-LOG-01)
|
||||||
|
|
||||||
|
- [x] **Step 1: register.ts 创建 WritingLogService 单例**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const writingLogs = new WritingLogService(new WritingLogRepository(userData), settings)
|
||||||
|
registerChapterHandlers(books, writingLogs)
|
||||||
|
registerInteractiveHandlers(registry, settings, aiClient, books, writingLogs, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
工厂函数:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function trackerFor(registry: BookRegistryService, bookId: string, writingLogs: WritingLogService) {
|
||||||
|
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: chapter.handler CHAPTER_UPDATE 后**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
wrap(() => {
|
||||||
|
const chapter = registry.getChapterRepo(bookId).update(...)
|
||||||
|
ftsSync.upsert(...)
|
||||||
|
trackerFor(registry, bookId, writingLogs).recordDelta(chapterId, chapter.wordCount)
|
||||||
|
registry.updateMeta(bookId, { lastChapterId: chapterId })
|
||||||
|
return chapter
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: interactive/auto finishChapter 后**
|
||||||
|
|
||||||
|
`finishChapter` 返回 `{ chapterId }` 后:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ch = registry.getChapterRepo(bookId).get(chapterId)!
|
||||||
|
trackerFor(registry, bookId, writingLogs).supplementOnFinish(chapterId, ch.wordCount)
|
||||||
|
if (settings.get().knowledgeAutoExtract !== false) {
|
||||||
|
setImmediate(() => void extractKnowledge(registry, bookId, chapterId, aiClient, settings))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
抽取辅助放在 `src/main/services/knowledge-extraction-runner.ts`(避免 handler 膨胀)。
|
||||||
|
|
||||||
|
- [x] **Step 4: IT-LOG-01**
|
||||||
|
|
||||||
|
两次 `CHAPTER_UPDATE` 模拟,断言 `writing_logs` 累计。
|
||||||
|
|
||||||
|
- [x] **Step 5: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/writing-log.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Cockpit 扩展 + writing IPC + preload
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/services/cockpit.service.ts`
|
||||||
|
- Create: `src/main/ipc/handlers/writing.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/register.ts`
|
||||||
|
- Modify: `src/preload/index.ts`
|
||||||
|
- Modify: `src/shared/electron-api.d.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: CockpitService 注入 WritingLogService**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
getSummary(activeVolumeId?: string, bookId?: string): CockpitSummary {
|
||||||
|
const base = { /* existing */ }
|
||||||
|
const writingStats = bookId ? this.writingLogs.getStats(bookId) : undefined
|
||||||
|
return { ...base, writingStats }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
更新 `cockpit.handler.ts` 传 `bookId`。
|
||||||
|
|
||||||
|
- [x] **Step 2: writing.handler**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ipcMain.handle(IPC.WRITING_GET_STATS, (_e, { bookId }: { bookId: string }) =>
|
||||||
|
wrap(() => writingLogs.getStats(bookId))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: preload**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
writing: {
|
||||||
|
getStats: (bookId: string): Promise<IpcResult<WritingStats>> =>
|
||||||
|
ipcRenderer.invoke(IPC.WRITING_GET_STATS, { bookId })
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: KnowledgeExtraction prompt + parse + service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/services/knowledge-extraction-prompt.ts`
|
||||||
|
- Create: `src/main/services/knowledge-extraction.service.ts`
|
||||||
|
- Create: `tests/main/knowledge-extraction.test.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: knowledge-extraction-prompt.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function buildExtractionPrompt(
|
||||||
|
chapterTitle: string,
|
||||||
|
plainText: string,
|
||||||
|
approvedTitles: Array<{ id: string; title: string }>
|
||||||
|
): string { /* 中文指令 + JSON schema 说明 */ }
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: KnowledgeExtractionService**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class KnowledgeExtractionService {
|
||||||
|
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') : undefined,
|
||||||
|
sourceChapterId: chapterId,
|
||||||
|
extractionConfidence: item.confidence,
|
||||||
|
mergeTargetId,
|
||||||
|
extractionBatchId: batchId
|
||||||
|
})
|
||||||
|
saved.push({ ...item, suggestedMergeId: mergeTargetId })
|
||||||
|
}
|
||||||
|
return { batchId, chapterId, items: saved }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: UT-EXT-01 / UT-EXT-02**
|
||||||
|
|
||||||
|
对 `parseExtractionJson` 测合法 JSON 与 `suggestedMergeId` 字段。
|
||||||
|
|
||||||
|
- [x] **Step 4: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/knowledge-extraction.test.ts -t "UT-EXT"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Knowledge 合并 IPC + KnowledgeService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/services/knowledge.service.ts`
|
||||||
|
- Modify: `src/main/ipc/handlers/knowledge.handler.ts`
|
||||||
|
- Modify: `src/preload/index.ts`
|
||||||
|
- Modify: `tests/main/knowledge-extraction.test.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: KnowledgeService.approveMerge**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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 patch: Partial<CreateKnowledgeInput> = {
|
||||||
|
content: pending.content,
|
||||||
|
importance: pending.importance,
|
||||||
|
lastMentionChapterId: pending.lastMentionChapterId ?? pending.sourceChapterId
|
||||||
|
}
|
||||||
|
const updated = this.repo.update(target.id, patch)
|
||||||
|
this.repo.update(pendingId, { status: 'rejected' })
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMergeAsNew(pendingId: string): KnowledgeEntry {
|
||||||
|
return this.repo.clearMergeTarget(pendingId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: knowledge.handler IPC**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ipcMain.handle(IPC.KNOWLEDGE_EXTRACT_CHAPTER, (_e, { bookId, chapterId }) =>
|
||||||
|
wrap(() => new KnowledgeExtractionService(registry.getDb(bookId), aiClient).extractForChapter(chapterId))
|
||||||
|
)
|
||||||
|
ipcMain.handle(IPC.KNOWLEDGE_APPROVE_MERGE, (_e, { bookId, pendingId }) =>
|
||||||
|
wrap(() => new KnowledgeService(registry.getDb(bookId)).approveMerge(pendingId))
|
||||||
|
)
|
||||||
|
ipcMain.handle(IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE, (_e, { bookId, threshold }) =>
|
||||||
|
wrap(() => {
|
||||||
|
const t = threshold ?? settings.get().knowledgeExtractConfidenceThreshold ?? 0.8
|
||||||
|
return new KnowledgeRepository(registry.getDb(bookId)).batchApproveHighConfidence(t)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: IT-EXT-01 / IT-EXT-02**(240s)
|
||||||
|
|
||||||
|
- [x] **Step 4: IT-EXT-03** — 抽取 → approve → `KnowledgeRetrievalService.suggest` 含新条目
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 驾驶舱 UI + 状态栏日更进度
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/renderer/components/cockpit/WritingHeatmap.tsx`
|
||||||
|
- Modify: `src/renderer/components/cockpit/CockpitModal.tsx`
|
||||||
|
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
|
||||||
|
- Modify: `src/renderer/styles/layout.css`
|
||||||
|
|
||||||
|
- [x] **Step 1: WritingHeatmap 组件**
|
||||||
|
|
||||||
|
Props:`heatmap: WritingDayLog[]`;Canvas 13×7 格(52 周近似 365 天);四档颜色;`data-testid="cockpit-heatmap"`。
|
||||||
|
|
||||||
|
- [x] **Step 2: CockpitModal 扩展**
|
||||||
|
|
||||||
|
在 `cockpit-grid` 下方:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{summary.writingStats && summary.writingStats.dailyGoal > 0 && (
|
||||||
|
<div data-testid="cockpit-today-progress">...</div>
|
||||||
|
)}
|
||||||
|
{summary.writingStats && summary.writingStats.streakDays > 0 && (
|
||||||
|
<div data-testid="cockpit-streak">{t('cockpit.streak', { days: summary.writingStats.streakDays })}</div>
|
||||||
|
)}
|
||||||
|
<WritingHeatmap heatmap={summary.writingStats?.heatmap ?? []} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: EditorLayout 状态栏**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const writingStats = useWritingStatsStore() // 或 cockpit summary 缓存
|
||||||
|
{dailyGoal > 0 && (
|
||||||
|
<span className={`status-daily-goal ${progress >= 1 ? 'goal-met' : progress >= 0.8 ? 'goal-near' : ''}`} data-testid="status-daily-goal">
|
||||||
|
{t('status.dailyGoal', { current: todayWords, goal: dailyGoal })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
章节保存后 `writing.getStats` 刷新(监听 save 或轮询 `useBookStore` wordCount)。
|
||||||
|
|
||||||
|
- [x] **Step 4: build 验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: 知识库 UI + 设置 + 抽取入口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/renderer/components/knowledge/KnowledgePanel.tsx`
|
||||||
|
- Modify: `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx`
|
||||||
|
- Modify: `src/renderer/stores/useKnowledgeStore.ts`
|
||||||
|
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
|
||||||
|
- Modify: `src/renderer/components/layout/ChapterList.tsx`(或 `EditorLayout` 章节菜单)
|
||||||
|
- Modify: `public/locales/zh-CN/translation.json`
|
||||||
|
- Modify: `public/locales/en/translation.json`
|
||||||
|
|
||||||
|
- [x] **Step 1: KnowledgePanel 待审核增强**
|
||||||
|
|
||||||
|
- 卡片显示 `extractionConfidence` 百分比徽章
|
||||||
|
- `mergeTargetId` 时显示 `t('knowledge.mergeSuggest')` + `data-testid="knowledge-merge-review-{id}"`
|
||||||
|
- 工具栏按钮 `knowledge-batch-high-confidence`
|
||||||
|
- 工具栏「从当前章抽取」`knowledge-extract-current`(需 `selectedChapterId`)
|
||||||
|
|
||||||
|
- [x] **Step 2: KnowledgeEditorDialog 合并模式**
|
||||||
|
|
||||||
|
props:`mergeTarget?: KnowledgeEntry`、`initialPatch?: Partial<CreateKnowledgeInput>`
|
||||||
|
|
||||||
|
底部按钮:「确认更新」→ `approveMerge`;「作为新条目」→ `saveMergeAsNew` 后常规编辑。
|
||||||
|
|
||||||
|
- [x] **Step 3: SettingsPage**
|
||||||
|
|
||||||
|
`settings-knowledge-auto-extract` 开关;`settings-extract-threshold` 数字输入。
|
||||||
|
|
||||||
|
- [x] **Step 4: 章节抽取入口**
|
||||||
|
|
||||||
|
章节列表项或编辑器右键:`data-testid="chapter-extract-knowledge"` → `knowledge.extractChapter`。
|
||||||
|
|
||||||
|
- [x] **Step 5: i18n** — spec §9 全部键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: E2E + v0.8.0 交付
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `e2e/writing-logs.spec.ts`
|
||||||
|
- Create: `e2e/knowledge-extraction.spec.ts`
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `src/renderer/components/settings/SettingsPage.tsx`(about 版本)
|
||||||
|
- Modify: `docs/superpowers/plans/2026-07-07-bilin-p52-writing-logs-extraction.md`(勾选完成项)
|
||||||
|
|
||||||
|
- [x] **Step 1: E2E-LOG-01 / E2E-LOG-02**
|
||||||
|
|
||||||
|
写 100 字保存 → 打开驾驶舱 → `cockpit-today-progress` 更新;`cockpit-heatmap` 可见。
|
||||||
|
|
||||||
|
- [x] **Step 2: E2E-EXT-01 / E2E-EXT-02 / E2E-EXT-03**(300s timeout on AI tests)
|
||||||
|
|
||||||
|
- [x] **Step 3: 全量测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
|
npm run test:e2e -- e2e/writing-logs.spec.ts e2e/knowledge-extraction.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: 版本 0.8.0**
|
||||||
|
|
||||||
|
`package.json`、`README.md` 追加:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- 写作日志与热力图、AI 章节知识自动抽取(P5.2)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat: ship v0.8.0 with writing logs and AI knowledge extraction"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec Coverage Checklist
|
||||||
|
|
||||||
|
| Spec 要求 | Task |
|
||||||
|
|-----------|------|
|
||||||
|
| writing_logs 全局表 | Task 3 |
|
||||||
|
| chapter_writing_snapshots v7 | Task 2, 4 |
|
||||||
|
| 保存差分 + 成章补计 | Task 4, 5 |
|
||||||
|
| 连更 + 热力图 | Task 3, 9 |
|
||||||
|
| 状态栏日更进度 | Task 9 |
|
||||||
|
| knowledgeAutoExtract 设置 | Task 1, 5, 10 |
|
||||||
|
| AI 抽取五类 + JSON | Task 7 |
|
||||||
|
| 合并建议 approveMerge | Task 8, 10 |
|
||||||
|
| 批量高置信度 | Task 2, 8, 10 |
|
||||||
|
| P6 suggestContext 联动 | Task 8 IT-EXT-03 |
|
||||||
|
| UT-LOG / UT-EXT / IT / E2E | Task 3–8, 11 |
|
||||||
|
| v0.8.0 DoD | Task 11 |
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
# 笔临 P5.3 + P6.1 写作目标 + 知识注入历史 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 (`- [x]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 交付 v0.9.0:§8 番茄钟(15/25/45)、连更里程碑徽章(7/30/100)、日更/番茄/里程碑通知;P6.1 写作流三模式知识注入历史记录与知识库反向链接 UI。
|
||||||
|
|
||||||
|
**Architecture:** 主进程 `PomodoroService` + `AchievementService` + `GoalNotificationService` 操作全局 `writing_logs.sqlite` 扩展表;每书 schema v8 增加 `knowledge_injection_logs`;`interactive/auto/wizard` 的 `confirmContext` 成功后异步写 injection log;渲染进程状态栏番茄控件 + 驾驶舱成就区 + 知识库注入预览。番茄 tick 采用主进程 push `GOAL_NOTIFICATION` / `POMODORO_TICK` 事件。
|
||||||
|
|
||||||
|
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、i18next、Vitest、Playwright
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-07-bilin-p53-p61-goals-injection-design.md`
|
||||||
|
|
||||||
|
**E2E 番茄加速:** `BILIN_E2E=1` 且 `POMODORO_TEST_SEC=3` 时覆盖倒计时(仅测试,不暴露生产 UI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/shared/types.ts` | `PomodoroState`、`WritingAchievement`、`KnowledgeInjectionLog`;扩展 `CockpitSummary`、`GlobalSettings` |
|
||||||
|
| `src/shared/ipc-channels.ts` | pomodoro / achievement / injection / push 通道 |
|
||||||
|
| `src/main/db/schema-v8.sql` | `knowledge_injection_logs` |
|
||||||
|
| `src/main/db/migrate.ts` | v8 |
|
||||||
|
| `src/main/db/repositories/writing-log.repo.ts` | 扩展 migrate:milestones + pomodoro_daily |
|
||||||
|
| `src/main/db/repositories/writing-milestone.repo.ts` | 成就 CRUD |
|
||||||
|
| `src/main/db/repositories/pomodoro-daily.repo.ts` | 番茄日统计 |
|
||||||
|
| `src/main/db/repositories/knowledge-injection.repo.ts` | 每书 injection logs |
|
||||||
|
| `src/main/services/pomodoro.service.ts` | 番茄状态机 |
|
||||||
|
| `src/main/services/achievement.service.ts` | 里程碑检测 |
|
||||||
|
| `src/main/services/goal-notification.service.ts` | Toast push + Notification |
|
||||||
|
| `src/main/services/knowledge-injection.service.ts` | 注入记录 |
|
||||||
|
| `src/main/services/writing-log.service.ts` | addToday 后触发 goal-met + milestones |
|
||||||
|
| `src/main/services/cockpit.service.ts` | achievements + pomodoroTodayCount |
|
||||||
|
| `src/main/ipc/handlers/pomodoro.handler.ts` | 番茄 IPC |
|
||||||
|
| `src/main/ipc/handlers/achievement.handler.ts` | 成就 IPC |
|
||||||
|
| `src/main/ipc/handlers/knowledge.handler.ts` | listInjections |
|
||||||
|
| `src/main/ipc/handlers/interactive.handler.ts` | confirmContext 挂钩 |
|
||||||
|
| `src/main/ipc/handlers/auto.handler.ts` | 同上 |
|
||||||
|
| `src/main/ipc/handlers/wizard.handler.ts` | 同上 |
|
||||||
|
| `src/main/ipc/register.ts` | 注入新服务 |
|
||||||
|
| `src/preload/index.ts` + `electron-api.d.ts` | 新 API + push 监听 |
|
||||||
|
| `src/renderer/components/layout/EditorLayout.tsx` | 番茄控件 + goal 通知 |
|
||||||
|
| `src/renderer/components/cockpit/CockpitModal.tsx` | 成就区 |
|
||||||
|
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 注入预览 |
|
||||||
|
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 完整历史 |
|
||||||
|
| `src/renderer/components/settings/SettingsPage.tsx` | 番茄/通知设置 |
|
||||||
|
| `src/renderer/stores/usePomodoroStore.ts` | 番茄 UI 状态 |
|
||||||
|
| `public/locales/*/translation.json` | P5.3/P6.1 文案 |
|
||||||
|
| `tests/main/pomodoro.test.ts` | UT-POM-* |
|
||||||
|
| `tests/main/achievement.test.ts` | UT-ACH-* / IT-GOAL-01 |
|
||||||
|
| `tests/main/knowledge-injection.test.ts` | UT/IT-INJ-* |
|
||||||
|
| `tests/main/migrate-v8.test.ts` | UT-MIG-08 |
|
||||||
|
| `e2e/writing-goals.spec.ts` | E2E-GOAL-* |
|
||||||
|
| `e2e/knowledge-injection-history.spec.ts` | E2E-INJ-01 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 类型 + IPC + GlobalSettings
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/shared/types.ts`
|
||||||
|
- Modify: `src/shared/ipc-channels.ts`
|
||||||
|
- Modify: `src/main/services/global-settings.ts`
|
||||||
|
- Modify: `src/renderer/stores/useSettingsStore.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: 扩展 types**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type PomodoroDuration = 15 | 25 | 45
|
||||||
|
export type WritingMilestone = 'streak_7' | 'streak_30' | 'streak_100'
|
||||||
|
export type InjectionFlowMode = 'interactive' | 'auto' | 'wizard'
|
||||||
|
|
||||||
|
export interface PomodoroState {
|
||||||
|
running: boolean
|
||||||
|
paused: boolean
|
||||||
|
remainingSec: number
|
||||||
|
bookId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritingAchievement {
|
||||||
|
milestone: WritingMilestone
|
||||||
|
unlockedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeInjectionLog {
|
||||||
|
id: string
|
||||||
|
knowledgeEntryId: string
|
||||||
|
flowMode: InjectionFlowMode
|
||||||
|
flowId: string
|
||||||
|
chapterId?: string
|
||||||
|
injectedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalNotificationPayload {
|
||||||
|
kind: 'daily_met' | 'milestone' | 'pomodoro_complete' | 'pomodoro_cancelled_book_switch'
|
||||||
|
messageKey: string
|
||||||
|
messageParams?: Record<string, string | number>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `CockpitSummary`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
achievements?: WritingAchievement[]
|
||||||
|
pomodoroTodayCount?: number
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `GlobalSettings`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pomodoroDurationMinutes?: PomodoroDuration
|
||||||
|
enableSystemNotifications?: boolean
|
||||||
|
enableGoalNotifications?: boolean
|
||||||
|
dailyGoalNotifiedDate?: string // YYYY-MM-DD,防重复达标 Toast
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: IPC 通道**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
POMODORO_START: 'pomodoro:start',
|
||||||
|
POMODORO_PAUSE: 'pomodoro:pause',
|
||||||
|
POMODORO_RESUME: 'pomodoro:resume',
|
||||||
|
POMODORO_CANCEL: 'pomodoro:cancel',
|
||||||
|
POMODORO_GET_STATE: 'pomodoro:getState',
|
||||||
|
ACHIEVEMENT_LIST: 'achievement:list',
|
||||||
|
KNOWLEDGE_LIST_INJECTIONS: 'knowledge:listInjections',
|
||||||
|
POMODORO_TICK: 'pomodoro:tick', // main → renderer push
|
||||||
|
GOAL_NOTIFICATION: 'goal:notification', // main → renderer push
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: global-settings 默认值**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pomodoroDurationMinutes: 25,
|
||||||
|
enableSystemNotifications: false,
|
||||||
|
enableGoalNotifications: true,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: useSettingsStore 同步**
|
||||||
|
|
||||||
|
- [x] **Step 5: build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 全局 sqlite 扩展 + Milestone/Pomodoro Repos
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/db/repositories/writing-log.repo.ts`
|
||||||
|
- Create: `src/main/db/repositories/writing-milestone.repo.ts`
|
||||||
|
- Create: `src/main/db/repositories/pomodoro-daily.repo.ts`
|
||||||
|
- Create: `tests/main/achievement.test.ts`(先写 milestone repo 部分)
|
||||||
|
|
||||||
|
- [x] **Step 1: writing-log.repo migrate 扩展**
|
||||||
|
|
||||||
|
在 `migrate()` 追加:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS writing_milestones (
|
||||||
|
book_id TEXT NOT NULL, milestone TEXT NOT NULL, unlocked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (book_id, milestone)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pomodoro_daily (
|
||||||
|
date TEXT NOT NULL, book_id TEXT NOT NULL,
|
||||||
|
completed_count INTEGER NOT NULL DEFAULT 0, total_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (date, book_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: WritingMilestoneRepository**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class WritingMilestoneRepository {
|
||||||
|
constructor(private db: DatabaseSync) {}
|
||||||
|
list(bookId: string): WritingAchievement[] { /* SELECT */ }
|
||||||
|
has(bookId: string, milestone: WritingMilestone): boolean { /* ... */ }
|
||||||
|
unlock(bookId: string, milestone: WritingMilestone): WritingAchievement { /* INSERT OR IGNORE */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: PomodoroDailyRepository**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
recordComplete(bookId: string, wordDelta: number, date?: string): void {
|
||||||
|
// INSERT ... ON CONFLICT DO UPDATE completed_count += 1, total_words += wordDelta
|
||||||
|
}
|
||||||
|
getTodayCount(bookId: string): number
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: UT-ACH repo smoke**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('UT-ACH-01: unlock streak_7 once', () => {
|
||||||
|
const repo = new WritingMilestoneRepository(db)
|
||||||
|
repo.unlock('b1', 'streak_7')
|
||||||
|
expect(repo.has('b1', 'streak_7')).toBe(true)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/achievement.test.ts -t "UT-ACH-01"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: schema v8 + KnowledgeInjectionRepository
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/db/schema-v8.sql`
|
||||||
|
- Modify: `src/main/db/migrate.ts`
|
||||||
|
- Create: `src/main/db/repositories/knowledge-injection.repo.ts`
|
||||||
|
- Create: `tests/main/migrate-v8.test.ts`
|
||||||
|
- Create: `tests/main/knowledge-injection.test.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: schema-v8.sql**(见 spec §3.2)
|
||||||
|
|
||||||
|
- [x] **Step 2: migrate.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import schemaV8 from './schema-v8.sql?raw'
|
||||||
|
const CURRENT_VERSION = 8
|
||||||
|
|
||||||
|
if (current < 8) {
|
||||||
|
db.exec(schemaV8)
|
||||||
|
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: KnowledgeInjectionRepository**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
insertBatch(rows: Array<Omit<KnowledgeInjectionLog, 'id' | 'injectedAt'>>): void
|
||||||
|
listForEntry(entryId: string, limit = 50): KnowledgeInjectionLog[]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: UT-MIG-08 + UT-INJ-01**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('UT-MIG-08: v7 database migrates to v8 with injection table', () => { /* ... */ })
|
||||||
|
it('UT-INJ-01: insertBatch creates 3 rows', () => { /* ... */ })
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/migrate-v8.test.ts tests/main/knowledge-injection.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: PomodoroService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/services/pomodoro.service.ts`
|
||||||
|
- Create: `tests/main/pomodoro.test.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: PomodoroService 实现**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class PomodoroService {
|
||||||
|
private state: PomodoroState = { running: false, paused: false, remainingSec: 0, bookId: null }
|
||||||
|
private wordSnapshot = 0
|
||||||
|
private timer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private writingLogs: WritingLogService,
|
||||||
|
private pomodoroDaily: PomodoroDailyRepository,
|
||||||
|
private settings: GlobalSettingsService,
|
||||||
|
private notify: GoalNotificationService,
|
||||||
|
private pushTick: (state: PomodoroState) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
start(bookId: string): PomodoroState {
|
||||||
|
if (this.state.running && this.state.bookId !== bookId) this.cancel()
|
||||||
|
const duration = this.resolveDurationSec()
|
||||||
|
this.wordSnapshot = this.writingLogs.getStats(bookId).todayWords
|
||||||
|
this.state = { running: true, paused: false, remainingSec: duration, bookId }
|
||||||
|
this.startTimer()
|
||||||
|
return this.state
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDurationSec(): number {
|
||||||
|
if (process.env.BILIN_E2E === '1' && process.env.POMODORO_TEST_SEC) {
|
||||||
|
return Number(process.env.POMODORO_TEST_SEC) || 3
|
||||||
|
}
|
||||||
|
const mins = this.settings.get().pomodoroDurationMinutes ?? 25
|
||||||
|
return mins * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
private complete(): void {
|
||||||
|
const bookId = this.state.bookId!
|
||||||
|
const delta = Math.max(0, this.writingLogs.getStats(bookId).todayWords - this.wordSnapshot)
|
||||||
|
this.pomodoroDaily.recordComplete(bookId, delta)
|
||||||
|
this.notify.notifyPomodoroComplete(bookId, delta)
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
// pause, resume, cancel, getState, tick...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: UT-POM-01 / UT-POM-02**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('UT-POM-01: complete records word delta', () => { /* mock writingLogs */ })
|
||||||
|
it('UT-POM-02: start different book cancels previous', () => { /* ... */ })
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/pomodoro.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: GoalNotificationService + AchievementService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/services/goal-notification.service.ts`
|
||||||
|
- Create: `src/main/services/achievement.service.ts`
|
||||||
|
- Modify: `src/main/services/writing-log.service.ts`
|
||||||
|
- Modify: `tests/main/achievement.test.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: GoalNotificationService**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class GoalNotificationService {
|
||||||
|
constructor(
|
||||||
|
private settings: GlobalSettingsService,
|
||||||
|
private getMainWindow: () => BrowserWindow | null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
pushToast(payload: GoalNotificationPayload): void {
|
||||||
|
this.getMainWindow()?.webContents.send(IPC.GOAL_NOTIFICATION, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeSystemNotify(title: string, body: string): void {
|
||||||
|
if (!this.settings.get().enableSystemNotifications) return
|
||||||
|
try {
|
||||||
|
new Notification({ title, body }).show()
|
||||||
|
} catch { /* ignore permission denied */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyDailyGoalMet(bookId: string, current: number, goal: number): void { /* ... */ }
|
||||||
|
notifyMilestone(milestone: WritingMilestone): void { /* ... */ }
|
||||||
|
notifyPomodoroComplete(bookId: string, words: number): void { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: AchievementService**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
checkMilestones(bookId: string): WritingAchievement[] {
|
||||||
|
const { dailyWordGoal } = this.settings.get()
|
||||||
|
if (dailyWordGoal <= 0) return []
|
||||||
|
const streak = this.writingLogs.getStats(bookId).streakDays
|
||||||
|
const unlocked: WritingAchievement[] = []
|
||||||
|
for (const [threshold, m] of [[7,'streak_7'],[30,'streak_30'],[100,'streak_100']] as const) {
|
||||||
|
if (streak >= threshold && !this.repo.has(bookId, m)) {
|
||||||
|
const a = this.repo.unlock(bookId, m)
|
||||||
|
this.notify.notifyMilestone(m)
|
||||||
|
unlocked.push(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unlocked
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: WritingLogService.addToday 扩展**
|
||||||
|
|
||||||
|
`addToday` 返回后:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const stats = this.getStats(bookId)
|
||||||
|
if (stats.dailyGoal > 0 && stats.todayWords >= stats.dailyGoal) {
|
||||||
|
this.goalNotifications.maybeNotifyDailyMet(bookId, stats) // 内部检查 dailyGoalNotifiedDate
|
||||||
|
}
|
||||||
|
this.achievementService.checkMilestones(bookId)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: UT-ACH-02 + IT-GOAL-01**
|
||||||
|
|
||||||
|
- [x] **Step 5: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/achievement.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: KnowledgeInjectionService + confirmContext 挂钩
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/services/knowledge-injection.service.ts`
|
||||||
|
- Modify: `src/main/ipc/handlers/interactive.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/handlers/auto.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/handlers/wizard.handler.ts`
|
||||||
|
- Modify: `tests/main/knowledge-injection.test.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: KnowledgeInjectionService**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class KnowledgeInjectionService {
|
||||||
|
record(bookDb: SqliteDb, input: {
|
||||||
|
knowledgeIds: string[]
|
||||||
|
flowMode: InjectionFlowMode
|
||||||
|
flowId: string
|
||||||
|
chapterId?: string
|
||||||
|
}): void {
|
||||||
|
if (input.knowledgeIds.length === 0) return
|
||||||
|
const knowledgeRepo = new KnowledgeRepository(bookDb)
|
||||||
|
const injectionRepo = new KnowledgeInjectionRepository(bookDb)
|
||||||
|
const rows = input.knowledgeIds
|
||||||
|
.filter((id) => knowledgeRepo.get(id)?.status === 'approved')
|
||||||
|
.map((id) => ({ knowledgeEntryId: id, flowMode: input.flowMode, flowId: input.flowId, chapterId: input.chapterId }))
|
||||||
|
injectionRepo.insertBatch(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: interactive.handler 挂钩**
|
||||||
|
|
||||||
|
在 `INTERACTIVE_CONFIRM_CONTEXT` 的 `buildFlowContextPayload` 成功后:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
new KnowledgeInjectionService().record(registry.getDb(bookId), {
|
||||||
|
knowledgeIds: binding.knowledgeIds,
|
||||||
|
flowMode: 'interactive',
|
||||||
|
flowId,
|
||||||
|
chapterId: registry.getMeta(bookId)?.lastChapterId ?? undefined
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[knowledge-injection]', err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: auto / wizard 同理**
|
||||||
|
|
||||||
|
- [x] **Step 4: IT-INJ-01**
|
||||||
|
|
||||||
|
直接调用 handler 或 service + 内存 DB,断言 `knowledge_injection_logs` 行数。
|
||||||
|
|
||||||
|
- [x] **Step 5: 运行**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- tests/main/knowledge-injection.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: IPC + register + preload + Cockpit 扩展
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/ipc/handlers/pomodoro.handler.ts`
|
||||||
|
- Create: `src/main/ipc/handlers/achievement.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/handlers/knowledge.handler.ts`
|
||||||
|
- Modify: `src/main/ipc/register.ts`
|
||||||
|
- Modify: `src/main/services/cockpit.service.ts`
|
||||||
|
- Modify: `src/preload/index.ts`
|
||||||
|
- Modify: `src/shared/electron-api.d.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: pomodoro.handler + achievement.handler**
|
||||||
|
|
||||||
|
- [x] **Step 2: knowledge.handler**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ipcMain.handle(IPC.KNOWLEDGE_LIST_INJECTIONS, (_e, { bookId, entryId, limit }) =>
|
||||||
|
wrap(() => new KnowledgeInjectionService(registry.getDb(bookId)).listForEntry(entryId, limit))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: register.ts 注入**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const goalNotify = new GoalNotificationService(settings, () => getMainWindow())
|
||||||
|
const achievementService = new AchievementService(writingLogs, milestoneRepo, settings, goalNotify)
|
||||||
|
writingLogs.setAchievementHooks(achievementService, goalNotify)
|
||||||
|
const pomodoro = new PomodoroService(writingLogs, pomodoroDailyRepo, settings, goalNotify, pushTick)
|
||||||
|
registerPomodoroHandlers(pomodoro)
|
||||||
|
registerAchievementHandlers(achievementService)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: preload**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pomodoro: {
|
||||||
|
start: (bookId: string) => ipcRenderer.invoke(IPC.POMODORO_START, { bookId }),
|
||||||
|
pause: () => ipcRenderer.invoke(IPC.POMODORO_PAUSE),
|
||||||
|
resume: () => ipcRenderer.invoke(IPC.POMODORO_RESUME),
|
||||||
|
cancel: () => ipcRenderer.invoke(IPC.POMODORO_CANCEL),
|
||||||
|
getState: () => ipcRenderer.invoke(IPC.POMODORO_GET_STATE)
|
||||||
|
},
|
||||||
|
onPomodoroTick: (cb) => { ipcRenderer.on(IPC.POMODORO_TICK, cb); return () => ... },
|
||||||
|
onGoalNotification: (cb) => { ipcRenderer.on(IPC.GOAL_NOTIFICATION, cb); return () => ... },
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: CockpitService 扩展**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
achievements: this.achievementService.listAchievements(bookId),
|
||||||
|
pomodoroTodayCount: this.pomodoroDaily.getTodayCount(bookId),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 6: build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 状态栏番茄 + Goal 通知监听
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/renderer/stores/usePomodoroStore.ts`
|
||||||
|
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
|
||||||
|
- Modify: `src/renderer/App.tsx`(注册 onGoalNotification 全局 Toast)
|
||||||
|
- Modify: `src/renderer/styles/layout.css`
|
||||||
|
|
||||||
|
- [x] **Step 1: usePomodoroStore**
|
||||||
|
|
||||||
|
订阅 `onPomodoroTick`;actions:`start/pause/resume/cancel`。
|
||||||
|
|
||||||
|
- [x] **Step 2: EditorLayout 状态栏控件**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="pomodoro-control" data-testid="pomodoro-control">
|
||||||
|
{!running ? (
|
||||||
|
<button onClick={() => void start(currentBookId!)}>▶</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => void (paused ? resume() : pause())}>{paused ? '▶' : '⏸'}</button>
|
||||||
|
<span>{formatMmSs(remainingSec)}</span>
|
||||||
|
<button onClick={() => void cancel()}>✕</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: App.tsx goal notification**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
return window.electronAPI.onGoalNotification((payload) => {
|
||||||
|
showToast(t(payload.messageKey, payload.messageParams))
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: build 验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 驾驶舱成就区 + 设置页
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/renderer/components/cockpit/CockpitModal.tsx`
|
||||||
|
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
|
||||||
|
- Modify: `public/locales/zh-CN/translation.json`
|
||||||
|
- Modify: `public/locales/en/translation.json`
|
||||||
|
|
||||||
|
- [x] **Step 1: CockpitModal 成就区**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{summary.achievements && summary.achievements.length > 0 && (
|
||||||
|
<div data-testid="cockpit-achievements">...</div>
|
||||||
|
)}
|
||||||
|
<div data-testid="cockpit-pomodoro-today">
|
||||||
|
{t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: SettingsPage 三项设置**
|
||||||
|
|
||||||
|
`settings-pomodoro-duration` / `settings-system-notifications` / `settings-goal-notifications`
|
||||||
|
|
||||||
|
- [x] **Step 3: i18n**(spec §7 全部键,中英)
|
||||||
|
|
||||||
|
- [x] **Step 4: build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: 知识库注入历史 UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/renderer/components/knowledge/KnowledgePanel.tsx`
|
||||||
|
- Modify: `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx`
|
||||||
|
- Modify: `src/renderer/stores/useKnowledgeStore.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: KnowledgePanel 加载注入预览**
|
||||||
|
|
||||||
|
条目 render 时调用 `knowledge.listInjections(bookId, entry.id, 3)`(或 batch 加载 map)。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div data-testid={`knowledge-injection-preview-${entry.id}`}>
|
||||||
|
{injections.map((log) => (
|
||||||
|
<div key={log.id}>{t('knowledge.injectionEntry', { date, mode, chapter })}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: KnowledgeEditorDialog 完整历史**
|
||||||
|
|
||||||
|
`data-testid="knowledge-injection-history"`;章节名点击 `switchTarget({ kind:'chapter', id })`。
|
||||||
|
|
||||||
|
- [x] **Step 3: build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: E2E + v0.9.0 交付
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `e2e/writing-goals.spec.ts`
|
||||||
|
- Create: `e2e/knowledge-injection-history.spec.ts`
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `tests/main/migrate-v7.test.ts` 等(若 CURRENT_VERSION→8,期望版本改为 8)
|
||||||
|
|
||||||
|
- [x] **Step 1: E2E-GOAL-01~03**
|
||||||
|
|
||||||
|
- E2E-GOAL-01:设 dailyWordGoal=100,打字保存,断言 toast / status-daily-goal
|
||||||
|
- E2E-GOAL-02:通过测试 hook 或 seed API 写入 streak_7 milestone,打开驾驶舱断言 `cockpit-achievements`
|
||||||
|
- E2E-GOAL-03:`POMODORO_TEST_SEC=3`,启动番茄,等待完成 Toast
|
||||||
|
|
||||||
|
- [x] **Step 2: E2E-INJ-01**
|
||||||
|
|
||||||
|
向导 confirm 知识上下文(可 stub 空 AI),断言 `knowledge-injection-preview-*` 可见。
|
||||||
|
|
||||||
|
- [x] **Step 3: 全量测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
|
npm run test:e2e -- e2e/writing-goals.spec.ts e2e/knowledge-injection-history.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: 版本 0.9.0**
|
||||||
|
|
||||||
|
`package.json`、`README.md`、`SettingsPage` about 版本号。
|
||||||
|
|
||||||
|
- [x] **Step 5: 更新 migrate-v2~v7 测试期望版本 → 8**(与 P5.2 v7 时同样处理)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec Coverage Checklist
|
||||||
|
|
||||||
|
| Spec 要求 | Task |
|
||||||
|
|-----------|------|
|
||||||
|
| 番茄钟 15/25/45 + 状态栏 UI | Task 1, 4, 8 |
|
||||||
|
| 番茄完成字数 + pomodoro_daily | Task 2, 4 |
|
||||||
|
| 7/30/100 里程碑 + 成就区 | Task 2, 5, 9 |
|
||||||
|
| Toast + 系统通知 + 设置开关 | Task 1, 5, 8, 9 |
|
||||||
|
| 日更首次达标通知 | Task 5 |
|
||||||
|
| knowledge_injection_logs v8 | Task 3 |
|
||||||
|
| 三模式 confirmContext 挂钩 | Task 6 |
|
||||||
|
| 知识库注入预览 + 完整历史 | Task 10 |
|
||||||
|
| UT/IT/E2E | Task 2–6, 11 |
|
||||||
|
| v0.9.0 DoD | Task 11 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Notes
|
||||||
|
|
||||||
|
- `WritingLogService` 需轻量 refactor 接受 `AchievementService` / `GoalNotificationService` 回调(constructor 注入或 setter,避免循环依赖:Achievement 依赖 WritingLog,WritingLog 回调 Achievement)
|
||||||
|
- 切换书籍时:`useBookStore` 订阅 `currentBookId` 变化 → 若 pomodoro running 且 bookId 不同 → `cancel()` + toast `pomodoro.switchedBook`
|
||||||
|
- 注入历史 E2E **禁止 Mock AI** 指生成类测试;confirmContext 本身不调用 AI,可纯 UI 流程
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
# 笔临 P5.2 写作日志 + AI 知识抽取 设计规格
|
||||||
|
|
||||||
|
**版本**:1.0
|
||||||
|
**日期**:2026-07-07
|
||||||
|
**范围**:P5.2 — `writing_logs` 日更追踪 + 驾驶舱热力图/连更 + AI 章节知识自动抽取与审核增强
|
||||||
|
**前置**:P6 AI 知识上下文已交付(v0.7.0,`aa2c7df`)
|
||||||
|
**参考**:`design/readme.md` v1.1 §6.7 / §8 / §9、`docs/superpowers/specs/2026-07-06-bilin-p5-workflow-knowledge-design.md`、`docs/superpowers/specs/2026-07-07-bilin-p6-ai-context-design.md`
|
||||||
|
|
||||||
|
**目标版本**:v0.8.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 阶段定位
|
||||||
|
|
||||||
|
v0.7.0 已交付知识半自动推荐与四种 AI 模式注入,但知识库条目仍依赖**手动录入**;驾驶舱缺少日更写作数据(`writing_logs`、热力图、连更),`dailyWordGoal` 仅有设置项未驱动 UI。本阶段(P5.2)补齐 **写作数据闭环** 与 **AI 知识抽取上游**,形成「成章/保存 → 抽取 → 审核 → P6 注入」完整链路。
|
||||||
|
|
||||||
|
### 1.2 已确认决策(Brainstorming 2026-07-07)
|
||||||
|
|
||||||
|
| 维度 | 决策 |
|
||||||
|
|------|------|
|
||||||
|
| 交付范围 | **核心包**:`writing_logs` + 热力图 + 连更 + AI 抽取;**不含**番茄钟 / 里程碑徽章 / 预计完本 |
|
||||||
|
| 抽取触发 | **成章自动**(默认开)+ 手动「抽取本章」+ 全局 `knowledgeAutoExtract` 开关 |
|
||||||
|
| 统计范围 | **当前书籍**:热力图、连更、`dailyWordGoal` 达标均按本书当日净增 |
|
||||||
|
| 日更累计 | **保存差分 + 成章补计**:`CHAPTER_UPDATE` 记录净增;`finishChapter` 补计未入账字数 |
|
||||||
|
| 重复处理 | **AI 合并建议 + 预填更新**:`suggestedMergeId` + `proposedPatch`;确认后 patch 已有条目或另存为新条目 |
|
||||||
|
| 架构 | **方案 2**:全局 `writing_logs` + 每书 `chapter_writing_snapshots` + 独立 `KnowledgeExtractionService` |
|
||||||
|
| schema | **v7** 迁移(每书);全局 `writing_logs.sqlite`(userData) |
|
||||||
|
| AI 测试 | 集成/E2E 含抽取时禁止 Mock,走 LM Studio |
|
||||||
|
|
||||||
|
### 1.3 验收标准
|
||||||
|
|
||||||
|
用户能完成:
|
||||||
|
|
||||||
|
1. 编辑章节并保存后,驾驶舱**今日字数**与 **365 天热力图**更新;`dailyWordGoal > 0` 时状态栏进度条反映本书当日进度(绿≥100%,橙≥80%)
|
||||||
|
2. 连更天数在驾驶舱展示(仅当 `dailyWordGoal > 0`);中断后归零
|
||||||
|
3. 交互/自动/向导 **成章落盘**后,若 `knowledgeAutoExtract` 开启,知识库「待审核」出现本批 AI 抽取项(含置信度)
|
||||||
|
4. 在知识库或章节操作区点击 **「抽取本章」** 手动触发;设置中可关闭自动抽取
|
||||||
|
5. 待审核项含 **合并建议** 时,打开预填编辑器;确认后更新已有 `approved` 条目,或选择「作为新条目」
|
||||||
|
6. 「全部采纳高置信度」批量通过 ≥ 阈值(默认 0.8)的纯新建条目;含合并建议的条目不自动批量采纳
|
||||||
|
7. 采纳后的条目可被 P6 `suggestContext` 推荐并注入 AI 上下文
|
||||||
|
|
||||||
|
### 1.4 本规格不实现(明确排除)
|
||||||
|
|
||||||
|
| 项 | 归属 |
|
||||||
|
|----|------|
|
||||||
|
| 番茄钟、7/30/100 天里程碑徽章、达标推送通知 | §8 延后 |
|
||||||
|
| 预计完本、写作速度仪表盘、时段热力图 | §9 延后 |
|
||||||
|
| 角色弧线简报、Cytoscape 关系图谱 | P6+ / §20.3 |
|
||||||
|
| 语义 embedding 检索 | 后续 |
|
||||||
|
| 注入历史 / 反向链接 | P6.1 |
|
||||||
|
| 导入导出、平台投稿预设 | P7 |
|
||||||
|
| 关系「建议」自动写入 `settings` 表 | P6+(本阶段仅 `relationship` 类型知识条目) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 架构
|
||||||
|
|
||||||
|
### 2.1 进程模型
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 主进程 │
|
||||||
|
│ · WritingLogService(userData/writing_logs.sqlite) │
|
||||||
|
│ · ChapterWritingTracker(差分 + 成章补计) │
|
||||||
|
│ · KnowledgeExtractionService(AiClient + 解析 JSON) │
|
||||||
|
│ · KnowledgeService 扩展(合并 patch、批量高置信度采纳) │
|
||||||
|
│ · CockpitService 扩展(热力图、连更、今日进度) │
|
||||||
|
│ · chapter.handler / interactive|auto finishChapter 挂钩 │
|
||||||
|
└────────────────────────────┬─────────────────────────────────────┘
|
||||||
|
│ IPC
|
||||||
|
┌────────────────────────────▼─────────────────────────────────────┐
|
||||||
|
│ 渲染进程 │
|
||||||
|
│ · CockpitModal 热力图 + 连更 + 今日进度 │
|
||||||
|
│ · EditorLayout 状态栏日更进度条 │
|
||||||
|
│ · KnowledgePanel 抽取批次、合并建议 UI、批量高置信度 │
|
||||||
|
│ · SettingsPage knowledgeAutoExtract / 置信度阈值 │
|
||||||
|
│ · 章节列表/编辑器「抽取本章」入口 │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 模块边界
|
||||||
|
|
||||||
|
| 模块 | 职责 | 依赖 |
|
||||||
|
|------|------|------|
|
||||||
|
| `WritingLogService` | 按 `(date, bookId)` 读写/累加日字数 | 全局 sqlite |
|
||||||
|
| `ChapterWritingTracker` | 维护 `chapter_writing_snapshots`,计算 delta | 每书 DB、`WritingLogService` |
|
||||||
|
| `KnowledgeExtractionService` | 构建 prompt、调用 AI、解析、落库 pending | `AiClientService`, `KnowledgeRepository` |
|
||||||
|
| `KnowledgeService` | 合并 patch、批量采纳、拒绝 | 现有 CRUD |
|
||||||
|
| `CockpitService` | 扩展 `WritingStats` 聚合 | `WritingLogService`, `GlobalSettings` |
|
||||||
|
|
||||||
|
**原则**:抽取异步不阻塞成章;抽取失败 toast + 可重试;写作追踪同步、轻量。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 数据模型
|
||||||
|
|
||||||
|
### 3.1 全局 userData:`writing_logs.sqlite`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE writing_logs (
|
||||||
|
date TEXT NOT NULL, -- YYYY-MM-DD,本地时区
|
||||||
|
book_id TEXT NOT NULL,
|
||||||
|
word_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (date, book_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
由 `WritingLogService` 在 `userDataDir` 管理,与 `books_registry.json` 同级。
|
||||||
|
|
||||||
|
### 3.2 每书 schema v7
|
||||||
|
|
||||||
|
新文件 `schema-v7.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE 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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- knowledge_entries 扩展列(迁移时 ALTER 或重建后拷贝)
|
||||||
|
-- extraction_confidence REAL
|
||||||
|
-- merge_target_id TEXT -- 建议合并的 approved 条目 id
|
||||||
|
-- extraction_batch_id TEXT -- 同次抽取 UUID,便于 UI 分组
|
||||||
|
```
|
||||||
|
|
||||||
|
`migrate.ts`:`CURRENT_VERSION = 7`;v6→v7 对已有 `knowledge_entries` 执行 `ALTER TABLE ADD COLUMN`(三列均可 NULL)。
|
||||||
|
|
||||||
|
### 3.3 类型扩展(`src/shared/types.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface WritingDayLog {
|
||||||
|
date: string
|
||||||
|
bookId: string
|
||||||
|
wordCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritingStats {
|
||||||
|
todayWords: number
|
||||||
|
dailyGoal: number
|
||||||
|
goalProgress: number // 0–1+,goal=0 时为 0
|
||||||
|
streakDays: number // goal=0 时为 0
|
||||||
|
heatmap: WritingDayLog[] // 最近 365 天,含 wordCount=0 的日期
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `CockpitSummary`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
writingStats?: WritingStats
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `KnowledgeEntry`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
extractionConfidence?: number
|
||||||
|
mergeTargetId?: string
|
||||||
|
extractionBatchId?: string
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `GlobalSettings`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
knowledgeAutoExtract: boolean // default true
|
||||||
|
knowledgeExtractConfidenceThreshold: number // default 0.8
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 日更追踪
|
||||||
|
|
||||||
|
### 4.1 差分算法(`ChapterWritingTracker`)
|
||||||
|
|
||||||
|
**`recordDelta(bookId, chapterId, newWordCount)`**(在 `CHAPTER_UPDATE` 成功后调用):
|
||||||
|
|
||||||
|
1. 读取 `chapter_writing_snapshots.logged_word_count`(无则 0)
|
||||||
|
2. `delta = newWordCount - logged`
|
||||||
|
3. 若 `delta !== 0`:`WritingLogService.addToday(bookId, delta)`(当日累计不低于 0)
|
||||||
|
4. 更新 snapshot:`logged_word_count = newWordCount`
|
||||||
|
|
||||||
|
**`supplementOnFinish(bookId, chapterId, wordCount)`**(在 `finishChapter` 创建章节后调用):
|
||||||
|
|
||||||
|
1. 若 snapshot 不存在,创建 `logged=0, finish_supplemented=0`
|
||||||
|
2. `gap = wordCount - snapshot.logged_word_count`
|
||||||
|
3. 若 `gap > 0`:`addToday(bookId, gap)`,更新 `logged = wordCount`
|
||||||
|
4. 设 `finish_supplemented = 1`
|
||||||
|
|
||||||
|
删除章节时 CASCADE 删除 snapshot;`writing_logs` 历史不回溯修正(接受已知限制)。
|
||||||
|
|
||||||
|
### 4.2 连更计算
|
||||||
|
|
||||||
|
- 仅当 `dailyWordGoal > 0` 时计算
|
||||||
|
- 从今日向前遍历 `writing_logs`(本书),连续天数满足 `word_count >= dailyWordGoal`
|
||||||
|
- 今日未达标时 streak 不计入今日(展示为截至昨日的连更)
|
||||||
|
|
||||||
|
### 4.3 时区
|
||||||
|
|
||||||
|
使用主进程 `Intl` 或 `toLocaleDateString('sv-SE')` 生成本地 `YYYY-MM-DD`,与作者所在系统时区一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. AI 知识抽取
|
||||||
|
|
||||||
|
### 5.1 触发点
|
||||||
|
|
||||||
|
| 触发 | 条件 | 行为 |
|
||||||
|
|------|------|------|
|
||||||
|
| 成章落盘 | `finishChapter` 成功且 `knowledgeAutoExtract` | 入队 `extractForChapter(bookId, chapterId)` |
|
||||||
|
| 手动 | `knowledge:extractChapter` IPC | 同上 |
|
||||||
|
| 设置关闭 | `knowledgeAutoExtract === false` | 仅手动触发 |
|
||||||
|
|
||||||
|
抽取在 `setImmediate` / 微任务队列异步执行,不延长成章 IPC 响应时间。
|
||||||
|
|
||||||
|
### 5.2 Prompt 与解析
|
||||||
|
|
||||||
|
**输入**:章节标题 + 正文纯文本(`stripHtml`,上限 12000 字,超出截断并注明)+ 本书已有 `approved` 条目标题列表(上限 30 条,供合并判断)。
|
||||||
|
|
||||||
|
**输出 JSON 数组**(严格解析,失败重试一次「仅返回 JSON」):
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "foreshadow",
|
||||||
|
"title": "测灵石异常",
|
||||||
|
"content": "入门考核时石头发热,暗示主角特殊灵根",
|
||||||
|
"importance": 4,
|
||||||
|
"confidence": 0.92,
|
||||||
|
"foreshadowStatus": "buried",
|
||||||
|
"suggestedMergeId": null,
|
||||||
|
"proposedPatch": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "character",
|
||||||
|
"title": "林远状态更新",
|
||||||
|
"content": "通过入门考核,声望上升",
|
||||||
|
"importance": 3,
|
||||||
|
"confidence": 0.85,
|
||||||
|
"suggestedMergeId": "uuid-of-existing-entry",
|
||||||
|
"proposedPatch": {
|
||||||
|
"content": "已通过入门考核,同门态度由轻视转为敬畏",
|
||||||
|
"importance": 4,
|
||||||
|
"lastMentionChapterId": "<chapterId>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**类型**:五种全做 — `character` / `foreshadow` / `relationship` / `location` / `fact`。
|
||||||
|
|
||||||
|
**落库**:每条 → `knowledge_entries`,`status=pending`,`source_chapter_id=chapterId`,`extraction_confidence`,`extraction_batch_id`,`merge_target_id`(若有)。
|
||||||
|
|
||||||
|
### 5.3 合并审核 UX(决策 D)
|
||||||
|
|
||||||
|
| 场景 | UI |
|
||||||
|
|------|-----|
|
||||||
|
| 无 `mergeTargetId` | 待审核列表常规展示;采纳 → `approve` |
|
||||||
|
| 有 `mergeTargetId` | 卡片标注「建议更新:{目标标题}」;点击打开 `KnowledgeEditorDialog` **预填** `proposedPatch` + 目标条目只读摘要 |
|
||||||
|
| 确认更新 | `knowledge:approveMerge` → patch 目标 `approved` 条目,删除或标记本 pending 为 `rejected` |
|
||||||
|
| 作为新条目 | 清除 `mergeTargetId`,保留 pending,用户编辑后采纳 |
|
||||||
|
|
||||||
|
**批量「全部采纳高置信度」**:仅处理 `mergeTargetId IS NULL` 且 `confidence >= threshold` 的 pending 项。
|
||||||
|
|
||||||
|
### 5.4 IPC 新增
|
||||||
|
|
||||||
|
| 通道 | 参数 | 返回 |
|
||||||
|
|------|------|------|
|
||||||
|
| `writing:getStats` | `{ bookId }` | `WritingStats` |
|
||||||
|
| `knowledge:extractChapter` | `{ bookId, chapterId }` | `KnowledgeExtractionResult` |
|
||||||
|
| `knowledge:approveMerge` | `{ bookId, pendingId }` | `KnowledgeEntry`(更新后的目标) |
|
||||||
|
| `knowledge:batchApproveHighConfidence` | `{ bookId, threshold? }` | `number`(采纳条数) |
|
||||||
|
|
||||||
|
`CHAPTER_UPDATE` handler 内联调用 `ChapterWritingTracker`(无需新 IPC)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI 规格
|
||||||
|
|
||||||
|
### 6.1 驾驶舱(`CockpitModal`)
|
||||||
|
|
||||||
|
在现有四卡片下方新增:
|
||||||
|
|
||||||
|
- **今日进度**:`todayWords / dailyGoal` 文字 + 细进度条(`data-testid="cockpit-today-progress"`)
|
||||||
|
- **连更**:`🔥 {streakDays} 天`(`dailyGoal=0` 时隐藏,`data-testid="cockpit-streak"`)
|
||||||
|
- **热力图**:Canvas 52×7 格,365 天;颜色深浅按 `word_count` 分档(0 / 1–999 / 1000–2999 / ≥3000);今天描边高亮(`data-testid="cockpit-heatmap"`)
|
||||||
|
|
||||||
|
### 6.2 状态栏(`EditorLayout` `#editor-statusbar`)
|
||||||
|
|
||||||
|
当 `dailyWordGoal > 0` 且当前有打开书籍:
|
||||||
|
|
||||||
|
- 显示 `今日 1234 / 3000 字` + 迷你进度条
|
||||||
|
- CSS class:`goal-met`(≥100%)、`goal-near`(≥80%)
|
||||||
|
- `data-testid="status-daily-goal"`
|
||||||
|
|
||||||
|
### 6.3 知识库(`KnowledgePanel`)
|
||||||
|
|
||||||
|
- 待审核 Tab 顶部:本批抽取分组(按 `extractionBatchId` 折叠,显示来源章节)
|
||||||
|
- 条目卡片:置信度徽章、合并建议标签
|
||||||
|
- 工具栏:「全部采纳高置信度」`data-testid="knowledge-batch-high-confidence"`
|
||||||
|
- 合并项:「查看更新建议」`data-testid="knowledge-merge-review-{id}"`
|
||||||
|
|
||||||
|
### 6.4 抽取入口
|
||||||
|
|
||||||
|
- 章节列表项右键或编辑器菜单:**「抽取本章知识」** `data-testid="chapter-extract-knowledge"`
|
||||||
|
- 知识库工具栏:**「从当前章抽取」**(无选中章时 disabled)
|
||||||
|
|
||||||
|
### 6.5 设置(`SettingsPage`)
|
||||||
|
|
||||||
|
写作 / AI 分区:
|
||||||
|
|
||||||
|
- `knowledgeAutoExtract` 开关 `data-testid="settings-knowledge-auto-extract"`
|
||||||
|
- `knowledgeExtractConfidenceThreshold` 数字输入(0.5–1.0,步进 0.05)`data-testid="settings-extract-threshold"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 错误处理
|
||||||
|
|
||||||
|
| 场景 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| AI 抽取 JSON 解析失败 | 重试一次;仍失败 toast「抽取失败,请稍后重试」,写 `console.error` |
|
||||||
|
| 章节正文为空 | 跳过抽取,toast「章节无内容」 |
|
||||||
|
| `suggestedMergeId` 指向已删除条目 | 视为无合并建议,按新建处理 |
|
||||||
|
| `addToday` 扣减后当日 < 0 | 钳制为 0 |
|
||||||
|
| 成章时抽取进行中 | 允许并行;同章多次抽取生成新 `batchId`,旧 pending 保留 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 测试
|
||||||
|
|
||||||
|
### 8.1 测试 ID
|
||||||
|
|
||||||
|
| ID | 层级 | 场景 | 预期 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| UT-LOG-01 | 单元 | `recordDelta` 保存 +200 字 | 当日 +200 |
|
||||||
|
| UT-LOG-02 | 单元 | `recordDelta` 删 100 字 | 当日 -100(不低于 0) |
|
||||||
|
| UT-LOG-03 | 单元 | `supplementOnFinish` gap=500 | 当日 +500,`finish_supplemented=1` |
|
||||||
|
| UT-LOG-04 | 单元 | 连更 3 天达标 | `streakDays=3` |
|
||||||
|
| UT-EXT-01 | 单元 | 解析合法抽取 JSON | 返回 N 条 `ExtractedKnowledgeItem` |
|
||||||
|
| UT-EXT-02 | 单元 | 含 `suggestedMergeId` | `merge_target_id` 正确写入 |
|
||||||
|
| IT-LOG-01 | 集成 | `CHAPTER_UPDATE` 两次 | `writing_logs` 累计正确 |
|
||||||
|
| IT-EXT-01 | 集成 | `extractChapter` 有正文 | 返回 ≥1 pending |
|
||||||
|
| IT-EXT-02 | 集成 | `approveMerge` | 目标 `approved` 内容更新 |
|
||||||
|
| IT-EXT-03 | 集成 | 抽取 + P6 `suggestContext` | 新采纳条目出现在推荐中 |
|
||||||
|
| E2E-LOG-01 | E2E | 写 100 字保存 | 驾驶舱今日字数更新 |
|
||||||
|
| E2E-LOG-02 | E2E | 热力图可见 | `cockpit-heatmap` 存在 |
|
||||||
|
| E2E-EXT-01 | E2E | 成章后待审核有抽取项 | pending 列表非空 |
|
||||||
|
| E2E-EXT-02 | E2E | 手动抽取本章 | 待审核增加 |
|
||||||
|
| E2E-EXT-03 | E2E | 合并建议确认更新 | 目标条目内容变化 |
|
||||||
|
|
||||||
|
### 8.2 超时
|
||||||
|
|
||||||
|
- IT-EXT-01 / IT-EXT-02 / IT-EXT-03:**240s**
|
||||||
|
- E2E-EXT-01 / E2E-EXT-03:**300s**
|
||||||
|
|
||||||
|
### 8.3 原则
|
||||||
|
|
||||||
|
集成/E2E 含 AI 抽取时 **禁止 Mock**,使用 LM Studio 默认配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. i18n 键(节选)
|
||||||
|
|
||||||
|
```json
|
||||||
|
"cockpit.todayProgress": "今日 {{current}} / {{goal}} 字",
|
||||||
|
"cockpit.streak": "连更 {{days}} 天",
|
||||||
|
"cockpit.heatmap": "写作热力图",
|
||||||
|
"status.dailyGoal": "今日 {{current}} / {{goal}}",
|
||||||
|
"knowledge.extractChapter": "抽取本章知识",
|
||||||
|
"knowledge.extractFromCurrent": "从当前章抽取",
|
||||||
|
"knowledge.batchHighConfidence": "全部采纳高置信度",
|
||||||
|
"knowledge.mergeSuggest": "建议更新:{{title}}",
|
||||||
|
"knowledge.approveMerge": "确认更新",
|
||||||
|
"knowledge.saveAsNew": "作为新条目",
|
||||||
|
"knowledge.extracting": "正在抽取知识…",
|
||||||
|
"knowledge.extractFailed": "知识抽取失败,请稍后重试",
|
||||||
|
"settings.knowledgeAutoExtract": "成章后自动抽取知识",
|
||||||
|
"settings.extractThreshold": "高置信度阈值"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 文件映射
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/main/db/schema-v7.sql` | snapshots + knowledge 扩展列 |
|
||||||
|
| `src/main/db/migrate.ts` | v7 迁移 |
|
||||||
|
| `src/main/db/repositories/writing-log.repo.ts` | 全局 writing_logs CRUD |
|
||||||
|
| `src/main/db/repositories/chapter-writing-snapshot.repo.ts` | 每书 snapshot |
|
||||||
|
| `src/main/services/writing-log.service.ts` | 日更累加、连更、热力图数据 |
|
||||||
|
| `src/main/services/chapter-writing-tracker.service.ts` | 差分 + 补计 |
|
||||||
|
| `src/main/services/knowledge-extraction.service.ts` | AI 抽取 |
|
||||||
|
| `src/main/services/knowledge-extraction-prompt.ts` | prompt 构建 + JSON 解析 |
|
||||||
|
| `src/main/services/cockpit.service.ts` | 扩展 WritingStats |
|
||||||
|
| `src/main/ipc/handlers/writing.handler.ts` | `writing:getStats` |
|
||||||
|
| `src/main/ipc/handlers/knowledge.handler.ts` | 抽取/合并 IPC |
|
||||||
|
| `src/main/ipc/handlers/chapter.handler.ts` | 挂钩 tracker |
|
||||||
|
| `src/renderer/components/cockpit/WritingHeatmap.tsx` | Canvas 热力图 |
|
||||||
|
| `src/renderer/components/cockpit/CockpitModal.tsx` | 扩展布局 |
|
||||||
|
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 批次/合并 UI |
|
||||||
|
| `tests/main/writing-log.test.ts` | UT-LOG-* |
|
||||||
|
| `tests/main/knowledge-extraction.test.ts` | UT-EXT-* / IT-EXT-* |
|
||||||
|
| `e2e/writing-logs.spec.ts` | E2E-LOG-* |
|
||||||
|
| `e2e/knowledge-extraction.spec.ts` | E2E-EXT-* |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Spec Coverage Checklist
|
||||||
|
|
||||||
|
| design/readme 要求 | 本规格 |
|
||||||
|
|--------------------|--------|
|
||||||
|
| §6.7 每章自动抽取 + 审核 | §5 |
|
||||||
|
| §6.7 合并/忽略/批量采纳 | §5.3 |
|
||||||
|
| §8 writing_logs + 热力图 + 连更 | §3–§4、§6.1 |
|
||||||
|
| §8 番茄钟/里程碑 | 排除 §1.4 |
|
||||||
|
| §9 驾驶舱内嵌分析 | 热力图 + 今日进度(轻量) |
|
||||||
|
| P5 延后「AI 抽取」 | 本阶段交付 |
|
||||||
|
| P5 延后 writing_logs | 本阶段交付 |
|
||||||
|
| P6 知识注入上游 | §5 + IT-EXT-03 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 版本与 DoD
|
||||||
|
|
||||||
|
- `package.json` → **0.8.0**
|
||||||
|
- `README.md` 功能概览追加 P5.2 一行
|
||||||
|
- `SettingsPage` about 版本号更新
|
||||||
|
- `npm run build && npm run test && npm run test:e2e` 全绿
|
||||||
|
- LM Studio 集成测试按 §8.2 超时执行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本规格为笔临 P5.2 子项目。角色弧线、关系图谱、导入导出等各自在后续 spec 中增量修订。*
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
# 笔临 P5.3 + P6.1 写作目标补全 + 知识注入历史 设计规格
|
||||||
|
|
||||||
|
**版本**:1.0
|
||||||
|
**日期**:2026-07-07
|
||||||
|
**范围**:P5.3(§8 补全)番茄钟、连更里程碑徽章、达标通知;P6.1 知识库条目注入历史与反向链接
|
||||||
|
**前置**:P5.2 写作日志 + AI 知识抽取已交付(v0.8.0,`a39e06f`);P6 AI 知识上下文(v0.7.0)
|
||||||
|
**参考**:`design/readme.md` v1.1 §8 / §5.5.7、`docs/superpowers/specs/2026-07-07-bilin-p52-writing-logs-extraction-design.md`、`docs/superpowers/specs/2026-07-07-bilin-p6-ai-context-design.md`
|
||||||
|
|
||||||
|
**目标版本**:v0.9.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 阶段定位
|
||||||
|
|
||||||
|
v0.8.0 已交付 `writing_logs` 日更追踪、驾驶舱热力图/连更、状态栏日更进度,以及 AI 章节知识抽取与 P6 注入链路。但 `design/readme.md` §8 仍缺 **番茄钟**、**7/30/100 天连更里程碑徽章** 与 **达标/完成通知**;P6 spec 延后的 **注入历史 / 反向链接** 尚未落地,作者无法在知识库条目上查看「这条知识曾在哪些写作流中被 AI 使用」。
|
||||||
|
|
||||||
|
本阶段(v0.9.0)双线交付:
|
||||||
|
|
||||||
|
- **P5.3**:补齐 §8 剩余能力,强化日更作者的正反馈与专注工具
|
||||||
|
- **P6.1**:在知识库条目上记录并展示写作流(交互/自动/向导)上下文确认时的注入历史
|
||||||
|
|
||||||
|
### 1.2 已确认决策(Brainstorming 2026-07-07)
|
||||||
|
|
||||||
|
| 维度 | 决策 |
|
||||||
|
|------|------|
|
||||||
|
| 交付版本 | **v0.9.0** 单版交付 P5.3 + P6.1 |
|
||||||
|
| 架构方案 | **主进程服务分层**:`PomodoroService` + `AchievementService` + `GoalNotificationService` + `KnowledgeInjectionService` |
|
||||||
|
| 统计范围 | **当前书籍**(与 P5.2 一致:连更、里程碑、番茄字数增量、成就均按 `bookId`) |
|
||||||
|
| 通知 | **Toast + 可选 Electron 系统通知**;设置页可关闭系统通知与目标通知 |
|
||||||
|
| 番茄钟 UI | **状态栏紧凑控件**(▶/⏸ + 剩余时间);驾驶舱展示今日番茄完成次数 |
|
||||||
|
| 番茄时长 | 设置页 **15 / 25 / 45 分钟** 三档,默认 25 |
|
||||||
|
| 里程碑 UI | **庆祝 + 持久展示**:达成时 Toast/系统通知;驾驶舱「成就区」展示已解锁 7/30/100 天徽章 |
|
||||||
|
| P6.1 范围 | **仅知识库条目**(不含设定条目 AI 注入历史) |
|
||||||
|
| P6.1 记录场景 | **交互 / 自动 / 向导** 三模式 `confirmContext` 且 `knowledgeIds` 非空;**不含 AI 对话助手** |
|
||||||
|
| 注入历史 UI | **列表最近 3 条预览 + 编辑对话框完整时间线** |
|
||||||
|
| 切换书籍(番茄进行中) | **自动取消番茄**并 Toast 提示 |
|
||||||
|
|
||||||
|
### 1.3 验收标准
|
||||||
|
|
||||||
|
用户能完成:
|
||||||
|
|
||||||
|
1. 在状态栏启动 **15/25/45 分钟**番茄钟,暂停/取消/完成后看到剩余时间变化;完成时 Toast 显示本次新增字数
|
||||||
|
2. `enableSystemNotifications` 开启时,番茄完成与里程碑解锁可收到 **系统通知**(权限被拒则降级 Toast)
|
||||||
|
3. `dailyWordGoal > 0` 且当日首次达标时收到 Toast(及可选系统通知);同一自然日不重复提醒
|
||||||
|
4. 连更达到 **7 / 30 / 100 天**(基于 P5.2 `streakDays`)时:庆祝通知 + 驾驶舱成就区永久展示对应徽章;每档仅庆祝一次
|
||||||
|
5. 交互/自动/向导 **确认知识上下文**后,被注入的 `approved` 知识条目在 **KnowledgePanel** 卡片显示最近 3 条注入记录
|
||||||
|
6. 打开 **KnowledgeEditorDialog** 可查看该条目完整注入历史(时间、模式、关联章节)
|
||||||
|
7. 注入记录失败 **不阻断** 写作流确认步骤
|
||||||
|
|
||||||
|
### 1.4 本规格不实现(明确排除)
|
||||||
|
|
||||||
|
| 项 | 归属 |
|
||||||
|
|----|------|
|
||||||
|
| 预计完本、写作速度仪表盘、时段热力图 | §9 延后 |
|
||||||
|
| 设定条目「被 AI 注入的轮次」 | P6.2 |
|
||||||
|
| AI 对话助手注入历史 | 本阶段排除(用户确认 C) |
|
||||||
|
| 番茄钟自定义任意分钟数、休息间隔、番茄统计全屏页 | 后续 |
|
||||||
|
| 里程碑 365 天等扩展档位 | 后续 |
|
||||||
|
| 语义 embedding、注入内容 diff | 后续 |
|
||||||
|
| 导入导出、章节模板 | P7 / §10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 架构
|
||||||
|
|
||||||
|
### 2.1 进程模型
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 主进程 │
|
||||||
|
│ · PomodoroService(倒计时、开始/暂停/取消、完成字数差分) │
|
||||||
|
│ · AchievementService(7/30/100 里程碑解锁检测与持久化) │
|
||||||
|
│ · GoalNotificationService(Toast IPC + Notification,受设置控制) │
|
||||||
|
│ · WritingLogService(已有,番茄快照/差分、streakDays) │
|
||||||
|
│ · KnowledgeInjectionService(写入/查询 injection logs) │
|
||||||
|
│ · CockpitService 扩展(成就区、pomodoroTodayCount) │
|
||||||
|
│ · interactive/auto/wizard confirmContext 挂钩 injection 记录 │
|
||||||
|
└──────────────────────────────┬─────────────────────────────────────┘
|
||||||
|
│ IPC
|
||||||
|
┌──────────────────────────────▼─────────────────────────────────────┐
|
||||||
|
│ 渲染进程 │
|
||||||
|
│ · EditorLayout 状态栏:番茄控件 + 日更进度(已有) │
|
||||||
|
│ · CockpitModal:成就区徽章 + 今日番茄次数 │
|
||||||
|
│ · SettingsPage:番茄时长、通知开关 │
|
||||||
|
│ · KnowledgePanel:注入历史最近 3 条预览 │
|
||||||
|
│ · KnowledgeEditorDialog:完整注入历史列表 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 模块边界
|
||||||
|
|
||||||
|
| 模块 | 职责 | 依赖 |
|
||||||
|
|------|------|------|
|
||||||
|
| `PomodoroService` | 单实例番茄状态机;完成时计算 `wordDelta` | `WritingLogService`, `GlobalSettings` |
|
||||||
|
| `AchievementService` | 读 `streakDays`,写 `writing_milestones`,触发庆祝 | `WritingLogService`, `GoalNotificationService` |
|
||||||
|
| `GoalNotificationService` | 统一发送 goal-met / milestone / pomodoro 通知 | `GlobalSettings`, `Notification`(Electron) |
|
||||||
|
| `KnowledgeInjectionService` | batch 写入、按 entry 查询、recent N | 每书 `KnowledgeInjectionRepository` |
|
||||||
|
| `CockpitService` | 聚合 `achievements[]`、`pomodoroTodayCount` | 上述服务 |
|
||||||
|
|
||||||
|
**原则**:
|
||||||
|
|
||||||
|
- 番茄钟状态在主进程,保证最小化/失焦时计时准确、完成时可发系统通知
|
||||||
|
- 注入记录与 confirmContext **同事务意图**:build 成功即写 log;写 log 失败仅 `console.error`,不抛给 IPC
|
||||||
|
- 成就不回溯:仅在未来 `streakDays` 首次跨档时解锁
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 数据模型
|
||||||
|
|
||||||
|
### 3.1 全局 userData:扩展 `writing_logs.sqlite`
|
||||||
|
|
||||||
|
在现有 `writing_logs` 表基础上 **同库追加**(`WritingLogRepository.migrate()` 扩展):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS writing_milestones (
|
||||||
|
book_id TEXT NOT NULL,
|
||||||
|
milestone TEXT NOT NULL, -- 'streak_7' | 'streak_30' | 'streak_100'
|
||||||
|
unlocked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (book_id, milestone)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pomodoro_daily (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
book_id TEXT NOT NULL,
|
||||||
|
completed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (date, book_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`pomodoro_daily` 在每次番茄 **完成** 时 `completed_count += 1`,`total_words += wordDelta`。
|
||||||
|
|
||||||
|
### 3.2 每书 schema v8
|
||||||
|
|
||||||
|
新文件 `schema-v8.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS knowledge_injection_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
knowledge_entry_id TEXT NOT NULL,
|
||||||
|
flow_mode TEXT NOT NULL, -- 'interactive' | 'auto' | 'wizard'
|
||||||
|
flow_id TEXT NOT NULL,
|
||||||
|
chapter_id TEXT,
|
||||||
|
injected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (knowledge_entry_id) REFERENCES knowledge_entries(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_injection_entry_time
|
||||||
|
ON knowledge_injection_logs (knowledge_entry_id, injected_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
`migrate.ts`:`CURRENT_VERSION = 8`;v7→v8 仅 `exec(schemaV8)`。
|
||||||
|
|
||||||
|
### 3.3 TypeScript 类型(`src/shared/types.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type PomodoroDuration = 15 | 25 | 45
|
||||||
|
export type WritingMilestone = 'streak_7' | 'streak_30' | 'streak_100'
|
||||||
|
export type InjectionFlowMode = 'interactive' | 'auto' | 'wizard'
|
||||||
|
|
||||||
|
export interface PomodoroState {
|
||||||
|
running: boolean
|
||||||
|
paused: boolean
|
||||||
|
remainingSec: number
|
||||||
|
bookId: string | null
|
||||||
|
startedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritingAchievement {
|
||||||
|
milestone: WritingMilestone
|
||||||
|
unlockedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeInjectionLog {
|
||||||
|
id: string
|
||||||
|
knowledgeEntryId: string
|
||||||
|
flowMode: InjectionFlowMode
|
||||||
|
flowId: string
|
||||||
|
chapterId?: string
|
||||||
|
injectedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitSummary {
|
||||||
|
// 已有字段…
|
||||||
|
achievements?: WritingAchievement[]
|
||||||
|
pomodoroTodayCount?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展 `GlobalSettings`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pomodoroDurationMinutes?: PomodoroDuration // default 25
|
||||||
|
enableSystemNotifications?: boolean // default false
|
||||||
|
enableGoalNotifications?: boolean // default true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 服务行为
|
||||||
|
|
||||||
|
### 4.1 PomodoroService
|
||||||
|
|
||||||
|
- **start(bookId)**:若已有运行中番茄且 `bookId` 不同,先 cancel 再 start;记录 `wordSnapshot = WritingLogService.getStats(bookId).todayWords`
|
||||||
|
- **pause / resume / cancel**:标准状态机;cancel 清空快照
|
||||||
|
- **tick**:主进程 `setInterval(1s)` 递减;`remainingSec === 0` 触发 `complete()`
|
||||||
|
- **complete()**:
|
||||||
|
- `wordDelta = max(0, todayWords_now - wordSnapshot)`
|
||||||
|
- 更新 `pomodoro_daily`
|
||||||
|
- `GoalNotificationService.notifyPomodoroComplete(bookId, wordDelta)`
|
||||||
|
- 重置状态
|
||||||
|
|
||||||
|
渲染进程每 1s 通过 IPC `pomodoro:getState` 同步 UI(或由主进程 push `POMODORO_TICK` 事件,实现时二选一,计划阶段选定 push 以减少轮询)。
|
||||||
|
|
||||||
|
### 4.2 AchievementService
|
||||||
|
|
||||||
|
- 在 **WritingLogService 写入后** 或 **Cockpit getSummary** 时调用 `checkMilestones(bookId)`:
|
||||||
|
- 读 `streakDays`(需 `dailyWordGoal > 0`)
|
||||||
|
- 若 `streakDays >= 7` 且 DB 无 `streak_7` → insert + notify
|
||||||
|
- 同理 30、100
|
||||||
|
- `listAchievements(bookId)` → `WritingAchievement[]` 按 milestone 排序
|
||||||
|
|
||||||
|
### 4.3 GoalNotificationService
|
||||||
|
|
||||||
|
| 事件 | Toast | 系统通知(`enableSystemNotifications`) |
|
||||||
|
|------|-------|----------------------------------------|
|
||||||
|
| 日更首次达标 | ✓(`enableGoalNotifications`) | 可选 |
|
||||||
|
| 里程碑解锁 | ✓ | 可选 |
|
||||||
|
| 番茄完成 | ✓ | 可选 |
|
||||||
|
|
||||||
|
Toast 经现有 `useAppStore.showToast` 模式:主进程 `webContents.send(GOAL_NOTIFICATION, payload)` 或复用通用 notification IPC。
|
||||||
|
|
||||||
|
系统通知权限被拒:catch 并忽略,不抛错。
|
||||||
|
|
||||||
|
### 4.4 KnowledgeInjectionService
|
||||||
|
|
||||||
|
挂钩点(均在 `buildFlowContextPayload` **成功返回之后**):
|
||||||
|
|
||||||
|
- `interactive.handler` → `INTERACTIVE_CONFIRM_CONTEXT`
|
||||||
|
- `auto.handler` → `AUTO_CONFIRM_CONTEXT`
|
||||||
|
- `wizard.handler` → `WIZARD_CONFIRM_CONTEXT`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
recordInjection(bookId, {
|
||||||
|
knowledgeIds: binding.knowledgeIds,
|
||||||
|
flowMode: 'interactive' | 'auto' | 'wizard',
|
||||||
|
flowId,
|
||||||
|
chapterId?: string // flow 关联章或 registry meta lastChapterId
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
对每个 `knowledgeId`:校验 entry 存在且 `status === 'approved'`(防御性;UI 本不应注入 pending)。
|
||||||
|
|
||||||
|
查询:
|
||||||
|
|
||||||
|
- `listRecentForEntry(entryId, limit=3)`
|
||||||
|
- `listForEntry(entryId, limit=50)` 供编辑对话框
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI 规格
|
||||||
|
|
||||||
|
### 5.1 状态栏番茄控件
|
||||||
|
|
||||||
|
- 位置:`EditorLayout` `#editor-statusbar`,日更进度右侧
|
||||||
|
- 控件:`data-testid="pomodoro-control"`
|
||||||
|
- idle:▶ 按钮,点击 start
|
||||||
|
- running:⏸ + `MM:SS` + ✕取消
|
||||||
|
- paused:▶ 继续 + ✕取消
|
||||||
|
- 无 `currentBookId` 时禁用
|
||||||
|
|
||||||
|
### 5.2 驾驶舱成就区
|
||||||
|
|
||||||
|
- 位置:`CockpitModal` 写作统计区下方
|
||||||
|
- `data-testid="cockpit-achievements"`
|
||||||
|
- 展示已解锁徽章(7🔥 / 30🔥 / 100🔥 或 i18n 文案);未解锁灰显或隐藏
|
||||||
|
- `data-testid="cockpit-pomodoro-today"`:今日完成番茄次数
|
||||||
|
|
||||||
|
### 5.3 设置页
|
||||||
|
|
||||||
|
| 控件 | testid | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 番茄时长 | `settings-pomodoro-duration` | select: 15/25/45 |
|
||||||
|
| 系统通知 | `settings-system-notifications` | checkbox,默认 false |
|
||||||
|
| 目标通知 | `settings-goal-notifications` | checkbox,默认 true |
|
||||||
|
|
||||||
|
### 5.4 知识库注入历史
|
||||||
|
|
||||||
|
**KnowledgePanel 列表项**:
|
||||||
|
|
||||||
|
- `data-testid="knowledge-injection-preview-{entryId}"`
|
||||||
|
- 最多 3 行:`{date} · {flowMode} · {chapterTitle}`
|
||||||
|
|
||||||
|
**KnowledgeEditorDialog**:
|
||||||
|
|
||||||
|
- 折叠区「注入历史」`data-testid="knowledge-injection-history"`
|
||||||
|
- 完整列表,按时间倒序;章节名可点击跳转(调用现有 `switchTarget`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. IPC 通道
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
POMODORO_START: 'pomodoro:start',
|
||||||
|
POMODORO_PAUSE: 'pomodoro:pause',
|
||||||
|
POMODORO_RESUME: 'pomodoro:resume',
|
||||||
|
POMODORO_CANCEL: 'pomodoro:cancel',
|
||||||
|
POMODORO_GET_STATE: 'pomodoro:getState',
|
||||||
|
ACHIEVEMENT_LIST: 'achievement:list',
|
||||||
|
KNOWLEDGE_LIST_INJECTIONS: 'knowledge:listInjections',
|
||||||
|
GOAL_NOTIFICATION: 'goal:notification', // main → renderer push
|
||||||
|
```
|
||||||
|
|
||||||
|
preload / `electron-api.d.ts` 同步扩展。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. i18n 键(节选)
|
||||||
|
|
||||||
|
```json
|
||||||
|
"pomodoro.start": "开始番茄钟",
|
||||||
|
"pomodoro.pause": "暂停",
|
||||||
|
"pomodoro.cancel": "取消",
|
||||||
|
"pomodoro.complete": "番茄钟完成!本次写作 {{words}} 字",
|
||||||
|
"pomodoro.switchedBook": "已切换书籍,番茄钟已取消",
|
||||||
|
"goal.dailyMet": "今日目标已达成 {{current}} / {{goal}} 字",
|
||||||
|
"achievement.streak7": "连更 7 天",
|
||||||
|
"achievement.streak30": "连更 30 天",
|
||||||
|
"achievement.streak100": "连更 100 天",
|
||||||
|
"achievement.unlocked": "解锁成就:{{name}}",
|
||||||
|
"cockpit.achievements": "写作成就",
|
||||||
|
"cockpit.pomodoroToday": "今日番茄 {{count}} 次",
|
||||||
|
"knowledge.injectionHistory": "注入历史",
|
||||||
|
"knowledge.injectionPreview": "最近注入",
|
||||||
|
"knowledge.injectionEntry": "{{date}} · {{mode}} · {{chapter}}",
|
||||||
|
"settings.pomodoroDuration": "番茄钟时长(分钟)",
|
||||||
|
"settings.systemNotifications": "启用系统通知",
|
||||||
|
"settings.goalNotifications": "启用目标达成提醒"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 测试要求
|
||||||
|
|
||||||
|
### 8.1 单元 / 集成
|
||||||
|
|
||||||
|
| 用例ID | 场景 | 预期 |
|
||||||
|
|--------|------|------|
|
||||||
|
| UT-POM-01 | start → complete,todayWords +50 | wordDelta=50,`pomodoro_daily` 更新 |
|
||||||
|
| UT-POM-02 | 切换 bookId start | 旧番茄 cancel,新番茄 running |
|
||||||
|
| UT-ACH-01 | streakDays=7 首次检测 | `writing_milestones` 写入 streak_7 |
|
||||||
|
| UT-ACH-02 | streak_7 已存在 | 不重复 notify |
|
||||||
|
| UT-INJ-01 | recordInjection 3 ids | 3 rows inserted |
|
||||||
|
| IT-INJ-01 | interactive confirmContext with knowledgeIds | DB 有对应 logs |
|
||||||
|
| IT-GOAL-01 | addToday 达标首次 | notification payload 发送一次 |
|
||||||
|
|
||||||
|
### 8.2 E2E(对齐 design/readme §8.2)
|
||||||
|
|
||||||
|
| 用例ID | 场景 | 预期 |
|
||||||
|
|--------|------|------|
|
||||||
|
| E2E-GOAL-01 | 日更 3000 目标写 3500 | 进度变绿,达标 Toast |
|
||||||
|
| E2E-GOAL-02 | 连更 7 天(测试数据 seed) | 成就区显示 7 天徽章 |
|
||||||
|
| E2E-GOAL-03 | 启动番茄并完成(测试环境可缩短 duration mock 或 settings=15 + 加速 hook 仅限 E2E) | 完成 Toast 含字数 |
|
||||||
|
| E2E-INJ-01 | 向导 confirm 知识上下文 | Panel 预览可见注入记录 |
|
||||||
|
|
||||||
|
**E2E 番茄加速**:主进程识别 `BILIN_E2E=1` 时允许 `POMODORO_TEST_SEC=3` 环境变量覆盖时长(仅 E2E,不暴露给生产 UI)。
|
||||||
|
|
||||||
|
### 8.3 原则
|
||||||
|
|
||||||
|
- 注入历史 IT/E2E **不需要** AI 调用,可纯 DB 断言
|
||||||
|
- 含通知的 E2E 断言 Toast DOM,不断言 OS 通知栏
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 文件映射
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/main/db/schema-v8.sql` | `knowledge_injection_logs` |
|
||||||
|
| `src/main/db/migrate.ts` | v8 |
|
||||||
|
| `src/main/db/repositories/writing-milestone.repo.ts` | 全局 milestones |
|
||||||
|
| `src/main/db/repositories/pomodoro-daily.repo.ts` | 全局 pomodoro 日统计 |
|
||||||
|
| `src/main/db/repositories/knowledge-injection.repo.ts` | 每书 injection logs |
|
||||||
|
| `src/main/services/pomodoro.service.ts` | 番茄状态机 |
|
||||||
|
| `src/main/services/achievement.service.ts` | 里程碑 |
|
||||||
|
| `src/main/services/goal-notification.service.ts` | 通知 |
|
||||||
|
| `src/main/services/knowledge-injection.service.ts` | 注入记录 |
|
||||||
|
| `src/main/services/cockpit.service.ts` | 扩展 summary |
|
||||||
|
| `src/main/ipc/handlers/pomodoro.handler.ts` | 番茄 IPC |
|
||||||
|
| `src/main/ipc/handlers/achievement.handler.ts` | 成就 IPC |
|
||||||
|
| `src/main/ipc/handlers/knowledge.handler.ts` | 扩展 listInjections |
|
||||||
|
| `src/main/ipc/handlers/interactive|auto|wizard.handler.ts` | confirmContext 挂钩 |
|
||||||
|
| `src/renderer/components/layout/EditorLayout.tsx` | 番茄控件 |
|
||||||
|
| `src/renderer/components/cockpit/CockpitModal.tsx` | 成就区 |
|
||||||
|
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 注入预览 |
|
||||||
|
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 完整历史 |
|
||||||
|
| `src/renderer/components/settings/SettingsPage.tsx` | 新设置 |
|
||||||
|
| `tests/main/pomodoro.test.ts` | UT-POM-* |
|
||||||
|
| `tests/main/achievement.test.ts` | UT-ACH-* |
|
||||||
|
| `tests/main/knowledge-injection.test.ts` | UT/IT-INJ-* |
|
||||||
|
| `e2e/writing-goals.spec.ts` | E2E-GOAL-* |
|
||||||
|
| `e2e/knowledge-injection-history.spec.ts` | E2E-INJ-* |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Definition of Done(v0.9.0)
|
||||||
|
|
||||||
|
- [ ] §8 番茄钟 + 里程碑 + 通知按 §1.3 验收
|
||||||
|
- [ ] P6.1 注入历史按 §1.3 第 5–6 条验收
|
||||||
|
- [ ] schema v8 + 全局 sqlite 扩展迁移
|
||||||
|
- [ ] `npm run build` 通过
|
||||||
|
- [ ] `npm run test` 全绿(新增 UT/IT)
|
||||||
|
- [ ] E2E-GOAL-01~03、E2E-INJ-01 通过
|
||||||
|
- [ ] `package.json` / README → v0.9.0
|
||||||
|
- [ ] i18n zh-CN / en 键齐全
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本规格为笔临 P5.3 + P6.1 子项目。§9 个人写作分析、设定条目注入历史等在后续 spec 增量修订。*
|
||||||
@@ -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')
|
||||||
|
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||||
|
await page.getByRole('button', { name: '跳过' }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBookWithContent(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||||
|
await page.locator('.dialog-content input').first().fill('注入历史书')
|
||||||
|
await page.getByRole('button', { name: '创建' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
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 page.getByRole('button', { name: '+ 新章' }).click()
|
||||||
|
const editor = page.locator('.ProseMirror')
|
||||||
|
await expect(editor).toBeVisible({ timeout: 10_000 })
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially('林远站在演武场中央,众人目光汇聚。')
|
||||||
|
await page.keyboard.press('Control+s')
|
||||||
|
await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addApprovedKnowledge(page: Page, title: string): Promise<string> {
|
||||||
|
await page.locator('[data-testid="panel-tab-knowledge"]').click()
|
||||||
|
await page.locator('[data-testid="knowledge-new"]').click()
|
||||||
|
await page.locator('[data-testid="knowledge-title-input"]').fill(title)
|
||||||
|
await page.locator('[data-testid="knowledge-save"]').click()
|
||||||
|
await page.locator('[data-testid="knowledge-tab-pending"]').click()
|
||||||
|
const approveBtn = page.locator('[data-testid^="knowledge-approve-"]').first()
|
||||||
|
const testId = await approveBtn.getAttribute('data-testid')
|
||||||
|
await approveBtn.click()
|
||||||
|
const entryId = testId?.replace('knowledge-approve-', '') ?? ''
|
||||||
|
return entryId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWizardToContext(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId('panel-tab-ai').click()
|
||||||
|
await page.getByTestId('ai-session-new').click()
|
||||||
|
await expect(page.getByTestId('ai-mode-tab-wizard')).toBeEnabled({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('ai-mode-tab-wizard').click()
|
||||||
|
await expect(page.getByTestId('wizard-panel')).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('wizard-goal-input').fill('林远通过入门考核')
|
||||||
|
await page.getByRole('button', { name: '下一步' }).click()
|
||||||
|
await page.getByTestId('wizard-rhythm-mixed').click()
|
||||||
|
await page.getByRole('button', { name: '下一步' }).click()
|
||||||
|
await page.getByRole('button', { name: '下一步' }).click()
|
||||||
|
await expect(page.getByTestId('wizard-context-edit')).toBeVisible({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Knowledge injection history P6.1', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-inj-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-INJ-01: wizard confirmContext shows injection preview', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await openBookWithContent(page)
|
||||||
|
const entryId = await addApprovedKnowledge(page, '测灵石异常')
|
||||||
|
expect(entryId).toBeTruthy()
|
||||||
|
|
||||||
|
await openWizardToContext(page)
|
||||||
|
await page.getByTestId('wizard-context-edit').click()
|
||||||
|
await expect(page.getByTestId('context-editor-modal')).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('context-tab-knowledge').click()
|
||||||
|
await page.locator(`[data-testid="context-knowledge-item-${entryId}"] input`).check()
|
||||||
|
await page.getByTestId('context-save').click()
|
||||||
|
await page.getByRole('button', { name: '下一步' }).click()
|
||||||
|
|
||||||
|
await page.locator('[data-testid="panel-tab-knowledge"]').click()
|
||||||
|
await page.locator('[data-testid="knowledge-tab-all"]').click()
|
||||||
|
await expect(page.locator(`[data-testid="knowledge-injection-preview-${entryId}"]`)).toBeVisible({
|
||||||
|
timeout: 10_000
|
||||||
|
})
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { test, expect, _electron as electron, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const ROOT = path.join(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
async function launchApp(userDataDir: string, extraEnv: Record<string, string> = {}) {
|
||||||
|
return electron.launch({
|
||||||
|
args: [ROOT],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BILIN_E2E: '1',
|
||||||
|
BILIN_E2E_USER_DATA: userDataDir,
|
||||||
|
...extraEnv
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipOnboarding(page: Page): Promise<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||||
|
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureChapterEditor(page: Page): Promise<void> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedMilestone(userDataDir: string, bookId: string, milestone: string): void {
|
||||||
|
const dbPath = join(userDataDir, 'writing_logs.sqlite')
|
||||||
|
const db = new DatabaseSync(dbPath)
|
||||||
|
db.prepare('INSERT INTO writing_milestones (book_id, milestone) VALUES (?, ?)').run(
|
||||||
|
bookId,
|
||||||
|
milestone
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Writing goals P5.3', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-goal-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-GOAL-01: daily goal met shows progress and toast', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createAndOpenBook(page, '目标测试书')
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '设置' }).click()
|
||||||
|
await page.locator('[data-testid="settings-daily-word-goal"]').fill('100')
|
||||||
|
await page.getByRole('button', { name: '书籍' }).click()
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
|
||||||
|
const editor = page.locator('.ProseMirror')
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially(
|
||||||
|
'这是一段用于测试每日写作目标达标的示例文字,需要足够长度才能触发字数统计与达标提醒通知。'
|
||||||
|
)
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="status-daily-goal"].goal-met')).toBeVisible({
|
||||||
|
timeout: 15_000
|
||||||
|
})
|
||||||
|
await expect(page.locator('.toast')).toContainText(/目标|goal/i, { timeout: 10_000 })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-GOAL-02: cockpit shows unlocked achievement badge', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createAndOpenBook(page, '成就测试书')
|
||||||
|
const bookId = await page.evaluate(async () => {
|
||||||
|
const res = await window.electronAPI.book.list()
|
||||||
|
return res.ok ? res.data[0]?.id : null
|
||||||
|
})
|
||||||
|
expect(bookId).toBeTruthy()
|
||||||
|
await app.close()
|
||||||
|
|
||||||
|
seedMilestone(userDataDir, bookId!, 'streak_7')
|
||||||
|
|
||||||
|
const app2 = await launchApp(userDataDir)
|
||||||
|
const page2 = await app2.firstWindow()
|
||||||
|
await skipOnboarding(page2)
|
||||||
|
await page2.locator('.book-card').filter({ hasText: '成就测试书' }).click()
|
||||||
|
await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await dismissCockpitIfOpen(page2)
|
||||||
|
await page2.getByTestId('topbar-cockpit').click()
|
||||||
|
await expect(page2.locator('[data-testid="cockpit-modal"]')).toBeVisible({ timeout: 10_000 })
|
||||||
|
const badge = page2.locator('[data-testid="cockpit-achievements"] .cockpit-achievement-badge.unlocked')
|
||||||
|
await expect(badge).toHaveCount(1, { timeout: 10_000 })
|
||||||
|
await app2.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-GOAL-03: pomodoro completes with toast', async () => {
|
||||||
|
const app = await launchApp(userDataDir, { POMODORO_TEST_SEC: '3' })
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createAndOpenBook(page, '番茄测试书')
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
|
||||||
|
await page.locator('[data-testid="pomodoro-control"] button').first().click()
|
||||||
|
await expect(page.locator('.pomodoro-time')).toBeVisible()
|
||||||
|
await page.waitForTimeout(4500)
|
||||||
|
await expect(page.locator('.toast')).toContainText(/番茄|Pomodoro|完成|complete/i, {
|
||||||
|
timeout: 10_000
|
||||||
|
})
|
||||||
|
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",
|
"name": "bilin",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"description": "笔临 - 长篇创作智能协作平台",
|
"description": "笔临 - 长篇创作智能协作平台",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -203,6 +203,14 @@
|
|||||||
"knowledge.statsSummary": "Buried {{buried}} · Resolved {{resolved}} · Forgotten {{forgotten}}",
|
"knowledge.statsSummary": "Buried {{buried}} · Resolved {{resolved}} · Forgotten {{forgotten}}",
|
||||||
"knowledge.syncFromLandmark": "Sync this foreshadow to knowledge base?",
|
"knowledge.syncFromLandmark": "Sync this foreshadow to knowledge base?",
|
||||||
"knowledge.syncedFromLandmark": "Synced to knowledge base (pending review)",
|
"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.title": "Writing cockpit",
|
||||||
"cockpit.loading": "Loading…",
|
"cockpit.loading": "Loading…",
|
||||||
"cockpit.totalWords": "Book words",
|
"cockpit.totalWords": "Book words",
|
||||||
@@ -213,6 +221,31 @@
|
|||||||
"cockpit.resumeEdit": "Resume writing",
|
"cockpit.resumeEdit": "Resume writing",
|
||||||
"cockpit.bridgeCheck": "Chapter bridge check",
|
"cockpit.bridgeCheck": "Chapter bridge check",
|
||||||
"cockpit.forgottenForeshadow": "Forgotten foreshadows",
|
"cockpit.forgottenForeshadow": "Forgotten foreshadows",
|
||||||
|
"cockpit.todayProgress": "Today {{current}} / {{goal}} words",
|
||||||
|
"cockpit.streak": "{{days}}-day streak",
|
||||||
|
"cockpit.heatmap": "Writing heatmap",
|
||||||
|
"cockpit.achievements": "Writing achievements",
|
||||||
|
"cockpit.pomodoroToday": "{{count}} pomodoros today",
|
||||||
|
"pomodoro.start": "Start pomodoro",
|
||||||
|
"pomodoro.pause": "Pause",
|
||||||
|
"pomodoro.resume": "Resume",
|
||||||
|
"pomodoro.cancel": "Cancel",
|
||||||
|
"pomodoro.complete": "Pomodoro complete! {{words}} words written",
|
||||||
|
"pomodoro.switchedBook": "Book switched — pomodoro cancelled",
|
||||||
|
"goal.dailyMet": "Daily goal met: {{current}} / {{goal}} words",
|
||||||
|
"achievement.streak7": "7-day streak",
|
||||||
|
"achievement.streak30": "30-day streak",
|
||||||
|
"achievement.streak100": "100-day streak",
|
||||||
|
"achievement.unlocked": "Achievement unlocked: {{name}}",
|
||||||
|
"knowledge.injectionHistory": "Injection history",
|
||||||
|
"knowledge.injectionPreview": "Recent injections",
|
||||||
|
"knowledge.injectionEntry": "{{date}} · {{mode}} · {{chapter}}",
|
||||||
|
"knowledge.injectionMode.interactive": "Interactive",
|
||||||
|
"knowledge.injectionMode.auto": "Auto writing",
|
||||||
|
"knowledge.injectionMode.wizard": "Wizard",
|
||||||
|
"settings.pomodoroDuration": "Pomodoro duration (minutes)",
|
||||||
|
"settings.systemNotifications": "Enable system notifications",
|
||||||
|
"settings.goalNotifications": "Enable goal notifications",
|
||||||
"bridge.title": "Chapter bridge",
|
"bridge.title": "Chapter bridge",
|
||||||
"bridge.loading": "Analyzing…",
|
"bridge.loading": "Analyzing…",
|
||||||
"bridge.previousTail": "Previous chapter ending",
|
"bridge.previousTail": "Previous chapter ending",
|
||||||
@@ -231,6 +264,9 @@
|
|||||||
"settings.updateSchedule.alternate": "Every other day",
|
"settings.updateSchedule.alternate": "Every other day",
|
||||||
"settings.updateSchedule.weekly": "Weekly",
|
"settings.updateSchedule.weekly": "Weekly",
|
||||||
"settings.stockBufferThreshold": "Stock buffer threshold (chapters)",
|
"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",
|
"stock.warning": "Low stock: {{count}}/{{threshold}} chapters ready",
|
||||||
"landmark.foreshadowConfirm": "Mark as foreshadow landmark?",
|
"landmark.foreshadowConfirm": "Mark as foreshadow landmark?",
|
||||||
"ai.settings.backend": "Backend",
|
"ai.settings.backend": "Backend",
|
||||||
@@ -248,6 +284,7 @@
|
|||||||
"status.chapter": "Chapter",
|
"status.chapter": "Chapter",
|
||||||
"status.volume": "Volume",
|
"status.volume": "Volume",
|
||||||
"status.book": "Book",
|
"status.book": "Book",
|
||||||
|
"status.dailyGoal": "Today {{current}} / {{goal}}",
|
||||||
"toast.bookCreated": "Book created",
|
"toast.bookCreated": "Book created",
|
||||||
"toast.saveFailed": "Save failed, please retry",
|
"toast.saveFailed": "Save failed, please retry",
|
||||||
"toast.enterBookName": "Please enter a book title",
|
"toast.enterBookName": "Please enter a book title",
|
||||||
|
|||||||
@@ -203,6 +203,14 @@
|
|||||||
"knowledge.statsSummary": "埋设 {{buried}} · 回收 {{resolved}} · 遗忘 {{forgotten}}",
|
"knowledge.statsSummary": "埋设 {{buried}} · 回收 {{resolved}} · 遗忘 {{forgotten}}",
|
||||||
"knowledge.syncFromLandmark": "是否同步此伏笔到知识库?",
|
"knowledge.syncFromLandmark": "是否同步此伏笔到知识库?",
|
||||||
"knowledge.syncedFromLandmark": "已同步到知识库(待审核)",
|
"knowledge.syncedFromLandmark": "已同步到知识库(待审核)",
|
||||||
|
"knowledge.extractChapter": "抽取本章知识",
|
||||||
|
"knowledge.extractFromCurrent": "从当前章抽取",
|
||||||
|
"knowledge.batchHighConfidence": "全部采纳高置信度",
|
||||||
|
"knowledge.mergeSuggest": "建议更新:{{title}}",
|
||||||
|
"knowledge.approveMerge": "确认更新",
|
||||||
|
"knowledge.saveAsNew": "作为新条目",
|
||||||
|
"knowledge.extracting": "正在抽取知识…",
|
||||||
|
"knowledge.extractFailed": "知识抽取失败,请稍后重试",
|
||||||
"cockpit.title": "写作驾驶舱",
|
"cockpit.title": "写作驾驶舱",
|
||||||
"cockpit.loading": "加载中…",
|
"cockpit.loading": "加载中…",
|
||||||
"cockpit.totalWords": "全书字数",
|
"cockpit.totalWords": "全书字数",
|
||||||
@@ -213,6 +221,31 @@
|
|||||||
"cockpit.resumeEdit": "继续写作",
|
"cockpit.resumeEdit": "继续写作",
|
||||||
"cockpit.bridgeCheck": "章间衔接检查",
|
"cockpit.bridgeCheck": "章间衔接检查",
|
||||||
"cockpit.forgottenForeshadow": "查看遗忘伏笔",
|
"cockpit.forgottenForeshadow": "查看遗忘伏笔",
|
||||||
|
"cockpit.todayProgress": "今日 {{current}} / {{goal}} 字",
|
||||||
|
"cockpit.streak": "连更 {{days}} 天",
|
||||||
|
"cockpit.heatmap": "写作热力图",
|
||||||
|
"cockpit.achievements": "写作成就",
|
||||||
|
"cockpit.pomodoroToday": "今日番茄 {{count}} 次",
|
||||||
|
"pomodoro.start": "开始番茄钟",
|
||||||
|
"pomodoro.pause": "暂停",
|
||||||
|
"pomodoro.resume": "继续",
|
||||||
|
"pomodoro.cancel": "取消",
|
||||||
|
"pomodoro.complete": "番茄钟完成!本次写作 {{words}} 字",
|
||||||
|
"pomodoro.switchedBook": "已切换书籍,番茄钟已取消",
|
||||||
|
"goal.dailyMet": "今日目标已达成 {{current}} / {{goal}} 字",
|
||||||
|
"achievement.streak7": "连更 7 天",
|
||||||
|
"achievement.streak30": "连更 30 天",
|
||||||
|
"achievement.streak100": "连更 100 天",
|
||||||
|
"achievement.unlocked": "解锁成就:{{name}}",
|
||||||
|
"knowledge.injectionHistory": "注入历史",
|
||||||
|
"knowledge.injectionPreview": "最近注入",
|
||||||
|
"knowledge.injectionEntry": "{{date}} · {{mode}} · {{chapter}}",
|
||||||
|
"knowledge.injectionMode.interactive": "交互写作",
|
||||||
|
"knowledge.injectionMode.auto": "自动续写",
|
||||||
|
"knowledge.injectionMode.wizard": "向导模式",
|
||||||
|
"settings.pomodoroDuration": "番茄钟时长(分钟)",
|
||||||
|
"settings.systemNotifications": "启用系统通知",
|
||||||
|
"settings.goalNotifications": "启用目标达成提醒",
|
||||||
"bridge.title": "章间衔接",
|
"bridge.title": "章间衔接",
|
||||||
"bridge.loading": "分析中…",
|
"bridge.loading": "分析中…",
|
||||||
"bridge.previousTail": "上一章末尾",
|
"bridge.previousTail": "上一章末尾",
|
||||||
@@ -231,6 +264,9 @@
|
|||||||
"settings.updateSchedule.alternate": "隔日更",
|
"settings.updateSchedule.alternate": "隔日更",
|
||||||
"settings.updateSchedule.weekly": "周更",
|
"settings.updateSchedule.weekly": "周更",
|
||||||
"settings.stockBufferThreshold": "存稿缓冲阈值(章)",
|
"settings.stockBufferThreshold": "存稿缓冲阈值(章)",
|
||||||
|
"settings.dailyWordGoal": "每日写作目标(字)",
|
||||||
|
"settings.knowledgeAutoExtract": "成章后自动抽取知识",
|
||||||
|
"settings.extractThreshold": "高置信度阈值",
|
||||||
"stock.warning": "存稿不足:{{count}}/{{threshold}} 章待发布",
|
"stock.warning": "存稿不足:{{count}}/{{threshold}} 章待发布",
|
||||||
"landmark.foreshadowConfirm": "标记为伏笔地标?",
|
"landmark.foreshadowConfirm": "标记为伏笔地标?",
|
||||||
"ai.settings.backend": "后端类型",
|
"ai.settings.backend": "后端类型",
|
||||||
@@ -248,6 +284,7 @@
|
|||||||
"status.chapter": "本章",
|
"status.chapter": "本章",
|
||||||
"status.volume": "本卷",
|
"status.volume": "本卷",
|
||||||
"status.book": "全书",
|
"status.book": "全书",
|
||||||
|
"status.dailyGoal": "今日 {{current}} / {{goal}}",
|
||||||
"toast.bookCreated": "书籍已创建",
|
"toast.bookCreated": "书籍已创建",
|
||||||
"toast.saveFailed": "保存失败,请重试",
|
"toast.saveFailed": "保存失败,请重试",
|
||||||
"toast.enterBookName": "请输入书名",
|
"toast.enterBookName": "请输入书名",
|
||||||
|
|||||||
+18
-1
@@ -5,8 +5,10 @@ import schemaV3 from './schema-v3.sql?raw'
|
|||||||
import schemaV4 from './schema-v4.sql?raw'
|
import schemaV4 from './schema-v4.sql?raw'
|
||||||
import schemaV5 from './schema-v5.sql?raw'
|
import schemaV5 from './schema-v5.sql?raw'
|
||||||
import schemaV6 from './schema-v6.sql?raw'
|
import schemaV6 from './schema-v6.sql?raw'
|
||||||
|
import schemaV7 from './schema-v7.sql?raw'
|
||||||
|
import schemaV8 from './schema-v8.sql?raw'
|
||||||
|
|
||||||
const CURRENT_VERSION = 6
|
const CURRENT_VERSION = 8
|
||||||
|
|
||||||
function tryAlter(db: SqliteDb, sql: string): void {
|
function tryAlter(db: SqliteDb, sql: string): void {
|
||||||
try {
|
try {
|
||||||
@@ -71,5 +73,20 @@ export function migrate(db: SqliteDb): void {
|
|||||||
if (current < 6) {
|
if (current < 6) {
|
||||||
db.exec(schemaV6)
|
db.exec(schemaV6)
|
||||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(6, 'P5 knowledge/cockpit schema')
|
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')
|
||||||
|
current = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current < 8) {
|
||||||
|
db.exec(schemaV8)
|
||||||
|
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type { InjectionFlowMode, KnowledgeInjectionLog } from '../../../shared/types'
|
||||||
|
import type { SqliteDb } from '../types'
|
||||||
|
|
||||||
|
function mapRow(row: Record<string, unknown>): KnowledgeInjectionLog {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
knowledgeEntryId: row.knowledge_entry_id as string,
|
||||||
|
flowMode: row.flow_mode as InjectionFlowMode,
|
||||||
|
flowId: row.flow_id as string,
|
||||||
|
chapterId: (row.chapter_id as string) || undefined,
|
||||||
|
injectedAt: row.injected_at as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KnowledgeInjectionRepository {
|
||||||
|
constructor(private db: SqliteDb) {}
|
||||||
|
|
||||||
|
insertBatch(
|
||||||
|
rows: Array<{
|
||||||
|
knowledgeEntryId: string
|
||||||
|
flowMode: InjectionFlowMode
|
||||||
|
flowId: string
|
||||||
|
chapterId?: string
|
||||||
|
}>
|
||||||
|
): void {
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO knowledge_injection_logs
|
||||||
|
(id, knowledge_entry_id, flow_mode, flow_id, chapter_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
for (const row of rows) {
|
||||||
|
stmt.run(
|
||||||
|
randomUUID(),
|
||||||
|
row.knowledgeEntryId,
|
||||||
|
row.flowMode,
|
||||||
|
row.flowId,
|
||||||
|
row.chapterId ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listForEntry(entryId: string, limit = 50): KnowledgeInjectionLog[] {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM knowledge_injection_logs
|
||||||
|
WHERE knowledge_entry_id = ?
|
||||||
|
ORDER BY injected_at DESC LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(entryId, limit) as Record<string, unknown>[]
|
||||||
|
return rows.map(mapRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ function mapRow(row: Record<string, unknown>): KnowledgeEntry {
|
|||||||
sourceChapterId: (row.source_chapter_id as string) || undefined,
|
sourceChapterId: (row.source_chapter_id as string) || undefined,
|
||||||
lastMentionChapterId: (row.last_mention_chapter_id as string) || undefined,
|
lastMentionChapterId: (row.last_mention_chapter_id as string) || undefined,
|
||||||
linkedBookmarkId: (row.linked_bookmark_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,
|
createdAt: row.created_at as string,
|
||||||
updatedAt: row.updated_at as string
|
updatedAt: row.updated_at as string
|
||||||
}
|
}
|
||||||
@@ -53,6 +56,41 @@ export class KnowledgeRepository {
|
|||||||
return this.get(id)!
|
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 {
|
get(id: string): KnowledgeEntry | null {
|
||||||
const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as
|
const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
@@ -101,6 +139,15 @@ export class KnowledgeRepository {
|
|||||||
return this.get(id)!
|
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 {
|
delete(id: string): void {
|
||||||
this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id)
|
this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id)
|
||||||
}
|
}
|
||||||
@@ -111,4 +158,20 @@ export class KnowledgeRepository {
|
|||||||
.get(status) as { c: number }
|
.get(status) as { c: number }
|
||||||
return row.c
|
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,25 @@
|
|||||||
|
import type { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { localDateString } from '../../services/writing-date.util'
|
||||||
|
|
||||||
|
export class PomodoroDailyRepository {
|
||||||
|
constructor(private db: DatabaseSync) {}
|
||||||
|
|
||||||
|
recordComplete(bookId: string, wordDelta: number, date = localDateString()): void {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO pomodoro_daily (date, book_id, completed_count, total_words)
|
||||||
|
VALUES (?, ?, 1, ?)
|
||||||
|
ON CONFLICT(date, book_id) DO UPDATE SET
|
||||||
|
completed_count = completed_count + 1,
|
||||||
|
total_words = total_words + excluded.total_words`
|
||||||
|
)
|
||||||
|
.run(date, bookId, wordDelta)
|
||||||
|
}
|
||||||
|
|
||||||
|
getTodayCount(bookId: string, date = localDateString()): number {
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT completed_count FROM pomodoro_daily WHERE date = ? AND book_id = ?')
|
||||||
|
.get(date, bookId) as { completed_count: number } | undefined
|
||||||
|
return row?.completed_count ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS writing_milestones (
|
||||||
|
book_id TEXT NOT NULL,
|
||||||
|
milestone TEXT NOT NULL,
|
||||||
|
unlocked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (book_id, milestone)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pomodoro_daily (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
book_id TEXT NOT NULL,
|
||||||
|
completed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (date, book_id)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDb(): DatabaseSync {
|
||||||
|
return this.db
|
||||||
|
}
|
||||||
|
|
||||||
|
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,37 @@
|
|||||||
|
import type { DatabaseSync } from 'node:sqlite'
|
||||||
|
import type { WritingAchievement, WritingMilestone } from '../../../shared/types'
|
||||||
|
|
||||||
|
const ORDER: WritingMilestone[] = ['streak_7', 'streak_30', 'streak_100']
|
||||||
|
|
||||||
|
export class WritingMilestoneRepository {
|
||||||
|
constructor(private db: DatabaseSync) {}
|
||||||
|
|
||||||
|
list(bookId: string): WritingAchievement[] {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare('SELECT milestone, unlocked_at FROM writing_milestones WHERE book_id = ?')
|
||||||
|
.all(bookId) as Array<{ milestone: string; unlocked_at: string }>
|
||||||
|
return rows
|
||||||
|
.map((r) => ({ milestone: r.milestone as WritingMilestone, unlockedAt: r.unlocked_at }))
|
||||||
|
.sort((a, b) => ORDER.indexOf(a.milestone) - ORDER.indexOf(b.milestone))
|
||||||
|
}
|
||||||
|
|
||||||
|
has(bookId: string, milestone: WritingMilestone): boolean {
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT 1 FROM writing_milestones WHERE book_id = ? AND milestone = ?')
|
||||||
|
.get(bookId, milestone)
|
||||||
|
return row != null
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock(bookId: string, milestone: WritingMilestone): WritingAchievement | null {
|
||||||
|
if (this.has(bookId, milestone)) return null
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO writing_milestones (book_id, milestone) VALUES (?, ?)`
|
||||||
|
)
|
||||||
|
.run(bookId, milestone)
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT unlocked_at FROM writing_milestones WHERE book_id = ? AND milestone = ?')
|
||||||
|
.get(bookId, milestone) as { unlocked_at: string }
|
||||||
|
return { milestone, unlockedAt: row.unlocked_at }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS knowledge_injection_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
knowledge_entry_id TEXT NOT NULL,
|
||||||
|
flow_mode TEXT NOT NULL,
|
||||||
|
flow_id TEXT NOT NULL,
|
||||||
|
chapter_id TEXT,
|
||||||
|
injected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (knowledge_entry_id) REFERENCES knowledge_entries(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_injection_entry_time
|
||||||
|
ON knowledge_injection_logs (knowledge_entry_id, injected_at DESC);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import type { AchievementService } from '../../services/achievement.service'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
export function registerAchievementHandlers(achievement: AchievementService): void {
|
||||||
|
ipcMain.handle(IPC.ACHIEVEMENT_LIST, (_e, { bookId }: { bookId: string }) =>
|
||||||
|
wrap(() => achievement.listAchievements(bookId))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@ import { AiClientService } from '../../services/ai-client.service'
|
|||||||
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
||||||
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
||||||
import { AutoWritingService } from '../../services/auto-writing.service'
|
import { AutoWritingService } from '../../services/auto-writing.service'
|
||||||
|
import { scheduleChapterExtraction } from '../../services/knowledge-extraction-runner'
|
||||||
|
import { trackerFor } from '../../services/chapter-writing-tracker.service'
|
||||||
|
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
|
||||||
|
import type { WritingLogService } from '../../services/writing-log.service'
|
||||||
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
|
|
||||||
@@ -33,9 +37,9 @@ function readContextText(registry: BookRegistryService, bookId: string, flowId:
|
|||||||
export function registerAutoHandlers(
|
export function registerAutoHandlers(
|
||||||
registry: BookRegistryService,
|
registry: BookRegistryService,
|
||||||
settings: GlobalSettingsService,
|
settings: GlobalSettingsService,
|
||||||
aiClient: AiClientService
|
aiClient: AiClientService,
|
||||||
|
writingLogs: WritingLogService
|
||||||
): void {
|
): void {
|
||||||
void settings
|
|
||||||
|
|
||||||
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
|
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
|
||||||
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
|
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
|
||||||
@@ -86,6 +90,12 @@ export function registerAutoHandlers(
|
|||||||
bookId,
|
bookId,
|
||||||
settings.get().penName
|
settings.get().penName
|
||||||
)
|
)
|
||||||
|
recordKnowledgeInjection(registry, bookId, {
|
||||||
|
knowledgeIds: binding.knowledgeIds,
|
||||||
|
flowMode: 'auto',
|
||||||
|
flowId,
|
||||||
|
chapterId: registry.getMeta(bookId)?.lastChapterId ?? undefined
|
||||||
|
})
|
||||||
return svc.enrichFlow(svc.confirmContext(flowId, payload))
|
return svc.enrichFlow(svc.confirmContext(flowId, payload))
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -203,7 +213,14 @@ export function registerAutoHandlers(
|
|||||||
(
|
(
|
||||||
_e,
|
_e,
|
||||||
{ bookId, flowId, volumeId, title }: { bookId: string; flowId: string; volumeId: string; title?: string }
|
{ 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 }) =>
|
ipcMain.handle(IPC.AUTO_ABORT, (_e, { flowId }: { flowId: string }) =>
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { ipcMain } from 'electron'
|
|||||||
import { IPC } from '../../../shared/ipc-channels'
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
import type { ChapterStatus, PublishStatus } from '../../../shared/types'
|
import type { ChapterStatus, PublishStatus } from '../../../shared/types'
|
||||||
import { BookRegistryService } from '../../services/book-registry'
|
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 { ftsSync } from '../../services/fts-sync.service'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
|
|
||||||
export function registerChapterHandlers(registry: BookRegistryService): void {
|
export function registerChapterHandlers(
|
||||||
|
registry: BookRegistryService,
|
||||||
|
writingLogs: WritingLogService
|
||||||
|
): void {
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.VOLUME_CREATE,
|
IPC.VOLUME_CREATE,
|
||||||
(_event, { bookId, name }: { bookId: string; name: string }) =>
|
(_event, { bookId, name }: { bookId: string; name: string }) =>
|
||||||
@@ -91,6 +96,7 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
|
|||||||
chapter.title,
|
chapter.title,
|
||||||
chapter.content
|
chapter.content
|
||||||
)
|
)
|
||||||
|
trackerFor(registry, bookId, writingLogs).recordDelta(chapterId, chapter.wordCount)
|
||||||
registry.updateMeta(bookId, { lastChapterId: chapterId })
|
registry.updateMeta(bookId, { lastChapterId: chapterId })
|
||||||
return chapter
|
return chapter
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,16 +3,30 @@ import { IPC } from '../../../shared/ipc-channels'
|
|||||||
import { BookRegistryService } from '../../services/book-registry'
|
import { BookRegistryService } from '../../services/book-registry'
|
||||||
import { CockpitService } from '../../services/cockpit.service'
|
import { CockpitService } from '../../services/cockpit.service'
|
||||||
import type { GlobalSettingsService } from '../../services/global-settings'
|
import type { GlobalSettingsService } from '../../services/global-settings'
|
||||||
|
import type { AchievementService } from '../../services/achievement.service'
|
||||||
|
import type { PomodoroDailyRepository } from '../../db/repositories/pomodoro-daily.repo'
|
||||||
|
import type { WritingLogService } from '../../services/writing-log.service'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
|
|
||||||
export function registerCockpitHandlers(
|
export function registerCockpitHandlers(
|
||||||
registry: BookRegistryService,
|
registry: BookRegistryService,
|
||||||
settings: GlobalSettingsService
|
settings: GlobalSettingsService,
|
||||||
|
writingLogs: WritingLogService,
|
||||||
|
achievementService: AchievementService,
|
||||||
|
pomodoroDaily: PomodoroDailyRepository
|
||||||
): void {
|
): void {
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.COCKPIT_SUMMARY,
|
IPC.COCKPIT_SUMMARY,
|
||||||
(_e, { bookId, volumeId }: { bookId: string; volumeId?: string }) =>
|
(_e, { bookId, volumeId }: { bookId: string; volumeId?: string }) =>
|
||||||
wrap(() => new CockpitService(registry.getDb(bookId), settings).getSummary(volumeId))
|
wrap(() =>
|
||||||
|
new CockpitService(
|
||||||
|
registry.getDb(bookId),
|
||||||
|
settings,
|
||||||
|
writingLogs,
|
||||||
|
achievementService,
|
||||||
|
pomodoroDaily
|
||||||
|
).getSummary(volumeId, bookId)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle(IPC.COCKPIT_MARK_SEEN, (_e, { bookId }: { bookId: string }) =>
|
ipcMain.handle(IPC.COCKPIT_MARK_SEEN, (_e, { bookId }: { bookId: string }) =>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { AiClientService } from '../../services/ai-client.service'
|
|||||||
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
||||||
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
||||||
import { InteractiveWritingService } from '../../services/interactive-writing.service'
|
import { InteractiveWritingService } from '../../services/interactive-writing.service'
|
||||||
|
import { scheduleChapterExtraction } from '../../services/knowledge-extraction-runner'
|
||||||
|
import { trackerFor } from '../../services/chapter-writing-tracker.service'
|
||||||
|
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
|
||||||
|
import type { WritingLogService } from '../../services/writing-log.service'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
|
|
||||||
const activeInteractive = new Map<string, AbortController>()
|
const activeInteractive = new Map<string, AbortController>()
|
||||||
@@ -24,7 +28,8 @@ function enrich(registry: BookRegistryService, bookId: string, aiClient: AiClien
|
|||||||
export function registerInteractiveHandlers(
|
export function registerInteractiveHandlers(
|
||||||
registry: BookRegistryService,
|
registry: BookRegistryService,
|
||||||
settings: GlobalSettingsService,
|
settings: GlobalSettingsService,
|
||||||
aiClient: AiClientService
|
aiClient: AiClientService,
|
||||||
|
writingLogs: WritingLogService
|
||||||
): void {
|
): void {
|
||||||
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
|
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
|
||||||
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
|
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
|
||||||
@@ -62,6 +67,12 @@ export function registerInteractiveHandlers(
|
|||||||
bookId,
|
bookId,
|
||||||
settings.get().penName
|
settings.get().penName
|
||||||
)
|
)
|
||||||
|
recordKnowledgeInjection(registry, bookId, {
|
||||||
|
knowledgeIds: binding.knowledgeIds,
|
||||||
|
flowMode: 'interactive',
|
||||||
|
flowId,
|
||||||
|
chapterId: registry.getMeta(bookId)?.lastChapterId ?? undefined
|
||||||
|
})
|
||||||
const flow = serviceFor(registry, bookId, aiClient).confirmContext(flowId, payload)
|
const flow = serviceFor(registry, bookId, aiClient).confirmContext(flowId, payload)
|
||||||
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
||||||
})
|
})
|
||||||
@@ -194,7 +205,13 @@ export function registerInteractiveHandlers(
|
|||||||
title
|
title
|
||||||
}: { bookId: string; flowId: string; volumeId: string; title?: string }
|
}: { 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 }) =>
|
ipcMain.handle(IPC.INTERACTIVE_ABORT, (_e, { flowId }: { flowId: string }) =>
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import { IPC } from '../../../shared/ipc-channels'
|
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 { 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 { KnowledgeInjectionService } from '../../services/knowledge-injection.service'
|
||||||
import { KnowledgeRetrievalService } from '../../services/knowledge-retrieval.service'
|
import { KnowledgeRetrievalService } from '../../services/knowledge-retrieval.service'
|
||||||
import { KnowledgeService } from '../../services/knowledge.service'
|
import { KnowledgeService } from '../../services/knowledge.service'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
|
|
||||||
export function registerKnowledgeHandlers(
|
export function registerKnowledgeHandlers(
|
||||||
registry: BookRegistryService,
|
registry: BookRegistryService,
|
||||||
settings: GlobalSettingsService
|
settings: GlobalSettingsService,
|
||||||
|
aiClient: AiClientService
|
||||||
): void {
|
): void {
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.KNOWLEDGE_LIST,
|
IPC.KNOWLEDGE_LIST,
|
||||||
@@ -82,4 +92,44 @@ 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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.KNOWLEDGE_LIST_INJECTIONS,
|
||||||
|
(
|
||||||
|
_e,
|
||||||
|
{ bookId, entryId, limit }: { bookId: string; entryId: string; limit?: number }
|
||||||
|
) =>
|
||||||
|
wrap(() =>
|
||||||
|
new KnowledgeInjectionService(registry.getDb(bookId)).listForEntry(entryId, limit ?? 50)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import type { PomodoroService } from '../../services/pomodoro.service'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
export function registerPomodoroHandlers(pomodoro: PomodoroService): void {
|
||||||
|
ipcMain.handle(IPC.POMODORO_START, (_e, { bookId }: { bookId: string }) =>
|
||||||
|
wrap(() => pomodoro.start(bookId))
|
||||||
|
)
|
||||||
|
ipcMain.handle(IPC.POMODORO_PAUSE, () => wrap(() => pomodoro.pause()))
|
||||||
|
ipcMain.handle(IPC.POMODORO_RESUME, () => wrap(() => pomodoro.resume()))
|
||||||
|
ipcMain.handle(IPC.POMODORO_CANCEL, () => wrap(() => pomodoro.cancel()))
|
||||||
|
ipcMain.handle(IPC.POMODORO_GET_STATE, () => wrap(() => pomodoro.getState()))
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { GlobalSettingsService } from '../../services/global-settings'
|
|||||||
import { AiClientService } from '../../services/ai-client.service'
|
import { AiClientService } from '../../services/ai-client.service'
|
||||||
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
||||||
import { WizardWritingService } from '../../services/wizard-writing.service'
|
import { WizardWritingService } from '../../services/wizard-writing.service'
|
||||||
|
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
|
||||||
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
|
|
||||||
@@ -99,6 +100,12 @@ export function registerWizardHandlers(
|
|||||||
bookId,
|
bookId,
|
||||||
settings.get().penName
|
settings.get().penName
|
||||||
)
|
)
|
||||||
|
recordKnowledgeInjection(registry, bookId, {
|
||||||
|
knowledgeIds: binding.knowledgeIds,
|
||||||
|
flowMode: 'wizard',
|
||||||
|
flowId,
|
||||||
|
chapterId: registry.getMeta(bookId)?.lastChapterId ?? undefined
|
||||||
|
})
|
||||||
const svc = serviceFor(registry, bookId, aiClient)
|
const svc = serviceFor(registry, bookId, aiClient)
|
||||||
return svc.enrichFlow(svc.confirmContext(flowId, payload))
|
return svc.enrichFlow(svc.confirmContext(flowId, payload))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { join } from 'path'
|
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { GlobalSettingsService } from '../services/global-settings'
|
import { GlobalSettingsService } from '../services/global-settings'
|
||||||
import { BookRegistryService } from '../services/book-registry'
|
import { BookRegistryService } from '../services/book-registry'
|
||||||
import { SnapshotService } from '../services/snapshot.service'
|
import { SnapshotService } from '../services/snapshot.service'
|
||||||
import { ShortcutManager } from '../shortcuts/manager'
|
import { ShortcutManager } from '../shortcuts/manager'
|
||||||
|
import { WritingLogRepository } from '../db/repositories/writing-log.repo'
|
||||||
|
import { WritingMilestoneRepository } from '../db/repositories/writing-milestone.repo'
|
||||||
|
import { PomodoroDailyRepository } from '../db/repositories/pomodoro-daily.repo'
|
||||||
|
import { WritingLogService } from '../services/writing-log.service'
|
||||||
|
import { GoalNotificationService } from '../services/goal-notification.service'
|
||||||
|
import { AchievementService } from '../services/achievement.service'
|
||||||
|
import { PomodoroService } from '../services/pomodoro.service'
|
||||||
import { registerSettingsHandlers } from './handlers/settings.handler'
|
import { registerSettingsHandlers } from './handlers/settings.handler'
|
||||||
import { registerBookHandlers } from './handlers/book.handler'
|
import { registerBookHandlers } from './handlers/book.handler'
|
||||||
import { registerChapterHandlers } from './handlers/chapter.handler'
|
import { registerChapterHandlers } from './handlers/chapter.handler'
|
||||||
@@ -20,6 +26,9 @@ import { registerAutoHandlers } from './handlers/auto.handler'
|
|||||||
import { registerWizardHandlers } from './handlers/wizard.handler'
|
import { registerWizardHandlers } from './handlers/wizard.handler'
|
||||||
import { registerKnowledgeHandlers } from './handlers/knowledge.handler'
|
import { registerKnowledgeHandlers } from './handlers/knowledge.handler'
|
||||||
import { registerCockpitHandlers } from './handlers/cockpit.handler'
|
import { registerCockpitHandlers } from './handlers/cockpit.handler'
|
||||||
|
import { registerWritingHandlers } from './handlers/writing.handler'
|
||||||
|
import { registerPomodoroHandlers } from './handlers/pomodoro.handler'
|
||||||
|
import { registerAchievementHandlers } from './handlers/achievement.handler'
|
||||||
import { registerBridgeHandlers } from './handlers/bridge.handler'
|
import { registerBridgeHandlers } from './handlers/bridge.handler'
|
||||||
import { AiClientService } from '../services/ai-client.service'
|
import { AiClientService } from '../services/ai-client.service'
|
||||||
import { NetworkMonitorService } from '../services/network-monitor.service'
|
import { NetworkMonitorService } from '../services/network-monitor.service'
|
||||||
@@ -33,13 +42,27 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
|||||||
|
|
||||||
const settings = new GlobalSettingsService(userData)
|
const settings = new GlobalSettingsService(userData)
|
||||||
const books = new BookRegistryService(userData)
|
const books = new BookRegistryService(userData)
|
||||||
|
const writingLogRepo = new WritingLogRepository(userData)
|
||||||
|
const writingLogs = new WritingLogService(writingLogRepo, settings)
|
||||||
|
const milestoneRepo = new WritingMilestoneRepository(writingLogRepo.getDb())
|
||||||
|
const pomodoroDailyRepo = new PomodoroDailyRepository(writingLogRepo.getDb())
|
||||||
|
const goalNotify = new GoalNotificationService(settings)
|
||||||
|
const achievementService = new AchievementService(
|
||||||
|
milestoneRepo,
|
||||||
|
writingLogs,
|
||||||
|
settings,
|
||||||
|
goalNotify
|
||||||
|
)
|
||||||
|
writingLogs.setGoalHooks(achievementService, goalNotify)
|
||||||
|
const pomodoro = new PomodoroService(writingLogs, pomodoroDailyRepo, settings, goalNotify)
|
||||||
|
|
||||||
shortcutManager = new ShortcutManager(settings)
|
shortcutManager = new ShortcutManager(settings)
|
||||||
snapshotService = new SnapshotService(() => settings.get())
|
snapshotService = new SnapshotService(() => settings.get())
|
||||||
networkMonitor = new NetworkMonitorService()
|
networkMonitor = new NetworkMonitorService()
|
||||||
|
|
||||||
registerSettingsHandlers(settings)
|
registerSettingsHandlers(settings)
|
||||||
registerBookHandlers(books)
|
registerBookHandlers(books)
|
||||||
registerChapterHandlers(books)
|
registerChapterHandlers(books, writingLogs)
|
||||||
registerShortcutHandlers(shortcutManager)
|
registerShortcutHandlers(shortcutManager)
|
||||||
registerOutlineHandlers(books)
|
registerOutlineHandlers(books)
|
||||||
registerSettingHandlers(books)
|
registerSettingHandlers(books)
|
||||||
@@ -49,11 +72,14 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
|||||||
registerSearchHandlers(books, settings)
|
registerSearchHandlers(books, settings)
|
||||||
const aiClient = new AiClientService(() => settings.get().aiConfig)
|
const aiClient = new AiClientService(() => settings.get().aiConfig)
|
||||||
registerAiHandlers(books, settings, aiClient)
|
registerAiHandlers(books, settings, aiClient)
|
||||||
registerInteractiveHandlers(books, settings, aiClient)
|
registerInteractiveHandlers(books, settings, aiClient, writingLogs)
|
||||||
registerAutoHandlers(books, settings, aiClient)
|
registerAutoHandlers(books, settings, aiClient, writingLogs)
|
||||||
registerWizardHandlers(books, settings, aiClient)
|
registerWizardHandlers(books, settings, aiClient)
|
||||||
registerKnowledgeHandlers(books, settings)
|
registerKnowledgeHandlers(books, settings, aiClient)
|
||||||
registerCockpitHandlers(books, settings)
|
registerCockpitHandlers(books, settings, writingLogs, achievementService, pomodoroDailyRepo)
|
||||||
|
registerWritingHandlers(writingLogs)
|
||||||
|
registerPomodoroHandlers(pomodoro)
|
||||||
|
registerAchievementHandlers(achievementService)
|
||||||
registerBridgeHandlers(books, aiClient)
|
registerBridgeHandlers(books, aiClient)
|
||||||
|
|
||||||
return { settings, books, shortcuts: shortcutManager }
|
return { settings, books, shortcuts: shortcutManager }
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { WritingAchievement, WritingMilestone } from '../../shared/types'
|
||||||
|
import { WritingMilestoneRepository } from '../db/repositories/writing-milestone.repo'
|
||||||
|
import type { GoalNotificationService } from './goal-notification.service'
|
||||||
|
import type { GlobalSettingsService } from './global-settings'
|
||||||
|
import type { WritingLogService } from './writing-log.service'
|
||||||
|
|
||||||
|
const THRESHOLDS: Array<{ days: number; milestone: WritingMilestone }> = [
|
||||||
|
{ days: 7, milestone: 'streak_7' },
|
||||||
|
{ days: 30, milestone: 'streak_30' },
|
||||||
|
{ days: 100, milestone: 'streak_100' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export class AchievementService {
|
||||||
|
constructor(
|
||||||
|
private milestoneRepo: WritingMilestoneRepository,
|
||||||
|
private writingLogs: WritingLogService,
|
||||||
|
private settings: GlobalSettingsService,
|
||||||
|
private notify: GoalNotificationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
listAchievements(bookId: string): WritingAchievement[] {
|
||||||
|
return this.milestoneRepo.list(bookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkMilestones(bookId: string): WritingAchievement[] {
|
||||||
|
const { dailyWordGoal } = this.settings.get()
|
||||||
|
if (dailyWordGoal <= 0) return []
|
||||||
|
const streak = this.writingLogs.getStats(bookId).streakDays
|
||||||
|
const unlocked: WritingAchievement[] = []
|
||||||
|
for (const { days, milestone } of THRESHOLDS) {
|
||||||
|
if (streak >= days) {
|
||||||
|
const a = this.milestoneRepo.unlock(bookId, milestone)
|
||||||
|
if (a) {
|
||||||
|
this.notify.notifyMilestone(milestone)
|
||||||
|
unlocked.push(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unlocked
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,21 @@ import { BookPrefsRepository } from '../db/repositories/book-prefs.repo'
|
|||||||
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||||
import type { GlobalSettingsService } from './global-settings'
|
import type { GlobalSettingsService } from './global-settings'
|
||||||
import { KnowledgeService } from './knowledge.service'
|
import { KnowledgeService } from './knowledge.service'
|
||||||
|
import type { AchievementService } from './achievement.service'
|
||||||
|
import type { PomodoroDailyRepository } from '../db/repositories/pomodoro-daily.repo'
|
||||||
|
import type { WritingLogService } from './writing-log.service'
|
||||||
import { stripHtml } from './word-count'
|
import { stripHtml } from './word-count'
|
||||||
|
|
||||||
export class CockpitService {
|
export class CockpitService {
|
||||||
constructor(
|
constructor(
|
||||||
private db: SqliteDb,
|
private db: SqliteDb,
|
||||||
private settings: GlobalSettingsService
|
private settings: GlobalSettingsService,
|
||||||
|
private writingLogs?: WritingLogService,
|
||||||
|
private achievementService?: AchievementService,
|
||||||
|
private pomodoroDaily?: PomodoroDailyRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getSummary(activeVolumeId?: string): CockpitSummary {
|
getSummary(activeVolumeId?: string, bookId?: string): CockpitSummary {
|
||||||
const chapters = new ChapterRepository(this.db).list()
|
const chapters = new ChapterRepository(this.db).list()
|
||||||
const totalWords = chapters.reduce((s, c) => s + stripHtml(c.content).length, 0)
|
const totalWords = chapters.reduce((s, c) => s + stripHtml(c.content).length, 0)
|
||||||
const volumeWords = activeVolumeId
|
const volumeWords = activeVolumeId
|
||||||
@@ -23,6 +29,14 @@ export class CockpitService {
|
|||||||
const stockReadyCount = new ChapterRepository(this.db).countByPublishStatus('ready')
|
const stockReadyCount = new ChapterRepository(this.db).countByPublishStatus('ready')
|
||||||
const stats = new KnowledgeService(this.db).getForeshadowStats()
|
const stats = new KnowledgeService(this.db).getForeshadowStats()
|
||||||
const { stockBufferThreshold } = this.settings.get()
|
const { stockBufferThreshold } = this.settings.get()
|
||||||
|
const writingStats =
|
||||||
|
bookId && this.writingLogs ? this.writingLogs.getStats(bookId) : undefined
|
||||||
|
const achievements =
|
||||||
|
bookId && this.achievementService
|
||||||
|
? this.achievementService.listAchievements(bookId)
|
||||||
|
: undefined
|
||||||
|
const pomodoroTodayCount =
|
||||||
|
bookId && this.pomodoroDaily ? this.pomodoroDaily.getTodayCount(bookId) : undefined
|
||||||
return {
|
return {
|
||||||
totalWords,
|
totalWords,
|
||||||
volumeWords,
|
volumeWords,
|
||||||
@@ -30,7 +44,10 @@ export class CockpitService {
|
|||||||
stockThreshold: stockBufferThreshold,
|
stockThreshold: stockBufferThreshold,
|
||||||
foreshadowBuried: stats.buried,
|
foreshadowBuried: stats.buried,
|
||||||
foreshadowResolved: stats.resolved,
|
foreshadowResolved: stats.resolved,
|
||||||
foreshadowForgotten: stats.forgotten
|
foreshadowForgotten: stats.forgotten,
|
||||||
|
writingStats,
|
||||||
|
achievements,
|
||||||
|
pomodoroTodayCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ function defaults(): GlobalSettings {
|
|||||||
aiConfig: defaultAiConfig(),
|
aiConfig: defaultAiConfig(),
|
||||||
namingIgnoreWords: [],
|
namingIgnoreWords: [],
|
||||||
knowledgeAutoSuggest: true,
|
knowledgeAutoSuggest: true,
|
||||||
knowledgeSuggestTopN: 8
|
knowledgeSuggestTopN: 8,
|
||||||
|
knowledgeAutoExtract: true,
|
||||||
|
knowledgeExtractConfidenceThreshold: 0.8,
|
||||||
|
pomodoroDurationMinutes: 25,
|
||||||
|
enableSystemNotifications: false,
|
||||||
|
enableGoalNotifications: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +58,12 @@ export class GlobalSettingsService {
|
|||||||
stockBufferThreshold: raw.stockBufferThreshold ?? 3,
|
stockBufferThreshold: raw.stockBufferThreshold ?? 3,
|
||||||
knowledgeAutoSuggest: raw.knowledgeAutoSuggest ?? true,
|
knowledgeAutoSuggest: raw.knowledgeAutoSuggest ?? true,
|
||||||
knowledgeSuggestTopN: raw.knowledgeSuggestTopN ?? 8,
|
knowledgeSuggestTopN: raw.knowledgeSuggestTopN ?? 8,
|
||||||
|
knowledgeAutoExtract: raw.knowledgeAutoExtract ?? true,
|
||||||
|
knowledgeExtractConfidenceThreshold: raw.knowledgeExtractConfidenceThreshold ?? 0.8,
|
||||||
|
pomodoroDurationMinutes: raw.pomodoroDurationMinutes ?? 25,
|
||||||
|
enableSystemNotifications: raw.enableSystemNotifications ?? false,
|
||||||
|
enableGoalNotifications: raw.enableGoalNotifications ?? true,
|
||||||
|
dailyGoalNotifiedDate: raw.dailyGoalNotifiedDate,
|
||||||
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
||||||
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { BrowserWindow, Notification } from 'electron'
|
||||||
|
import { IPC } from '../../shared/ipc-channels'
|
||||||
|
import type { GoalNotificationPayload, WritingMilestone } from '../../shared/types'
|
||||||
|
import type { GlobalSettingsService } from './global-settings'
|
||||||
|
|
||||||
|
const MILESTONE_LABEL: Record<WritingMilestone, string> = {
|
||||||
|
streak_7: 'achievement.streak7',
|
||||||
|
streak_30: 'achievement.streak30',
|
||||||
|
streak_100: 'achievement.streak100'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GoalNotificationService {
|
||||||
|
constructor(private settings: GlobalSettingsService) {}
|
||||||
|
|
||||||
|
private getWindow(): BrowserWindow | null {
|
||||||
|
const wins = BrowserWindow.getAllWindows()
|
||||||
|
return wins.length > 0 ? wins[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast(payload: GoalNotificationPayload): void {
|
||||||
|
this.getWindow()?.webContents.send(IPC.GOAL_NOTIFICATION, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeSystemNotify(title: string, body: string): void {
|
||||||
|
if (!this.settings.get().enableSystemNotifications) return
|
||||||
|
if (!Notification.isSupported()) return
|
||||||
|
try {
|
||||||
|
new Notification({ title, body }).show()
|
||||||
|
} catch {
|
||||||
|
/* permission denied */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyPomodoroComplete(words: number): void {
|
||||||
|
const payload: GoalNotificationPayload = {
|
||||||
|
kind: 'pomodoro_complete',
|
||||||
|
messageKey: 'pomodoro.complete',
|
||||||
|
messageParams: { words }
|
||||||
|
}
|
||||||
|
this.pushToast(payload)
|
||||||
|
this.maybeSystemNotify('笔临', `番茄钟完成,本次写作 ${words} 字`)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyPomodoroBookSwitch(): void {
|
||||||
|
this.pushToast({
|
||||||
|
kind: 'pomodoro_cancelled_book_switch',
|
||||||
|
messageKey: 'pomodoro.switchedBook'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeNotifyDailyMet(current: number, goal: number): void {
|
||||||
|
if (!this.settings.get().enableGoalNotifications) return
|
||||||
|
const today = new Date().toLocaleDateString('sv-SE')
|
||||||
|
if (this.settings.get().dailyGoalNotifiedDate === today) return
|
||||||
|
if (current < goal) return
|
||||||
|
this.settings.update({ dailyGoalNotifiedDate: today })
|
||||||
|
this.pushToast({
|
||||||
|
kind: 'daily_met',
|
||||||
|
messageKey: 'goal.dailyMet',
|
||||||
|
messageParams: { current, goal }
|
||||||
|
})
|
||||||
|
this.maybeSystemNotify('笔临', `今日目标已达成 ${current} / ${goal} 字`)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyMilestone(milestone: WritingMilestone): void {
|
||||||
|
this.pushToast({
|
||||||
|
kind: 'milestone',
|
||||||
|
messageKey: MILESTONE_LABEL[milestone]
|
||||||
|
})
|
||||||
|
this.maybeSystemNotify('笔临', '解锁写作成就')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { BookRegistryService } from './book-registry'
|
||||||
|
import type { InjectionFlowMode } from '../../shared/types'
|
||||||
|
import { KnowledgeInjectionService } from './knowledge-injection.service'
|
||||||
|
|
||||||
|
export function recordKnowledgeInjection(
|
||||||
|
registry: BookRegistryService,
|
||||||
|
bookId: string,
|
||||||
|
input: {
|
||||||
|
knowledgeIds: string[]
|
||||||
|
flowMode: InjectionFlowMode
|
||||||
|
flowId: string
|
||||||
|
chapterId?: string
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
new KnowledgeInjectionService(registry.getDb(bookId)).record(input)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[knowledge-injection]', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { InjectionFlowMode, KnowledgeInjectionLog } from '../../shared/types'
|
||||||
|
import type { SqliteDb } from '../db/types'
|
||||||
|
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
|
||||||
|
import { KnowledgeInjectionRepository } from '../db/repositories/knowledge-injection.repo'
|
||||||
|
|
||||||
|
export class KnowledgeInjectionService {
|
||||||
|
private injectionRepo: KnowledgeInjectionRepository
|
||||||
|
private knowledgeRepo: KnowledgeRepository
|
||||||
|
|
||||||
|
constructor(db: SqliteDb) {
|
||||||
|
this.injectionRepo = new KnowledgeInjectionRepository(db)
|
||||||
|
this.knowledgeRepo = new KnowledgeRepository(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
record(input: {
|
||||||
|
knowledgeIds: string[]
|
||||||
|
flowMode: InjectionFlowMode
|
||||||
|
flowId: string
|
||||||
|
chapterId?: string
|
||||||
|
}): void {
|
||||||
|
if (input.knowledgeIds.length === 0) return
|
||||||
|
const rows = input.knowledgeIds
|
||||||
|
.filter((id) => {
|
||||||
|
const entry = this.knowledgeRepo.get(id)
|
||||||
|
return entry?.status === 'approved'
|
||||||
|
})
|
||||||
|
.map((id) => ({
|
||||||
|
knowledgeEntryId: id,
|
||||||
|
flowMode: input.flowMode,
|
||||||
|
flowId: input.flowId,
|
||||||
|
chapterId: input.chapterId
|
||||||
|
}))
|
||||||
|
if (rows.length > 0) this.injectionRepo.insertBatch(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
listForEntry(entryId: string, limit = 50): KnowledgeInjectionLog[] {
|
||||||
|
return this.injectionRepo.listForEntry(entryId, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,28 @@ export class KnowledgeService {
|
|||||||
return count
|
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 {
|
createFromBookmark(bookmarkId: string): KnowledgeEntry {
|
||||||
const bm = this.bookmarkRepo.get(bookmarkId)
|
const bm = this.bookmarkRepo.get(bookmarkId)
|
||||||
if (!bm) throw new Error('Bookmark not found')
|
if (!bm) throw new Error('Bookmark not found')
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { PomodoroState } from '../../shared/types'
|
||||||
|
import { PomodoroDailyRepository } from '../db/repositories/pomodoro-daily.repo'
|
||||||
|
import type { GoalNotificationService } from './goal-notification.service'
|
||||||
|
import type { GlobalSettingsService } from './global-settings'
|
||||||
|
import type { WritingLogService } from './writing-log.service'
|
||||||
|
import { IPC } from '../../shared/ipc-channels'
|
||||||
|
import { BrowserWindow } from 'electron'
|
||||||
|
|
||||||
|
export class PomodoroService {
|
||||||
|
private state: PomodoroState = { running: false, paused: false, remainingSec: 0, bookId: null }
|
||||||
|
private wordSnapshot = 0
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private writingLogs: WritingLogService,
|
||||||
|
private pomodoroDaily: PomodoroDailyRepository,
|
||||||
|
private settings: GlobalSettingsService,
|
||||||
|
private notify: GoalNotificationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getState(): PomodoroState {
|
||||||
|
return { ...this.state }
|
||||||
|
}
|
||||||
|
|
||||||
|
start(bookId: string): PomodoroState {
|
||||||
|
if (this.state.running && this.state.bookId !== bookId) {
|
||||||
|
this.notify.notifyPomodoroBookSwitch()
|
||||||
|
this.cancelInternal()
|
||||||
|
}
|
||||||
|
const duration = this.resolveDurationSec()
|
||||||
|
this.wordSnapshot = this.writingLogs.getStats(bookId).todayWords
|
||||||
|
this.state = { running: true, paused: false, remainingSec: duration, bookId }
|
||||||
|
this.startTimer()
|
||||||
|
this.pushTick()
|
||||||
|
return this.getState()
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): PomodoroState {
|
||||||
|
if (!this.state.running || this.state.paused) return this.getState()
|
||||||
|
this.state = { ...this.state, paused: true }
|
||||||
|
this.stopTimer()
|
||||||
|
return this.getState()
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(): PomodoroState {
|
||||||
|
if (!this.state.running || !this.state.paused) return this.getState()
|
||||||
|
this.state = { ...this.state, paused: false }
|
||||||
|
this.startTimer()
|
||||||
|
return this.getState()
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): PomodoroState {
|
||||||
|
this.cancelInternal()
|
||||||
|
return this.getState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelInternal(): void {
|
||||||
|
this.stopTimer()
|
||||||
|
this.state = { running: false, paused: false, remainingSec: 0, bookId: null }
|
||||||
|
this.wordSnapshot = 0
|
||||||
|
this.pushTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private complete(): void {
|
||||||
|
const bookId = this.state.bookId!
|
||||||
|
const delta = Math.max(
|
||||||
|
0,
|
||||||
|
this.writingLogs.getStats(bookId).todayWords - this.wordSnapshot
|
||||||
|
)
|
||||||
|
this.pomodoroDaily.recordComplete(bookId, delta)
|
||||||
|
this.notify.notifyPomodoroComplete(delta)
|
||||||
|
this.cancelInternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick(): void {
|
||||||
|
if (!this.state.running || this.state.paused) return
|
||||||
|
if (this.state.remainingSec <= 1) {
|
||||||
|
this.complete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.state = { ...this.state, remainingSec: this.state.remainingSec - 1 }
|
||||||
|
this.pushTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTimer(): void {
|
||||||
|
this.stopTimer()
|
||||||
|
this.timer = setInterval(() => this.tick(), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopTimer(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushTick(): void {
|
||||||
|
const wins = BrowserWindow.getAllWindows()
|
||||||
|
for (const win of wins) {
|
||||||
|
win.webContents.send(IPC.POMODORO_TICK, this.getState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDurationSec(): number {
|
||||||
|
if (process.env.BILIN_E2E === '1' && process.env.POMODORO_TEST_SEC) {
|
||||||
|
return Number(process.env.POMODORO_TEST_SEC) || 3
|
||||||
|
}
|
||||||
|
const mins = this.settings.get().pomodoroDurationMinutes ?? 25
|
||||||
|
return mins * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,59 @@
|
|||||||
|
import type { WritingStats } from '../../shared/types'
|
||||||
|
import { WritingLogRepository } from '../db/repositories/writing-log.repo'
|
||||||
|
import type { AchievementService } from './achievement.service'
|
||||||
|
import type { GlobalSettingsService } from './global-settings'
|
||||||
|
import type { GoalNotificationService } from './goal-notification.service'
|
||||||
|
import { addDays, localDateString } from './writing-date.util'
|
||||||
|
|
||||||
|
export class WritingLogService {
|
||||||
|
private achievementService: AchievementService | null = null
|
||||||
|
private goalNotify: GoalNotificationService | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private repo: WritingLogRepository,
|
||||||
|
private settings: GlobalSettingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
setGoalHooks(achievement: AchievementService, notify: GoalNotificationService): void {
|
||||||
|
this.achievementService = achievement
|
||||||
|
this.goalNotify = notify
|
||||||
|
}
|
||||||
|
|
||||||
|
addToday(bookId: string, delta: number): number {
|
||||||
|
const result = this.repo.addToday(bookId, delta)
|
||||||
|
if (delta !== 0) this.afterWordsChanged(bookId)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private afterWordsChanged(bookId: string): void {
|
||||||
|
const stats = this.getStats(bookId)
|
||||||
|
if (stats.dailyGoal > 0 && stats.todayWords >= stats.dailyGoal) {
|
||||||
|
this.goalNotify?.maybeNotifyDailyMet(stats.todayWords, stats.dailyGoal)
|
||||||
|
}
|
||||||
|
this.achievementService?.checkMilestones(bookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-2
@@ -44,7 +44,13 @@ import type {
|
|||||||
KnowledgeType,
|
KnowledgeType,
|
||||||
CockpitSummary,
|
CockpitSummary,
|
||||||
ChapterBridgeResult,
|
ChapterBridgeResult,
|
||||||
PublishStatus
|
PublishStatus,
|
||||||
|
WritingStats,
|
||||||
|
KnowledgeExtractionResult,
|
||||||
|
PomodoroState,
|
||||||
|
GoalNotificationPayload,
|
||||||
|
KnowledgeInjectionLog,
|
||||||
|
WritingAchievement
|
||||||
} from '../shared/types'
|
} from '../shared/types'
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
@@ -461,7 +467,43 @@ const electronAPI = {
|
|||||||
bookId: string,
|
bookId: string,
|
||||||
opts?: KnowledgeSuggestOptions
|
opts?: KnowledgeSuggestOptions
|
||||||
): Promise<IpcResult<ScoredKnowledge[]>> =>
|
): 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 }),
|
||||||
|
listInjections: (
|
||||||
|
bookId: string,
|
||||||
|
entryId: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<IpcResult<KnowledgeInjectionLog[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_LIST_INJECTIONS, { bookId, entryId, limit })
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
getStats: (bookId: string): Promise<IpcResult<WritingStats>> =>
|
||||||
|
ipcRenderer.invoke(IPC.WRITING_GET_STATS, { bookId })
|
||||||
|
},
|
||||||
|
pomodoro: {
|
||||||
|
start: (bookId: string): Promise<IpcResult<PomodoroState>> =>
|
||||||
|
ipcRenderer.invoke(IPC.POMODORO_START, { bookId }),
|
||||||
|
pause: (): Promise<IpcResult<PomodoroState>> => ipcRenderer.invoke(IPC.POMODORO_PAUSE),
|
||||||
|
resume: (): Promise<IpcResult<PomodoroState>> => ipcRenderer.invoke(IPC.POMODORO_RESUME),
|
||||||
|
cancel: (): Promise<IpcResult<PomodoroState>> => ipcRenderer.invoke(IPC.POMODORO_CANCEL),
|
||||||
|
getState: (): Promise<IpcResult<PomodoroState>> => ipcRenderer.invoke(IPC.POMODORO_GET_STATE)
|
||||||
|
},
|
||||||
|
achievement: {
|
||||||
|
list: (bookId: string): Promise<IpcResult<WritingAchievement[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.ACHIEVEMENT_LIST, { bookId })
|
||||||
},
|
},
|
||||||
cockpit: {
|
cockpit: {
|
||||||
getSummary: (bookId: string, volumeId?: string): Promise<IpcResult<CockpitSummary>> =>
|
getSummary: (bookId: string, volumeId?: string): Promise<IpcResult<CockpitSummary>> =>
|
||||||
@@ -523,6 +565,20 @@ const electronAPI = {
|
|||||||
}
|
}
|
||||||
ipcRenderer.on(IPC.INTERACTIVE_STREAM_CHUNK, handler)
|
ipcRenderer.on(IPC.INTERACTIVE_STREAM_CHUNK, handler)
|
||||||
return () => ipcRenderer.removeListener(IPC.INTERACTIVE_STREAM_CHUNK, handler)
|
return () => ipcRenderer.removeListener(IPC.INTERACTIVE_STREAM_CHUNK, handler)
|
||||||
|
},
|
||||||
|
onPomodoroTick: (callback: (state: PomodoroState) => void): (() => void) => {
|
||||||
|
const handler = (_event: IpcRendererEvent, state: PomodoroState) => {
|
||||||
|
callback(state)
|
||||||
|
}
|
||||||
|
ipcRenderer.on(IPC.POMODORO_TICK, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.POMODORO_TICK, handler)
|
||||||
|
},
|
||||||
|
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void): (() => void) => {
|
||||||
|
const handler = (_event: IpcRendererEvent, payload: GoalNotificationPayload) => {
|
||||||
|
callback(payload)
|
||||||
|
}
|
||||||
|
ipcRenderer.on(IPC.GOAL_NOTIFICATION, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.GOAL_NOTIFICATION, handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ function AppInner(): React.JSX.Element {
|
|||||||
if (loaded && !onboardingCompleted) setShowOnboarding(true)
|
if (loaded && !onboardingCompleted) setShowOnboarding(true)
|
||||||
}, [loaded, onboardingCompleted])
|
}, [loaded, onboardingCompleted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return window.electronAPI.onGoalNotification((payload) => {
|
||||||
|
showToast(t(payload.messageKey, payload.messageParams))
|
||||||
|
})
|
||||||
|
}, [showToast, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const openSearch = (): void => useSearchStore.getState().setOpen(true)
|
const openSearch = (): void => useSearchStore.getState().setOpen(true)
|
||||||
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
|
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import type { WritingMilestone } from '@shared/types'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||||
|
import { WritingHeatmap } from '@renderer/components/cockpit/WritingHeatmap'
|
||||||
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
|
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
|
||||||
|
|
||||||
interface CockpitModalProps {
|
interface CockpitModalProps {
|
||||||
onOpenBridge: (chapterId: string) => void
|
onOpenBridge: (chapterId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ALL_MILESTONES: WritingMilestone[] = ['streak_7', 'streak_30', 'streak_100']
|
||||||
|
|
||||||
|
const MILESTONE_I18N: Record<WritingMilestone, string> = {
|
||||||
|
streak_7: 'achievement.streak7',
|
||||||
|
streak_30: 'achievement.streak30',
|
||||||
|
streak_100: 'achievement.streak100'
|
||||||
|
}
|
||||||
|
|
||||||
export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Element | null {
|
export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Element | null {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const bookId = useBookStore((s) => s.currentBookId)
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
@@ -60,6 +70,7 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
|
|||||||
{loading || !summary ? (
|
{loading || !summary ? (
|
||||||
<div className="sidebar-empty">{t('cockpit.loading')}</div>
|
<div className="sidebar-empty">{t('cockpit.loading')}</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div className="cockpit-grid">
|
<div className="cockpit-grid">
|
||||||
<div className="cockpit-card" data-testid="cockpit-total-words">
|
<div className="cockpit-card" data-testid="cockpit-total-words">
|
||||||
<div className="cockpit-card-label">{t('cockpit.totalWords')}</div>
|
<div className="cockpit-card-label">{t('cockpit.totalWords')}</div>
|
||||||
@@ -86,6 +97,44 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 className="cockpit-pomodoro-today" data-testid="cockpit-pomodoro-today">
|
||||||
|
{t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
|
||||||
|
</div>
|
||||||
|
<div className="cockpit-achievements-section">
|
||||||
|
<div className="cockpit-heatmap-label">{t('cockpit.achievements')}</div>
|
||||||
|
<div className="cockpit-achievements" data-testid="cockpit-achievements">
|
||||||
|
{ALL_MILESTONES.map((milestone) => {
|
||||||
|
const unlocked = summary.achievements?.some((a) => a.milestone === milestone)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={milestone}
|
||||||
|
className={`cockpit-achievement-badge ${unlocked ? 'unlocked' : 'locked'}`}
|
||||||
|
title={t(MILESTONE_I18N[milestone])}
|
||||||
|
>
|
||||||
|
{milestone === 'streak_7' ? '7🔥' : milestone === 'streak_30' ? '30🔥' : '100🔥'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer cockpit-actions">
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import type {
|
|||||||
CreateKnowledgeInput,
|
CreateKnowledgeInput,
|
||||||
ForeshadowStatus,
|
ForeshadowStatus,
|
||||||
KnowledgeEntry,
|
KnowledgeEntry,
|
||||||
|
KnowledgeInjectionLog,
|
||||||
KnowledgeType
|
KnowledgeType
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
|
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
@@ -26,11 +28,18 @@ export function KnowledgeEditorDialog({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const bookId = useBookStore((s) => s.currentBookId)
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
const chapters = useBookStore((s) => s.chapters)
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
|
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||||
const entries = useKnowledgeStore((s) => s.entries)
|
const entries = useKnowledgeStore((s) => s.entries)
|
||||||
const create = useKnowledgeStore((s) => s.create)
|
const create = useKnowledgeStore((s) => s.create)
|
||||||
const update = useKnowledgeStore((s) => s.update)
|
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 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 [type, setType] = useState<KnowledgeType>('fact')
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
@@ -39,6 +48,17 @@ export function KnowledgeEditorDialog({
|
|||||||
const [foreshadowStatus, setForeshadowStatus] = useState<ForeshadowStatus>('buried')
|
const [foreshadowStatus, setForeshadowStatus] = useState<ForeshadowStatus>('buried')
|
||||||
const [sourceChapterId, setSourceChapterId] = useState('')
|
const [sourceChapterId, setSourceChapterId] = useState('')
|
||||||
const [approveNow, setApproveNow] = useState(false)
|
const [approveNow, setApproveNow] = useState(false)
|
||||||
|
const [injections, setInjections] = useState<KnowledgeInjectionLog[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !bookId || !entryId) {
|
||||||
|
setInjections([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void ipcCall(() => window.electronAPI.knowledge.listInjections(bookId, entryId)).then(
|
||||||
|
setInjections
|
||||||
|
)
|
||||||
|
}, [open, bookId, entryId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -177,8 +197,65 @@ export function KnowledgeEditorDialog({
|
|||||||
{t('knowledge.approveNow')}
|
{t('knowledge.approveNow')}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{existing && injections.length > 0 && (
|
||||||
|
<div className="knowledge-injection-history" data-testid="knowledge-injection-history">
|
||||||
|
<div className="kb-injection-label">{t('knowledge.injectionHistory')}</div>
|
||||||
|
{injections.map((log) => {
|
||||||
|
const chapterTitle =
|
||||||
|
chapters.find((c) => c.id === log.chapterId)?.title ?? t('knowledge.noChapter')
|
||||||
|
return (
|
||||||
|
<div key={log.id} className="kb-injection-row">
|
||||||
|
<span className="kb-injection-date">
|
||||||
|
{new Date(log.injectedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="kb-injection-mode">
|
||||||
|
{t(`knowledge.injectionMode.${log.flowMode}`)}
|
||||||
|
</span>
|
||||||
|
{log.chapterId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kb-injection-chapter-link"
|
||||||
|
onClick={() => {
|
||||||
|
void switchTarget({ kind: 'chapter', id: log.chapterId! })
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chapterTitle}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span>{chapterTitle}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<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}>
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
{t('dialog.cancel')}
|
{t('dialog.cancel')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
|
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
|
||||||
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,9 @@ import {
|
|||||||
export function KnowledgePanel(): React.JSX.Element {
|
export function KnowledgePanel(): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const bookId = useBookStore((s) => s.currentBookId)
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
|
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||||
|
const extractThreshold = useSettingsStore((s) => s.knowledgeExtractConfidenceThreshold)
|
||||||
const {
|
const {
|
||||||
entries,
|
entries,
|
||||||
stats,
|
stats,
|
||||||
@@ -25,8 +29,13 @@ export function KnowledgePanel(): React.JSX.Element {
|
|||||||
approve,
|
approve,
|
||||||
reject,
|
reject,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
batchApprove
|
batchApprove,
|
||||||
|
extractChapter,
|
||||||
|
batchApproveHighConfidence
|
||||||
} = useKnowledgeStore()
|
} = useKnowledgeStore()
|
||||||
|
const injectionPreviews = useKnowledgeStore((s) => s.injectionPreviews)
|
||||||
|
const loadInjectionPreviews = useKnowledgeStore((s) => s.loadInjectionPreviews)
|
||||||
|
const [extracting, setExtracting] = useState(false)
|
||||||
const [namingOpen, setNamingOpen] = useState(false)
|
const [namingOpen, setNamingOpen] = useState(false)
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [pendingCount, setPendingCount] = useState(0)
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
@@ -45,6 +54,14 @@ export function KnowledgePanel(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
}, [bookId, entries])
|
}, [bookId, entries])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bookId || entries.length === 0) return
|
||||||
|
void loadInjectionPreviews(
|
||||||
|
bookId,
|
||||||
|
entries.map((e) => e.id)
|
||||||
|
)
|
||||||
|
}, [bookId, entries, loadInjectionPreviews])
|
||||||
|
|
||||||
const handleBatchApprove = async (): Promise<void> => {
|
const handleBatchApprove = async (): Promise<void> => {
|
||||||
if (!bookId || selected.size === 0) return
|
if (!bookId || selected.size === 0) return
|
||||||
await batchApprove(bookId, [...selected])
|
await batchApprove(bookId, [...selected])
|
||||||
@@ -71,6 +88,30 @@ export function KnowledgePanel(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
{t('naming.open')}
|
{t('naming.open')}
|
||||||
</button>
|
</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>
|
||||||
<div className="knowledge-tabs">
|
<div className="knowledge-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -134,11 +175,49 @@ export function KnowledgePanel(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="kb-item-main">
|
<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">
|
<div className="kb-item-meta">
|
||||||
{t(`knowledge.type.${entry.type}`)} · {t(`knowledge.status.${entry.status}`)}
|
{t(`knowledge.type.${entry.type}`)} · {t(`knowledge.status.${entry.status}`)}
|
||||||
</div>
|
</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>}
|
{entry.content && <div className="kb-item-content">{entry.content}</div>}
|
||||||
|
{(injectionPreviews[entry.id]?.length ?? 0) > 0 && (
|
||||||
|
<div
|
||||||
|
className="kb-injection-preview"
|
||||||
|
data-testid={`knowledge-injection-preview-${entry.id}`}
|
||||||
|
>
|
||||||
|
<div className="kb-injection-label">{t('knowledge.injectionPreview')}</div>
|
||||||
|
{injectionPreviews[entry.id]!.map((log) => (
|
||||||
|
<div key={log.id} className="kb-injection-preview-row">
|
||||||
|
{t('knowledge.injectionEntry', {
|
||||||
|
date: new Date(log.injectedAt).toLocaleDateString(),
|
||||||
|
mode: t(`knowledge.injectionMode.${log.flowMode}`),
|
||||||
|
chapter:
|
||||||
|
chapters.find((c) => c.id === log.chapterId)?.title ??
|
||||||
|
t('knowledge.noChapter')
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="kb-item-actions">
|
<div className="kb-item-actions">
|
||||||
{entry.status === 'pending' && bookId && (
|
{entry.status === 'pending' && bookId && (
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useOutlineStore } from '@renderer/stores/useOutlineStore'
|
|||||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||||
|
import { useWritingStatsStore } from '@renderer/stores/useWritingStatsStore'
|
||||||
|
import { usePomodoroStore, formatPomodoroTime } from '@renderer/stores/usePomodoroStore'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||||
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||||
@@ -90,6 +92,18 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||||
const setTarget = useEditStore((s) => s.setTarget)
|
const setTarget = useEditStore((s) => s.setTarget)
|
||||||
const updateSchedule = useSettingsStore((s) => s.updateSchedule)
|
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 pomodoroRunning = usePomodoroStore((s) => s.running)
|
||||||
|
const pomodoroPaused = usePomodoroStore((s) => s.paused)
|
||||||
|
const pomodoroRemaining = usePomodoroStore((s) => s.remainingSec)
|
||||||
|
const pomodoroBookId = usePomodoroStore((s) => s.bookId)
|
||||||
|
const pomodoroInit = usePomodoroStore((s) => s.init)
|
||||||
|
const pomodoroStart = usePomodoroStore((s) => s.start)
|
||||||
|
const pomodoroPause = usePomodoroStore((s) => s.pause)
|
||||||
|
const pomodoroResume = usePomodoroStore((s) => s.resume)
|
||||||
|
const pomodoroCancel = usePomodoroStore((s) => s.cancel)
|
||||||
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
|
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
|
||||||
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
|
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
|
||||||
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||||
@@ -107,11 +121,21 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
const isChapterTarget = target?.kind === 'chapter'
|
const isChapterTarget = target?.kind === 'chapter'
|
||||||
const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId
|
const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void pomodoroInit()
|
||||||
|
}, [pomodoroInit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pomodoroRunning || !pomodoroBookId || !currentBookId) return
|
||||||
|
if (pomodoroBookId === currentBookId) return
|
||||||
|
void pomodoroCancel()
|
||||||
|
}, [currentBookId, pomodoroRunning, pomodoroBookId, pomodoroCancel])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedChapterId && (!target || target.kind === 'chapter')) {
|
if (selectedChapterId && (!target || target.kind === 'chapter')) {
|
||||||
setTarget({ kind: 'chapter', id: selectedChapterId })
|
setTarget({ kind: 'chapter', id: selectedChapterId })
|
||||||
}
|
}
|
||||||
}, [selectedChapterId, setTarget])
|
}, [selectedChapterId, setTarget, target])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentBookId || target?.kind !== 'chapter') return
|
if (!currentBookId || target?.kind !== 'chapter') return
|
||||||
@@ -122,6 +146,11 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [currentBookId, target?.kind, target?.id])
|
}, [currentBookId, target?.kind, target?.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentBookId) return
|
||||||
|
void refreshWritingStats(currentBookId)
|
||||||
|
}, [currentBookId, lastSaved, refreshWritingStats])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentBookId) return
|
if (!currentBookId) return
|
||||||
void ipcCall(() =>
|
void ipcCall(() =>
|
||||||
@@ -344,6 +373,19 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
{activePanel === 'chapters' && (
|
{activePanel === 'chapters' && (
|
||||||
<div className="sidebar-footer">
|
<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()}>
|
<button type="button" onClick={() => void handleNewChapter()}>
|
||||||
+ {t('editor.newChapter')}
|
+ {t('editor.newChapter')}
|
||||||
</button>
|
</button>
|
||||||
@@ -404,7 +446,57 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
bookId={currentBookId}
|
bookId={currentBookId}
|
||||||
/>
|
/>
|
||||||
<div id="editor-statusbar">
|
<div id="editor-statusbar">
|
||||||
|
<div className="pomodoro-control" data-testid="pomodoro-control">
|
||||||
|
{!pomodoroRunning ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pomodoro-btn"
|
||||||
|
title={t('pomodoro.start')}
|
||||||
|
disabled={!currentBookId}
|
||||||
|
onClick={() => currentBookId && void pomodoroStart(currentBookId)}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pomodoro-btn"
|
||||||
|
title={pomodoroPaused ? t('pomodoro.resume') : t('pomodoro.pause')}
|
||||||
|
onClick={() => void (pomodoroPaused ? pomodoroResume() : pomodoroPause())}
|
||||||
|
>
|
||||||
|
{pomodoroPaused ? '▶' : '⏸'}
|
||||||
|
</button>
|
||||||
|
<span className="pomodoro-time">{formatPomodoroTime(pomodoroRemaining)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pomodoro-btn"
|
||||||
|
title={t('pomodoro.cancel')}
|
||||||
|
onClick={() => void pomodoroCancel()}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
<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 && (
|
{showStockWarning && (
|
||||||
<span className="status-stock-warning" data-testid="status-stock-warning">
|
<span className="status-stock-warning" data-testid="status-stock-warning">
|
||||||
{t('stock.warning', {
|
{t('stock.warning', {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { ThemeId, Language } from '@shared/types'
|
import type { ThemeId, Language, PomodoroDuration } from '@shared/types'
|
||||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
||||||
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
||||||
@@ -131,13 +131,104 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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.pomodoroDuration')}</span>
|
||||||
|
<select
|
||||||
|
data-testid="settings-pomodoro-duration"
|
||||||
|
className="form-control form-control--narrow"
|
||||||
|
value={settings.pomodoroDurationMinutes ?? 25}
|
||||||
|
onChange={(e) =>
|
||||||
|
void settings.update({
|
||||||
|
pomodoroDurationMinutes: Number(e.target.value) as PomodoroDuration
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value={15}>15</option>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={45}>45</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="setting-item">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="settings-system-notifications"
|
||||||
|
checked={settings.enableSystemNotifications === true}
|
||||||
|
onChange={(e) =>
|
||||||
|
void settings.update({ enableSystemNotifications: e.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('settings.systemNotifications')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="setting-item">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="settings-goal-notifications"
|
||||||
|
checked={settings.enableGoalNotifications !== false}
|
||||||
|
onChange={(e) =>
|
||||||
|
void settings.update({ enableGoalNotifications: e.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('settings.goalNotifications')}
|
||||||
|
</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 === 'ai' && <AiSettingsPage />}
|
||||||
{section === 'shortcuts' && <ShortcutEditor />}
|
{section === 'shortcuts' && <ShortcutEditor />}
|
||||||
{section === 'about' && (
|
{section === 'about' && (
|
||||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
{t('app.name')} v0.7.0
|
{t('app.name')} v0.9.0
|
||||||
<br />
|
<br />
|
||||||
{t('app.tagline')}
|
{t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from 'zustand'
|
|||||||
import type {
|
import type {
|
||||||
CreateKnowledgeInput,
|
CreateKnowledgeInput,
|
||||||
KnowledgeEntry,
|
KnowledgeEntry,
|
||||||
|
KnowledgeInjectionLog,
|
||||||
KnowledgeStatus,
|
KnowledgeStatus,
|
||||||
KnowledgeType
|
KnowledgeType
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
@@ -17,7 +18,9 @@ interface KnowledgeState {
|
|||||||
forgottenOnly: boolean
|
forgottenOnly: boolean
|
||||||
editorOpen: boolean
|
editorOpen: boolean
|
||||||
editingId: string | null
|
editingId: string | null
|
||||||
|
injectionPreviews: Record<string, KnowledgeInjectionLog[]>
|
||||||
load: (bookId: string) => Promise<void>
|
load: (bookId: string) => Promise<void>
|
||||||
|
loadInjectionPreviews: (bookId: string, entryIds: string[]) => Promise<void>
|
||||||
setTab: (tab: KnowledgeTab) => void
|
setTab: (tab: KnowledgeTab) => void
|
||||||
openForgottenFilter: () => void
|
openForgottenFilter: () => void
|
||||||
openEditor: (id?: string | null) => void
|
openEditor: (id?: string | null) => void
|
||||||
@@ -32,6 +35,10 @@ interface KnowledgeState {
|
|||||||
approve: (bookId: string, id: string) => Promise<void>
|
approve: (bookId: string, id: string) => Promise<void>
|
||||||
reject: (bookId: string, id: string) => Promise<void>
|
reject: (bookId: string, id: string) => Promise<void>
|
||||||
batchApprove: (bookId: string, ids: 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[]
|
filteredEntries: () => KnowledgeEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +49,7 @@ export const useKnowledgeStore = create<KnowledgeState>((set, get) => ({
|
|||||||
forgottenOnly: false,
|
forgottenOnly: false,
|
||||||
editorOpen: false,
|
editorOpen: false,
|
||||||
editingId: null,
|
editingId: null,
|
||||||
|
injectionPreviews: {},
|
||||||
load: async (bookId) => {
|
load: async (bookId) => {
|
||||||
const [entries, stats] = await Promise.all([
|
const [entries, stats] = await Promise.all([
|
||||||
ipcCall(() => window.electronAPI.knowledge.list(bookId)),
|
ipcCall(() => window.electronAPI.knowledge.list(bookId)),
|
||||||
@@ -49,6 +57,21 @@ export const useKnowledgeStore = create<KnowledgeState>((set, get) => ({
|
|||||||
])
|
])
|
||||||
set({ entries, stats })
|
set({ entries, stats })
|
||||||
},
|
},
|
||||||
|
loadInjectionPreviews: async (bookId, entryIds) => {
|
||||||
|
if (entryIds.length === 0) {
|
||||||
|
set({ injectionPreviews: {} })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pairs = await Promise.all(
|
||||||
|
entryIds.map(async (id) => {
|
||||||
|
const logs = await ipcCall(() =>
|
||||||
|
window.electronAPI.knowledge.listInjections(bookId, id, 3)
|
||||||
|
)
|
||||||
|
return [id, logs] as const
|
||||||
|
})
|
||||||
|
)
|
||||||
|
set({ injectionPreviews: Object.fromEntries(pairs) })
|
||||||
|
},
|
||||||
setTab: (tab) => set({ tab, forgottenOnly: tab === 'foreshadow' ? get().forgottenOnly : false }),
|
setTab: (tab) => set({ tab, forgottenOnly: tab === 'foreshadow' ? get().forgottenOnly : false }),
|
||||||
openForgottenFilter: () => {
|
openForgottenFilter: () => {
|
||||||
useAppStore.getState().setRightPanel('knowledge')
|
useAppStore.getState().setRightPanel('knowledge')
|
||||||
@@ -82,6 +105,22 @@ export const useKnowledgeStore = create<KnowledgeState>((set, get) => ({
|
|||||||
await ipcCall(() => window.electronAPI.knowledge.batchApprove(bookId, ids))
|
await ipcCall(() => window.electronAPI.knowledge.batchApprove(bookId, ids))
|
||||||
await get().load(bookId)
|
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: () => {
|
filteredEntries: () => {
|
||||||
const { entries, tab, forgottenOnly } = get()
|
const { entries, tab, forgottenOnly } = get()
|
||||||
let list = entries
|
let list = entries
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { PomodoroState } from '@shared/types'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
interface PomodoroStore extends PomodoroState {
|
||||||
|
tickUnsub: (() => void) | null
|
||||||
|
init: () => Promise<void>
|
||||||
|
start: (bookId: string) => Promise<void>
|
||||||
|
pause: () => Promise<void>
|
||||||
|
resume: () => Promise<void>
|
||||||
|
cancel: () => Promise<void>
|
||||||
|
applyState: (state: PomodoroState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle: PomodoroState = { running: false, paused: false, remainingSec: 0, bookId: null }
|
||||||
|
|
||||||
|
export const usePomodoroStore = create<PomodoroStore>((set, get) => ({
|
||||||
|
...idle,
|
||||||
|
tickUnsub: null,
|
||||||
|
init: async () => {
|
||||||
|
const state = await ipcCall(() => window.electronAPI.pomodoro.getState())
|
||||||
|
set(state)
|
||||||
|
if (!get().tickUnsub) {
|
||||||
|
const unsub = window.electronAPI.onPomodoroTick((next) => {
|
||||||
|
set(next)
|
||||||
|
})
|
||||||
|
set({ tickUnsub: unsub })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applyState: (state) => set(state),
|
||||||
|
start: async (bookId) => {
|
||||||
|
const state = await ipcCall(() => window.electronAPI.pomodoro.start(bookId))
|
||||||
|
set(state)
|
||||||
|
},
|
||||||
|
pause: async () => {
|
||||||
|
const state = await ipcCall(() => window.electronAPI.pomodoro.pause())
|
||||||
|
set(state)
|
||||||
|
},
|
||||||
|
resume: async () => {
|
||||||
|
const state = await ipcCall(() => window.electronAPI.pomodoro.resume())
|
||||||
|
set(state)
|
||||||
|
},
|
||||||
|
cancel: async () => {
|
||||||
|
const state = await ipcCall(() => window.electronAPI.pomodoro.cancel())
|
||||||
|
set(state)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function formatPomodoroTime(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = sec % 60
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
namingIgnoreWords: [] as string[],
|
namingIgnoreWords: [] as string[],
|
||||||
knowledgeAutoSuggest: true,
|
knowledgeAutoSuggest: true,
|
||||||
knowledgeSuggestTopN: 8,
|
knowledgeSuggestTopN: 8,
|
||||||
|
knowledgeAutoExtract: true,
|
||||||
|
knowledgeExtractConfidenceThreshold: 0.8,
|
||||||
|
pomodoroDurationMinutes: 25,
|
||||||
|
enableSystemNotifications: false,
|
||||||
|
enableGoalNotifications: true,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
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,164 @@
|
|||||||
gap: 8px;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pomodoro-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pomodoro-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pomodoro-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pomodoro-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pomodoro-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 42px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-achievements-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-achievements {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-achievement-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-achievement-badge.unlocked {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-achievement-badge.locked {
|
||||||
|
opacity: 0.35;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-pomodoro-today {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-injection-preview,
|
||||||
|
.knowledge-injection-history {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-injection-label {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-injection-preview-row,
|
||||||
|
.kb-injection-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-injection-chapter-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent-light);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.bridge-body {
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
Vendored
+35
-1
@@ -42,7 +42,14 @@ import type {
|
|||||||
KnowledgeType,
|
KnowledgeType,
|
||||||
CockpitSummary,
|
CockpitSummary,
|
||||||
ChapterBridgeResult,
|
ChapterBridgeResult,
|
||||||
PublishStatus
|
PublishStatus,
|
||||||
|
WritingStats,
|
||||||
|
KnowledgeExtractionResult,
|
||||||
|
PomodoroState,
|
||||||
|
GoalNotificationPayload,
|
||||||
|
KnowledgeInjectionLog,
|
||||||
|
WritingAchievement,
|
||||||
|
PomodoroDuration
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
@@ -349,6 +356,31 @@ export interface ElectronAPI {
|
|||||||
bookId: string,
|
bookId: string,
|
||||||
opts?: KnowledgeSuggestOptions
|
opts?: KnowledgeSuggestOptions
|
||||||
) => Promise<IpcResult<ScoredKnowledge[]>>
|
) => 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>>
|
||||||
|
listInjections: (
|
||||||
|
bookId: string,
|
||||||
|
entryId: string,
|
||||||
|
limit?: number
|
||||||
|
) => Promise<IpcResult<KnowledgeInjectionLog[]>>
|
||||||
|
}
|
||||||
|
writing: {
|
||||||
|
getStats: (bookId: string) => Promise<IpcResult<WritingStats>>
|
||||||
|
}
|
||||||
|
pomodoro: {
|
||||||
|
start: (bookId: string) => Promise<IpcResult<PomodoroState>>
|
||||||
|
pause: () => Promise<IpcResult<PomodoroState>>
|
||||||
|
resume: () => Promise<IpcResult<PomodoroState>>
|
||||||
|
cancel: () => Promise<IpcResult<PomodoroState>>
|
||||||
|
getState: () => Promise<IpcResult<PomodoroState>>
|
||||||
|
}
|
||||||
|
achievement: {
|
||||||
|
list: (bookId: string) => Promise<IpcResult<WritingAchievement[]>>
|
||||||
}
|
}
|
||||||
cockpit: {
|
cockpit: {
|
||||||
getSummary: (bookId: string, volumeId?: string) => Promise<IpcResult<CockpitSummary>>
|
getSummary: (bookId: string, volumeId?: string) => Promise<IpcResult<CockpitSummary>>
|
||||||
@@ -378,6 +410,8 @@ export interface ElectronAPI {
|
|||||||
onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void) => () => void
|
onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void) => () => void
|
||||||
onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void) => () => void
|
onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void) => () => void
|
||||||
onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void) => () => void
|
onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void) => () => void
|
||||||
|
onPomodoroTick: (callback: (state: PomodoroState) => void) => () => void
|
||||||
|
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -112,6 +112,20 @@ export const IPC = {
|
|||||||
KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark',
|
KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark',
|
||||||
KNOWLEDGE_STATS: 'knowledge:stats',
|
KNOWLEDGE_STATS: 'knowledge:stats',
|
||||||
KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext',
|
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',
|
||||||
|
POMODORO_START: 'pomodoro:start',
|
||||||
|
POMODORO_PAUSE: 'pomodoro:pause',
|
||||||
|
POMODORO_RESUME: 'pomodoro:resume',
|
||||||
|
POMODORO_CANCEL: 'pomodoro:cancel',
|
||||||
|
POMODORO_GET_STATE: 'pomodoro:getState',
|
||||||
|
POMODORO_TICK: 'pomodoro:tick',
|
||||||
|
ACHIEVEMENT_LIST: 'achievement:list',
|
||||||
|
KNOWLEDGE_LIST_INJECTIONS: 'knowledge:listInjections',
|
||||||
|
GOAL_NOTIFICATION: 'goal:notification',
|
||||||
COCKPIT_SUMMARY: 'cockpit:summary',
|
COCKPIT_SUMMARY: 'cockpit:summary',
|
||||||
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
|
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
|
||||||
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
|
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export interface KnowledgeEntry {
|
|||||||
sourceChapterId?: string
|
sourceChapterId?: string
|
||||||
lastMentionChapterId?: string
|
lastMentionChapterId?: string
|
||||||
linkedBookmarkId?: string
|
linkedBookmarkId?: string
|
||||||
|
extractionConfidence?: number
|
||||||
|
mergeTargetId?: string
|
||||||
|
extractionBatchId?: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -90,6 +93,71 @@ export interface CockpitSummary {
|
|||||||
foreshadowBuried: number
|
foreshadowBuried: number
|
||||||
foreshadowResolved: number
|
foreshadowResolved: number
|
||||||
foreshadowForgotten: number
|
foreshadowForgotten: number
|
||||||
|
writingStats?: WritingStats
|
||||||
|
achievements?: WritingAchievement[]
|
||||||
|
pomodoroTodayCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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 type PomodoroDuration = 15 | 25 | 45
|
||||||
|
export type WritingMilestone = 'streak_7' | 'streak_30' | 'streak_100'
|
||||||
|
export type InjectionFlowMode = 'interactive' | 'auto' | 'wizard'
|
||||||
|
|
||||||
|
export interface PomodoroState {
|
||||||
|
running: boolean
|
||||||
|
paused: boolean
|
||||||
|
remainingSec: number
|
||||||
|
bookId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritingAchievement {
|
||||||
|
milestone: WritingMilestone
|
||||||
|
unlockedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeInjectionLog {
|
||||||
|
id: string
|
||||||
|
knowledgeEntryId: string
|
||||||
|
flowMode: InjectionFlowMode
|
||||||
|
flowId: string
|
||||||
|
chapterId?: string
|
||||||
|
injectedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalNotificationPayload {
|
||||||
|
kind: 'daily_met' | 'milestone' | 'pomodoro_complete' | 'pomodoro_cancelled_book_switch'
|
||||||
|
messageKey: string
|
||||||
|
messageParams?: Record<string, string | number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChapterBridgeResult {
|
export interface ChapterBridgeResult {
|
||||||
@@ -134,6 +202,12 @@ export interface GlobalSettings {
|
|||||||
namingIgnoreWords: string[]
|
namingIgnoreWords: string[]
|
||||||
knowledgeAutoSuggest?: boolean
|
knowledgeAutoSuggest?: boolean
|
||||||
knowledgeSuggestTopN?: number
|
knowledgeSuggestTopN?: number
|
||||||
|
knowledgeAutoExtract?: boolean
|
||||||
|
knowledgeExtractConfidenceThreshold?: number
|
||||||
|
pomodoroDurationMinutes?: PomodoroDuration
|
||||||
|
enableSystemNotifications?: boolean
|
||||||
|
enableGoalNotifications?: boolean
|
||||||
|
dailyGoalNotifiedDate?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookMeta {
|
export interface BookMeta {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
BrowserWindow: {
|
||||||
|
getAllWindows: () => []
|
||||||
|
},
|
||||||
|
Notification: {
|
||||||
|
isSupported: () => false
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo'
|
||||||
|
import { WritingMilestoneRepository } from '../../src/main/db/repositories/writing-milestone.repo'
|
||||||
|
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||||
|
import { WritingLogService } from '../../src/main/services/writing-log.service'
|
||||||
|
import { GoalNotificationService } from '../../src/main/services/goal-notification.service'
|
||||||
|
import { AchievementService } from '../../src/main/services/achievement.service'
|
||||||
|
import { addDays, localDateString } from '../../src/main/services/writing-date.util'
|
||||||
|
|
||||||
|
describe('WritingMilestoneRepository', () => {
|
||||||
|
let userDir: string
|
||||||
|
let repo: WritingLogRepository
|
||||||
|
let milestoneRepo: WritingMilestoneRepository
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo?.close()
|
||||||
|
if (userDir) rmSync(userDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-ACH-01: unlock streak_7 once', () => {
|
||||||
|
userDir = mkdtempSync(join(tmpdir(), 'bilin-ach-'))
|
||||||
|
repo = new WritingLogRepository(userDir)
|
||||||
|
milestoneRepo = new WritingMilestoneRepository(repo.getDb())
|
||||||
|
milestoneRepo.unlock('b1', 'streak_7')
|
||||||
|
expect(milestoneRepo.has('b1', 'streak_7')).toBe(true)
|
||||||
|
expect(milestoneRepo.unlock('b1', 'streak_7')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AchievementService', () => {
|
||||||
|
let userDir: string
|
||||||
|
let settingsDir: string
|
||||||
|
let settings: GlobalSettingsService
|
||||||
|
let writingRepo: WritingLogRepository
|
||||||
|
let writingLogs: WritingLogService
|
||||||
|
let milestoneRepo: WritingMilestoneRepository
|
||||||
|
let notify: GoalNotificationService
|
||||||
|
let achievement: AchievementService
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
writingRepo?.close()
|
||||||
|
if (userDir) rmSync(userDir, { recursive: true, force: true })
|
||||||
|
if (settingsDir) rmSync(settingsDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function setup(goal = 100): void {
|
||||||
|
userDir = mkdtempSync(join(tmpdir(), 'bilin-ach-svc-'))
|
||||||
|
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-ach-settings-'))
|
||||||
|
settings = new GlobalSettingsService(settingsDir)
|
||||||
|
settings.update({
|
||||||
|
...settings.get(),
|
||||||
|
dailyWordGoal: goal,
|
||||||
|
enableGoalNotifications: false
|
||||||
|
})
|
||||||
|
writingRepo = new WritingLogRepository(userDir)
|
||||||
|
writingLogs = new WritingLogService(writingRepo, settings)
|
||||||
|
milestoneRepo = new WritingMilestoneRepository(writingRepo.getDb())
|
||||||
|
notify = new GoalNotificationService(settings)
|
||||||
|
achievement = new AchievementService(milestoneRepo, writingLogs, settings, notify)
|
||||||
|
writingLogs.setGoalHooks(achievement, notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('UT-ACH-02: streakDays=7 unlocks milestone and notifies once', () => {
|
||||||
|
setup(100)
|
||||||
|
const bookId = 'book-1'
|
||||||
|
const today = localDateString()
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
writingRepo.addToday(bookId, 150, addDays(today, -i))
|
||||||
|
}
|
||||||
|
writingRepo.addToday(bookId, 120, today)
|
||||||
|
vi.spyOn(notify, 'notifyMilestone')
|
||||||
|
writingLogs.addToday(bookId, 1)
|
||||||
|
expect(milestoneRepo.has(bookId, 'streak_7')).toBe(true)
|
||||||
|
expect(notify.notifyMilestone).toHaveBeenCalledTimes(1)
|
||||||
|
writingLogs.addToday(bookId, 1)
|
||||||
|
expect(notify.notifyMilestone).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||||
|
import { KnowledgeInjectionRepository } from '../../src/main/db/repositories/knowledge-injection.repo'
|
||||||
|
import { KnowledgeInjectionService } from '../../src/main/services/knowledge-injection.service'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('KnowledgeInjectionRepository', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-INJ-01: insertBatch creates rows listable by entry', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const knowledge = new KnowledgeRepository(db)
|
||||||
|
const entry = knowledge.create({ type: 'fact', title: '测试', status: 'approved' })
|
||||||
|
const repo = new KnowledgeInjectionRepository(db)
|
||||||
|
repo.insertBatch([
|
||||||
|
{ knowledgeEntryId: entry.id, flowMode: 'wizard', flowId: 'flow-1', chapterId: 'ch-1' },
|
||||||
|
{ knowledgeEntryId: entry.id, flowMode: 'interactive', flowId: 'flow-2' },
|
||||||
|
{ knowledgeEntryId: entry.id, flowMode: 'auto', flowId: 'flow-3', chapterId: 'ch-2' }
|
||||||
|
])
|
||||||
|
const logs = repo.listForEntry(entry.id)
|
||||||
|
expect(logs).toHaveLength(3)
|
||||||
|
expect(logs.map((l) => l.flowMode).sort()).toEqual(['auto', 'interactive', 'wizard'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('KnowledgeInjectionService', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('IT-INJ-01: record only approved knowledge entries', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const knowledge = new KnowledgeRepository(db)
|
||||||
|
const approved = knowledge.create({ type: 'fact', title: '已审', status: 'approved' })
|
||||||
|
const pending = knowledge.create({ type: 'fact', title: '待审', status: 'pending' })
|
||||||
|
const service = new KnowledgeInjectionService(db)
|
||||||
|
service.record({
|
||||||
|
knowledgeIds: [approved.id, pending.id],
|
||||||
|
flowMode: 'interactive',
|
||||||
|
flowId: 'flow-x',
|
||||||
|
chapterId: 'ch-1'
|
||||||
|
})
|
||||||
|
const logs = service.listForEntry(approved.id)
|
||||||
|
expect(logs).toHaveLength(1)
|
||||||
|
expect(logs[0].flowMode).toBe('interactive')
|
||||||
|
expect(service.listForEntry(pending.id)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -58,4 +58,29 @@ describe('KnowledgeService', () => {
|
|||||||
expect(service.batchApprove(ids)).toBe(3)
|
expect(service.batchApprove(ids)).toBe(3)
|
||||||
expect(service.countPending()).toBe(0)
|
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)
|
expect(tableExists(db, 'search_fts')).toBe(true)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
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(8)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates existing v1 database to v2', () => {
|
it('migrates existing v1 database to v2', () => {
|
||||||
@@ -47,7 +47,7 @@ describe('migrate v2', () => {
|
|||||||
|
|
||||||
expect(tableExists(db, 'outline_items')).toBe(true)
|
expect(tableExists(db, 'outline_items')).toBe(true)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
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(8)
|
||||||
|
|
||||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||||
const colNames = cols.map((c) => c.name)
|
const colNames = cols.map((c) => c.name)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
|
|||||||
expect(tableExists(db, 'ai_messages')).toBe(true)
|
expect(tableExists(db, 'ai_messages')).toBe(true)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
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(8)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates existing v2 database to v3', () => {
|
it('migrates existing v2 database to v3', () => {
|
||||||
@@ -49,6 +49,6 @@ describe('migrate v3', () => {
|
|||||||
expect(tables.map((t) => t.name)).toContain('ai_messages')
|
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 }
|
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(8)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe('migrate v4', () => {
|
|||||||
migrate(db)
|
migrate(db)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
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(8)
|
||||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||||
expect(tableExists(db, 'interactive_scenes')).toBe(true)
|
expect(tableExists(db, 'interactive_scenes')).toBe(true)
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ describe('migrate v4', () => {
|
|||||||
migrate(db)
|
migrate(db)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
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(8)
|
||||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('migrate v5', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
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(8)
|
||||||
const cols = db.prepare('PRAGMA table_info(interactive_flows)').all() as { name: string }[]
|
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 === 'flow_mode')).toBe(true)
|
||||||
expect(cols.some((c) => c.name === 'mode_config_json')).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()
|
db.prepare('INSERT INTO schema_version (version) VALUES (4)').run()
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
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(8)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('migrate v6', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
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(8)
|
||||||
const tables = db
|
const tables = db
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
.all() as { name: string }[]
|
.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(8)
|
||||||
|
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,27 @@
|
|||||||
|
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 v8', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-MIG-08: v7 database migrates to v8 with injection table', () => {
|
||||||
|
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(8)
|
||||||
|
const table = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_injection_logs'"
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
expect(table).toBeTruthy()
|
||||||
|
const cols = db.prepare('PRAGMA table_info(knowledge_injection_logs)').all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
expect(cols.some((c) => c.name === 'knowledge_entry_id')).toBe(true)
|
||||||
|
expect(cols.some((c) => c.name === 'flow_mode')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
BrowserWindow: {
|
||||||
|
getAllWindows: () => []
|
||||||
|
},
|
||||||
|
Notification: {
|
||||||
|
isSupported: () => false
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo'
|
||||||
|
import { PomodoroDailyRepository } from '../../src/main/db/repositories/pomodoro-daily.repo'
|
||||||
|
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||||
|
import { WritingLogService } from '../../src/main/services/writing-log.service'
|
||||||
|
import { GoalNotificationService } from '../../src/main/services/goal-notification.service'
|
||||||
|
import { PomodoroService } from '../../src/main/services/pomodoro.service'
|
||||||
|
|
||||||
|
describe('PomodoroService', () => {
|
||||||
|
let userDir: string
|
||||||
|
let settingsDir: string
|
||||||
|
let settings: GlobalSettingsService
|
||||||
|
let writingRepo: WritingLogRepository
|
||||||
|
let writingLogs: WritingLogService
|
||||||
|
let pomodoroDaily: PomodoroDailyRepository
|
||||||
|
let notify: GoalNotificationService
|
||||||
|
let pomodoro: PomodoroService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
process.env.BILIN_E2E = '1'
|
||||||
|
process.env.POMODORO_TEST_SEC = '3'
|
||||||
|
userDir = mkdtempSync(join(tmpdir(), 'bilin-pom-'))
|
||||||
|
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-pom-settings-'))
|
||||||
|
settings = new GlobalSettingsService(settingsDir)
|
||||||
|
writingRepo = new WritingLogRepository(userDir)
|
||||||
|
writingLogs = new WritingLogService(writingRepo, settings)
|
||||||
|
pomodoroDaily = new PomodoroDailyRepository(writingRepo.getDb())
|
||||||
|
notify = new GoalNotificationService(settings)
|
||||||
|
vi.spyOn(notify, 'notifyPomodoroComplete')
|
||||||
|
vi.spyOn(notify, 'notifyPomodoroBookSwitch')
|
||||||
|
pomodoro = new PomodoroService(writingLogs, pomodoroDaily, settings, notify)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
delete process.env.POMODORO_TEST_SEC
|
||||||
|
writingRepo?.close()
|
||||||
|
if (userDir) rmSync(userDir, { recursive: true, force: true })
|
||||||
|
if (settingsDir) rmSync(settingsDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-POM-01: complete records word delta in pomodoro_daily', () => {
|
||||||
|
const bookId = 'book-1'
|
||||||
|
writingLogs.addToday(bookId, 100)
|
||||||
|
pomodoro.start(bookId)
|
||||||
|
writingLogs.addToday(bookId, 50)
|
||||||
|
vi.advanceTimersByTime(3000)
|
||||||
|
expect(pomodoroDaily.getTodayCount(bookId)).toBe(1)
|
||||||
|
expect(notify.notifyPomodoroComplete).toHaveBeenCalledWith(50)
|
||||||
|
expect(pomodoro.getState().running).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-POM-02: start different book cancels previous', () => {
|
||||||
|
pomodoro.start('book-a')
|
||||||
|
expect(pomodoro.getState().bookId).toBe('book-a')
|
||||||
|
pomodoro.start('book-b')
|
||||||
|
expect(notify.notifyPomodoroBookSwitch).toHaveBeenCalled()
|
||||||
|
expect(pomodoro.getState().bookId).toBe('book-b')
|
||||||
|
expect(pomodoro.getState().running).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