From b33d2e7b34bb1b32325a64a0cebad0c31deb50da Mon Sep 17 00:00:00 2001 From: kun1h Date: Mon, 6 Jul 2026 23:03:11 +0800 Subject: [PATCH] feat: ship v0.6.0 with cockpit, knowledge base, and chapter bridge Deliver P5 writer workflow: writing cockpit, manual knowledge CRUD with review, chapter bridge with optional AI, publish status, and stock buffer reminders. Co-authored-by: Cursor --- README.md | 10 +- e2e/cockpit-knowledge.spec.ts | 191 ++++++++++++++++ package.json | 2 +- public/locales/en/translation.json | 63 +++++ public/locales/zh-CN/translation.json | 63 +++++ src/main/db/migrate.ts | 9 +- src/main/db/repositories/book-prefs.repo.ts | 21 ++ .../db/repositories/bridge-dismiss.repo.ts | 21 ++ src/main/db/repositories/chapter.repo.ts | 40 +++- src/main/db/repositories/knowledge.repo.ts | 114 +++++++++ src/main/db/schema-v6.sql | 30 +++ src/main/ipc/handlers/bridge.handler.ts | 35 +++ src/main/ipc/handlers/chapter.handler.ts | 10 +- src/main/ipc/handlers/cockpit.handler.ts | 27 +++ src/main/ipc/handlers/knowledge.handler.ts | 68 ++++++ src/main/ipc/register.ts | 6 + src/main/services/chapter-bridge.service.ts | 59 +++++ src/main/services/cockpit.service.ts | 44 ++++ src/main/services/global-settings.ts | 4 + src/main/services/knowledge.service.ts | 117 ++++++++++ src/preload/index.ts | 66 +++++- .../components/bridge/ChapterBridgeModal.tsx | 98 ++++++++ .../components/cockpit/CockpitModal.tsx | 120 ++++++++++ .../knowledge/KnowledgeEditorDialog.tsx | 216 ++++++++++++++++++ .../components/knowledge/KnowledgePanel.tsx | 164 ++++++++++++- .../components/layout/EditorLayout.tsx | 113 ++++++++- src/renderer/components/layout/TopBar.tsx | 17 +- .../components/settings/SettingsPage.tsx | 36 ++- src/renderer/stores/useBookStore.ts | 11 + src/renderer/stores/useCockpitStore.ts | 42 ++++ src/renderer/stores/useKnowledgeStore.ts | 97 ++++++++ src/renderer/stores/useSettingsStore.ts | 2 + src/renderer/styles/layout.css | 174 ++++++++++++++ src/shared/electron-api.d.ts | 48 +++- src/shared/ipc-channels.ts | 18 +- src/shared/types.ts | 51 +++++ tests/main/chapter-bridge.test.ts | 53 +++++ tests/main/cockpit.service.test.ts | 35 +++ tests/main/knowledge.repo.test.ts | 24 ++ tests/main/knowledge.service.test.ts | 61 +++++ tests/main/migrate-v2.test.ts | 4 +- tests/main/migrate-v3.test.ts | 4 +- tests/main/migrate-v4.test.ts | 4 +- tests/main/migrate-v5.test.ts | 4 +- tests/main/migrate-v6.test.ts | 22 ++ 45 files changed, 2389 insertions(+), 29 deletions(-) create mode 100644 e2e/cockpit-knowledge.spec.ts create mode 100644 src/main/db/repositories/book-prefs.repo.ts create mode 100644 src/main/db/repositories/bridge-dismiss.repo.ts create mode 100644 src/main/db/repositories/knowledge.repo.ts create mode 100644 src/main/db/schema-v6.sql create mode 100644 src/main/ipc/handlers/bridge.handler.ts create mode 100644 src/main/ipc/handlers/cockpit.handler.ts create mode 100644 src/main/ipc/handlers/knowledge.handler.ts create mode 100644 src/main/services/chapter-bridge.service.ts create mode 100644 src/main/services/cockpit.service.ts create mode 100644 src/main/services/knowledge.service.ts create mode 100644 src/renderer/components/bridge/ChapterBridgeModal.tsx create mode 100644 src/renderer/components/cockpit/CockpitModal.tsx create mode 100644 src/renderer/components/knowledge/KnowledgeEditorDialog.tsx create mode 100644 src/renderer/stores/useCockpitStore.ts create mode 100644 src/renderer/stores/useKnowledgeStore.ts create mode 100644 tests/main/chapter-bridge.test.ts create mode 100644 tests/main/cockpit.service.test.ts create mode 100644 tests/main/knowledge.repo.test.ts create mode 100644 tests/main/knowledge.service.test.ts create mode 100644 tests/main/migrate-v6.test.ts diff --git a/README.md b/README.md index 55f7163..1be70df 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ # 笔临 (Bilin) -长篇创作智能协作平台 — Electron 桌面客户端 v0.5.0(P4.1 自动 + 向导写作) +长篇创作智能协作平台 — Electron 桌面客户端 v0.6.0(P5 作家工作流 + 知识库) -## 功能概览(v0.5.0) +## 功能概览(v0.6.0) +- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5) +- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5) +- 章间衔接:上下章片段 + 可选 AI 建议(P5) +- 更新计划:发布状态、存稿阈值提醒(P5) - 自动写作:章节目标、场景规划、连续生成、暂停转交互(P4.1) - 向导式写作:5 步引导、策略预览、生成后交互微调(P4.1) - 交互式写作:剧情走向建议、场景生成、命名暂停、微调确认、成章落盘(P4) @@ -32,6 +36,8 @@ npm run test:e2e # E2E 测试(需先 build) ## 文档 +- P5 作家工作流 + 知识库设计规格:`docs/superpowers/specs/2026-07-06-bilin-p5-workflow-knowledge-design.md` +- P5 实现计划:`docs/superpowers/plans/2026-07-06-bilin-p5-workflow-knowledge.md` - P4.1 自动/向导写作设计规格:`docs/superpowers/specs/2026-07-09-bilin-p41-auto-wizard-design.md` - P4.1 实现计划:`docs/superpowers/plans/2026-07-09-bilin-p41-auto-wizard.md` - P4 交互式写作设计规格:`docs/superpowers/specs/2026-07-08-bilin-p4-interactive-design.md` diff --git a/e2e/cockpit-knowledge.spec.ts b/e2e/cockpit-knowledge.spec.ts new file mode 100644 index 0000000..fb73c5c --- /dev/null +++ b/e2e/cockpit-knowledge.spec.ts @@ -0,0 +1,191 @@ +import path from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect, _electron as electron, type Page } from '@playwright/test' + +const ROOT = path.join(import.meta.dirname, '..') + +async function launchApp(userDataDir: string) { + return electron.launch({ + args: [ROOT], + env: { + ...process.env, + BILIN_E2E: '1', + BILIN_E2E_USER_DATA: userDataDir + } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 }) + await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 }) + await page.getByRole('button', { name: '跳过' }).click() + await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 }) +} + +async function dismissCockpitIfOpen(page: Page): Promise { + const cockpit = page.locator('[data-testid="cockpit-modal"]') + if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.locator('[data-testid="cockpit-modal"] .modal-close').click() + await expect(cockpit).toBeHidden({ timeout: 5000 }) + } +} + +async function ensureChapterEditor(page: Page): Promise { + 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 }) +} + +async function createAndOpenBook(page: Page, name: string): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill(name) + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) + await dismissCockpitIfOpen(page) +} + +test.describe('Cockpit & Knowledge P5', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-p5-')) + }) + + test.afterEach(async () => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-COCK-01: cockpit modal shows stats', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '驾驶舱书') + await page.locator('[data-testid="topbar-cockpit"]').click() + await expect(page.locator('[data-testid="cockpit-modal"]')).toBeVisible() + await expect(page.locator('[data-testid="cockpit-total-words"]')).toBeVisible() + await app.close() + }) + + test('E2E-COCK-02: first open shows cockpit once', async () => { + const app1 = await launchApp(userDataDir) + const page1 = await app1.firstWindow() + await skipOnboarding(page1) + await page1.getByRole('button', { name: /新建书籍/ }).first().click() + await page1.locator('.dialog-content input').first().fill('首次驾驶舱') + await page1.getByRole('button', { name: '创建' }).click() + await expect(page1.locator('[data-testid="cockpit-modal"]')).toBeVisible({ timeout: 15_000 }) + await page1.locator('[data-testid="cockpit-modal"] .modal-close').click() + await app1.close() + + const app2 = await launchApp(userDataDir) + const page2 = await app2.firstWindow() + await expect(page2.getByText('欢迎使用笔临')).toBeHidden({ timeout: 15_000 }) + await page2.getByText('首次驾驶舱').click() + await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) + await expect(page2.locator('[data-testid="cockpit-modal"]')).toBeHidden({ timeout: 3000 }) + await app2.close() + }) + + test('E2E-PUB-01: publish ready increases stock count', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '发布书') + await ensureChapterEditor(page) + const chapterItem = page.locator('.chapter-item').first() + await expect(chapterItem).toBeVisible() + const chapterId = await chapterItem.getAttribute('data-testid') + const id = chapterId?.replace('chapter-item-', '') ?? '' + await page.locator(`[data-testid="chapter-publish-${id}"]`).click() + await page.locator('[data-testid="topbar-cockpit"]').click() + await expect(page.locator('[data-testid="cockpit-stock-count"]')).toContainText('1') + await app.close() + }) + + test('E2E-KNOW-01: create approve knowledge entry', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '知识库书') + 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('测灵石异常') + await page.locator('[data-testid="knowledge-save"]').click() + await page.locator('[data-testid="knowledge-tab-pending"]').click() + await expect(page.locator('.kb-item')).toContainText('测灵石异常') + const approveBtn = page.locator('[data-testid^="knowledge-approve-"]').first() + await approveBtn.click() + await page.locator('[data-testid="knowledge-tab-all"]').click() + await expect(page.locator('.kb-item')).toContainText('测灵石异常') + await app.close() + }) + + test('E2E-BRIDGE-01: new chapter triggers bridge modal', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '衔接书') + await ensureChapterEditor(page) + const editor = page.locator('.ProseMirror') + await editor.click() + await editor.pressSequentially('上一章结尾内容,林远站在演武场中央。') + await page.keyboard.press('Control+s') + await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 }) + await dismissCockpitIfOpen(page) + await page.getByRole('button', { name: '+ 新章' }).click() + await dismissCockpitIfOpen(page) + await expect(page.locator('[data-testid="bridge-modal"]')).toBeVisible({ timeout: 10_000 }) + await expect(page.locator('[data-testid="bridge-previous-tail"]')).toBeVisible({ timeout: 60_000 }) + await expect(page.locator('[data-testid="bridge-previous-tail"]')).not.toHaveText('(无)') + await app.close() + }) + + test('E2E-STOCK-01: low stock warning in statusbar', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '存稿书') + await page.locator('.top-btn').filter({ hasText: '⚙' }).click() + await page.locator('[data-testid="settings-update-schedule"]').selectOption('daily') + await page.locator('[data-testid="settings-stock-threshold"]').fill('3') + await page.locator('[data-testid="settings-stock-threshold"]').blur() + await page.waitForTimeout(500) + await page.getByText('存稿书').click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 10_000 }) + await expect(page.locator('[data-testid="status-stock-warning"]')).toBeVisible({ timeout: 10_000 }) + await app.close() + }) + + test('E2E-BRIDGE-02: bridge shows AI suggestion', async () => { + test.setTimeout(300_000) + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, 'AI衔接书') + await ensureChapterEditor(page) + const editor = page.locator('.ProseMirror') + await editor.click() + await editor.pressSequentially('林远站在演武场中央,众人目光汇聚,他知道从这一刻起一切都将不同。') + await page.keyboard.press('Control+s') + await page.waitForTimeout(1000) + await dismissCockpitIfOpen(page) + await page.getByRole('button', { name: '+ 新章' }).click() + await dismissCockpitIfOpen(page) + await expect(page.locator('[data-testid="bridge-modal"]')).toBeVisible({ timeout: 15_000 }) + await expect(page.locator('[data-testid="bridge-ai-suggestion"]')).not.toBeEmpty({ timeout: 240_000 }) + await app.close() + }) +}) diff --git a/package.json b/package.json index a189a90..bb677e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "0.5.0", + "version": "0.6.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 655286c..dbcfdeb 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -13,6 +13,7 @@ "dialog.newBook.category": "Category", "dialog.newBook.target": "Target word count (optional)", "dialog.cancel": "Cancel", + "dialog.save": "Save", "dialog.create": "Create", "settings.title": "Settings", "settings.general": "General", @@ -159,6 +160,68 @@ "naming.ignored": "Added to ignore list", "naming.replaced": "Replaced {{count}} occurrence(s)", "knowledge.placeholder": "Knowledge base entries will appear here", + "knowledge.new": "New entry", + "knowledge.edit": "Edit entry", + "knowledge.type": "Type", + "knowledge.type.character": "Character", + "knowledge.type.foreshadow": "Foreshadow", + "knowledge.type.location": "Location", + "knowledge.type.relationship": "Relationship", + "knowledge.type.fact": "Fact", + "knowledge.titleLabel": "Title", + "knowledge.contentLabel": "Content", + "knowledge.importance": "Importance", + "knowledge.foreshadowStatus": "Foreshadow status", + "knowledge.foreshadow.buried": "Buried", + "knowledge.foreshadow.partial": "Partial", + "knowledge.foreshadow.resolved": "Resolved", + "knowledge.sourceChapter": "Source chapter", + "knowledge.noChapter": "(none)", + "knowledge.approveNow": "Approve immediately", + "knowledge.tab.pending": "Pending", + "knowledge.tab.all": "All", + "knowledge.tab.foreshadow": "Foreshadow", + "knowledge.approve": "Approve", + "knowledge.reject": "Reject", + "knowledge.delete": "Delete", + "knowledge.batchApprove": "Approve selected ({{count}})", + "knowledge.empty": "No entries yet", + "knowledge.status.pending": "Pending", + "knowledge.status.approved": "Approved", + "knowledge.status.rejected": "Rejected", + "knowledge.statsSummary": "Buried {{buried}} · Resolved {{resolved}} · Forgotten {{forgotten}}", + "knowledge.syncFromLandmark": "Sync this foreshadow to knowledge base?", + "knowledge.syncedFromLandmark": "Synced to knowledge base (pending review)", + "cockpit.title": "Writing cockpit", + "cockpit.loading": "Loading…", + "cockpit.totalWords": "Book words", + "cockpit.volumeWords": "Volume words", + "cockpit.stockReady": "Stock buffer", + "cockpit.foreshadow": "Foreshadow", + "cockpit.foreshadowDetail": "Buried {{buried}} / Resolved {{resolved}} / Forgotten {{forgotten}}", + "cockpit.resumeEdit": "Resume writing", + "cockpit.bridgeCheck": "Chapter bridge check", + "cockpit.forgottenForeshadow": "Forgotten foreshadows", + "bridge.title": "Chapter bridge", + "bridge.loading": "Analyzing…", + "bridge.previousTail": "Previous chapter ending", + "bridge.currentHead": "Current chapter opening", + "bridge.aiSuggestion": "AI suggestion", + "bridge.none": "(none)", + "bridge.empty": "Could not load bridge info", + "bridge.dismissLabel": "Do not prompt for this chapter", + "bridge.startWriting": "Start writing", + "publish.status.draft": "Draft", + "publish.status.ready": "Ready", + "publish.status.published": "Published", + "settings.updateSchedule": "Update schedule", + "settings.updateSchedule.none": "No reminder", + "settings.updateSchedule.daily": "Daily", + "settings.updateSchedule.alternate": "Every other day", + "settings.updateSchedule.weekly": "Weekly", + "settings.stockBufferThreshold": "Stock buffer threshold (chapters)", + "stock.warning": "Low stock: {{count}}/{{threshold}} chapters ready", + "landmark.foreshadowConfirm": "Mark as foreshadow landmark?", "ai.settings.backend": "Backend", "ai.settings.backend.lmstudio": "LM Studio (local)", "ai.settings.backend.openai": "OpenAI", diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index 5c62b3f..67c9802 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -13,6 +13,7 @@ "dialog.newBook.category": "分类", "dialog.newBook.target": "目标总字数(可选)", "dialog.cancel": "取消", + "dialog.save": "保存", "dialog.create": "创建", "settings.title": "系统设置", "settings.general": "通用", @@ -159,6 +160,68 @@ "naming.ignored": "已加入忽略词库", "naming.replaced": "已替换 {{count}} 处", "knowledge.placeholder": "知识库条目将在此展示(伏笔、角色状态等)", + "knowledge.new": "新建条目", + "knowledge.edit": "编辑条目", + "knowledge.type": "类型", + "knowledge.type.character": "角色", + "knowledge.type.foreshadow": "伏笔", + "knowledge.type.location": "地点", + "knowledge.type.relationship": "关系", + "knowledge.type.fact": "事实", + "knowledge.titleLabel": "标题", + "knowledge.contentLabel": "内容", + "knowledge.importance": "重要度", + "knowledge.foreshadowStatus": "伏笔状态", + "knowledge.foreshadow.buried": "已埋设", + "knowledge.foreshadow.partial": "部分回收", + "knowledge.foreshadow.resolved": "已回收", + "knowledge.sourceChapter": "来源章节", + "knowledge.noChapter": "(无)", + "knowledge.approveNow": "直接采纳", + "knowledge.tab.pending": "待审核", + "knowledge.tab.all": "全部", + "knowledge.tab.foreshadow": "伏笔", + "knowledge.approve": "采纳", + "knowledge.reject": "拒绝", + "knowledge.delete": "删除", + "knowledge.batchApprove": "批量采纳 ({{count}})", + "knowledge.empty": "暂无条目", + "knowledge.status.pending": "待审核", + "knowledge.status.approved": "已采纳", + "knowledge.status.rejected": "已拒绝", + "knowledge.statsSummary": "埋设 {{buried}} · 回收 {{resolved}} · 遗忘 {{forgotten}}", + "knowledge.syncFromLandmark": "是否同步此伏笔到知识库?", + "knowledge.syncedFromLandmark": "已同步到知识库(待审核)", + "cockpit.title": "写作驾驶舱", + "cockpit.loading": "加载中…", + "cockpit.totalWords": "全书字数", + "cockpit.volumeWords": "本卷字数", + "cockpit.stockReady": "存稿缓冲", + "cockpit.foreshadow": "伏笔概览", + "cockpit.foreshadowDetail": "埋设 {{buried}} / 回收 {{resolved}} / 遗忘 {{forgotten}}", + "cockpit.resumeEdit": "继续写作", + "cockpit.bridgeCheck": "章间衔接检查", + "cockpit.forgottenForeshadow": "查看遗忘伏笔", + "bridge.title": "章间衔接", + "bridge.loading": "分析中…", + "bridge.previousTail": "上一章末尾", + "bridge.currentHead": "本章开头", + "bridge.aiSuggestion": "AI 建议", + "bridge.none": "(无)", + "bridge.empty": "无法获取衔接信息", + "bridge.dismissLabel": "不再提示本章", + "bridge.startWriting": "开始写作", + "publish.status.draft": "草稿", + "publish.status.ready": "待发布", + "publish.status.published": "已发布", + "settings.updateSchedule": "更新计划", + "settings.updateSchedule.none": "不提醒", + "settings.updateSchedule.daily": "日更", + "settings.updateSchedule.alternate": "隔日更", + "settings.updateSchedule.weekly": "周更", + "settings.stockBufferThreshold": "存稿缓冲阈值(章)", + "stock.warning": "存稿不足:{{count}}/{{threshold}} 章待发布", + "landmark.foreshadowConfirm": "标记为伏笔地标?", "ai.settings.backend": "后端类型", "ai.settings.backend.lmstudio": "LM Studio(本地)", "ai.settings.backend.openai": "OpenAI", diff --git a/src/main/db/migrate.ts b/src/main/db/migrate.ts index 9be3a12..c87bf0a 100644 --- a/src/main/db/migrate.ts +++ b/src/main/db/migrate.ts @@ -4,8 +4,9 @@ import schemaV2 from './schema-v2.sql?raw' import schemaV3 from './schema-v3.sql?raw' import schemaV4 from './schema-v4.sql?raw' import schemaV5 from './schema-v5.sql?raw' +import schemaV6 from './schema-v6.sql?raw' -const CURRENT_VERSION = 5 +const CURRENT_VERSION = 6 function tryAlter(db: SqliteDb, sql: string): void { try { @@ -64,5 +65,11 @@ export function migrate(db: SqliteDb): void { tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive'") tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}'") db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(5, 'P4.1 auto/wizard schema') + current = 5 + } + + if (current < 6) { + db.exec(schemaV6) + db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(6, 'P5 knowledge/cockpit schema') } } diff --git a/src/main/db/repositories/book-prefs.repo.ts b/src/main/db/repositories/book-prefs.repo.ts new file mode 100644 index 0000000..35722a9 --- /dev/null +++ b/src/main/db/repositories/book-prefs.repo.ts @@ -0,0 +1,21 @@ +import type { SqliteDb } from '../types' + +export class BookPrefsRepository { + constructor(private db: SqliteDb) {} + + get(key: string): string | null { + const row = this.db.prepare('SELECT value FROM book_preferences WHERE key = ?').get(key) as + | { value: string } + | undefined + return row?.value ?? null + } + + set(key: string, value: string): void { + this.db + .prepare( + `INSERT INTO book_preferences (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ) + .run(key, value) + } +} diff --git a/src/main/db/repositories/bridge-dismiss.repo.ts b/src/main/db/repositories/bridge-dismiss.repo.ts new file mode 100644 index 0000000..4994d11 --- /dev/null +++ b/src/main/db/repositories/bridge-dismiss.repo.ts @@ -0,0 +1,21 @@ +import type { SqliteDb } from '../types' + +export class BridgeDismissRepository { + constructor(private db: SqliteDb) {} + + isDismissed(chapterId: string): boolean { + const row = this.db + .prepare('SELECT chapter_id FROM chapter_bridge_dismiss WHERE chapter_id = ?') + .get(chapterId) + return Boolean(row) + } + + dismiss(chapterId: string): void { + this.db + .prepare( + `INSERT INTO chapter_bridge_dismiss (chapter_id) VALUES (?) + ON CONFLICT(chapter_id) DO UPDATE SET dismissed_at = datetime('now')` + ) + .run(chapterId) + } +} diff --git a/src/main/db/repositories/chapter.repo.ts b/src/main/db/repositories/chapter.repo.ts index b4a4c65..43df0e9 100644 --- a/src/main/db/repositories/chapter.repo.ts +++ b/src/main/db/repositories/chapter.repo.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto' import { countWords } from '../../services/word-count' -import type { Chapter, ChapterOrigin, ChapterStatus } from '../../../shared/types' +import type { Chapter, ChapterOrigin, ChapterStatus, PublishStatus } from '../../../shared/types' import type { SqliteDb } from '../types' function mapRow(row: Record): Chapter { return { @@ -12,7 +12,8 @@ function mapRow(row: Record): Chapter { wordCount: row.word_count as number, sortOrder: row.sort_order as number, cursorOffset: (row.cursor_offset as number) ?? 0, - origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin + origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin, + publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus } } @@ -59,7 +60,13 @@ export class ChapterRepository { update( id: string, - patch: Partial<{ title: string; content: string; status: ChapterStatus; cursorOffset: number }> + patch: Partial<{ + title: string + content: string + status: ChapterStatus + cursorOffset: number + publishStatus: PublishStatus + }> ): Chapter { const existing = this.get(id) if (!existing) throw new Error('Chapter not found') @@ -71,7 +78,7 @@ export class ChapterRepository { .prepare( `UPDATE chapters SET title = ?, content = ?, status = ?, cursor_offset = ?, - word_count = ?, updated_at = datetime('now') + publish_status = ?, word_count = ?, updated_at = datetime('now') WHERE id = ?` ) .run( @@ -79,12 +86,37 @@ export class ChapterRepository { content, patch.status ?? existing.status, patch.cursorOffset ?? existing.cursorOffset, + patch.publishStatus ?? existing.publishStatus ?? 'draft', wordCount, id ) return this.get(id)! } + setPublishStatus(id: string, publishStatus: PublishStatus): Chapter { + return this.update(id, { publishStatus }) + } + + countByPublishStatus(status: PublishStatus): number { + const row = this.db + .prepare('SELECT COUNT(*) AS c FROM chapters WHERE publish_status = ?') + .get(status) as { c: number } + return row.c + } + + getPreviousChapter(chapterId: string): Chapter | null { + const current = this.get(chapterId) + if (!current) return null + const row = this.db + .prepare( + `SELECT * FROM chapters + WHERE sort_order < ? + ORDER BY sort_order DESC LIMIT 1` + ) + .get(current.sortOrder) as Record | undefined + return row ? mapRow(row) : null + } + delete(id: string): void { this.db.prepare('DELETE FROM chapters WHERE id = ?').run(id) } diff --git a/src/main/db/repositories/knowledge.repo.ts b/src/main/db/repositories/knowledge.repo.ts new file mode 100644 index 0000000..30ec515 --- /dev/null +++ b/src/main/db/repositories/knowledge.repo.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'crypto' +import type { + CreateKnowledgeInput, + ForeshadowStatus, + KnowledgeEntry, + KnowledgeStatus, + KnowledgeType +} from '../../../shared/types' +import type { SqliteDb } from '../types' + +function mapRow(row: Record): KnowledgeEntry { + return { + id: row.id as string, + type: row.type as KnowledgeType, + title: row.title as string, + content: row.content as string, + importance: row.importance as number, + status: row.status as KnowledgeStatus, + foreshadowStatus: (row.foreshadow_status as ForeshadowStatus) || undefined, + sourceChapterId: (row.source_chapter_id as string) || undefined, + lastMentionChapterId: (row.last_mention_chapter_id as string) || undefined, + linkedBookmarkId: (row.linked_bookmark_id as string) || undefined, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string + } +} + +export class KnowledgeRepository { + constructor(private db: SqliteDb) {} + + create(input: CreateKnowledgeInput): KnowledgeEntry { + const id = randomUUID() + const status = input.status ?? 'pending' + this.db + .prepare( + `INSERT INTO knowledge_entries ( + id, type, title, content, importance, status, foreshadow_status, + source_chapter_id, last_mention_chapter_id, linked_bookmark_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + id, + input.type, + input.title, + input.content ?? '', + input.importance ?? 3, + status, + input.foreshadowStatus ?? null, + input.sourceChapterId ?? null, + input.lastMentionChapterId ?? null, + input.linkedBookmarkId ?? null + ) + return this.get(id)! + } + + get(id: string): KnowledgeEntry | null { + const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as + | Record + | undefined + return row ? mapRow(row) : null + } + + list(filter?: { status?: KnowledgeStatus; type?: KnowledgeType }): KnowledgeEntry[] { + let sql = 'SELECT * FROM knowledge_entries WHERE 1=1' + const params: unknown[] = [] + if (filter?.status) { + sql += ' AND status = ?' + params.push(filter.status) + } + if (filter?.type) { + sql += ' AND type = ?' + params.push(filter.type) + } + sql += ' ORDER BY updated_at DESC' + return (this.db.prepare(sql).all(...params) as Record[]).map(mapRow) + } + + update(id: string, patch: Partial): KnowledgeEntry { + const existing = this.get(id) + if (!existing) throw new Error('Knowledge entry not found') + this.db + .prepare( + `UPDATE knowledge_entries SET + type = ?, title = ?, content = ?, importance = ?, status = ?, + foreshadow_status = ?, source_chapter_id = ?, last_mention_chapter_id = ?, + linked_bookmark_id = ?, updated_at = datetime('now') + WHERE id = ?` + ) + .run( + patch.type ?? existing.type, + patch.title ?? existing.title, + patch.content ?? existing.content, + patch.importance ?? existing.importance, + patch.status ?? existing.status, + patch.foreshadowStatus ?? existing.foreshadowStatus ?? null, + patch.sourceChapterId ?? existing.sourceChapterId ?? null, + patch.lastMentionChapterId ?? existing.lastMentionChapterId ?? null, + patch.linkedBookmarkId ?? existing.linkedBookmarkId ?? null, + id + ) + return this.get(id)! + } + + delete(id: string): void { + this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id) + } + + countByStatus(status: KnowledgeStatus): number { + const row = this.db + .prepare('SELECT COUNT(*) AS c FROM knowledge_entries WHERE status = ?') + .get(status) as { c: number } + return row.c + } +} diff --git a/src/main/db/schema-v6.sql b/src/main/db/schema-v6.sql new file mode 100644 index 0000000..c1c1596 --- /dev/null +++ b/src/main/db/schema-v6.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS knowledge_entries ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + importance INTEGER NOT NULL DEFAULT 3, + status TEXT NOT NULL DEFAULT 'pending', + foreshadow_status TEXT, + source_chapter_id TEXT, + last_mention_chapter_id TEXT, + linked_bookmark_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (source_chapter_id) REFERENCES chapters(id) ON DELETE SET NULL, + FOREIGN KEY (last_mention_chapter_id) REFERENCES chapters(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_status ON knowledge_entries(status); +CREATE INDEX IF NOT EXISTS idx_knowledge_type ON knowledge_entries(type); + +CREATE TABLE IF NOT EXISTS book_preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS chapter_bridge_dismiss ( + chapter_id TEXT PRIMARY KEY, + dismissed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE +); diff --git a/src/main/ipc/handlers/bridge.handler.ts b/src/main/ipc/handlers/bridge.handler.ts new file mode 100644 index 0000000..56912de --- /dev/null +++ b/src/main/ipc/handlers/bridge.handler.ts @@ -0,0 +1,35 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import { BookRegistryService } from '../../services/book-registry' +import { ChapterBridgeService } from '../../services/chapter-bridge.service' +import { AiClientService } from '../../services/ai-client.service' +import { wrap } from '../result' + +export function registerBridgeHandlers( + registry: BookRegistryService, + aiClient: AiClientService +): void { + ipcMain.handle( + IPC.BRIDGE_GET, + ( + _e, + { bookId, chapterId, withAi }: { bookId: string; chapterId: string; withAi?: boolean } + ) => + wrap(() => + new ChapterBridgeService(registry.getDb(bookId), aiClient).getBridge( + chapterId, + withAi ?? false + ) + ) + ) + + ipcMain.handle(IPC.BRIDGE_DISMISS, (_e, { bookId, chapterId }: { bookId: string; chapterId: string }) => + wrap(() => new ChapterBridgeService(registry.getDb(bookId)).dismiss(chapterId)) + ) + + ipcMain.handle( + IPC.BRIDGE_SHOULD_PROMPT, + (_e, { bookId, chapterId }: { bookId: string; chapterId: string }) => + wrap(() => new ChapterBridgeService(registry.getDb(bookId)).shouldPrompt(chapterId)) + ) +} diff --git a/src/main/ipc/handlers/chapter.handler.ts b/src/main/ipc/handlers/chapter.handler.ts index 7f10bf6..3fe7c2c 100644 --- a/src/main/ipc/handlers/chapter.handler.ts +++ b/src/main/ipc/handlers/chapter.handler.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron' import { IPC } from '../../../shared/ipc-channels' -import type { ChapterStatus } from '../../../shared/types' +import type { ChapterStatus, PublishStatus } from '../../../shared/types' import { BookRegistryService } from '../../services/book-registry' import { ftsSync } from '../../services/fts-sync.service' import { wrap } from '../result' @@ -124,4 +124,12 @@ export function registerChapterHandlers(registry: BookRegistryService): void { ) => wrap(() => registry.getChapterRepo(bookId).moveToVolume(chapterId, targetVolumeId, targetIndex)) ) + + ipcMain.handle( + IPC.CHAPTER_SET_PUBLISH_STATUS, + ( + _event, + { bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus } + ) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, publishStatus)) + ) } diff --git a/src/main/ipc/handlers/cockpit.handler.ts b/src/main/ipc/handlers/cockpit.handler.ts new file mode 100644 index 0000000..db022db --- /dev/null +++ b/src/main/ipc/handlers/cockpit.handler.ts @@ -0,0 +1,27 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import { BookRegistryService } from '../../services/book-registry' +import { CockpitService } from '../../services/cockpit.service' +import type { GlobalSettingsService } from '../../services/global-settings' +import { wrap } from '../result' + +export function registerCockpitHandlers( + registry: BookRegistryService, + settings: GlobalSettingsService +): void { + ipcMain.handle( + IPC.COCKPIT_SUMMARY, + (_e, { bookId, volumeId }: { bookId: string; volumeId?: string }) => + wrap(() => new CockpitService(registry.getDb(bookId), settings).getSummary(volumeId)) + ) + + ipcMain.handle(IPC.COCKPIT_MARK_SEEN, (_e, { bookId }: { bookId: string }) => + wrap(() => { + new CockpitService(registry.getDb(bookId), settings).markSeen() + }) + ) + + ipcMain.handle(IPC.COCKPIT_SHOULD_SHOW, (_e, { bookId }: { bookId: string }) => + wrap(() => new CockpitService(registry.getDb(bookId), settings).shouldShowOnOpen()) + ) +} diff --git a/src/main/ipc/handlers/knowledge.handler.ts b/src/main/ipc/handlers/knowledge.handler.ts new file mode 100644 index 0000000..5ad3a7e --- /dev/null +++ b/src/main/ipc/handlers/knowledge.handler.ts @@ -0,0 +1,68 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { CreateKnowledgeInput, KnowledgeStatus, KnowledgeType } from '../../../shared/types' +import { BookRegistryService } from '../../services/book-registry' +import { KnowledgeService } from '../../services/knowledge.service' +import { wrap } from '../result' + +export function registerKnowledgeHandlers(registry: BookRegistryService): void { + ipcMain.handle( + IPC.KNOWLEDGE_LIST, + ( + _e, + { + bookId, + filter + }: { + bookId: string + filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean } + } + ) => wrap(() => new KnowledgeService(registry.getDb(bookId)).list(filter)) + ) + + ipcMain.handle( + IPC.KNOWLEDGE_CREATE, + (_e, { bookId, input }: { bookId: string; input: CreateKnowledgeInput }) => + wrap(() => new KnowledgeService(registry.getDb(bookId)).create(input)) + ) + + ipcMain.handle( + IPC.KNOWLEDGE_UPDATE, + ( + _e, + { + bookId, + id, + patch + }: { bookId: string; id: string; patch: Partial } + ) => wrap(() => new KnowledgeService(registry.getDb(bookId)).update(id, patch)) + ) + + ipcMain.handle(IPC.KNOWLEDGE_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) => + wrap(() => new KnowledgeService(registry.getDb(bookId)).delete(id)) + ) + + ipcMain.handle(IPC.KNOWLEDGE_APPROVE, (_e, { bookId, id }: { bookId: string; id: string }) => + wrap(() => new KnowledgeService(registry.getDb(bookId)).approve(id)) + ) + + ipcMain.handle(IPC.KNOWLEDGE_REJECT, (_e, { bookId, id }: { bookId: string; id: string }) => + wrap(() => new KnowledgeService(registry.getDb(bookId)).reject(id)) + ) + + ipcMain.handle( + IPC.KNOWLEDGE_BATCH_APPROVE, + (_e, { bookId, ids }: { bookId: string; ids: string[] }) => + wrap(() => new KnowledgeService(registry.getDb(bookId)).batchApprove(ids)) + ) + + ipcMain.handle( + IPC.KNOWLEDGE_CREATE_FROM_BOOKMARK, + (_e, { bookId, bookmarkId }: { bookId: string; bookmarkId: string }) => + wrap(() => new KnowledgeService(registry.getDb(bookId)).createFromBookmark(bookmarkId)) + ) + + ipcMain.handle(IPC.KNOWLEDGE_STATS, (_e, { bookId }: { bookId: string }) => + wrap(() => new KnowledgeService(registry.getDb(bookId)).getForeshadowStats()) + ) +} diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 1da88d0..0ba55e5 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -18,6 +18,9 @@ import { registerAiHandlers } from './handlers/ai.handler' import { registerInteractiveHandlers } from './handlers/interactive.handler' import { registerAutoHandlers } from './handlers/auto.handler' import { registerWizardHandlers } from './handlers/wizard.handler' +import { registerKnowledgeHandlers } from './handlers/knowledge.handler' +import { registerCockpitHandlers } from './handlers/cockpit.handler' +import { registerBridgeHandlers } from './handlers/bridge.handler' import { AiClientService } from '../services/ai-client.service' import { NetworkMonitorService } from '../services/network-monitor.service' @@ -49,6 +52,9 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg registerInteractiveHandlers(books, settings, aiClient) registerAutoHandlers(books, settings, aiClient) registerWizardHandlers(books, settings, aiClient) + registerKnowledgeHandlers(books) + registerCockpitHandlers(books, settings) + registerBridgeHandlers(books, aiClient) return { settings, books, shortcuts: shortcutManager } } diff --git a/src/main/services/chapter-bridge.service.ts b/src/main/services/chapter-bridge.service.ts new file mode 100644 index 0000000..a85655e --- /dev/null +++ b/src/main/services/chapter-bridge.service.ts @@ -0,0 +1,59 @@ +import type { ChapterBridgeResult } from '../../shared/types' +import type { SqliteDb } from '../db/types' +import { BridgeDismissRepository } from '../db/repositories/bridge-dismiss.repo' +import { ChapterRepository } from '../db/repositories/chapter.repo' +import type { AiClientService } from './ai-client.service' +import { stripHtml } from './word-count' + +export class ChapterBridgeService { + private chapterRepo: ChapterRepository + private dismissRepo: BridgeDismissRepository + + constructor( + private db: SqliteDb, + private aiClient?: AiClientService + ) { + this.chapterRepo = new ChapterRepository(db) + this.dismissRepo = new BridgeDismissRepository(db) + } + + shouldPrompt(chapterId: string): boolean { + if (this.dismissRepo.isDismissed(chapterId)) return false + const ch = this.chapterRepo.get(chapterId) + if (!ch || stripHtml(ch.content).length >= 200) return false + return this.chapterRepo.getPreviousChapter(chapterId) !== null + } + + extract(chapterId: string): ChapterBridgeResult { + const prev = this.chapterRepo.getPreviousChapter(chapterId) + const cur = this.chapterRepo.get(chapterId) + if (!cur) throw new Error('Chapter not found') + const prevText = prev ? stripHtml(prev.content) : '' + const curText = stripHtml(cur.content) + return { + previousChapterId: prev?.id ?? null, + previousTail: prevText.slice(-300), + currentHead: curText.slice(0, 200) + } + } + + async getBridge(chapterId: string, withAi: boolean): Promise { + const base = this.extract(chapterId) + if (!withAi || !this.aiClient || !base.previousTail) return base + try { + const suggestion = await this.aiClient.chat([ + { + role: 'user', + content: `你是连载小说编辑。上一章末尾:「${base.previousTail}」。本章开头:「${base.currentHead || '(尚未撰写)'}」。用 2-3 句话指出衔接是否顺畅,并给出一个开头方向建议。不要续写正文。` + } + ]) + return { ...base, aiSuggestion: suggestion.trim() } + } catch { + return base + } + } + + dismiss(chapterId: string): void { + this.dismissRepo.dismiss(chapterId) + } +} diff --git a/src/main/services/cockpit.service.ts b/src/main/services/cockpit.service.ts new file mode 100644 index 0000000..b3e6710 --- /dev/null +++ b/src/main/services/cockpit.service.ts @@ -0,0 +1,44 @@ +import type { CockpitSummary } from '../../shared/types' +import type { SqliteDb } from '../db/types' +import { BookPrefsRepository } from '../db/repositories/book-prefs.repo' +import { ChapterRepository } from '../db/repositories/chapter.repo' +import type { GlobalSettingsService } from './global-settings' +import { KnowledgeService } from './knowledge.service' +import { stripHtml } from './word-count' + +export class CockpitService { + constructor( + private db: SqliteDb, + private settings: GlobalSettingsService + ) {} + + getSummary(activeVolumeId?: string): CockpitSummary { + const chapters = new ChapterRepository(this.db).list() + const totalWords = chapters.reduce((s, c) => s + stripHtml(c.content).length, 0) + const volumeWords = activeVolumeId + ? chapters + .filter((c) => c.volumeId === activeVolumeId) + .reduce((s, c) => s + stripHtml(c.content).length, 0) + : totalWords + const stockReadyCount = new ChapterRepository(this.db).countByPublishStatus('ready') + const stats = new KnowledgeService(this.db).getForeshadowStats() + const { stockBufferThreshold } = this.settings.get() + return { + totalWords, + volumeWords, + stockReadyCount, + stockThreshold: stockBufferThreshold, + foreshadowBuried: stats.buried, + foreshadowResolved: stats.resolved, + foreshadowForgotten: stats.forgotten + } + } + + shouldShowOnOpen(): boolean { + return new BookPrefsRepository(this.db).get('cockpitSeen') !== 'true' + } + + markSeen(): void { + new BookPrefsRepository(this.db).set('cockpitSeen', 'true') + } +} diff --git a/src/main/services/global-settings.ts b/src/main/services/global-settings.ts index ec7acee..c5ccef4 100644 --- a/src/main/services/global-settings.ts +++ b/src/main/services/global-settings.ts @@ -20,6 +20,8 @@ function defaults(): GlobalSettings { theme: 'default', language: 'zh-CN', dailyWordGoal: 0, + updateSchedule: 'none', + stockBufferThreshold: 3, shortcuts: { ...DEFAULT_SHORTCUTS }, snapshotIntervalMs: 300_000, snapshotMaxAuto: 20, @@ -45,6 +47,8 @@ export class GlobalSettingsService { return { ...defaults(), ...raw, + updateSchedule: raw.updateSchedule ?? 'none', + stockBufferThreshold: raw.stockBufferThreshold ?? 3, shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts }, aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig } } diff --git a/src/main/services/knowledge.service.ts b/src/main/services/knowledge.service.ts new file mode 100644 index 0000000..b828191 --- /dev/null +++ b/src/main/services/knowledge.service.ts @@ -0,0 +1,117 @@ +import type { + CreateKnowledgeInput, + ForeshadowStatus, + KnowledgeEntry, + KnowledgeStatus, + KnowledgeType +} from '../../shared/types' +import type { SqliteDb } from '../db/types' +import { BookmarkRepository } from '../db/repositories/bookmark.repo' +import { ChapterRepository } from '../db/repositories/chapter.repo' +import { KnowledgeRepository } from '../db/repositories/knowledge.repo' + +const FORGOTTEN_GAP = 20 + +export class KnowledgeService { + private repo: KnowledgeRepository + private chapterRepo: ChapterRepository + private bookmarkRepo: BookmarkRepository + + constructor(private db: SqliteDb) { + this.repo = new KnowledgeRepository(db) + this.chapterRepo = new ChapterRepository(db) + this.bookmarkRepo = new BookmarkRepository(db) + } + + list(filter?: { + status?: KnowledgeStatus + type?: KnowledgeType + forgottenOnly?: boolean + }): KnowledgeEntry[] { + let entries = this.repo.list({ status: filter?.status, type: filter?.type }) + if (filter?.forgottenOnly) { + entries = entries.filter((e) => this.isForgotten(e)) + } + return entries + } + + create(input: CreateKnowledgeInput): KnowledgeEntry { + const foreshadowStatus = + input.type === 'foreshadow' ? (input.foreshadowStatus ?? 'buried') : input.foreshadowStatus + return this.repo.create({ ...input, foreshadowStatus }) + } + + update(id: string, patch: Partial): KnowledgeEntry { + return this.repo.update(id, patch) + } + + delete(id: string): void { + this.repo.delete(id) + } + + approve(id: string): KnowledgeEntry { + return this.repo.update(id, { status: 'approved' }) + } + + reject(id: string): KnowledgeEntry { + return this.repo.update(id, { status: 'rejected' }) + } + + batchApprove(ids: string[]): number { + let count = 0 + for (const id of ids) { + const entry = this.repo.get(id) + if (entry?.status === 'pending') { + this.repo.update(id, { status: 'approved' }) + count++ + } + } + return count + } + + createFromBookmark(bookmarkId: string): KnowledgeEntry { + const bm = this.bookmarkRepo.get(bookmarkId) + if (!bm) throw new Error('Bookmark not found') + return this.repo.create({ + type: 'foreshadow', + title: bm.label, + content: bm.label, + status: 'pending', + foreshadowStatus: 'buried', + sourceChapterId: bm.chapterId, + linkedBookmarkId: bm.id + }) + } + + countPending(): number { + return this.repo.countByStatus('pending') + } + + getForeshadowStats(): { buried: number; resolved: number; forgotten: number } { + const approved = this.repo.list({ type: 'foreshadow', status: 'approved' }) + let buried = 0 + let resolved = 0 + let forgotten = 0 + for (const entry of approved) { + const fs = entry.foreshadowStatus ?? 'buried' + if (fs === 'resolved') resolved++ + else if (fs === 'buried') { + buried++ + if (this.isForgotten(entry)) forgotten++ + } else buried++ + } + return { buried, resolved, forgotten } + } + + isForgotten(entry: KnowledgeEntry): boolean { + if (entry.type !== 'foreshadow' || entry.status !== 'approved') return false + if ((entry.foreshadowStatus ?? 'buried') !== 'buried') return false + const mentionId = entry.lastMentionChapterId ?? entry.sourceChapterId + if (!mentionId) return false + const mention = this.chapterRepo.get(mentionId) + if (!mention) return false + const all = this.chapterRepo.list() + const latestOrder = Math.max(...all.map((c) => c.sortOrder), 0) + return latestOrder - mention.sortOrder >= FORGOTTEN_GAP + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 205fdd7..04d8156 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -35,7 +35,14 @@ import type { SnapshotType, UpdateChapterParams, Volume, - WordFreqResult + WordFreqResult, + KnowledgeEntry, + CreateKnowledgeInput, + KnowledgeStatus, + KnowledgeType, + CockpitSummary, + ChapterBridgeResult, + PublishStatus } from '../shared/types' const electronAPI = { @@ -91,7 +98,13 @@ const electronAPI = { targetVolumeId: string, targetIndex: number ): Promise> => - ipcRenderer.invoke(IPC.CHAPTER_MOVE_TO_VOLUME, { bookId, chapterId, targetVolumeId, targetIndex }) + ipcRenderer.invoke(IPC.CHAPTER_MOVE_TO_VOLUME, { bookId, chapterId, targetVolumeId, targetIndex }), + setPublishStatus: ( + bookId: string, + chapterId: string, + publishStatus: PublishStatus + ): Promise> => + ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus }) }, outline: { list: (bookId: string): Promise> => @@ -408,6 +421,55 @@ const electronAPI = { abort: (flowId: string): Promise> => ipcRenderer.invoke(IPC.WIZARD_ABORT, { flowId }) }, + knowledge: { + list: ( + bookId: string, + filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean } + ): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_LIST, { bookId, filter }), + create: (bookId: string, input: CreateKnowledgeInput): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_CREATE, { bookId, input }), + update: ( + bookId: string, + id: string, + patch: Partial + ): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_UPDATE, { bookId, id, patch }), + delete: (bookId: string, id: string): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_DELETE, { bookId, id }), + approve: (bookId: string, id: string): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_APPROVE, { bookId, id }), + reject: (bookId: string, id: string): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_REJECT, { bookId, id }), + batchApprove: (bookId: string, ids: string[]): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_BATCH_APPROVE, { bookId, ids }), + createFromBookmark: (bookId: string, bookmarkId: string): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_CREATE_FROM_BOOKMARK, { bookId, bookmarkId }), + stats: ( + bookId: string + ): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_STATS, { bookId }) + }, + cockpit: { + getSummary: (bookId: string, volumeId?: string): Promise> => + ipcRenderer.invoke(IPC.COCKPIT_SUMMARY, { bookId, volumeId }), + markSeen: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.COCKPIT_MARK_SEEN, { bookId }), + shouldShowOnOpen: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.COCKPIT_SHOULD_SHOW, { bookId }) + }, + bridge: { + get: ( + bookId: string, + chapterId: string, + withAi?: boolean + ): Promise> => + ipcRenderer.invoke(IPC.BRIDGE_GET, { bookId, chapterId, withAi }), + dismiss: (bookId: string, chapterId: string): Promise> => + ipcRenderer.invoke(IPC.BRIDGE_DISMISS, { bookId, chapterId }), + shouldPrompt: (bookId: string, chapterId: string): Promise> => + ipcRenderer.invoke(IPC.BRIDGE_SHOULD_PROMPT, { bookId, chapterId }) + }, wordfreq: { analyze: ( bookId: string, diff --git a/src/renderer/components/bridge/ChapterBridgeModal.tsx b/src/renderer/components/bridge/ChapterBridgeModal.tsx new file mode 100644 index 0000000..d78b05c --- /dev/null +++ b/src/renderer/components/bridge/ChapterBridgeModal.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { ChapterBridgeResult } from '@shared/types' +import { useBookStore } from '@renderer/stores/useBookStore' +import { ipcCall } from '@renderer/lib/ipc-client' + +interface ChapterBridgeModalProps { + open: boolean + chapterId: string | null + onClose: () => void +} + +export function ChapterBridgeModal({ + open, + chapterId, + onClose +}: ChapterBridgeModalProps): React.JSX.Element | null { + const { t } = useTranslation() + const bookId = useBookStore((s) => s.currentBookId) + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(false) + const [dismiss, setDismiss] = useState(false) + + useEffect(() => { + if (!open || !bookId || !chapterId) return + setLoading(true) + setResult(null) + void ipcCall(() => window.electronAPI.bridge.get(bookId, chapterId, true)) + .then(setResult) + .finally(() => setLoading(false)) + }, [open, bookId, chapterId]) + + const handleClose = async (): Promise => { + if (dismiss && bookId && chapterId) { + await ipcCall(() => window.electronAPI.bridge.dismiss(bookId, chapterId)) + } + setDismiss(false) + onClose() + } + + if (!open) return null + + return ( +
+
+
+

