feat: ship v0.9.0 with pomodoro, achievements, and injection history

Add pomodoro timer with goal notifications, writing streak milestones, and knowledge injection logging with UI previews across AI writing flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 19:15:52 +08:00
parent 255f1a99a0
commit d4122c8f95
53 changed files with 1679 additions and 80 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# 笔临 (Bilin) # 笔临 (Bilin)
长篇创作智能协作平台 — Electron 桌面客户端 v0.8.0P5.2 写作日志 + AI 知识抽取 长篇创作智能协作平台 — Electron 桌面客户端 v0.9.0P5.3 番茄钟/成就 + P6.1 知识注入历史
## 功能概览(v0.8.0 ## 功能概览(v0.9.0
- 写作日志与热力图、连更统计、状态栏日更进度(P5.2) - 写作日志与热力图、连更统计、状态栏日更进度(P5.2)
- AI 章节知识自动抽取、合并审核、高置信度批量采纳(P5.2) - AI 章节知识自动抽取、合并审核、高置信度批量采纳(P5.2)
@@ -1,6 +1,6 @@
# 笔临 P5.3 + P6.1 写作目标 + 知识注入历史 Implementation Plan # 笔临 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 (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** 交付 v0.9.0:§8 番茄钟(15/25/45)、连更里程碑徽章(7/30/100)、日更/番茄/里程碑通知;P6.1 写作流三模式知识注入历史记录与知识库反向链接 UI。 **Goal:** 交付 v0.9.0:§8 番茄钟(15/25/45)、连更里程碑徽章(7/30/100)、日更/番茄/里程碑通知;P6.1 写作流三模式知识注入历史记录与知识库反向链接 UI。
@@ -64,7 +64,7 @@
- Modify: `src/main/services/global-settings.ts` - Modify: `src/main/services/global-settings.ts`
- Modify: `src/renderer/stores/useSettingsStore.ts` - Modify: `src/renderer/stores/useSettingsStore.ts`
- [ ] **Step 1: 扩展 types** - [x] **Step 1: 扩展 types**
```typescript ```typescript
export type PomodoroDuration = 15 | 25 | 45 export type PomodoroDuration = 15 | 25 | 45
@@ -115,7 +115,7 @@ enableGoalNotifications?: boolean
dailyGoalNotifiedDate?: string // YYYY-MM-DD,防重复达标 Toast dailyGoalNotifiedDate?: string // YYYY-MM-DD,防重复达标 Toast
``` ```
- [ ] **Step 2: IPC 通道** - [x] **Step 2: IPC 通道**
```typescript ```typescript
POMODORO_START: 'pomodoro:start', POMODORO_START: 'pomodoro:start',
@@ -129,7 +129,7 @@ POMODORO_TICK: 'pomodoro:tick', // main → renderer push
GOAL_NOTIFICATION: 'goal:notification', // main → renderer push GOAL_NOTIFICATION: 'goal:notification', // main → renderer push
``` ```
- [ ] **Step 3: global-settings 默认值** - [x] **Step 3: global-settings 默认值**
```typescript ```typescript
pomodoroDurationMinutes: 25, pomodoroDurationMinutes: 25,
@@ -137,9 +137,9 @@ enableSystemNotifications: false,
enableGoalNotifications: true, enableGoalNotifications: true,
``` ```
- [ ] **Step 4: useSettingsStore 同步** - [x] **Step 4: useSettingsStore 同步**
- [ ] **Step 5: build** - [x] **Step 5: build**
```bash ```bash
npm run build npm run build
@@ -155,7 +155,7 @@ npm run build
- Create: `src/main/db/repositories/pomodoro-daily.repo.ts` - Create: `src/main/db/repositories/pomodoro-daily.repo.ts`
- Create: `tests/main/achievement.test.ts`(先写 milestone repo 部分) - Create: `tests/main/achievement.test.ts`(先写 milestone repo 部分)
- [ ] **Step 1: writing-log.repo migrate 扩展** - [x] **Step 1: writing-log.repo migrate 扩展**
`migrate()` 追加: `migrate()` 追加:
@@ -171,7 +171,7 @@ CREATE TABLE IF NOT EXISTS pomodoro_daily (
); );
``` ```
- [ ] **Step 2: WritingMilestoneRepository** - [x] **Step 2: WritingMilestoneRepository**
```typescript ```typescript
export class WritingMilestoneRepository { export class WritingMilestoneRepository {
@@ -182,7 +182,7 @@ export class WritingMilestoneRepository {
} }
``` ```
- [ ] **Step 3: PomodoroDailyRepository** - [x] **Step 3: PomodoroDailyRepository**
```typescript ```typescript
recordComplete(bookId: string, wordDelta: number, date?: string): void { recordComplete(bookId: string, wordDelta: number, date?: string): void {
@@ -191,7 +191,7 @@ recordComplete(bookId: string, wordDelta: number, date?: string): void {
getTodayCount(bookId: string): number getTodayCount(bookId: string): number
``` ```
- [ ] **Step 4: UT-ACH repo smoke** - [x] **Step 4: UT-ACH repo smoke**
```typescript ```typescript
it('UT-ACH-01: unlock streak_7 once', () => { it('UT-ACH-01: unlock streak_7 once', () => {
@@ -201,7 +201,7 @@ it('UT-ACH-01: unlock streak_7 once', () => {
}) })
``` ```
- [ ] **Step 5: 运行** - [x] **Step 5: 运行**
```bash ```bash
npm run test -- tests/main/achievement.test.ts -t "UT-ACH-01" npm run test -- tests/main/achievement.test.ts -t "UT-ACH-01"
@@ -218,9 +218,9 @@ npm run test -- tests/main/achievement.test.ts -t "UT-ACH-01"
- Create: `tests/main/migrate-v8.test.ts` - Create: `tests/main/migrate-v8.test.ts`
- Create: `tests/main/knowledge-injection.test.ts` - Create: `tests/main/knowledge-injection.test.ts`
- [ ] **Step 1: schema-v8.sql**(见 spec §3.2 - [x] **Step 1: schema-v8.sql**(见 spec §3.2
- [ ] **Step 2: migrate.ts** - [x] **Step 2: migrate.ts**
```typescript ```typescript
import schemaV8 from './schema-v8.sql?raw' import schemaV8 from './schema-v8.sql?raw'
@@ -232,21 +232,21 @@ if (current < 8) {
} }
``` ```
- [ ] **Step 3: KnowledgeInjectionRepository** - [x] **Step 3: KnowledgeInjectionRepository**
```typescript ```typescript
insertBatch(rows: Array<Omit<KnowledgeInjectionLog, 'id' | 'injectedAt'>>): void insertBatch(rows: Array<Omit<KnowledgeInjectionLog, 'id' | 'injectedAt'>>): void
listForEntry(entryId: string, limit = 50): KnowledgeInjectionLog[] listForEntry(entryId: string, limit = 50): KnowledgeInjectionLog[]
``` ```
- [ ] **Step 4: UT-MIG-08 + UT-INJ-01** - [x] **Step 4: UT-MIG-08 + UT-INJ-01**
```typescript ```typescript
it('UT-MIG-08: v7 database migrates to v8 with injection table', () => { /* ... */ }) it('UT-MIG-08: v7 database migrates to v8 with injection table', () => { /* ... */ })
it('UT-INJ-01: insertBatch creates 3 rows', () => { /* ... */ }) it('UT-INJ-01: insertBatch creates 3 rows', () => { /* ... */ })
``` ```
- [ ] **Step 5: 运行** - [x] **Step 5: 运行**
```bash ```bash
npm run test -- tests/main/migrate-v8.test.ts tests/main/knowledge-injection.test.ts npm run test -- tests/main/migrate-v8.test.ts tests/main/knowledge-injection.test.ts
@@ -260,7 +260,7 @@ npm run test -- tests/main/migrate-v8.test.ts tests/main/knowledge-injection.tes
- Create: `src/main/services/pomodoro.service.ts` - Create: `src/main/services/pomodoro.service.ts`
- Create: `tests/main/pomodoro.test.ts` - Create: `tests/main/pomodoro.test.ts`
- [ ] **Step 1: PomodoroService 实现** - [x] **Step 1: PomodoroService 实现**
```typescript ```typescript
export class PomodoroService { export class PomodoroService {
@@ -304,14 +304,14 @@ export class PomodoroService {
} }
``` ```
- [ ] **Step 2: UT-POM-01 / UT-POM-02** - [x] **Step 2: UT-POM-01 / UT-POM-02**
```typescript ```typescript
it('UT-POM-01: complete records word delta', () => { /* mock writingLogs */ }) it('UT-POM-01: complete records word delta', () => { /* mock writingLogs */ })
it('UT-POM-02: start different book cancels previous', () => { /* ... */ }) it('UT-POM-02: start different book cancels previous', () => { /* ... */ })
``` ```
- [ ] **Step 3: 运行** - [x] **Step 3: 运行**
```bash ```bash
npm run test -- tests/main/pomodoro.test.ts npm run test -- tests/main/pomodoro.test.ts
@@ -327,7 +327,7 @@ npm run test -- tests/main/pomodoro.test.ts
- Modify: `src/main/services/writing-log.service.ts` - Modify: `src/main/services/writing-log.service.ts`
- Modify: `tests/main/achievement.test.ts` - Modify: `tests/main/achievement.test.ts`
- [ ] **Step 1: GoalNotificationService** - [x] **Step 1: GoalNotificationService**
```typescript ```typescript
export class GoalNotificationService { export class GoalNotificationService {
@@ -353,7 +353,7 @@ export class GoalNotificationService {
} }
``` ```
- [ ] **Step 2: AchievementService** - [x] **Step 2: AchievementService**
```typescript ```typescript
checkMilestones(bookId: string): WritingAchievement[] { checkMilestones(bookId: string): WritingAchievement[] {
@@ -372,7 +372,7 @@ checkMilestones(bookId: string): WritingAchievement[] {
} }
``` ```
- [ ] **Step 3: WritingLogService.addToday 扩展** - [x] **Step 3: WritingLogService.addToday 扩展**
`addToday` 返回后: `addToday` 返回后:
@@ -384,9 +384,9 @@ if (stats.dailyGoal > 0 && stats.todayWords >= stats.dailyGoal) {
this.achievementService.checkMilestones(bookId) this.achievementService.checkMilestones(bookId)
``` ```
- [ ] **Step 4: UT-ACH-02 + IT-GOAL-01** - [x] **Step 4: UT-ACH-02 + IT-GOAL-01**
- [ ] **Step 5: 运行** - [x] **Step 5: 运行**
```bash ```bash
npm run test -- tests/main/achievement.test.ts npm run test -- tests/main/achievement.test.ts
@@ -403,7 +403,7 @@ npm run test -- tests/main/achievement.test.ts
- Modify: `src/main/ipc/handlers/wizard.handler.ts` - Modify: `src/main/ipc/handlers/wizard.handler.ts`
- Modify: `tests/main/knowledge-injection.test.ts` - Modify: `tests/main/knowledge-injection.test.ts`
- [ ] **Step 1: KnowledgeInjectionService** - [x] **Step 1: KnowledgeInjectionService**
```typescript ```typescript
export class KnowledgeInjectionService { export class KnowledgeInjectionService {
@@ -424,7 +424,7 @@ export class KnowledgeInjectionService {
} }
``` ```
- [ ] **Step 2: interactive.handler 挂钩** - [x] **Step 2: interactive.handler 挂钩**
`INTERACTIVE_CONFIRM_CONTEXT``buildFlowContextPayload` 成功后: `INTERACTIVE_CONFIRM_CONTEXT``buildFlowContextPayload` 成功后:
@@ -441,13 +441,13 @@ try {
} }
``` ```
- [ ] **Step 3: auto / wizard 同理** - [x] **Step 3: auto / wizard 同理**
- [ ] **Step 4: IT-INJ-01** - [x] **Step 4: IT-INJ-01**
直接调用 handler 或 service + 内存 DB,断言 `knowledge_injection_logs` 行数。 直接调用 handler 或 service + 内存 DB,断言 `knowledge_injection_logs` 行数。
- [ ] **Step 5: 运行** - [x] **Step 5: 运行**
```bash ```bash
npm run test -- tests/main/knowledge-injection.test.ts npm run test -- tests/main/knowledge-injection.test.ts
@@ -466,9 +466,9 @@ npm run test -- tests/main/knowledge-injection.test.ts
- Modify: `src/preload/index.ts` - Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts` - Modify: `src/shared/electron-api.d.ts`
- [ ] **Step 1: pomodoro.handler + achievement.handler** - [x] **Step 1: pomodoro.handler + achievement.handler**
- [ ] **Step 2: knowledge.handler** - [x] **Step 2: knowledge.handler**
```typescript ```typescript
ipcMain.handle(IPC.KNOWLEDGE_LIST_INJECTIONS, (_e, { bookId, entryId, limit }) => ipcMain.handle(IPC.KNOWLEDGE_LIST_INJECTIONS, (_e, { bookId, entryId, limit }) =>
@@ -476,7 +476,7 @@ ipcMain.handle(IPC.KNOWLEDGE_LIST_INJECTIONS, (_e, { bookId, entryId, limit }) =
) )
``` ```
- [ ] **Step 3: register.ts 注入** - [x] **Step 3: register.ts 注入**
```typescript ```typescript
const goalNotify = new GoalNotificationService(settings, () => getMainWindow()) const goalNotify = new GoalNotificationService(settings, () => getMainWindow())
@@ -487,7 +487,7 @@ registerPomodoroHandlers(pomodoro)
registerAchievementHandlers(achievementService) registerAchievementHandlers(achievementService)
``` ```
- [ ] **Step 4: preload** - [x] **Step 4: preload**
```typescript ```typescript
pomodoro: { pomodoro: {
@@ -501,14 +501,14 @@ onPomodoroTick: (cb) => { ipcRenderer.on(IPC.POMODORO_TICK, cb); return () => ..
onGoalNotification: (cb) => { ipcRenderer.on(IPC.GOAL_NOTIFICATION, cb); return () => ... }, onGoalNotification: (cb) => { ipcRenderer.on(IPC.GOAL_NOTIFICATION, cb); return () => ... },
``` ```
- [ ] **Step 5: CockpitService 扩展** - [x] **Step 5: CockpitService 扩展**
```typescript ```typescript
achievements: this.achievementService.listAchievements(bookId), achievements: this.achievementService.listAchievements(bookId),
pomodoroTodayCount: this.pomodoroDaily.getTodayCount(bookId), pomodoroTodayCount: this.pomodoroDaily.getTodayCount(bookId),
``` ```
- [ ] **Step 6: build** - [x] **Step 6: build**
```bash ```bash
npm run build npm run build
@@ -524,11 +524,11 @@ npm run build
- Modify: `src/renderer/App.tsx`(注册 onGoalNotification 全局 Toast - Modify: `src/renderer/App.tsx`(注册 onGoalNotification 全局 Toast
- Modify: `src/renderer/styles/layout.css` - Modify: `src/renderer/styles/layout.css`
- [ ] **Step 1: usePomodoroStore** - [x] **Step 1: usePomodoroStore**
订阅 `onPomodoroTick`actions`start/pause/resume/cancel` 订阅 `onPomodoroTick`actions`start/pause/resume/cancel`
- [ ] **Step 2: EditorLayout 状态栏控件** - [x] **Step 2: EditorLayout 状态栏控件**
```tsx ```tsx
<div className="pomodoro-control" data-testid="pomodoro-control"> <div className="pomodoro-control" data-testid="pomodoro-control">
@@ -544,7 +544,7 @@ npm run build
</div> </div>
``` ```
- [ ] **Step 3: App.tsx goal notification** - [x] **Step 3: App.tsx goal notification**
```typescript ```typescript
useEffect(() => { useEffect(() => {
@@ -554,7 +554,7 @@ useEffect(() => {
}, []) }, [])
``` ```
- [ ] **Step 4: build 验证** - [x] **Step 4: build 验证**
```bash ```bash
npm run build npm run build
@@ -570,7 +570,7 @@ npm run build
- Modify: `public/locales/zh-CN/translation.json` - Modify: `public/locales/zh-CN/translation.json`
- Modify: `public/locales/en/translation.json` - Modify: `public/locales/en/translation.json`
- [ ] **Step 1: CockpitModal 成就区** - [x] **Step 1: CockpitModal 成就区**
```tsx ```tsx
{summary.achievements && summary.achievements.length > 0 && ( {summary.achievements && summary.achievements.length > 0 && (
@@ -581,13 +581,13 @@ npm run build
</div> </div>
``` ```
- [ ] **Step 2: SettingsPage 三项设置** - [x] **Step 2: SettingsPage 三项设置**
`settings-pomodoro-duration` / `settings-system-notifications` / `settings-goal-notifications` `settings-pomodoro-duration` / `settings-system-notifications` / `settings-goal-notifications`
- [ ] **Step 3: i18n**spec §7 全部键,中英) - [x] **Step 3: i18n**spec §7 全部键,中英)
- [ ] **Step 4: build** - [x] **Step 4: build**
```bash ```bash
npm run build npm run build
@@ -602,7 +602,7 @@ npm run build
- Modify: `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` - Modify: `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx`
- Modify: `src/renderer/stores/useKnowledgeStore.ts` - Modify: `src/renderer/stores/useKnowledgeStore.ts`
- [ ] **Step 1: KnowledgePanel 加载注入预览** - [x] **Step 1: KnowledgePanel 加载注入预览**
条目 render 时调用 `knowledge.listInjections(bookId, entry.id, 3)`(或 batch 加载 map)。 条目 render 时调用 `knowledge.listInjections(bookId, entry.id, 3)`(或 batch 加载 map)。
@@ -614,11 +614,11 @@ npm run build
</div> </div>
``` ```
- [ ] **Step 2: KnowledgeEditorDialog 完整历史** - [x] **Step 2: KnowledgeEditorDialog 完整历史**
`data-testid="knowledge-injection-history"`;章节名点击 `switchTarget({ kind:'chapter', id })` `data-testid="knowledge-injection-history"`;章节名点击 `switchTarget({ kind:'chapter', id })`
- [ ] **Step 3: build** - [x] **Step 3: build**
```bash ```bash
npm run build npm run build
@@ -635,17 +635,17 @@ npm run build
- Modify: `README.md` - Modify: `README.md`
- Modify: `tests/main/migrate-v7.test.ts` 等(若 CURRENT_VERSION→8,期望版本改为 8 - Modify: `tests/main/migrate-v7.test.ts` 等(若 CURRENT_VERSION→8,期望版本改为 8
- [ ] **Step 1: E2E-GOAL-01~03** - [x] **Step 1: E2E-GOAL-01~03**
- E2E-GOAL-01:设 dailyWordGoal=100,打字保存,断言 toast / status-daily-goal - E2E-GOAL-01:设 dailyWordGoal=100,打字保存,断言 toast / status-daily-goal
- E2E-GOAL-02:通过测试 hook 或 seed API 写入 streak_7 milestone,打开驾驶舱断言 `cockpit-achievements` - E2E-GOAL-02:通过测试 hook 或 seed API 写入 streak_7 milestone,打开驾驶舱断言 `cockpit-achievements`
- E2E-GOAL-03`POMODORO_TEST_SEC=3`,启动番茄,等待完成 Toast - E2E-GOAL-03`POMODORO_TEST_SEC=3`,启动番茄,等待完成 Toast
- [ ] **Step 2: E2E-INJ-01** - [x] **Step 2: E2E-INJ-01**
向导 confirm 知识上下文(可 stub 空 AI),断言 `knowledge-injection-preview-*` 可见。 向导 confirm 知识上下文(可 stub 空 AI),断言 `knowledge-injection-preview-*` 可见。
- [ ] **Step 3: 全量测试** - [x] **Step 3: 全量测试**
```bash ```bash
npm run build npm run build
@@ -653,11 +653,11 @@ npm run test
npm run test:e2e -- e2e/writing-goals.spec.ts e2e/knowledge-injection-history.spec.ts npm run test:e2e -- e2e/writing-goals.spec.ts e2e/knowledge-injection-history.spec.ts
``` ```
- [ ] **Step 4: 版本 0.9.0** - [x] **Step 4: 版本 0.9.0**
`package.json``README.md``SettingsPage` about 版本号。 `package.json``README.md``SettingsPage` about 版本号。
- [ ] **Step 5: 更新 migrate-v2~v7 测试期望版本 → 8**(与 P5.2 v7 时同样处理) - [x] **Step 5: 更新 migrate-v2~v7 测试期望版本 → 8**(与 P5.2 v7 时同样处理)
--- ---
+108
View File
@@ -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()
})
})
+152
View File
@@ -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()
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bilin", "name": "bilin",
"version": "0.8.0", "version": "0.9.0",
"description": "笔临 - 长篇创作智能协作平台", "description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"type": "module", "type": "module",
+22
View File
@@ -224,6 +224,28 @@
"cockpit.todayProgress": "Today {{current}} / {{goal}} words", "cockpit.todayProgress": "Today {{current}} / {{goal}} words",
"cockpit.streak": "{{days}}-day streak", "cockpit.streak": "{{days}}-day streak",
"cockpit.heatmap": "Writing heatmap", "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",
+22
View File
@@ -224,6 +224,28 @@
"cockpit.todayProgress": "今日 {{current}} / {{goal}} 字", "cockpit.todayProgress": "今日 {{current}} / {{goal}} 字",
"cockpit.streak": "连更 {{days}} 天", "cockpit.streak": "连更 {{days}} 天",
"cockpit.heatmap": "写作热力图", "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": "上一章末尾",
+8 -1
View File
@@ -6,8 +6,9 @@ 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 schemaV7 from './schema-v7.sql?raw'
import schemaV8 from './schema-v8.sql?raw'
const CURRENT_VERSION = 7 const CURRENT_VERSION = 8
function tryAlter(db: SqliteDb, sql: string): void { function tryAlter(db: SqliteDb, sql: string): void {
try { try {
@@ -81,5 +82,11 @@ export function migrate(db: SqliteDb): void {
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN merge_target_id TEXT') tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN merge_target_id TEXT')
tryAlter(db, 'ALTER TABLE knowledge_entries ADD COLUMN extraction_batch_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') 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,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)
}
}
@@ -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
}
}
@@ -20,10 +20,27 @@ export class WritingLogRepository {
book_id TEXT NOT NULL, book_id TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0, word_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, book_id) 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 { addToday(bookId: string, delta: number, date = localDateString()): number {
const row = this.db const row = this.db
.prepare('SELECT word_count FROM writing_logs WHERE date = ? AND book_id = ?') .prepare('SELECT word_count FROM writing_logs WHERE date = ? AND book_id = ?')
@@ -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 }
}
}
+12
View File
@@ -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
View File
@@ -9,6 +9,7 @@ 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 { scheduleChapterExtraction } from '../../services/knowledge-extraction-runner'
import { trackerFor } from '../../services/chapter-writing-tracker.service' import { trackerFor } from '../../services/chapter-writing-tracker.service'
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
import type { WritingLogService } from '../../services/writing-log.service' import type { 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'
@@ -89,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))
}) })
) )
+12 -2
View File
@@ -3,19 +3,29 @@ 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 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 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(() => wrap(() =>
new CockpitService(registry.getDb(bookId), settings, writingLogs).getSummary(volumeId, bookId) new CockpitService(
registry.getDb(bookId),
settings,
writingLogs,
achievementService,
pomodoroDaily
).getSummary(volumeId, bookId)
) )
) )
@@ -9,6 +9,7 @@ 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 { scheduleChapterExtraction } from '../../services/knowledge-extraction-runner'
import { trackerFor } from '../../services/chapter-writing-tracker.service' import { trackerFor } from '../../services/chapter-writing-tracker.service'
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
import type { WritingLogService } from '../../services/writing-log.service' import type { WritingLogService } from '../../services/writing-log.service'
import { wrap } from '../result' import { wrap } from '../result'
@@ -66,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)
}) })
@@ -11,6 +11,7 @@ import type { AiClientService } from '../../services/ai-client.service'
import type { GlobalSettingsService } from '../../services/global-settings' import type { GlobalSettingsService } from '../../services/global-settings'
import { KnowledgeRepository } from '../../db/repositories/knowledge.repo' import { KnowledgeRepository } from '../../db/repositories/knowledge.repo'
import { KnowledgeExtractionService } from '../../services/knowledge-extraction.service' 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'
@@ -120,4 +121,15 @@ export function registerKnowledgeHandlers(
return new KnowledgeService(registry.getDb(bookId)).batchApproveHighConfidence(t) 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)
)
)
} }
+14
View File
@@ -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()))
}
+7
View File
@@ -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))
}) })
+24 -3
View File
@@ -1,11 +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 { 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 { 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'
@@ -23,6 +27,8 @@ 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 { 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'
@@ -36,7 +42,20 @@ 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 writingLogs = new WritingLogService(new WritingLogRepository(userData), settings) 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()
@@ -57,8 +76,10 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerAutoHandlers(books, settings, aiClient, writingLogs) registerAutoHandlers(books, settings, aiClient, writingLogs)
registerWizardHandlers(books, settings, aiClient) registerWizardHandlers(books, settings, aiClient)
registerKnowledgeHandlers(books, settings, aiClient) registerKnowledgeHandlers(books, settings, aiClient)
registerCockpitHandlers(books, settings, writingLogs) registerCockpitHandlers(books, settings, writingLogs, achievementService, pomodoroDailyRepo)
registerWritingHandlers(writingLogs) registerWritingHandlers(writingLogs)
registerPomodoroHandlers(pomodoro)
registerAchievementHandlers(achievementService)
registerBridgeHandlers(books, aiClient) registerBridgeHandlers(books, aiClient)
return { settings, books, shortcuts: shortcutManager } return { settings, books, shortcuts: shortcutManager }
+41
View File
@@ -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
}
}
+14 -2
View File
@@ -4,6 +4,8 @@ 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 type { WritingLogService } from './writing-log.service'
import { stripHtml } from './word-count' import { stripHtml } from './word-count'
@@ -11,7 +13,9 @@ export class CockpitService {
constructor( constructor(
private db: SqliteDb, private db: SqliteDb,
private settings: GlobalSettingsService, private settings: GlobalSettingsService,
private writingLogs?: WritingLogService private writingLogs?: WritingLogService,
private achievementService?: AchievementService,
private pomodoroDaily?: PomodoroDailyRepository
) {} ) {}
getSummary(activeVolumeId?: string, bookId?: string): CockpitSummary { getSummary(activeVolumeId?: string, bookId?: string): CockpitSummary {
@@ -27,6 +31,12 @@ export class CockpitService {
const { stockBufferThreshold } = this.settings.get() const { stockBufferThreshold } = this.settings.get()
const writingStats = const writingStats =
bookId && this.writingLogs ? this.writingLogs.getStats(bookId) : undefined 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,
@@ -35,7 +45,9 @@ export class CockpitService {
foreshadowBuried: stats.buried, foreshadowBuried: stats.buried,
foreshadowResolved: stats.resolved, foreshadowResolved: stats.resolved,
foreshadowForgotten: stats.forgotten, foreshadowForgotten: stats.forgotten,
writingStats writingStats,
achievements,
pomodoroTodayCount
} }
} }
+8 -1
View File
@@ -32,7 +32,10 @@ function defaults(): GlobalSettings {
knowledgeAutoSuggest: true, knowledgeAutoSuggest: true,
knowledgeSuggestTopN: 8, knowledgeSuggestTopN: 8,
knowledgeAutoExtract: true, knowledgeAutoExtract: true,
knowledgeExtractConfidenceThreshold: 0.8 knowledgeExtractConfidenceThreshold: 0.8,
pomodoroDurationMinutes: 25,
enableSystemNotifications: false,
enableGoalNotifications: true
} }
} }
@@ -57,6 +60,10 @@ export class GlobalSettingsService {
knowledgeSuggestTopN: raw.knowledgeSuggestTopN ?? 8, knowledgeSuggestTopN: raw.knowledgeSuggestTopN ?? 8,
knowledgeAutoExtract: raw.knowledgeAutoExtract ?? true, knowledgeAutoExtract: raw.knowledgeAutoExtract ?? true,
knowledgeExtractConfidenceThreshold: raw.knowledgeExtractConfidenceThreshold ?? 0.8, 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,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)
}
}
+111
View File
@@ -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
}
}
+21 -1
View File
@@ -1,16 +1,36 @@
import type { WritingStats } from '../../shared/types' import type { WritingStats } from '../../shared/types'
import { WritingLogRepository } from '../db/repositories/writing-log.repo' import { WritingLogRepository } from '../db/repositories/writing-log.repo'
import type { AchievementService } from './achievement.service'
import type { GlobalSettingsService } from './global-settings' import type { GlobalSettingsService } from './global-settings'
import type { GoalNotificationService } from './goal-notification.service'
import { addDays, localDateString } from './writing-date.util' import { addDays, localDateString } from './writing-date.util'
export class WritingLogService { export class WritingLogService {
private achievementService: AchievementService | null = null
private goalNotify: GoalNotificationService | null = null
constructor( constructor(
private repo: WritingLogRepository, private repo: WritingLogRepository,
private settings: GlobalSettingsService private settings: GlobalSettingsService
) {} ) {}
setGoalHooks(achievement: AchievementService, notify: GoalNotificationService): void {
this.achievementService = achievement
this.goalNotify = notify
}
addToday(bookId: string, delta: number): number { addToday(bookId: string, delta: number): number {
return this.repo.addToday(bookId, delta) 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 { getStats(bookId: string): WritingStats {
+38 -2
View File
@@ -46,7 +46,11 @@ import type {
ChapterBridgeResult, ChapterBridgeResult,
PublishStatus, PublishStatus,
WritingStats, WritingStats,
KnowledgeExtractionResult KnowledgeExtractionResult,
PomodoroState,
GoalNotificationPayload,
KnowledgeInjectionLog,
WritingAchievement
} from '../shared/types' } from '../shared/types'
const electronAPI = { const electronAPI = {
@@ -477,12 +481,30 @@ const electronAPI = {
bookId: string, bookId: string,
threshold?: number threshold?: number
): Promise<IpcResult<number>> => ): Promise<IpcResult<number>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE, { bookId, threshold }) 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: { writing: {
getStats: (bookId: string): Promise<IpcResult<WritingStats>> => getStats: (bookId: string): Promise<IpcResult<WritingStats>> =>
ipcRenderer.invoke(IPC.WRITING_GET_STATS, { bookId }) 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>> =>
ipcRenderer.invoke(IPC.COCKPIT_SUMMARY, { bookId, volumeId }), ipcRenderer.invoke(IPC.COCKPIT_SUMMARY, { bookId, volumeId }),
@@ -543,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)
} }
} }
+6
View File
@@ -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,4 +1,5 @@
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'
@@ -10,6 +11,14 @@ 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)
@@ -105,6 +114,26 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
<WritingHeatmap heatmap={summary.writingStats.heatmap} /> <WritingHeatmap heatmap={summary.writingStats.heatmap} />
</div> </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>
@@ -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,6 +28,7 @@ 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)
@@ -45,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
@@ -183,6 +197,39 @@ 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 && ( {isMergeMode && bookId && existing && (
@@ -13,6 +13,7 @@ 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 selectedChapterId = useBookStore((s) => s.selectedChapterId)
const extractThreshold = useSettingsStore((s) => s.knowledgeExtractConfidenceThreshold) const extractThreshold = useSettingsStore((s) => s.knowledgeExtractConfidenceThreshold)
const { const {
@@ -32,6 +33,8 @@ export function KnowledgePanel(): React.JSX.Element {
extractChapter, extractChapter,
batchApproveHighConfidence batchApproveHighConfidence
} = useKnowledgeStore() } = useKnowledgeStore()
const injectionPreviews = useKnowledgeStore((s) => s.injectionPreviews)
const loadInjectionPreviews = useKnowledgeStore((s) => s.loadInjectionPreviews)
const [extracting, setExtracting] = useState(false) 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())
@@ -51,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])
@@ -188,6 +199,25 @@ export function KnowledgePanel(): React.JSX.Element {
</div> </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 && (
@@ -10,6 +10,7 @@ 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 { 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'
@@ -94,6 +95,15 @@ export function EditorLayout(): React.JSX.Element {
const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal) const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal)
const writingStats = useWritingStatsStore((s) => s.stats) const writingStats = useWritingStatsStore((s) => s.stats)
const refreshWritingStats = useWritingStatsStore((s) => s.refresh) 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)
@@ -111,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
@@ -426,6 +446,39 @@ 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 && ( {dailyWordGoal > 0 && writingStats && (
<span <span
@@ -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'
@@ -159,6 +159,49 @@ export function SettingsPage(): React.JSX.Element {
{t('settings.knowledgeAutoExtract')} {t('settings.knowledgeAutoExtract')}
</label> </label>
</div> </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"> <div className="setting-item">
<span>{t('settings.extractThreshold')}</span> <span>{t('settings.extractThreshold')}</span>
<input <input
@@ -185,7 +228,7 @@ export function SettingsPage(): React.JSX.Element {
{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.8.0 {t('app.name')} v0.9.0
<br /> <br />
{t('app.tagline')} {t('app.tagline')}
</p> </p>
+19
View File
@@ -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
@@ -46,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)),
@@ -53,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')
+53
View File
@@ -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')}`
}
+3
View File
@@ -26,6 +26,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
knowledgeSuggestTopN: 8, knowledgeSuggestTopN: 8,
knowledgeAutoExtract: true, knowledgeAutoExtract: true,
knowledgeExtractConfidenceThreshold: 0.8, 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())
+102
View File
@@ -909,6 +909,108 @@
font-weight: 600; 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 { .kb-confidence-badge {
margin-left: 8px; margin-left: 8px;
font-size: 11px; font-size: 11px;
+23 -1
View File
@@ -44,7 +44,12 @@ import type {
ChapterBridgeResult, ChapterBridgeResult,
PublishStatus, PublishStatus,
WritingStats, WritingStats,
KnowledgeExtractionResult KnowledgeExtractionResult,
PomodoroState,
GoalNotificationPayload,
KnowledgeInjectionLog,
WritingAchievement,
PomodoroDuration
} from './types' } from './types'
export interface ElectronAPI { export interface ElectronAPI {
@@ -358,10 +363,25 @@ export interface ElectronAPI {
approveMerge: (bookId: string, pendingId: string) => Promise<IpcResult<KnowledgeEntry>> approveMerge: (bookId: string, pendingId: string) => Promise<IpcResult<KnowledgeEntry>>
saveMergeAsNew: (bookId: string, pendingId: string) => Promise<IpcResult<KnowledgeEntry>> saveMergeAsNew: (bookId: string, pendingId: string) => Promise<IpcResult<KnowledgeEntry>>
batchApproveHighConfidence: (bookId: string, threshold?: number) => Promise<IpcResult<number>> batchApproveHighConfidence: (bookId: string, threshold?: number) => Promise<IpcResult<number>>
listInjections: (
bookId: string,
entryId: string,
limit?: number
) => Promise<IpcResult<KnowledgeInjectionLog[]>>
} }
writing: { writing: {
getStats: (bookId: string) => Promise<IpcResult<WritingStats>> 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>>
markSeen: (bookId: string) => Promise<IpcResult<void>> markSeen: (bookId: string) => Promise<IpcResult<void>>
@@ -390,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 {
+9
View File
@@ -117,6 +117,15 @@ export const IPC = {
KNOWLEDGE_SAVE_MERGE_AS_NEW: 'knowledge:saveMergeAsNew', KNOWLEDGE_SAVE_MERGE_AS_NEW: 'knowledge:saveMergeAsNew',
KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE: 'knowledge:batchApproveHighConfidence', KNOWLEDGE_BATCH_APPROVE_HIGH_CONFIDENCE: 'knowledge:batchApproveHighConfidence',
WRITING_GET_STATS: 'writing:getStats', 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',
+37
View File
@@ -94,6 +94,8 @@ export interface CockpitSummary {
foreshadowResolved: number foreshadowResolved: number
foreshadowForgotten: number foreshadowForgotten: number
writingStats?: WritingStats writingStats?: WritingStats
achievements?: WritingAchievement[]
pomodoroTodayCount?: number
} }
export interface WritingDayLog { export interface WritingDayLog {
@@ -127,6 +129,37 @@ export interface KnowledgeExtractionResult {
items: ExtractedKnowledgeItem[] 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 {
previousChapterId: string | null previousChapterId: string | null
previousTail: string previousTail: string
@@ -171,6 +204,10 @@ export interface GlobalSettings {
knowledgeSuggestTopN?: number knowledgeSuggestTopN?: number
knowledgeAutoExtract?: boolean knowledgeAutoExtract?: boolean
knowledgeExtractConfidenceThreshold?: number knowledgeExtractConfidenceThreshold?: number
pomodoroDurationMinutes?: PomodoroDuration
enableSystemNotifications?: boolean
enableGoalNotifications?: boolean
dailyGoalNotifiedDate?: string
} }
export interface BookMeta { export interface BookMeta {
+91
View File
@@ -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)
})
})
+52
View File
@@ -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)
})
})
+2 -2
View File
@@ -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(7) 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(7) 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)
+2 -2
View File
@@ -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(7) 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(7) expect(version.v).toBe(8)
}) })
}) })
+2 -2
View File
@@ -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(7) 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(7) expect(version.v).toBe(8)
expect(tableExists(db, 'interactive_flows')).toBe(true) expect(tableExists(db, 'interactive_flows')).toBe(true)
}) })
}) })
+2 -2
View File
@@ -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(7) 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(7) expect(version.v).toBe(8)
}) })
}) })
+1 -1
View File
@@ -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(7) 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 }[]
+1 -1
View File
@@ -11,7 +11,7 @@ describe('migrate v7', () => {
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(7) expect(version.v).toBe(8)
const tables = db const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'") .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'")
.get() .get()
+27
View File
@@ -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)
})
})
+75
View File
@@ -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)
})
})