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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
# 笔临 (Bilin)
|
# 笔临 (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)
|
- 自动写作:章节目标、场景规划、连续生成、暂停转交互(P4.1)
|
||||||
- 向导式写作:5 步引导、策略预览、生成后交互微调(P4.1)
|
- 向导式写作:5 步引导、策略预览、生成后交互微调(P4.1)
|
||||||
- 交互式写作:剧情走向建议、场景生成、命名暂停、微调确认、成章落盘(P4)
|
- 交互式写作:剧情走向建议、场景生成、命名暂停、微调确认、成章落盘(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/specs/2026-07-09-bilin-p41-auto-wizard-design.md`
|
||||||
- P4.1 实现计划:`docs/superpowers/plans/2026-07-09-bilin-p41-auto-wizard.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`
|
- P4 交互式写作设计规格:`docs/superpowers/specs/2026-07-08-bilin-p4-interactive-design.md`
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await page.getByRole('button', { name: '跳过' }).click()
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissCockpitIfOpen(page: Page): Promise<void> {
|
||||||
|
const cockpit = page.locator('[data-testid="cockpit-modal"]')
|
||||||
|
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||||
|
await page.locator('[data-testid="cockpit-modal"] .modal-close').click()
|
||||||
|
await expect(cockpit).toBeHidden({ timeout: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndOpenBook(page: Page, name: string): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||||
|
await page.locator('.dialog-content input').first().fill(name)
|
||||||
|
await page.getByRole('button', { name: '创建' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await dismissCockpitIfOpen(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('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()
|
||||||
|
})
|
||||||
|
})
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"description": "笔临 - 长篇创作智能协作平台",
|
"description": "笔临 - 长篇创作智能协作平台",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dialog.newBook.category": "Category",
|
"dialog.newBook.category": "Category",
|
||||||
"dialog.newBook.target": "Target word count (optional)",
|
"dialog.newBook.target": "Target word count (optional)",
|
||||||
"dialog.cancel": "Cancel",
|
"dialog.cancel": "Cancel",
|
||||||
|
"dialog.save": "Save",
|
||||||
"dialog.create": "Create",
|
"dialog.create": "Create",
|
||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
"settings.general": "General",
|
"settings.general": "General",
|
||||||
@@ -159,6 +160,68 @@
|
|||||||
"naming.ignored": "Added to ignore list",
|
"naming.ignored": "Added to ignore list",
|
||||||
"naming.replaced": "Replaced {{count}} occurrence(s)",
|
"naming.replaced": "Replaced {{count}} occurrence(s)",
|
||||||
"knowledge.placeholder": "Knowledge base entries will appear here",
|
"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": "Backend",
|
||||||
"ai.settings.backend.lmstudio": "LM Studio (local)",
|
"ai.settings.backend.lmstudio": "LM Studio (local)",
|
||||||
"ai.settings.backend.openai": "OpenAI",
|
"ai.settings.backend.openai": "OpenAI",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dialog.newBook.category": "分类",
|
"dialog.newBook.category": "分类",
|
||||||
"dialog.newBook.target": "目标总字数(可选)",
|
"dialog.newBook.target": "目标总字数(可选)",
|
||||||
"dialog.cancel": "取消",
|
"dialog.cancel": "取消",
|
||||||
|
"dialog.save": "保存",
|
||||||
"dialog.create": "创建",
|
"dialog.create": "创建",
|
||||||
"settings.title": "系统设置",
|
"settings.title": "系统设置",
|
||||||
"settings.general": "通用",
|
"settings.general": "通用",
|
||||||
@@ -159,6 +160,68 @@
|
|||||||
"naming.ignored": "已加入忽略词库",
|
"naming.ignored": "已加入忽略词库",
|
||||||
"naming.replaced": "已替换 {{count}} 处",
|
"naming.replaced": "已替换 {{count}} 处",
|
||||||
"knowledge.placeholder": "知识库条目将在此展示(伏笔、角色状态等)",
|
"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": "后端类型",
|
||||||
"ai.settings.backend.lmstudio": "LM Studio(本地)",
|
"ai.settings.backend.lmstudio": "LM Studio(本地)",
|
||||||
"ai.settings.backend.openai": "OpenAI",
|
"ai.settings.backend.openai": "OpenAI",
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import schemaV2 from './schema-v2.sql?raw'
|
|||||||
import schemaV3 from './schema-v3.sql?raw'
|
import schemaV3 from './schema-v3.sql?raw'
|
||||||
import schemaV4 from './schema-v4.sql?raw'
|
import schemaV4 from './schema-v4.sql?raw'
|
||||||
import schemaV5 from './schema-v5.sql?raw'
|
import schemaV5 from './schema-v5.sql?raw'
|
||||||
|
import schemaV6 from './schema-v6.sql?raw'
|
||||||
|
|
||||||
const CURRENT_VERSION = 5
|
const CURRENT_VERSION = 6
|
||||||
|
|
||||||
function tryAlter(db: SqliteDb, sql: string): void {
|
function tryAlter(db: SqliteDb, sql: string): void {
|
||||||
try {
|
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 flow_mode TEXT NOT NULL DEFAULT 'interactive'")
|
||||||
tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}'")
|
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')
|
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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { countWords } from '../../services/word-count'
|
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'
|
import type { SqliteDb } from '../types'
|
||||||
function mapRow(row: Record<string, unknown>): Chapter {
|
function mapRow(row: Record<string, unknown>): Chapter {
|
||||||
return {
|
return {
|
||||||
@@ -12,7 +12,8 @@ function mapRow(row: Record<string, unknown>): Chapter {
|
|||||||
wordCount: row.word_count as number,
|
wordCount: row.word_count as number,
|
||||||
sortOrder: row.sort_order as number,
|
sortOrder: row.sort_order as number,
|
||||||
cursorOffset: (row.cursor_offset as number) ?? 0,
|
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(
|
update(
|
||||||
id: string,
|
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 {
|
): Chapter {
|
||||||
const existing = this.get(id)
|
const existing = this.get(id)
|
||||||
if (!existing) throw new Error('Chapter not found')
|
if (!existing) throw new Error('Chapter not found')
|
||||||
@@ -71,7 +78,7 @@ export class ChapterRepository {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE chapters SET
|
`UPDATE chapters SET
|
||||||
title = ?, content = ?, status = ?, cursor_offset = ?,
|
title = ?, content = ?, status = ?, cursor_offset = ?,
|
||||||
word_count = ?, updated_at = datetime('now')
|
publish_status = ?, word_count = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?`
|
WHERE id = ?`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
@@ -79,12 +86,37 @@ export class ChapterRepository {
|
|||||||
content,
|
content,
|
||||||
patch.status ?? existing.status,
|
patch.status ?? existing.status,
|
||||||
patch.cursorOffset ?? existing.cursorOffset,
|
patch.cursorOffset ?? existing.cursorOffset,
|
||||||
|
patch.publishStatus ?? existing.publishStatus ?? 'draft',
|
||||||
wordCount,
|
wordCount,
|
||||||
id
|
id
|
||||||
)
|
)
|
||||||
return this.get(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<string, unknown> | undefined
|
||||||
|
return row ? mapRow(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
delete(id: string): void {
|
delete(id: string): void {
|
||||||
this.db.prepare('DELETE FROM chapters WHERE id = ?').run(id)
|
this.db.prepare('DELETE FROM chapters WHERE id = ?').run(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>): 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<string, unknown>
|
||||||
|
| 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<string, unknown>[]).map(mapRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>): 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import { IPC } from '../../../shared/ipc-channels'
|
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 { BookRegistryService } from '../../services/book-registry'
|
||||||
import { ftsSync } from '../../services/fts-sync.service'
|
import { ftsSync } from '../../services/fts-sync.service'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
@@ -124,4 +124,12 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
|
|||||||
) =>
|
) =>
|
||||||
wrap(() => registry.getChapterRepo(bookId).moveToVolume(chapterId, targetVolumeId, targetIndex))
|
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))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<CreateKnowledgeInput & { status: KnowledgeStatus }> }
|
||||||
|
) => 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())
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ import { registerAiHandlers } from './handlers/ai.handler'
|
|||||||
import { registerInteractiveHandlers } from './handlers/interactive.handler'
|
import { registerInteractiveHandlers } from './handlers/interactive.handler'
|
||||||
import { registerAutoHandlers } from './handlers/auto.handler'
|
import { registerAutoHandlers } from './handlers/auto.handler'
|
||||||
import { registerWizardHandlers } from './handlers/wizard.handler'
|
import { registerWizardHandlers } from './handlers/wizard.handler'
|
||||||
|
import { registerKnowledgeHandlers } from './handlers/knowledge.handler'
|
||||||
|
import { registerCockpitHandlers } from './handlers/cockpit.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'
|
||||||
|
|
||||||
@@ -49,6 +52,9 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
|||||||
registerInteractiveHandlers(books, settings, aiClient)
|
registerInteractiveHandlers(books, settings, aiClient)
|
||||||
registerAutoHandlers(books, settings, aiClient)
|
registerAutoHandlers(books, settings, aiClient)
|
||||||
registerWizardHandlers(books, settings, aiClient)
|
registerWizardHandlers(books, settings, aiClient)
|
||||||
|
registerKnowledgeHandlers(books)
|
||||||
|
registerCockpitHandlers(books, settings)
|
||||||
|
registerBridgeHandlers(books, aiClient)
|
||||||
|
|
||||||
return { settings, books, shortcuts: shortcutManager }
|
return { settings, books, shortcuts: shortcutManager }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ChapterBridgeResult> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ function defaults(): GlobalSettings {
|
|||||||
theme: 'default',
|
theme: 'default',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
dailyWordGoal: 0,
|
dailyWordGoal: 0,
|
||||||
|
updateSchedule: 'none',
|
||||||
|
stockBufferThreshold: 3,
|
||||||
shortcuts: { ...DEFAULT_SHORTCUTS },
|
shortcuts: { ...DEFAULT_SHORTCUTS },
|
||||||
snapshotIntervalMs: 300_000,
|
snapshotIntervalMs: 300_000,
|
||||||
snapshotMaxAuto: 20,
|
snapshotMaxAuto: 20,
|
||||||
@@ -45,6 +47,8 @@ export class GlobalSettingsService {
|
|||||||
return {
|
return {
|
||||||
...defaults(),
|
...defaults(),
|
||||||
...raw,
|
...raw,
|
||||||
|
updateSchedule: raw.updateSchedule ?? 'none',
|
||||||
|
stockBufferThreshold: raw.stockBufferThreshold ?? 3,
|
||||||
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,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<CreateKnowledgeInput & { status: KnowledgeStatus }>): 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-2
@@ -35,7 +35,14 @@ import type {
|
|||||||
SnapshotType,
|
SnapshotType,
|
||||||
UpdateChapterParams,
|
UpdateChapterParams,
|
||||||
Volume,
|
Volume,
|
||||||
WordFreqResult
|
WordFreqResult,
|
||||||
|
KnowledgeEntry,
|
||||||
|
CreateKnowledgeInput,
|
||||||
|
KnowledgeStatus,
|
||||||
|
KnowledgeType,
|
||||||
|
CockpitSummary,
|
||||||
|
ChapterBridgeResult,
|
||||||
|
PublishStatus
|
||||||
} from '../shared/types'
|
} from '../shared/types'
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
@@ -91,7 +98,13 @@ const electronAPI = {
|
|||||||
targetVolumeId: string,
|
targetVolumeId: string,
|
||||||
targetIndex: number
|
targetIndex: number
|
||||||
): Promise<IpcResult<Chapter>> =>
|
): Promise<IpcResult<Chapter>> =>
|
||||||
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<IpcResult<Chapter>> =>
|
||||||
|
ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus })
|
||||||
},
|
},
|
||||||
outline: {
|
outline: {
|
||||||
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
|
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
|
||||||
@@ -408,6 +421,55 @@ const electronAPI = {
|
|||||||
abort: (flowId: string): Promise<IpcResult<void>> =>
|
abort: (flowId: string): Promise<IpcResult<void>> =>
|
||||||
ipcRenderer.invoke(IPC.WIZARD_ABORT, { flowId })
|
ipcRenderer.invoke(IPC.WIZARD_ABORT, { flowId })
|
||||||
},
|
},
|
||||||
|
knowledge: {
|
||||||
|
list: (
|
||||||
|
bookId: string,
|
||||||
|
filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean }
|
||||||
|
): Promise<IpcResult<KnowledgeEntry[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_LIST, { bookId, filter }),
|
||||||
|
create: (bookId: string, input: CreateKnowledgeInput): Promise<IpcResult<KnowledgeEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_CREATE, { bookId, input }),
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>
|
||||||
|
): Promise<IpcResult<KnowledgeEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_UPDATE, { bookId, id, patch }),
|
||||||
|
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_DELETE, { bookId, id }),
|
||||||
|
approve: (bookId: string, id: string): Promise<IpcResult<KnowledgeEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_APPROVE, { bookId, id }),
|
||||||
|
reject: (bookId: string, id: string): Promise<IpcResult<KnowledgeEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_REJECT, { bookId, id }),
|
||||||
|
batchApprove: (bookId: string, ids: string[]): Promise<IpcResult<number>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_BATCH_APPROVE, { bookId, ids }),
|
||||||
|
createFromBookmark: (bookId: string, bookmarkId: string): Promise<IpcResult<KnowledgeEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_CREATE_FROM_BOOKMARK, { bookId, bookmarkId }),
|
||||||
|
stats: (
|
||||||
|
bookId: string
|
||||||
|
): Promise<IpcResult<{ buried: number; resolved: number; forgotten: number }>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_STATS, { bookId })
|
||||||
|
},
|
||||||
|
cockpit: {
|
||||||
|
getSummary: (bookId: string, volumeId?: string): Promise<IpcResult<CockpitSummary>> =>
|
||||||
|
ipcRenderer.invoke(IPC.COCKPIT_SUMMARY, { bookId, volumeId }),
|
||||||
|
markSeen: (bookId: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.COCKPIT_MARK_SEEN, { bookId }),
|
||||||
|
shouldShowOnOpen: (bookId: string): Promise<IpcResult<boolean>> =>
|
||||||
|
ipcRenderer.invoke(IPC.COCKPIT_SHOULD_SHOW, { bookId })
|
||||||
|
},
|
||||||
|
bridge: {
|
||||||
|
get: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
withAi?: boolean
|
||||||
|
): Promise<IpcResult<ChapterBridgeResult>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BRIDGE_GET, { bookId, chapterId, withAi }),
|
||||||
|
dismiss: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BRIDGE_DISMISS, { bookId, chapterId }),
|
||||||
|
shouldPrompt: (bookId: string, chapterId: string): Promise<IpcResult<boolean>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BRIDGE_SHOULD_PROMPT, { bookId, chapterId })
|
||||||
|
},
|
||||||
wordfreq: {
|
wordfreq: {
|
||||||
analyze: (
|
analyze: (
|
||||||
bookId: string,
|
bookId: string,
|
||||||
|
|||||||
@@ -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<ChapterBridgeResult | null>(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<void> => {
|
||||||
|
if (dismiss && bookId && chapterId) {
|
||||||
|
await ipcCall(() => window.electronAPI.bridge.dismiss(bookId, chapterId))
|
||||||
|
}
|
||||||
|
setDismiss(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" data-testid="bridge-modal">
|
||||||
|
<div className="modal-content modal-content--wide">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{t('bridge.title')}</h3>
|
||||||
|
<button type="button" className="modal-close" onClick={() => void handleClose()}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body bridge-body">
|
||||||
|
{loading ? (
|
||||||
|
<div className="sidebar-empty">{t('bridge.loading')}</div>
|
||||||
|
) : result ? (
|
||||||
|
<>
|
||||||
|
<div className="bridge-section">
|
||||||
|
<h4>{t('bridge.previousTail')}</h4>
|
||||||
|
<p data-testid="bridge-previous-tail">{result.previousTail || t('bridge.none')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bridge-section">
|
||||||
|
<h4>{t('bridge.currentHead')}</h4>
|
||||||
|
<p data-testid="bridge-current-head">{result.currentHead || t('bridge.none')}</p>
|
||||||
|
</div>
|
||||||
|
{result.aiSuggestion && (
|
||||||
|
<div className="bridge-section">
|
||||||
|
<h4>{t('bridge.aiSuggestion')}</h4>
|
||||||
|
<p data-testid="bridge-ai-suggestion">{result.aiSuggestion}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="sidebar-empty">{t('bridge.empty')}</div>
|
||||||
|
)}
|
||||||
|
<label className="bridge-dismiss">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="bridge-dismiss-checkbox"
|
||||||
|
checked={dismiss}
|
||||||
|
onChange={(e) => setDismiss(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('bridge.dismissLabel')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="bridge-start-writing"
|
||||||
|
onClick={() => void handleClose()}
|
||||||
|
>
|
||||||
|
{t('bridge.startWriting')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<void> => {
|
||||||
|
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 (
|
||||||
|
<div className="modal-overlay" data-testid="cockpit-modal">
|
||||||
|
<div className="modal-content modal-content--wide">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{t('cockpit.title')}</h3>
|
||||||
|
<button type="button" className="modal-close" onClick={close}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="cockpit-body">
|
||||||
|
{loading || !summary ? (
|
||||||
|
<div className="sidebar-empty">{t('cockpit.loading')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="cockpit-grid">
|
||||||
|
<div className="cockpit-card" data-testid="cockpit-total-words">
|
||||||
|
<div className="cockpit-card-label">{t('cockpit.totalWords')}</div>
|
||||||
|
<div className="cockpit-card-value">{summary.totalWords.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="cockpit-card" data-testid="cockpit-volume-words">
|
||||||
|
<div className="cockpit-card-label">{t('cockpit.volumeWords')}</div>
|
||||||
|
<div className="cockpit-card-value">{summary.volumeWords.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="cockpit-card" data-testid="cockpit-stock-count">
|
||||||
|
<div className="cockpit-card-label">{t('cockpit.stockReady')}</div>
|
||||||
|
<div className="cockpit-card-value">
|
||||||
|
{summary.stockReadyCount} / {summary.stockThreshold}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cockpit-card" data-testid="cockpit-foreshadow-summary">
|
||||||
|
<div className="cockpit-card-label">{t('cockpit.foreshadow')}</div>
|
||||||
|
<div className="cockpit-card-value">
|
||||||
|
{t('cockpit.foreshadowDetail', {
|
||||||
|
buried: summary.foreshadowBuried,
|
||||||
|
resolved: summary.foreshadowResolved,
|
||||||
|
forgotten: summary.foreshadowForgotten
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer cockpit-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="cockpit-resume-edit"
|
||||||
|
onClick={() => void handleResume()}
|
||||||
|
>
|
||||||
|
{t('cockpit.resumeEdit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
data-testid="cockpit-bridge-check"
|
||||||
|
onClick={handleBridge}
|
||||||
|
>
|
||||||
|
{t('cockpit.bridgeCheck')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
data-testid="cockpit-forgotten-foreshadow"
|
||||||
|
onClick={handleForgotten}
|
||||||
|
>
|
||||||
|
{t('cockpit.forgottenForeshadow')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<KnowledgeType>('fact')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [importance, setImportance] = useState(3)
|
||||||
|
const [foreshadowStatus, setForeshadowStatus] = useState<ForeshadowStatus>('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<void> => {
|
||||||
|
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 (
|
||||||
|
<div className="modal-overlay" data-testid="knowledge-editor-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{existing ? t('knowledge.edit') : t('knowledge.new')}</h3>
|
||||||
|
<button type="button" className="modal-close" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<label>
|
||||||
|
{t('knowledge.type')}
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
data-testid="knowledge-type-select"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as KnowledgeType)}
|
||||||
|
>
|
||||||
|
{TYPES.map((tp) => (
|
||||||
|
<option key={tp} value={tp}>
|
||||||
|
{t(`knowledge.type.${tp}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('knowledge.titleLabel')}
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
data-testid="knowledge-title-input"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('knowledge.contentLabel')}
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
rows={4}
|
||||||
|
data-testid="knowledge-content-input"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('knowledge.importance')}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={importance}
|
||||||
|
onChange={(e) => setImportance(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
{importance}
|
||||||
|
</label>
|
||||||
|
{type === 'foreshadow' && (
|
||||||
|
<label>
|
||||||
|
{t('knowledge.foreshadowStatus')}
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
value={foreshadowStatus}
|
||||||
|
onChange={(e) => setForeshadowStatus(e.target.value as ForeshadowStatus)}
|
||||||
|
>
|
||||||
|
<option value="buried">{t('knowledge.foreshadow.buried')}</option>
|
||||||
|
<option value="partial">{t('knowledge.foreshadow.partial')}</option>
|
||||||
|
<option value="resolved">{t('knowledge.foreshadow.resolved')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
{t('knowledge.sourceChapter')}
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
value={sourceChapterId}
|
||||||
|
onChange={(e) => setSourceChapterId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('knowledge.noChapter')}</option>
|
||||||
|
{chapters.map((ch) => (
|
||||||
|
<option key={ch.id} value={ch.id}>
|
||||||
|
{ch.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{!existing && (
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="knowledge-approve-now"
|
||||||
|
checked={approveNow}
|
||||||
|
onChange={(e) => setApproveNow(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('knowledge.approveNow')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
{t('dialog.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="knowledge-save"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
{t('dialog.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadKnowledgeEntries(
|
||||||
|
bookId: string,
|
||||||
|
tab: 'pending' | 'all' | 'foreshadow',
|
||||||
|
forgottenOnly: boolean
|
||||||
|
): Promise<{ entries: KnowledgeEntry[]; stats: { buried: number; resolved: number; forgotten: number } }> {
|
||||||
|
let filter: { status?: 'pending'; type?: 'foreshadow'; forgottenOnly?: boolean } | undefined
|
||||||
|
if (tab === 'pending') filter = { status: 'pending' }
|
||||||
|
else if (tab === 'foreshadow') {
|
||||||
|
filter = forgottenOnly
|
||||||
|
? { type: 'foreshadow', forgottenOnly: true }
|
||||||
|
: { type: 'foreshadow' }
|
||||||
|
}
|
||||||
|
const [entries, stats] = await Promise.all([
|
||||||
|
ipcCall(() => window.electronAPI.knowledge.list(bookId, filter)),
|
||||||
|
ipcCall(() => window.electronAPI.knowledge.stats(bookId))
|
||||||
|
])
|
||||||
|
return { entries, stats }
|
||||||
|
}
|
||||||
@@ -1,15 +1,68 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||||
|
import {
|
||||||
|
KnowledgeEditorDialog,
|
||||||
|
loadKnowledgeEntries
|
||||||
|
} from '@renderer/components/knowledge/KnowledgeEditorDialog'
|
||||||
|
|
||||||
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 {
|
||||||
|
entries,
|
||||||
|
stats,
|
||||||
|
tab,
|
||||||
|
forgottenOnly,
|
||||||
|
editorOpen,
|
||||||
|
editingId,
|
||||||
|
setTab,
|
||||||
|
openEditor,
|
||||||
|
closeEditor,
|
||||||
|
approve,
|
||||||
|
reject,
|
||||||
|
deleteEntry,
|
||||||
|
batchApprove
|
||||||
|
} = useKnowledgeStore()
|
||||||
const [namingOpen, setNamingOpen] = useState(false)
|
const [namingOpen, setNamingOpen] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bookId) return
|
||||||
|
void loadKnowledgeEntries(bookId, tab, forgottenOnly).then(({ entries, stats }) => {
|
||||||
|
useKnowledgeStore.setState({ entries, stats })
|
||||||
|
})
|
||||||
|
}, [bookId, tab, forgottenOnly])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bookId) return
|
||||||
|
void ipcCall(() => window.electronAPI.knowledge.list(bookId, { status: 'pending' })).then(
|
||||||
|
(list) => setPendingCount(list.length)
|
||||||
|
)
|
||||||
|
}, [bookId, entries])
|
||||||
|
|
||||||
|
const handleBatchApprove = async (): Promise<void> => {
|
||||||
|
if (!bookId || selected.size === 0) return
|
||||||
|
await batchApprove(bookId, [...selected])
|
||||||
|
setSelected(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="knowledge-panel" data-testid="knowledge-panel">
|
<div className="knowledge-panel" data-testid="knowledge-panel">
|
||||||
<div className="knowledge-toolbar">
|
<div className="knowledge-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="knowledge-new"
|
||||||
|
onClick={() => openEditor(null)}
|
||||||
|
>
|
||||||
|
{t('knowledge.new')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
@@ -19,8 +72,115 @@ export function KnowledgePanel(): React.JSX.Element {
|
|||||||
{t('naming.open')}
|
{t('naming.open')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="placeholder-box">{t('knowledge.placeholder')}</div>
|
<div className="knowledge-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`kb-tab ${tab === 'pending' ? 'active' : ''}`}
|
||||||
|
data-testid="knowledge-tab-pending"
|
||||||
|
onClick={() => setTab('pending')}
|
||||||
|
>
|
||||||
|
{t('knowledge.tab.pending')}
|
||||||
|
{pendingCount > 0 && <span className="kb-badge">{pendingCount}</span>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`kb-tab ${tab === 'all' ? 'active' : ''}`}
|
||||||
|
data-testid="knowledge-tab-all"
|
||||||
|
onClick={() => setTab('all')}
|
||||||
|
>
|
||||||
|
{t('knowledge.tab.all')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`kb-tab ${tab === 'foreshadow' ? 'active' : ''}`}
|
||||||
|
data-testid="knowledge-tab-foreshadow"
|
||||||
|
onClick={() => setTab('foreshadow')}
|
||||||
|
>
|
||||||
|
{t('knowledge.tab.foreshadow')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{tab === 'foreshadow' && (
|
||||||
|
<div className="knowledge-stats" data-testid="knowledge-foreshadow-stats">
|
||||||
|
{t('knowledge.statsSummary', {
|
||||||
|
buried: stats.buried,
|
||||||
|
resolved: stats.resolved,
|
||||||
|
forgotten: stats.forgotten
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'pending' && selected.size > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
data-testid="knowledge-batch-approve"
|
||||||
|
onClick={() => void handleBatchApprove()}
|
||||||
|
>
|
||||||
|
{t('knowledge.batchApprove', { count: selected.size })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="knowledge-list">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div key={entry.id} className="kb-item" data-testid={`knowledge-item-${entry.id}`}>
|
||||||
|
{tab === 'pending' && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(entry.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = new Set(selected)
|
||||||
|
if (e.target.checked) next.add(entry.id)
|
||||||
|
else next.delete(entry.id)
|
||||||
|
setSelected(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="kb-item-main">
|
||||||
|
<div className="kb-item-title">{entry.title}</div>
|
||||||
|
<div className="kb-item-meta">
|
||||||
|
{t(`knowledge.type.${entry.type}`)} · {t(`knowledge.status.${entry.status}`)}
|
||||||
|
</div>
|
||||||
|
{entry.content && <div className="kb-item-content">{entry.content}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="kb-item-actions">
|
||||||
|
{entry.status === 'pending' && bookId && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
data-testid={`knowledge-approve-${entry.id}`}
|
||||||
|
onClick={() => void approve(bookId, entry.id)}
|
||||||
|
>
|
||||||
|
{t('knowledge.approve')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => void reject(bookId, entry.id)}
|
||||||
|
>
|
||||||
|
{t('knowledge.reject')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => openEditor(entry.id)}>
|
||||||
|
{t('knowledge.edit')}
|
||||||
|
</button>
|
||||||
|
{bookId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => void deleteEntry(bookId, entry.id)}
|
||||||
|
>
|
||||||
|
{t('knowledge.delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className="sidebar-empty">{t('knowledge.empty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<KnowledgeEditorDialog open={editorOpen} entryId={editingId} onClose={closeEditor} />
|
||||||
<NamingCheckModal open={namingOpen} onClose={() => setNamingOpen(false)} />
|
<NamingCheckModal open={namingOpen} onClose={() => setNamingOpen(false)} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
import type { CockpitSummary, PublishStatus } from '@shared/types'
|
||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
import { useOutlineStore } from '@renderer/stores/useOutlineStore'
|
import { useOutlineStore } from '@renderer/stores/useOutlineStore'
|
||||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||||
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
|
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||||
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'
|
||||||
@@ -17,6 +20,8 @@ import { SettingList } from '@renderer/components/setting/SettingList'
|
|||||||
import { InspirationList } from '@renderer/components/inspiration/InspirationList'
|
import { InspirationList } from '@renderer/components/inspiration/InspirationList'
|
||||||
import { VersionModal } from '@renderer/components/version/VersionModal'
|
import { VersionModal } from '@renderer/components/version/VersionModal'
|
||||||
import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
|
import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
|
||||||
|
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
|
||||||
|
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
|
||||||
import {
|
import {
|
||||||
editorDirtyAtom,
|
editorDirtyAtom,
|
||||||
editorSavingAtom,
|
editorSavingAtom,
|
||||||
@@ -24,6 +29,19 @@ import {
|
|||||||
wordCountAtom
|
wordCountAtom
|
||||||
} from '@renderer/atoms/editorAtoms'
|
} from '@renderer/atoms/editorAtoms'
|
||||||
|
|
||||||
|
const PUBLISH_CYCLE: PublishStatus[] = ['draft', 'ready', 'published']
|
||||||
|
|
||||||
|
function nextPublishStatus(current: PublishStatus | undefined): PublishStatus {
|
||||||
|
const idx = PUBLISH_CYCLE.indexOf(current ?? 'draft')
|
||||||
|
return PUBLISH_CYCLE[(idx + 1) % PUBLISH_CYCLE.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishIcon(status: PublishStatus | undefined): string {
|
||||||
|
if (status === 'ready') return '📦'
|
||||||
|
if (status === 'published') return '✅'
|
||||||
|
return '📝'
|
||||||
|
}
|
||||||
|
|
||||||
function useDocumentTitle(): string {
|
function useDocumentTitle(): string {
|
||||||
const target = useEditStore((s) => s.target)
|
const target = useEditStore((s) => s.target)
|
||||||
const chapters = useBookStore((s) => s.chapters)
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
@@ -71,6 +89,10 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
const target = useEditStore((s) => s.target)
|
const target = useEditStore((s) => s.target)
|
||||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||||
const setTarget = useEditStore((s) => s.setTarget)
|
const setTarget = useEditStore((s) => s.setTarget)
|
||||||
|
const updateSchedule = useSettingsStore((s) => s.updateSchedule)
|
||||||
|
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
|
||||||
|
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
|
||||||
|
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||||
|
|
||||||
const dirty = useAtomValue(editorDirtyAtom)
|
const dirty = useAtomValue(editorDirtyAtom)
|
||||||
const saving = useAtomValue(editorSavingAtom)
|
const saving = useAtomValue(editorSavingAtom)
|
||||||
@@ -78,6 +100,9 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
const wordCount = useAtomValue(wordCountAtom)
|
const wordCount = useAtomValue(wordCountAtom)
|
||||||
const documentTitle = useDocumentTitle()
|
const documentTitle = useDocumentTitle()
|
||||||
|
|
||||||
|
const [bridgeOpen, setBridgeOpen] = useState(false)
|
||||||
|
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
|
||||||
|
|
||||||
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||||
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
|
||||||
@@ -97,6 +122,47 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [currentBookId, target?.kind, target?.id])
|
}, [currentBookId, target?.kind, target?.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentBookId) return
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.cockpit.getSummary(currentBookId, activeVolumeId ?? undefined)
|
||||||
|
).then(setCockpitSummary)
|
||||||
|
}, [currentBookId, activeVolumeId, chapters])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bridgeChapterId) setBridgeOpen(true)
|
||||||
|
}, [bridgeChapterId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentBookId || target?.kind !== 'chapter') return
|
||||||
|
const chapterId = target.id
|
||||||
|
void ipcCall(() => window.electronAPI.bridge.shouldPrompt(currentBookId, chapterId)).then(
|
||||||
|
(should) => {
|
||||||
|
if (should) setTimeout(() => setBridgeOpen(true), 500)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, [currentBookId, target?.kind, target?.id])
|
||||||
|
|
||||||
|
const activeBridgeChapterId =
|
||||||
|
bridgeChapterId ?? (target?.kind === 'chapter' ? target.id : selectedChapterId)
|
||||||
|
|
||||||
|
const showStockWarning =
|
||||||
|
updateSchedule !== 'none' &&
|
||||||
|
cockpitSummary != null &&
|
||||||
|
cockpitSummary.stockReadyCount < cockpitSummary.stockThreshold
|
||||||
|
|
||||||
|
const handlePublishCycle = async (chapterId: string, e: React.MouseEvent): Promise<void> => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!currentBookId) return
|
||||||
|
const ch = chapters.find((c) => c.id === chapterId)
|
||||||
|
if (!ch) return
|
||||||
|
const next = nextPublishStatus(ch.publishStatus)
|
||||||
|
const updated = await ipcCall(() =>
|
||||||
|
window.electronAPI.chapter.setPublishStatus(currentBookId, chapterId, next)
|
||||||
|
)
|
||||||
|
updateChapterLocal(updated)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectChapter = async (chapterId: string): Promise<void> => {
|
const handleSelectChapter = async (chapterId: string): Promise<void> => {
|
||||||
setSelectedChapter(chapterId)
|
setSelectedChapter(chapterId)
|
||||||
await switchTarget({ kind: 'chapter', id: chapterId })
|
await switchTarget({ kind: 'chapter', id: chapterId })
|
||||||
@@ -178,12 +244,20 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
const label = prompt(t('landmark.prompt'))
|
const label = prompt(t('landmark.prompt'))
|
||||||
if (!label?.trim()) return
|
if (!label?.trim()) return
|
||||||
insertLandmarkInEditor({ label: label.trim(), landmarkType: 'todo' })
|
const isForeshadow = confirm(t('landmark.foreshadowConfirm'))
|
||||||
await ipcCall(() =>
|
const landmarkType = isForeshadow ? 'foreshadow' : 'todo'
|
||||||
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), 'todo')
|
const syncKnowledge = isForeshadow && confirm(t('knowledge.syncFromLandmark'))
|
||||||
|
insertLandmarkInEditor({ label: label.trim(), landmarkType })
|
||||||
|
const bm = await ipcCall(() =>
|
||||||
|
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), landmarkType)
|
||||||
)
|
)
|
||||||
|
if (syncKnowledge) {
|
||||||
|
await ipcCall(() => window.electronAPI.knowledge.createFromBookmark(currentBookId, bm.id))
|
||||||
|
showToast(t('knowledge.syncedFromLandmark'))
|
||||||
|
} else {
|
||||||
showToast(t('landmark.created'))
|
showToast(t('landmark.created'))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
||||||
|
|
||||||
@@ -254,6 +328,15 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<span>{idx + 1}.</span>
|
<span>{idx + 1}.</span>
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ch-publish-btn"
|
||||||
|
title={t(`publish.status.${ch.publishStatus ?? 'draft'}`)}
|
||||||
|
data-testid={`chapter-publish-${ch.id}`}
|
||||||
|
onClick={(e) => void handlePublishCycle(ch.id, e)}
|
||||||
|
>
|
||||||
|
{publishIcon(ch.publishStatus)}
|
||||||
|
</button>
|
||||||
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
|
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -322,6 +405,14 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
<div id="editor-statusbar">
|
<div id="editor-statusbar">
|
||||||
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
||||||
|
{showStockWarning && (
|
||||||
|
<span className="status-stock-warning" data-testid="status-stock-warning">
|
||||||
|
{t('stock.warning', {
|
||||||
|
count: cockpitSummary!.stockReadyCount,
|
||||||
|
threshold: cockpitSummary!.stockThreshold
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isChapterTarget ? (
|
{isChapterTarget ? (
|
||||||
<>
|
<>
|
||||||
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
||||||
@@ -343,6 +434,20 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
chapterId={chapterId}
|
chapterId={chapterId}
|
||||||
/>
|
/>
|
||||||
<LandmarkModal open={landmarksModalOpen} onClose={() => setLandmarksModalOpen(false)} />
|
<LandmarkModal open={landmarksModalOpen} onClose={() => setLandmarksModalOpen(false)} />
|
||||||
|
<CockpitModal
|
||||||
|
onOpenBridge={(chapterId) => {
|
||||||
|
useCockpitStore.getState().openBridgeForChapter(chapterId)
|
||||||
|
setBridgeOpen(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ChapterBridgeModal
|
||||||
|
open={bridgeOpen}
|
||||||
|
chapterId={activeBridgeChapterId}
|
||||||
|
onClose={() => {
|
||||||
|
setBridgeOpen(false)
|
||||||
|
clearBridgeChapter()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||||
|
|
||||||
export function TopBar(): React.JSX.Element {
|
export function TopBar(): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -9,6 +10,9 @@ export function TopBar(): React.JSX.Element {
|
|||||||
const showToast = useAppStore((s) => s.showToast)
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
|
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
|
||||||
const currentBookId = useBookStore((s) => s.currentBookId)
|
const currentBookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const openCockpit = useCockpitStore((s) => s.openModal)
|
||||||
|
const loadCockpitSummary = useCockpitStore((s) => s.loadSummary)
|
||||||
|
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
|
||||||
|
|
||||||
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
|
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -60,7 +64,18 @@ export function TopBar(): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="top-spacer" />
|
<div className="top-spacer" />
|
||||||
<button type="button" className="top-btn" title={t('feature.comingSoon')} onClick={() => showToast(t('feature.comingSoon'))}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="top-btn"
|
||||||
|
title={t('cockpit.title')}
|
||||||
|
data-testid="topbar-cockpit"
|
||||||
|
disabled={!currentBookId}
|
||||||
|
onClick={() => {
|
||||||
|
if (!currentBookId) return
|
||||||
|
openCockpit()
|
||||||
|
void loadCockpitSummary(currentBookId, activeVolumeId ?? undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
📊
|
📊
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
|
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
|
||||||
|
|||||||
@@ -97,13 +97,47 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="setting-item">
|
||||||
|
<span>{t('settings.updateSchedule')}</span>
|
||||||
|
<select
|
||||||
|
data-testid="settings-update-schedule"
|
||||||
|
className="form-control"
|
||||||
|
value={settings.updateSchedule}
|
||||||
|
onChange={(e) =>
|
||||||
|
void settings.update({
|
||||||
|
updateSchedule: e.target.value as typeof settings.updateSchedule
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="none">{t('settings.updateSchedule.none')}</option>
|
||||||
|
<option value="daily">{t('settings.updateSchedule.daily')}</option>
|
||||||
|
<option value="alternate">{t('settings.updateSchedule.alternate')}</option>
|
||||||
|
<option value="weekly">{t('settings.updateSchedule.weekly')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="setting-item">
|
||||||
|
<span>{t('settings.stockBufferThreshold')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
data-testid="settings-stock-threshold"
|
||||||
|
className="form-control form-control--narrow"
|
||||||
|
value={settings.stockBufferThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
void settings.update({
|
||||||
|
stockBufferThreshold: Math.min(20, Math.max(1, Number(e.target.value) || 3))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{section === 'ai' && <AiSettingsPage />}
|
{section === 'ai' && <AiSettingsPage />}
|
||||||
{section === 'shortcuts' && <ShortcutEditor />}
|
{section === 'shortcuts' && <ShortcutEditor />}
|
||||||
{section === 'about' && (
|
{section === 'about' && (
|
||||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
{t('app.name')} v0.5.0
|
{t('app.name')} v0.6.0
|
||||||
<br />
|
<br />
|
||||||
{t('app.tagline')}
|
{t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { BookMeta, Volume, Chapter, OutlineItem, SettingEntry, InspirationEntry } from '@shared/types'
|
import type { BookMeta, Volume, Chapter, OutlineItem, SettingEntry, InspirationEntry } from '@shared/types'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||||
|
|
||||||
interface BookState {
|
interface BookState {
|
||||||
books: BookMeta[]
|
books: BookMeta[]
|
||||||
@@ -62,6 +63,16 @@ export const useBookStore = create<BookState>((set, get) => ({
|
|||||||
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
|
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
|
||||||
selectedChapterId: lastChapter
|
selectedChapterId: lastChapter
|
||||||
})
|
})
|
||||||
|
const cockpit = useCockpitStore.getState()
|
||||||
|
const shouldShow = await cockpit.shouldShowOnOpen(bookId)
|
||||||
|
if (shouldShow) {
|
||||||
|
await cockpit.markSeen(bookId)
|
||||||
|
cockpit.openModal()
|
||||||
|
void cockpit.loadSummary(
|
||||||
|
bookId,
|
||||||
|
result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume ?? undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setSelectedChapter: (chapterId) => {
|
setSelectedChapter: (chapterId) => {
|
||||||
const ch = get().chapters.find((c) => c.id === chapterId)
|
const ch = get().chapters.find((c) => c.id === chapterId)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { CockpitSummary } from '@shared/types'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
interface CockpitState {
|
||||||
|
open: boolean
|
||||||
|
summary: CockpitSummary | null
|
||||||
|
loading: boolean
|
||||||
|
bridgeChapterId: string | null
|
||||||
|
loadSummary: (bookId: string, volumeId?: string) => Promise<void>
|
||||||
|
openModal: () => void
|
||||||
|
close: () => void
|
||||||
|
markSeen: (bookId: string) => Promise<void>
|
||||||
|
shouldShowOnOpen: (bookId: string) => Promise<boolean>
|
||||||
|
openBridgeForChapter: (chapterId: string) => void
|
||||||
|
clearBridgeChapter: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCockpitStore = create<CockpitState>((set) => ({
|
||||||
|
open: false,
|
||||||
|
summary: null,
|
||||||
|
loading: false,
|
||||||
|
bridgeChapterId: null,
|
||||||
|
loadSummary: async (bookId, volumeId) => {
|
||||||
|
set({ loading: true })
|
||||||
|
try {
|
||||||
|
const summary = await ipcCall(() => window.electronAPI.cockpit.getSummary(bookId, volumeId))
|
||||||
|
set({ summary })
|
||||||
|
} finally {
|
||||||
|
set({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openModal: () => set({ open: true }),
|
||||||
|
close: () => set({ open: false }),
|
||||||
|
markSeen: async (bookId) => {
|
||||||
|
await ipcCall(() => window.electronAPI.cockpit.markSeen(bookId))
|
||||||
|
},
|
||||||
|
shouldShowOnOpen: async (bookId) =>
|
||||||
|
ipcCall(() => window.electronAPI.cockpit.shouldShowOnOpen(bookId)),
|
||||||
|
openBridgeForChapter: (chapterId) => set({ bridgeChapterId: chapterId, open: false }),
|
||||||
|
clearBridgeChapter: () => set({ bridgeChapterId: null })
|
||||||
|
}))
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type {
|
||||||
|
CreateKnowledgeInput,
|
||||||
|
KnowledgeEntry,
|
||||||
|
KnowledgeStatus,
|
||||||
|
KnowledgeType
|
||||||
|
} from '@shared/types'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
|
||||||
|
export type KnowledgeTab = 'pending' | 'all' | 'foreshadow'
|
||||||
|
|
||||||
|
interface KnowledgeState {
|
||||||
|
entries: KnowledgeEntry[]
|
||||||
|
stats: { buried: number; resolved: number; forgotten: number }
|
||||||
|
tab: KnowledgeTab
|
||||||
|
forgottenOnly: boolean
|
||||||
|
editorOpen: boolean
|
||||||
|
editingId: string | null
|
||||||
|
load: (bookId: string) => Promise<void>
|
||||||
|
setTab: (tab: KnowledgeTab) => void
|
||||||
|
openForgottenFilter: () => void
|
||||||
|
openEditor: (id?: string | null) => void
|
||||||
|
closeEditor: () => void
|
||||||
|
create: (bookId: string, input: CreateKnowledgeInput) => Promise<KnowledgeEntry>
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>
|
||||||
|
) => Promise<KnowledgeEntry>
|
||||||
|
deleteEntry: (bookId: string, id: string) => Promise<void>
|
||||||
|
approve: (bookId: string, id: string) => Promise<void>
|
||||||
|
reject: (bookId: string, id: string) => Promise<void>
|
||||||
|
batchApprove: (bookId: string, ids: string[]) => Promise<void>
|
||||||
|
filteredEntries: () => KnowledgeEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useKnowledgeStore = create<KnowledgeState>((set, get) => ({
|
||||||
|
entries: [],
|
||||||
|
stats: { buried: 0, resolved: 0, forgotten: 0 },
|
||||||
|
tab: 'all',
|
||||||
|
forgottenOnly: false,
|
||||||
|
editorOpen: false,
|
||||||
|
editingId: null,
|
||||||
|
load: async (bookId) => {
|
||||||
|
const [entries, stats] = await Promise.all([
|
||||||
|
ipcCall(() => window.electronAPI.knowledge.list(bookId)),
|
||||||
|
ipcCall(() => window.electronAPI.knowledge.stats(bookId))
|
||||||
|
])
|
||||||
|
set({ entries, stats })
|
||||||
|
},
|
||||||
|
setTab: (tab) => set({ tab, forgottenOnly: tab === 'foreshadow' ? get().forgottenOnly : false }),
|
||||||
|
openForgottenFilter: () => {
|
||||||
|
useAppStore.getState().setRightPanel('knowledge')
|
||||||
|
set({ tab: 'foreshadow', forgottenOnly: true })
|
||||||
|
},
|
||||||
|
openEditor: (id = null) => set({ editorOpen: true, editingId: id }),
|
||||||
|
closeEditor: () => set({ editorOpen: false, editingId: null }),
|
||||||
|
create: async (bookId, input) => {
|
||||||
|
const entry = await ipcCall(() => window.electronAPI.knowledge.create(bookId, input))
|
||||||
|
await get().load(bookId)
|
||||||
|
return entry
|
||||||
|
},
|
||||||
|
update: async (bookId, id, patch) => {
|
||||||
|
const entry = await ipcCall(() => window.electronAPI.knowledge.update(bookId, id, patch))
|
||||||
|
await get().load(bookId)
|
||||||
|
return entry
|
||||||
|
},
|
||||||
|
deleteEntry: async (bookId, id) => {
|
||||||
|
await ipcCall(() => window.electronAPI.knowledge.delete(bookId, id))
|
||||||
|
await get().load(bookId)
|
||||||
|
},
|
||||||
|
approve: async (bookId, id) => {
|
||||||
|
await ipcCall(() => window.electronAPI.knowledge.approve(bookId, id))
|
||||||
|
await get().load(bookId)
|
||||||
|
},
|
||||||
|
reject: async (bookId, id) => {
|
||||||
|
await ipcCall(() => window.electronAPI.knowledge.reject(bookId, id))
|
||||||
|
await get().load(bookId)
|
||||||
|
},
|
||||||
|
batchApprove: async (bookId, ids) => {
|
||||||
|
await ipcCall(() => window.electronAPI.knowledge.batchApprove(bookId, ids))
|
||||||
|
await get().load(bookId)
|
||||||
|
},
|
||||||
|
filteredEntries: () => {
|
||||||
|
const { entries, tab, forgottenOnly } = get()
|
||||||
|
let list = entries
|
||||||
|
if (tab === 'pending') list = list.filter((e) => e.status === 'pending')
|
||||||
|
else if (tab === 'foreshadow') {
|
||||||
|
list = list.filter((e) => e.type === 'foreshadow')
|
||||||
|
if (forgottenOnly) {
|
||||||
|
// forgotten filter applied client-side via reload with filter in panel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -17,6 +17,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
theme: 'default',
|
theme: 'default',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
dailyWordGoal: 0,
|
dailyWordGoal: 0,
|
||||||
|
updateSchedule: 'none',
|
||||||
|
stockBufferThreshold: 3,
|
||||||
shortcuts: {} as Record<ActionId, string>,
|
shortcuts: {} as Record<ActionId, string>,
|
||||||
aiConfig: { ...DEFAULT_AI_CONFIG },
|
aiConfig: { ...DEFAULT_AI_CONFIG },
|
||||||
namingIgnoreWords: [] as string[],
|
namingIgnoreWords: [] as string[],
|
||||||
|
|||||||
@@ -741,6 +741,180 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-tab.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-badge {
|
||||||
|
margin-left: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-stats {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-item-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-item-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-item-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-item-content {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-item-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-card-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-card-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-body {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-section {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-section h4 {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-section p {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-dismiss {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-publish-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 2px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-publish-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-stock-warning {
|
||||||
|
color: var(--warning, #e6a23c);
|
||||||
|
margin-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.naming-modal-body {
|
.naming-modal-body {
|
||||||
|
|||||||
Vendored
+47
-1
@@ -33,7 +33,14 @@ import type {
|
|||||||
SnapshotType,
|
SnapshotType,
|
||||||
UpdateChapterParams,
|
UpdateChapterParams,
|
||||||
Volume,
|
Volume,
|
||||||
WordFreqResult
|
WordFreqResult,
|
||||||
|
KnowledgeEntry,
|
||||||
|
CreateKnowledgeInput,
|
||||||
|
KnowledgeStatus,
|
||||||
|
KnowledgeType,
|
||||||
|
CockpitSummary,
|
||||||
|
ChapterBridgeResult,
|
||||||
|
PublishStatus
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
@@ -77,6 +84,11 @@ export interface ElectronAPI {
|
|||||||
targetVolumeId: string,
|
targetVolumeId: string,
|
||||||
targetIndex: number
|
targetIndex: number
|
||||||
) => Promise<IpcResult<Chapter>>
|
) => Promise<IpcResult<Chapter>>
|
||||||
|
setPublishStatus: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
publishStatus: PublishStatus
|
||||||
|
) => Promise<IpcResult<Chapter>>
|
||||||
}
|
}
|
||||||
outline: {
|
outline: {
|
||||||
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
|
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
|
||||||
@@ -307,6 +319,40 @@ export interface ElectronAPI {
|
|||||||
handoffInteractive: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
|
handoffInteractive: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
|
||||||
abort: (flowId: string) => Promise<IpcResult<void>>
|
abort: (flowId: string) => Promise<IpcResult<void>>
|
||||||
}
|
}
|
||||||
|
knowledge: {
|
||||||
|
list: (
|
||||||
|
bookId: string,
|
||||||
|
filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean }
|
||||||
|
) => Promise<IpcResult<KnowledgeEntry[]>>
|
||||||
|
create: (bookId: string, input: CreateKnowledgeInput) => Promise<IpcResult<KnowledgeEntry>>
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>
|
||||||
|
) => Promise<IpcResult<KnowledgeEntry>>
|
||||||
|
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
|
||||||
|
approve: (bookId: string, id: string) => Promise<IpcResult<KnowledgeEntry>>
|
||||||
|
reject: (bookId: string, id: string) => Promise<IpcResult<KnowledgeEntry>>
|
||||||
|
batchApprove: (bookId: string, ids: string[]) => Promise<IpcResult<number>>
|
||||||
|
createFromBookmark: (bookId: string, bookmarkId: string) => Promise<IpcResult<KnowledgeEntry>>
|
||||||
|
stats: (
|
||||||
|
bookId: string
|
||||||
|
) => Promise<IpcResult<{ buried: number; resolved: number; forgotten: number }>>
|
||||||
|
}
|
||||||
|
cockpit: {
|
||||||
|
getSummary: (bookId: string, volumeId?: string) => Promise<IpcResult<CockpitSummary>>
|
||||||
|
markSeen: (bookId: string) => Promise<IpcResult<void>>
|
||||||
|
shouldShowOnOpen: (bookId: string) => Promise<IpcResult<boolean>>
|
||||||
|
}
|
||||||
|
bridge: {
|
||||||
|
get: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
withAi?: boolean
|
||||||
|
) => Promise<IpcResult<ChapterBridgeResult>>
|
||||||
|
dismiss: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
|
||||||
|
shouldPrompt: (bookId: string, chapterId: string) => Promise<IpcResult<boolean>>
|
||||||
|
}
|
||||||
wordfreq: {
|
wordfreq: {
|
||||||
analyze: (
|
analyze: (
|
||||||
bookId: string,
|
bookId: string,
|
||||||
|
|||||||
@@ -100,5 +100,21 @@ export const IPC = {
|
|||||||
WIZARD_BUILD_PREVIEW: 'wizard:buildPreview',
|
WIZARD_BUILD_PREVIEW: 'wizard:buildPreview',
|
||||||
WIZARD_START_GENERATE: 'wizard:startGenerate',
|
WIZARD_START_GENERATE: 'wizard:startGenerate',
|
||||||
WIZARD_HANDOFF_INTERACTIVE: 'wizard:handoffInteractive',
|
WIZARD_HANDOFF_INTERACTIVE: 'wizard:handoffInteractive',
|
||||||
WIZARD_ABORT: 'wizard:abort'
|
WIZARD_ABORT: 'wizard:abort',
|
||||||
|
KNOWLEDGE_LIST: 'knowledge:list',
|
||||||
|
KNOWLEDGE_CREATE: 'knowledge:create',
|
||||||
|
KNOWLEDGE_UPDATE: 'knowledge:update',
|
||||||
|
KNOWLEDGE_DELETE: 'knowledge:delete',
|
||||||
|
KNOWLEDGE_APPROVE: 'knowledge:approve',
|
||||||
|
KNOWLEDGE_REJECT: 'knowledge:reject',
|
||||||
|
KNOWLEDGE_BATCH_APPROVE: 'knowledge:batchApprove',
|
||||||
|
KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark',
|
||||||
|
KNOWLEDGE_STATS: 'knowledge:stats',
|
||||||
|
COCKPIT_SUMMARY: 'cockpit:summary',
|
||||||
|
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
|
||||||
|
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
|
||||||
|
BRIDGE_GET: 'bridge:get',
|
||||||
|
BRIDGE_DISMISS: 'bridge:dismiss',
|
||||||
|
BRIDGE_SHOULD_PROMPT: 'bridge:shouldPrompt',
|
||||||
|
CHAPTER_SET_PUBLISH_STATUS: 'chapter:setPublishStatus'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -50,12 +50,63 @@ export interface IpcError {
|
|||||||
|
|
||||||
export type IpcResult<T> = { ok: true; data: T } | { ok: false; error: IpcError }
|
export type IpcResult<T> = { ok: true; data: T } | { ok: false; error: IpcError }
|
||||||
|
|
||||||
|
export type KnowledgeType = 'character' | 'foreshadow' | 'location' | 'relationship' | 'fact'
|
||||||
|
export type KnowledgeStatus = 'pending' | 'approved' | 'rejected'
|
||||||
|
export type ForeshadowStatus = 'buried' | 'partial' | 'resolved'
|
||||||
|
export type UpdateSchedule = 'daily' | 'alternate' | 'weekly' | 'none'
|
||||||
|
|
||||||
|
export interface KnowledgeEntry {
|
||||||
|
id: string
|
||||||
|
type: KnowledgeType
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
importance: number
|
||||||
|
status: KnowledgeStatus
|
||||||
|
foreshadowStatus?: ForeshadowStatus
|
||||||
|
sourceChapterId?: string
|
||||||
|
lastMentionChapterId?: string
|
||||||
|
linkedBookmarkId?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateKnowledgeInput {
|
||||||
|
type: KnowledgeType
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
importance?: number
|
||||||
|
status?: KnowledgeStatus
|
||||||
|
foreshadowStatus?: ForeshadowStatus
|
||||||
|
sourceChapterId?: string
|
||||||
|
lastMentionChapterId?: string
|
||||||
|
linkedBookmarkId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitSummary {
|
||||||
|
totalWords: number
|
||||||
|
volumeWords: number
|
||||||
|
stockReadyCount: number
|
||||||
|
stockThreshold: number
|
||||||
|
foreshadowBuried: number
|
||||||
|
foreshadowResolved: number
|
||||||
|
foreshadowForgotten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterBridgeResult {
|
||||||
|
previousChapterId: string | null
|
||||||
|
previousTail: string
|
||||||
|
currentHead: string
|
||||||
|
aiSuggestion?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GlobalSettings {
|
export interface GlobalSettings {
|
||||||
penName: string
|
penName: string
|
||||||
onboardingCompleted: boolean
|
onboardingCompleted: boolean
|
||||||
theme: ThemeId
|
theme: ThemeId
|
||||||
language: Language
|
language: Language
|
||||||
dailyWordGoal: number
|
dailyWordGoal: number
|
||||||
|
updateSchedule: UpdateSchedule
|
||||||
|
stockBufferThreshold: number
|
||||||
shortcuts: Record<ActionId, string>
|
shortcuts: Record<ActionId, string>
|
||||||
snapshotIntervalMs: number
|
snapshotIntervalMs: number
|
||||||
snapshotMaxAuto: number
|
snapshotMaxAuto: number
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import { ChapterBridgeService } from '../../src/main/services/chapter-bridge.service'
|
||||||
|
import { AiClientService } from '../../src/main/services/ai-client.service'
|
||||||
|
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
|
||||||
|
|
||||||
|
describe('ChapterBridgeService', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-BRIDGE-01: extracts tail and head', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||||
|
const chapterRepo = new ChapterRepository(db)
|
||||||
|
chapterRepo.create(volId, '上一章', 0, `<p>${'甲'.repeat(400)}</p>`)
|
||||||
|
const cur = chapterRepo.create(volId, '本章', 1, '<p>开头内容</p>')
|
||||||
|
const service = new ChapterBridgeService(db)
|
||||||
|
const result = service.extract(cur.id)
|
||||||
|
expect(result.previousTail.length).toBeLessThanOrEqual(300)
|
||||||
|
expect(result.previousTail.length).toBeGreaterThan(50)
|
||||||
|
expect(result.currentHead).toContain('开头')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IT-BRIDGE-01: getBridge returns aiSuggestion from LM Studio', async () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||||
|
const chapterRepo = new ChapterRepository(db)
|
||||||
|
chapterRepo.create(
|
||||||
|
volId,
|
||||||
|
'上一章',
|
||||||
|
0,
|
||||||
|
'<p>林远站在演武场中央,众人目光汇聚,他知道从这一刻起一切都将不同。</p>'
|
||||||
|
)
|
||||||
|
const cur = chapterRepo.create(
|
||||||
|
volId,
|
||||||
|
'本章',
|
||||||
|
1,
|
||||||
|
'<p>晨光透过竹帘,林远深吸一口气推开了门。</p>'
|
||||||
|
)
|
||||||
|
const aiClient = new AiClientService(() => AI_TEST_CONFIG)
|
||||||
|
const service = new ChapterBridgeService(db, aiClient)
|
||||||
|
const result = await service.getBridge(cur.id, true)
|
||||||
|
expect(result.aiSuggestion?.trim().length).toBeGreaterThan(10)
|
||||||
|
}, 240_000)
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import { CockpitService } from '../../src/main/services/cockpit.service'
|
||||||
|
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
|
||||||
|
describe('CockpitService', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
let settingsDir: string
|
||||||
|
afterEach(() => {
|
||||||
|
db?.close()
|
||||||
|
if (settingsDir) rmSync(settingsDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-COCK-01: getSummary counts words and ready stock', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-cockpit-'))
|
||||||
|
const settings = new GlobalSettingsService(settingsDir)
|
||||||
|
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||||
|
const chapterRepo = new ChapterRepository(db)
|
||||||
|
const ch = chapterRepo.create(volId, 'A', 0, '<p>hello world</p>')
|
||||||
|
chapterRepo.setPublishStatus(ch.id, 'ready')
|
||||||
|
const service = new CockpitService(db, settings)
|
||||||
|
const summary = service.getSummary(volId)
|
||||||
|
expect(summary.totalWords).toBeGreaterThan(0)
|
||||||
|
expect(summary.stockReadyCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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 type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('KnowledgeRepository', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-KNOW-01: CRUD and approve updates status', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const repo = new KnowledgeRepository(db)
|
||||||
|
const entry = repo.create({
|
||||||
|
type: 'foreshadow',
|
||||||
|
title: '测灵石异常',
|
||||||
|
foreshadowStatus: 'buried'
|
||||||
|
})
|
||||||
|
expect(entry.status).toBe('pending')
|
||||||
|
const approved = repo.update(entry.id, { status: 'approved' })
|
||||||
|
expect(approved.status).toBe('approved')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import { BookmarkRepository } from '../../src/main/db/repositories/bookmark.repo'
|
||||||
|
import { KnowledgeService } from '../../src/main/services/knowledge.service'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('KnowledgeService', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-KNOW-02: forgotten when sort_order gap >= 20', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||||
|
const chapterRepo = new ChapterRepository(db)
|
||||||
|
const firstId = chapterRepo.create(volId, '第1章', 0, '<p>伏笔</p>').id
|
||||||
|
for (let i = 1; i < 25; i++) {
|
||||||
|
chapterRepo.create(volId, `第${i + 1}章`, i, '<p>x</p>')
|
||||||
|
}
|
||||||
|
const service = new KnowledgeService(db)
|
||||||
|
const entry = service.create({
|
||||||
|
type: 'foreshadow',
|
||||||
|
title: '测灵石',
|
||||||
|
status: 'approved',
|
||||||
|
foreshadowStatus: 'buried',
|
||||||
|
sourceChapterId: firstId
|
||||||
|
})
|
||||||
|
expect(service.isForgotten(entry)).toBe(true)
|
||||||
|
const stats = service.getForeshadowStats()
|
||||||
|
expect(stats.forgotten).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-KNOW-03: createFromBookmark creates pending entry', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||||
|
const chId = new ChapterRepository(db).create(volId, '章', 0).id
|
||||||
|
const bm = new BookmarkRepository(db).create(chId, 0, '测灵石发热', 'foreshadow')
|
||||||
|
const service = new KnowledgeService(db)
|
||||||
|
const entry = service.createFromBookmark(bm.id)
|
||||||
|
expect(entry.status).toBe('pending')
|
||||||
|
expect(entry.linkedBookmarkId).toBe(bm.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IT-KNOW-04: batchApprove clears pending', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const service = new KnowledgeService(db)
|
||||||
|
const ids = [
|
||||||
|
service.create({ type: 'fact', title: 'A' }).id,
|
||||||
|
service.create({ type: 'fact', title: 'B' }).id,
|
||||||
|
service.create({ type: 'fact', title: 'C' }).id
|
||||||
|
]
|
||||||
|
expect(service.countPending()).toBe(3)
|
||||||
|
expect(service.batchApprove(ids)).toBe(3)
|
||||||
|
expect(service.countPending()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,7 +30,7 @@ describe('migrate v2', () => {
|
|||||||
expect(tableExists(db, 'search_fts')).toBe(true)
|
expect(tableExists(db, 'search_fts')).toBe(true)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(5)
|
expect(version.v).toBe(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
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(5)
|
expect(version.v).toBe(6)
|
||||||
|
|
||||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||||
const colNames = cols.map((c) => c.name)
|
const colNames = cols.map((c) => c.name)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
|
|||||||
expect(tableExists(db, 'ai_messages')).toBe(true)
|
expect(tableExists(db, 'ai_messages')).toBe(true)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(5)
|
expect(version.v).toBe(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
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(5)
|
expect(version.v).toBe(6)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(5)
|
expect(version.v).toBe(6)
|
||||||
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(5)
|
expect(version.v).toBe(6)
|
||||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('migrate v5', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(5)
|
expect(version.v).toBe(6)
|
||||||
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(5)
|
expect(version.v).toBe(6)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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 v6', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-MIG-06: applies v6 on fresh database', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
|
expect(version.v).toBe(6)
|
||||||
|
const tables = db
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
.all() as { name: string }[]
|
||||||
|
expect(tables.some((t) => t.name === 'knowledge_entries')).toBe(true)
|
||||||
|
expect(tables.some((t) => t.name === 'book_preferences')).toBe(true)
|
||||||
|
expect(tables.some((t) => t.name === 'chapter_bridge_dismiss')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user