{t('bridge.title')}

+ +
+
+ {loading ? ( +
{t('bridge.loading')}
+ ) : result ? ( + <> +
+

{t('bridge.previousTail')}

+

{result.previousTail || t('bridge.none')}

+
+
+

{t('bridge.currentHead')}

+

{result.currentHead || t('bridge.none')}

+
+ {result.aiSuggestion && ( +
+

{t('bridge.aiSuggestion')}

+

{result.aiSuggestion}

+
+ )} + + ) : ( +
{t('bridge.empty')}
+ )} + +
+
+ +
+
+
+ ) +} diff --git a/src/renderer/components/cockpit/CockpitModal.tsx b/src/renderer/components/cockpit/CockpitModal.tsx new file mode 100644 index 0000000..e6f7bf5 --- /dev/null +++ b/src/renderer/components/cockpit/CockpitModal.tsx @@ -0,0 +1,120 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useEditStore } from '@renderer/stores/useEditStore' +import { useCockpitStore } from '@renderer/stores/useCockpitStore' +import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore' + +interface CockpitModalProps { + onOpenBridge: (chapterId: string) => void +} + +export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Element | null { + const { t } = useTranslation() + const bookId = useBookStore((s) => s.currentBookId) + const activeVolumeId = useBookStore((s) => s.activeVolumeId) + const selectedChapterId = useBookStore((s) => s.selectedChapterId) + const setSelectedChapter = useBookStore((s) => s.setSelectedChapter) + const switchTarget = useEditStore((s) => s.switchTarget) + const { open, summary, loading, close, loadSummary } = useCockpitStore() + const openForgottenFilter = useKnowledgeStore((s) => s.openForgottenFilter) + + useEffect(() => { + if (!open || !bookId) return + void loadSummary(bookId, activeVolumeId ?? undefined) + }, [open, bookId, activeVolumeId, loadSummary]) + + if (!open) return null + + const handleResume = async (): Promise => { + if (selectedChapterId) { + setSelectedChapter(selectedChapterId) + await switchTarget({ kind: 'chapter', id: selectedChapterId }) + } + close() + } + + const handleBridge = (): void => { + const chapterId = selectedChapterId + if (chapterId) { + close() + onOpenBridge(chapterId) + } + } + + const handleForgotten = (): void => { + openForgottenFilter() + close() + } + + return ( +
+
+
+

