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:
2026-07-06 23:03:11 +08:00
parent 78f046890d
commit b33d2e7b34
45 changed files with 2389 additions and 29 deletions
+8 -2
View File
@@ -1,9 +1,13 @@
# 笔临 (Bilin)
长篇创作智能协作平台 — Electron 桌面客户端 v0.5.0P4.1 自动 + 向导写作
长篇创作智能协作平台 — Electron 桌面客户端 v0.6.0P5 作家工作流 + 知识库
## 功能概览(v0.5.0
## 功能概览(v0.6.0
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5)
- 章间衔接:上下章片段 + 可选 AI 建议(P5)
- 更新计划:发布状态、存稿阈值提醒(P5)
- 自动写作:章节目标、场景规划、连续生成、暂停转交互(P4.1)
- 向导式写作:5 步引导、策略预览、生成后交互微调(P4.1)
- 交互式写作:剧情走向建议、场景生成、命名暂停、微调确认、成章落盘(P4)
@@ -32,6 +36,8 @@ npm run test:e2e # E2E 测试(需先 build
## 文档
- P5 作家工作流 + 知识库设计规格:`docs/superpowers/specs/2026-07-06-bilin-p5-workflow-knowledge-design.md`
- P5 实现计划:`docs/superpowers/plans/2026-07-06-bilin-p5-workflow-knowledge.md`
- P4.1 自动/向导写作设计规格:`docs/superpowers/specs/2026-07-09-bilin-p41-auto-wizard-design.md`
- P4.1 实现计划:`docs/superpowers/plans/2026-07-09-bilin-p41-auto-wizard.md`
- P4 交互式写作设计规格:`docs/superpowers/specs/2026-07-08-bilin-p4-interactive-design.md`
+191
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "bilin",
"version": "0.5.0",
"version": "0.6.0",
"description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js",
"type": "module",
+63
View File
@@ -13,6 +13,7 @@
"dialog.newBook.category": "Category",
"dialog.newBook.target": "Target word count (optional)",
"dialog.cancel": "Cancel",
"dialog.save": "Save",
"dialog.create": "Create",
"settings.title": "Settings",
"settings.general": "General",
@@ -159,6 +160,68 @@
"naming.ignored": "Added to ignore list",
"naming.replaced": "Replaced {{count}} occurrence(s)",
"knowledge.placeholder": "Knowledge base entries will appear here",
"knowledge.new": "New entry",
"knowledge.edit": "Edit entry",
"knowledge.type": "Type",
"knowledge.type.character": "Character",
"knowledge.type.foreshadow": "Foreshadow",
"knowledge.type.location": "Location",
"knowledge.type.relationship": "Relationship",
"knowledge.type.fact": "Fact",
"knowledge.titleLabel": "Title",
"knowledge.contentLabel": "Content",
"knowledge.importance": "Importance",
"knowledge.foreshadowStatus": "Foreshadow status",
"knowledge.foreshadow.buried": "Buried",
"knowledge.foreshadow.partial": "Partial",
"knowledge.foreshadow.resolved": "Resolved",
"knowledge.sourceChapter": "Source chapter",
"knowledge.noChapter": "(none)",
"knowledge.approveNow": "Approve immediately",
"knowledge.tab.pending": "Pending",
"knowledge.tab.all": "All",
"knowledge.tab.foreshadow": "Foreshadow",
"knowledge.approve": "Approve",
"knowledge.reject": "Reject",
"knowledge.delete": "Delete",
"knowledge.batchApprove": "Approve selected ({{count}})",
"knowledge.empty": "No entries yet",
"knowledge.status.pending": "Pending",
"knowledge.status.approved": "Approved",
"knowledge.status.rejected": "Rejected",
"knowledge.statsSummary": "Buried {{buried}} · Resolved {{resolved}} · Forgotten {{forgotten}}",
"knowledge.syncFromLandmark": "Sync this foreshadow to knowledge base?",
"knowledge.syncedFromLandmark": "Synced to knowledge base (pending review)",
"cockpit.title": "Writing cockpit",
"cockpit.loading": "Loading…",
"cockpit.totalWords": "Book words",
"cockpit.volumeWords": "Volume words",
"cockpit.stockReady": "Stock buffer",
"cockpit.foreshadow": "Foreshadow",
"cockpit.foreshadowDetail": "Buried {{buried}} / Resolved {{resolved}} / Forgotten {{forgotten}}",
"cockpit.resumeEdit": "Resume writing",
"cockpit.bridgeCheck": "Chapter bridge check",
"cockpit.forgottenForeshadow": "Forgotten foreshadows",
"bridge.title": "Chapter bridge",
"bridge.loading": "Analyzing…",
"bridge.previousTail": "Previous chapter ending",
"bridge.currentHead": "Current chapter opening",
"bridge.aiSuggestion": "AI suggestion",
"bridge.none": "(none)",
"bridge.empty": "Could not load bridge info",
"bridge.dismissLabel": "Do not prompt for this chapter",
"bridge.startWriting": "Start writing",
"publish.status.draft": "Draft",
"publish.status.ready": "Ready",
"publish.status.published": "Published",
"settings.updateSchedule": "Update schedule",
"settings.updateSchedule.none": "No reminder",
"settings.updateSchedule.daily": "Daily",
"settings.updateSchedule.alternate": "Every other day",
"settings.updateSchedule.weekly": "Weekly",
"settings.stockBufferThreshold": "Stock buffer threshold (chapters)",
"stock.warning": "Low stock: {{count}}/{{threshold}} chapters ready",
"landmark.foreshadowConfirm": "Mark as foreshadow landmark?",
"ai.settings.backend": "Backend",
"ai.settings.backend.lmstudio": "LM Studio (local)",
"ai.settings.backend.openai": "OpenAI",
+63
View File
@@ -13,6 +13,7 @@
"dialog.newBook.category": "分类",
"dialog.newBook.target": "目标总字数(可选)",
"dialog.cancel": "取消",
"dialog.save": "保存",
"dialog.create": "创建",
"settings.title": "系统设置",
"settings.general": "通用",
@@ -159,6 +160,68 @@
"naming.ignored": "已加入忽略词库",
"naming.replaced": "已替换 {{count}} 处",
"knowledge.placeholder": "知识库条目将在此展示(伏笔、角色状态等)",
"knowledge.new": "新建条目",
"knowledge.edit": "编辑条目",
"knowledge.type": "类型",
"knowledge.type.character": "角色",
"knowledge.type.foreshadow": "伏笔",
"knowledge.type.location": "地点",
"knowledge.type.relationship": "关系",
"knowledge.type.fact": "事实",
"knowledge.titleLabel": "标题",
"knowledge.contentLabel": "内容",
"knowledge.importance": "重要度",
"knowledge.foreshadowStatus": "伏笔状态",
"knowledge.foreshadow.buried": "已埋设",
"knowledge.foreshadow.partial": "部分回收",
"knowledge.foreshadow.resolved": "已回收",
"knowledge.sourceChapter": "来源章节",
"knowledge.noChapter": "(无)",
"knowledge.approveNow": "直接采纳",
"knowledge.tab.pending": "待审核",
"knowledge.tab.all": "全部",
"knowledge.tab.foreshadow": "伏笔",
"knowledge.approve": "采纳",
"knowledge.reject": "拒绝",
"knowledge.delete": "删除",
"knowledge.batchApprove": "批量采纳 ({{count}})",
"knowledge.empty": "暂无条目",
"knowledge.status.pending": "待审核",
"knowledge.status.approved": "已采纳",
"knowledge.status.rejected": "已拒绝",
"knowledge.statsSummary": "埋设 {{buried}} · 回收 {{resolved}} · 遗忘 {{forgotten}}",
"knowledge.syncFromLandmark": "是否同步此伏笔到知识库?",
"knowledge.syncedFromLandmark": "已同步到知识库(待审核)",
"cockpit.title": "写作驾驶舱",
"cockpit.loading": "加载中…",
"cockpit.totalWords": "全书字数",
"cockpit.volumeWords": "本卷字数",
"cockpit.stockReady": "存稿缓冲",
"cockpit.foreshadow": "伏笔概览",
"cockpit.foreshadowDetail": "埋设 {{buried}} / 回收 {{resolved}} / 遗忘 {{forgotten}}",
"cockpit.resumeEdit": "继续写作",
"cockpit.bridgeCheck": "章间衔接检查",
"cockpit.forgottenForeshadow": "查看遗忘伏笔",
"bridge.title": "章间衔接",
"bridge.loading": "分析中…",
"bridge.previousTail": "上一章末尾",
"bridge.currentHead": "本章开头",
"bridge.aiSuggestion": "AI 建议",
"bridge.none": "(无)",
"bridge.empty": "无法获取衔接信息",
"bridge.dismissLabel": "不再提示本章",
"bridge.startWriting": "开始写作",
"publish.status.draft": "草稿",
"publish.status.ready": "待发布",
"publish.status.published": "已发布",
"settings.updateSchedule": "更新计划",
"settings.updateSchedule.none": "不提醒",
"settings.updateSchedule.daily": "日更",
"settings.updateSchedule.alternate": "隔日更",
"settings.updateSchedule.weekly": "周更",
"settings.stockBufferThreshold": "存稿缓冲阈值(章)",
"stock.warning": "存稿不足:{{count}}/{{threshold}} 章待发布",
"landmark.foreshadowConfirm": "标记为伏笔地标?",
"ai.settings.backend": "后端类型",
"ai.settings.backend.lmstudio": "LM Studio(本地)",
"ai.settings.backend.openai": "OpenAI",
+8 -1
View File
@@ -4,8 +4,9 @@ import schemaV2 from './schema-v2.sql?raw'
import schemaV3 from './schema-v3.sql?raw'
import schemaV4 from './schema-v4.sql?raw'
import schemaV5 from './schema-v5.sql?raw'
import schemaV6 from './schema-v6.sql?raw'
const CURRENT_VERSION = 5
const CURRENT_VERSION = 6
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -64,5 +65,11 @@ export function migrate(db: SqliteDb): void {
tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive'")
tryAlter(db, "ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}'")
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(5, 'P4.1 auto/wizard schema')
current = 5
}
if (current < 6) {
db.exec(schemaV6)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(6, 'P5 knowledge/cockpit schema')
}
}
@@ -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)
}
}
+36 -4
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'crypto'
import { countWords } from '../../services/word-count'
import type { Chapter, ChapterOrigin, ChapterStatus } from '../../../shared/types'
import type { Chapter, ChapterOrigin, ChapterStatus, PublishStatus } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): Chapter {
return {
@@ -12,7 +12,8 @@ function mapRow(row: Record<string, unknown>): Chapter {
wordCount: row.word_count as number,
sortOrder: row.sort_order as number,
cursorOffset: (row.cursor_offset as number) ?? 0,
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus
}
}
@@ -59,7 +60,13 @@ export class ChapterRepository {
update(
id: string,
patch: Partial<{ title: string; content: string; status: ChapterStatus; cursorOffset: number }>
patch: Partial<{
title: string
content: string
status: ChapterStatus
cursorOffset: number
publishStatus: PublishStatus
}>
): Chapter {
const existing = this.get(id)
if (!existing) throw new Error('Chapter not found')
@@ -71,7 +78,7 @@ export class ChapterRepository {
.prepare(
`UPDATE chapters SET
title = ?, content = ?, status = ?, cursor_offset = ?,
word_count = ?, updated_at = datetime('now')
publish_status = ?, word_count = ?, updated_at = datetime('now')
WHERE id = ?`
)
.run(
@@ -79,12 +86,37 @@ export class ChapterRepository {
content,
patch.status ?? existing.status,
patch.cursorOffset ?? existing.cursorOffset,
patch.publishStatus ?? existing.publishStatus ?? 'draft',
wordCount,
id
)
return this.get(id)!
}
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
return this.update(id, { publishStatus })
}
countByPublishStatus(status: PublishStatus): number {
const row = this.db
.prepare('SELECT COUNT(*) AS c FROM chapters WHERE publish_status = ?')
.get(status) as { c: number }
return row.c
}
getPreviousChapter(chapterId: string): Chapter | null {
const current = this.get(chapterId)
if (!current) return null
const row = this.db
.prepare(
`SELECT * FROM chapters
WHERE sort_order < ?
ORDER BY sort_order DESC LIMIT 1`
)
.get(current.sortOrder) as Record<string, unknown> | undefined
return row ? mapRow(row) : null
}
delete(id: string): void {
this.db.prepare('DELETE FROM chapters WHERE id = ?').run(id)
}
+114
View File
@@ -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
}
}
+30
View File
@@ -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
);
+35
View File
@@ -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))
)
}
+9 -1
View File
@@ -1,6 +1,6 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ChapterStatus } from '../../../shared/types'
import type { ChapterStatus, PublishStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
@@ -124,4 +124,12 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
) =>
wrap(() => registry.getChapterRepo(bookId).moveToVolume(chapterId, targetVolumeId, targetIndex))
)
ipcMain.handle(
IPC.CHAPTER_SET_PUBLISH_STATUS,
(
_event,
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, publishStatus))
)
}
+27
View File
@@ -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())
)
}
+6
View File
@@ -18,6 +18,9 @@ import { registerAiHandlers } from './handlers/ai.handler'
import { registerInteractiveHandlers } from './handlers/interactive.handler'
import { registerAutoHandlers } from './handlers/auto.handler'
import { registerWizardHandlers } from './handlers/wizard.handler'
import { registerKnowledgeHandlers } from './handlers/knowledge.handler'
import { registerCockpitHandlers } from './handlers/cockpit.handler'
import { registerBridgeHandlers } from './handlers/bridge.handler'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -49,6 +52,9 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerInteractiveHandlers(books, settings, aiClient)
registerAutoHandlers(books, settings, aiClient)
registerWizardHandlers(books, settings, aiClient)
registerKnowledgeHandlers(books)
registerCockpitHandlers(books, settings)
registerBridgeHandlers(books, aiClient)
return { settings, books, shortcuts: shortcutManager }
}
@@ -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)
}
}
+44
View File
@@ -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')
}
}
+4
View File
@@ -20,6 +20,8 @@ function defaults(): GlobalSettings {
theme: 'default',
language: 'zh-CN',
dailyWordGoal: 0,
updateSchedule: 'none',
stockBufferThreshold: 3,
shortcuts: { ...DEFAULT_SHORTCUTS },
snapshotIntervalMs: 300_000,
snapshotMaxAuto: 20,
@@ -45,6 +47,8 @@ export class GlobalSettingsService {
return {
...defaults(),
...raw,
updateSchedule: raw.updateSchedule ?? 'none',
stockBufferThreshold: raw.stockBufferThreshold ?? 3,
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
}
+117
View File
@@ -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
View File
@@ -35,7 +35,14 @@ import type {
SnapshotType,
UpdateChapterParams,
Volume,
WordFreqResult
WordFreqResult,
KnowledgeEntry,
CreateKnowledgeInput,
KnowledgeStatus,
KnowledgeType,
CockpitSummary,
ChapterBridgeResult,
PublishStatus
} from '../shared/types'
const electronAPI = {
@@ -91,7 +98,13 @@ const electronAPI = {
targetVolumeId: string,
targetIndex: number
): Promise<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: {
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
@@ -408,6 +421,55 @@ const electronAPI = {
abort: (flowId: string): Promise<IpcResult<void>> =>
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: {
analyze: (
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 { 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 {
KnowledgeEditorDialog,
loadKnowledgeEntries
} from '@renderer/components/knowledge/KnowledgeEditorDialog'
export function KnowledgePanel(): React.JSX.Element {
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 [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 (
<>
<div className="knowledge-panel" data-testid="knowledge-panel">
<div className="knowledge-toolbar">
<button
type="button"
className="btn btn-primary"
data-testid="knowledge-new"
onClick={() => openEditor(null)}
>
{t('knowledge.new')}
</button>
<button
type="button"
className="btn"
@@ -19,8 +72,115 @@ export function KnowledgePanel(): React.JSX.Element {
{t('naming.open')}
</button>
</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>
{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)} />
</>
)
+109 -4
View File
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAtomValue } from 'jotai'
import type { CockpitSummary, PublishStatus } from '@shared/types'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useOutlineStore } from '@renderer/stores/useOutlineStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
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 { VersionModal } from '@renderer/components/version/VersionModal'
import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
import {
editorDirtyAtom,
editorSavingAtom,
@@ -24,6 +29,19 @@ import {
wordCountAtom
} 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 {
const target = useEditStore((s) => s.target)
const chapters = useBookStore((s) => s.chapters)
@@ -71,6 +89,10 @@ export function EditorLayout(): React.JSX.Element {
const target = useEditStore((s) => s.target)
const switchTarget = useEditStore((s) => s.switchTarget)
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 saving = useAtomValue(editorSavingAtom)
@@ -78,6 +100,9 @@ export function EditorLayout(): React.JSX.Element {
const wordCount = useAtomValue(wordCountAtom)
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 isChapterTarget = target?.kind === 'chapter'
const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId
@@ -97,6 +122,47 @@ export function EditorLayout(): React.JSX.Element {
}
}, [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> => {
setSelectedChapter(chapterId)
await switchTarget({ kind: 'chapter', id: chapterId })
@@ -178,11 +244,19 @@ export function EditorLayout(): React.JSX.Element {
}
const label = prompt(t('landmark.prompt'))
if (!label?.trim()) return
insertLandmarkInEditor({ label: label.trim(), landmarkType: 'todo' })
await ipcCall(() =>
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), 'todo')
const isForeshadow = confirm(t('landmark.foreshadowConfirm'))
const landmarkType = isForeshadow ? 'foreshadow' : '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)
)
showToast(t('landmark.created'))
if (syncKnowledge) {
await ipcCall(() => window.electronAPI.knowledge.createFromBookmark(currentBookId, bm.id))
showToast(t('knowledge.syncedFromLandmark'))
} else {
showToast(t('landmark.created'))
}
}
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 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>
</div>
))}
@@ -322,6 +405,14 @@ export function EditorLayout(): React.JSX.Element {
/>
<div id="editor-statusbar">
<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 ? (
<>
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
@@ -343,6 +434,20 @@ export function EditorLayout(): React.JSX.Element {
chapterId={chapterId}
/>
<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>
)
}
+16 -1
View File
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useTabStore } from '@renderer/stores/useTabStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
export function TopBar(): React.JSX.Element {
const { t } = useTranslation()
@@ -9,6 +10,9 @@ export function TopBar(): React.JSX.Element {
const showToast = useAppStore((s) => s.showToast)
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
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 => {
e.stopPropagation()
@@ -60,7 +64,18 @@ export function TopBar(): React.JSX.Element {
))}
</div>
<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 type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
@@ -97,13 +97,47 @@ export function SettingsPage(): React.JSX.Element {
<option value="en">English</option>
</select>
</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 === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v0.5.0
{t('app.name')} v0.6.0
<br />
{t('app.tagline')}
</p>
+11
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import type { BookMeta, Volume, Chapter, OutlineItem, SettingEntry, InspirationEntry } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
interface BookState {
books: BookMeta[]
@@ -62,6 +63,16 @@ export const useBookStore = create<BookState>((set, get) => ({
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
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) => {
const ch = get().chapters.find((c) => c.id === chapterId)
+42
View File
@@ -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 })
}))
+97
View File
@@ -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
}
}))
+2
View File
@@ -17,6 +17,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
theme: 'default',
language: 'zh-CN',
dailyWordGoal: 0,
updateSchedule: 'none',
stockBufferThreshold: 3,
shortcuts: {} as Record<ActionId, string>,
aiConfig: { ...DEFAULT_AI_CONFIG },
namingIgnoreWords: [] as string[],
+174
View File
@@ -741,6 +741,180 @@
display: flex;
flex-direction: column;
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 {
+47 -1
View File
@@ -33,7 +33,14 @@ import type {
SnapshotType,
UpdateChapterParams,
Volume,
WordFreqResult
WordFreqResult,
KnowledgeEntry,
CreateKnowledgeInput,
KnowledgeStatus,
KnowledgeType,
CockpitSummary,
ChapterBridgeResult,
PublishStatus
} from './types'
export interface ElectronAPI {
@@ -77,6 +84,11 @@ export interface ElectronAPI {
targetVolumeId: string,
targetIndex: number
) => Promise<IpcResult<Chapter>>
setPublishStatus: (
bookId: string,
chapterId: string,
publishStatus: PublishStatus
) => Promise<IpcResult<Chapter>>
}
outline: {
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
@@ -307,6 +319,40 @@ export interface ElectronAPI {
handoffInteractive: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
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: {
analyze: (
bookId: string,
+17 -1
View File
@@ -100,5 +100,21 @@ export const IPC = {
WIZARD_BUILD_PREVIEW: 'wizard:buildPreview',
WIZARD_START_GENERATE: 'wizard:startGenerate',
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
+51
View File
@@ -50,12 +50,63 @@ export interface 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 {
penName: string
onboardingCompleted: boolean
theme: ThemeId
language: Language
dailyWordGoal: number
updateSchedule: UpdateSchedule
stockBufferThreshold: number
shortcuts: Record<ActionId, string>
snapshotIntervalMs: number
snapshotMaxAuto: number
+53
View File
@@ -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)
})
+35
View File
@@ -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)
})
})
+24
View File
@@ -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')
})
})
+61
View File
@@ -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)
})
})
+2 -2
View File
@@ -30,7 +30,7 @@ describe('migrate v2', () => {
expect(tableExists(db, 'search_fts')).toBe(true)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(5)
expect(version.v).toBe(6)
})
it('migrates existing v1 database to v2', () => {
@@ -47,7 +47,7 @@ describe('migrate v2', () => {
expect(tableExists(db, 'outline_items')).toBe(true)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(5)
expect(version.v).toBe(6)
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
const colNames = cols.map((c) => c.name)
+2 -2
View File
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
expect(tableExists(db, 'ai_messages')).toBe(true)
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', () => {
@@ -49,6 +49,6 @@ describe('migrate v3', () => {
expect(tables.map((t) => t.name)).toContain('ai_messages')
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(5)
expect(version.v).toBe(6)
})
})
+2 -2
View File
@@ -23,7 +23,7 @@ describe('migrate v4', () => {
migrate(db)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(5)
expect(version.v).toBe(6)
expect(tableExists(db, 'interactive_flows')).toBe(true)
expect(tableExists(db, 'interactive_scenes')).toBe(true)
@@ -48,7 +48,7 @@ describe('migrate v4', () => {
migrate(db)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(5)
expect(version.v).toBe(6)
expect(tableExists(db, 'interactive_flows')).toBe(true)
})
})
+2 -2
View File
@@ -15,7 +15,7 @@ describe('migrate v5', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(5)
expect(version.v).toBe(6)
const cols = db.prepare('PRAGMA table_info(interactive_flows)').all() as { name: string }[]
expect(cols.some((c) => c.name === 'flow_mode')).toBe(true)
expect(cols.some((c) => c.name === 'mode_config_json')).toBe(true)
@@ -39,6 +39,6 @@ describe('migrate v5', () => {
db.prepare('INSERT INTO schema_version (version) VALUES (4)').run()
migrate(db)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(5)
expect(version.v).toBe(6)
})
})
+22
View File
@@ -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)
})
})