{t('cockpit.title')}

+ +
+
+ {loading || !summary ? ( +
{t('cockpit.loading')}
+ ) : ( +
+
+
{t('cockpit.totalWords')}
+
{summary.totalWords.toLocaleString()}
+
+
+
{t('cockpit.volumeWords')}
+
{summary.volumeWords.toLocaleString()}
+
+
+
{t('cockpit.stockReady')}
+
+ {summary.stockReadyCount} / {summary.stockThreshold} +
+
+
+
{t('cockpit.foreshadow')}
+
+ {t('cockpit.foreshadowDetail', { + buried: summary.foreshadowBuried, + resolved: summary.foreshadowResolved, + forgotten: summary.foreshadowForgotten + })} +
+
+
+ )} +
+
+ + + +
+
+
+ ) +} diff --git a/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx b/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx new file mode 100644 index 0000000..c9e0c92 --- /dev/null +++ b/src/renderer/components/knowledge/KnowledgeEditorDialog.tsx @@ -0,0 +1,216 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { + CreateKnowledgeInput, + ForeshadowStatus, + KnowledgeEntry, + KnowledgeType +} from '@shared/types' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore' +import { ipcCall } from '@renderer/lib/ipc-client' + +interface KnowledgeEditorDialogProps { + open: boolean + entryId: string | null + onClose: () => void +} + +const TYPES: KnowledgeType[] = ['character', 'foreshadow', 'location', 'relationship', 'fact'] + +export function KnowledgeEditorDialog({ + open, + entryId, + onClose +}: KnowledgeEditorDialogProps): React.JSX.Element | null { + const { t } = useTranslation() + const bookId = useBookStore((s) => s.currentBookId) + const chapters = useBookStore((s) => s.chapters) + const entries = useKnowledgeStore((s) => s.entries) + const create = useKnowledgeStore((s) => s.create) + const update = useKnowledgeStore((s) => s.update) + + const existing = entryId ? entries.find((e) => e.id === entryId) : null + + const [type, setType] = useState('fact') + const [title, setTitle] = useState('') + const [content, setContent] = useState('') + const [importance, setImportance] = useState(3) + const [foreshadowStatus, setForeshadowStatus] = useState('buried') + const [sourceChapterId, setSourceChapterId] = useState('') + const [approveNow, setApproveNow] = useState(false) + + useEffect(() => { + if (!open) return + if (existing) { + setType(existing.type) + setTitle(existing.title) + setContent(existing.content) + setImportance(existing.importance) + setForeshadowStatus(existing.foreshadowStatus ?? 'buried') + setSourceChapterId(existing.sourceChapterId ?? '') + setApproveNow(existing.status === 'approved') + } else { + setType('fact') + setTitle('') + setContent('') + setImportance(3) + setForeshadowStatus('buried') + setSourceChapterId(chapters[0]?.id ?? '') + setApproveNow(false) + } + }, [open, existing, chapters]) + + const handleSave = async (): Promise => { + if (!bookId || !title.trim()) return + const input: CreateKnowledgeInput = { + type, + title: title.trim(), + content, + importance, + foreshadowStatus: type === 'foreshadow' ? foreshadowStatus : undefined, + sourceChapterId: sourceChapterId || undefined, + status: approveNow ? 'approved' : 'pending' + } + if (existing) { + await update(bookId, existing.id, input) + } else { + await create(bookId, input) + } + onClose() + } + + if (!open) return null + + return ( +
+
+
+

{existing ? t('knowledge.edit') : t('knowledge.new')}

+ +
+
+ + +