Compare commits

..

10 Commits

Author SHA1 Message Date
bing aa2c7dfed3 feat: ship v0.7.0 with AI knowledge context integration
Add semi-automatic knowledge suggestions and user-confirmed injection across chat, interactive, auto, and wizard modes; fix writing flows to persist full systemPrompt in contextJson.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 15:33:53 +08:00
bing 2afbb83c43 docs: add P6 AI knowledge context implementation plan for v0.7.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 14:24:19 +08:00
bing b5ae04af18 docs: add P6 AI knowledge context integration design spec for v0.7.0
Define semi-auto knowledge retrieval, unified AiContextBuilder injection across all four AI modes, and systemPrompt fix for writing flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 14:19:56 +08:00
bing b33d2e7b34 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>
2026-07-06 23:03:11 +08:00
bing 78f046890d docs: add P5 workflow and knowledge MVP implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 22:36:40 +08:00
bing a02a665b0f docs: add P5 workflow and knowledge MVP design spec for v0.6.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 22:34:40 +08:00
bing 5d3e264cf3 test: complete P4.1 LM Studio coverage and fix auto/wizard handoff
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 21:45:18 +08:00
bing 6a949b54ba feat: ship v0.5.0 with auto and wizard writing modes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 20:57:31 +08:00
bing 86b66a311a docs: add P4.1 auto and wizard writing implementation plan for v0.5.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 20:47:50 +08:00
bing ab33213f99 docs: add P4.1 auto and wizard writing design spec for v0.5.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 20:43:53 +08:00
95 changed files with 10670 additions and 208 deletions
+13 -2
View File
@@ -1,9 +1,16 @@
# 笔临 (Bilin) # 笔临 (Bilin)
长篇创作智能协作平台 — Electron 桌面客户端 v0.4.0P4 交互式写作 长篇创作智能协作平台 — Electron 桌面客户端 v0.7.0P6 AI 知识上下文
## 功能概览(v0.4.0 ## 功能概览(v0.7.0
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5)
- 章间衔接:上下章片段 + 可选 AI 建议(P5)
- 更新计划:发布状态、存稿阈值提醒(P5)
- 自动写作:章节目标、场景规划、连续生成、暂停转交互(P4.1)
- 向导式写作:5 步引导、策略预览、生成后交互微调(P4.1)
- 交互式写作:剧情走向建议、场景生成、命名暂停、微调确认、成章落盘(P4) - 交互式写作:剧情走向建议、场景生成、命名暂停、微调确认、成章落盘(P4)
- 章节拖拽、全局搜索替换、灵感捕获、`@` 跳转、引用钉住(P2.1 - 章节拖拽、全局搜索替换、灵感捕获、`@` 跳转、引用钉住(P2.1
- AI 对话助手:会话持久化、流式输出、上下文编辑、快捷命令(P3) - AI 对话助手:会话持久化、流式输出、上下文编辑、快捷命令(P3)
@@ -30,6 +37,10 @@ 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` - P4 交互式写作设计规格:`docs/superpowers/specs/2026-07-08-bilin-p4-interactive-design.md`
- P4 实现计划:`docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md` - P4 实现计划:`docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md`
- P2.1 + P3 设计规格:`docs/superpowers/specs/2026-07-07-bilin-p21-p3-design.md` - P2.1 + P3 设计规格:`docs/superpowers/specs/2026-07-07-bilin-p21-p3-design.md`
@@ -0,0 +1,818 @@
# 笔临 P5 作家工作流 + 知识库 MVP Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 交付 v0.6.0:驾驶舱 + 存稿/更新计划 + 章间衔接(可选 AI)+ 知识库 MVP(手动 CRUD / 审核 / 伏笔追踪)。
**Architecture:** schema v6 新增 `knowledge_entries` / `book_preferences` / `chapter_bridge_dismiss``KnowledgeService` 负责 CRUD 与遗忘判定;`CockpitService` 只读聚合字数与存稿;`ChapterBridgeService` 提取相邻章片段 + 可选 `AiClientService`;渲染层 `CockpitModal` / `ChapterBridgeModal` / 重写 `KnowledgePanel`。测试 **禁止 Mock** AI 集成/E2E 打真实 LM Studio。
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、Jotai、i18next、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-06-bilin-p5-workflow-knowledge-design.md`
**LM Studio 默认:** `baseUrl=http://127.0.0.1:1234``model=gemma-4-e4b-it`
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v6.sql` | v6 DDL |
| `src/main/db/migrate.ts` | v5→v6 |
| `src/main/db/repositories/knowledge.repo.ts` | knowledge_entries CRUD |
| `src/main/db/repositories/book-prefs.repo.ts` | book_preferences KV |
| `src/main/db/repositories/bridge-dismiss.repo.ts` | chapter_bridge_dismiss |
| `src/main/services/knowledge.service.ts` | 审核、遗忘判定、bookmark 同步 |
| `src/main/services/cockpit.service.ts` | 驾驶舱聚合 |
| `src/main/services/chapter-bridge.service.ts` | 衔接文本 + AI |
| `src/main/db/repositories/chapter.repo.ts` | publish_status 读写 |
| `src/main/ipc/handlers/knowledge.handler.ts` | knowledge IPC |
| `src/main/ipc/handlers/cockpit.handler.ts` | cockpit IPC |
| `src/main/ipc/handlers/bridge.handler.ts` | bridge IPC |
| `src/main/ipc/register.ts` | 注册 handler |
| `src/shared/types.ts` | KnowledgeEntry / CockpitSummary 等 |
| `src/shared/ipc-channels.ts` | 新通道 |
| `src/preload/index.ts` | knowledge / cockpit / bridge API |
| `src/shared/electron-api.d.ts` | 类型 |
| `src/renderer/stores/useKnowledgeStore.ts` | 知识库状态 |
| `src/renderer/stores/useCockpitStore.ts` | 驾驶舱状态 |
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 重写 |
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 编辑对话框 |
| `src/renderer/components/cockpit/CockpitModal.tsx` | 驾驶舱 UI |
| `src/renderer/components/bridge/ChapterBridgeModal.tsx` | 衔接 UI |
| `src/renderer/components/layout/TopBar.tsx` | 驾驶舱按钮 |
| `src/renderer/components/layout/EditorLayout.tsx` | 发布状态、bridge 触发、statusbar |
| `src/renderer/components/settings/SettingsPage.tsx` | updateSchedule |
| `src/main/services/global-settings.ts` | 默认值扩展 |
| `public/locales/zh-CN/translation.json` | 文案 |
| `public/locales/en/translation.json` | 文案 |
| `tests/main/migrate-v6.test.ts` | UT-MIG-06 |
| `tests/main/knowledge.service.test.ts` | UT-KNOW-* |
| `tests/main/cockpit.service.test.ts` | UT-COCK-01 |
| `tests/main/chapter-bridge.test.ts` | UT-BRIDGE-01 / IT-BRIDGE-01 |
| `e2e/cockpit-knowledge.spec.ts` | E2E-COCK-* / KNOW-* / BRIDGE-* / STOCK-* |
---
## Task 1: Schema v6 + 类型 + IPC 通道
**Files:**
- Create: `src/main/db/schema-v6.sql`
- Modify: `src/main/db/migrate.ts`
- Modify: `src/shared/types.ts`
- Modify: `src/shared/ipc-channels.ts`
- Create: `tests/main/migrate-v6.test.ts`
- Modify: `tests/main/migrate-v5.test.ts`(期望 version **6**
- [ ] **Step 1: 写迁移失败测试**
`tests/main/migrate-v6.test.ts`
```typescript
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)
})
})
```
- [ ] **Step 2: 运行确认 FAIL**
```bash
npm run test -- tests/main/migrate-v6.test.ts
```
- [ ] **Step 3: 实现 schema v6**
`src/main/db/schema-v6.sql`(与 spec §3.1 一致)。
`src/main/db/migrate.ts`
```typescript
import schemaV6 from './schema-v6.sql?raw'
const CURRENT_VERSION = 6
// ...
if (current < 6) {
db.exec(schemaV6)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(6, 'P5 knowledge/cockpit schema')
}
```
`src/shared/types.ts` 新增:
```typescript
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
}
```
扩展 `GlobalSettings`
```typescript
updateSchedule: UpdateSchedule // default 'none'
stockBufferThreshold: number // default 3
```
扩展 `Chapter`
```typescript
publishStatus?: PublishStatus
```
`src/shared/ipc-channels.ts` 追加 spec §4.1 全部通道。
- [ ] **Step 4: 更新 migrate-v5 期望 version 6;运行 PASS**
```bash
npm run test -- tests/main/migrate-v5.test.ts tests/main/migrate-v6.test.ts
```
- [ ] **Step 5: Commit**
```bash
git add src/main/db/schema-v6.sql src/main/db/migrate.ts src/shared/types.ts src/shared/ipc-channels.ts tests/main/migrate-v6.test.ts tests/main/migrate-v5.test.ts
git commit -m "feat: add schema v6 for knowledge and cockpit workflow"
```
---
## Task 2: Repository 层
**Files:**
- Create: `src/main/db/repositories/knowledge.repo.ts`
- Create: `src/main/db/repositories/book-prefs.repo.ts`
- Create: `src/main/db/repositories/bridge-dismiss.repo.ts`
- Modify: `src/main/db/repositories/chapter.repo.ts`
- Create: `tests/main/knowledge.repo.test.ts`
- [ ] **Step 1: UT-KNOW-01 失败测试**
```typescript
it('UT-KNOW-01: CRUD and approve updates status', () => {
const 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')
db.close()
})
```
- [ ] **Step 2: 实现 KnowledgeRepository**
```typescript
export class KnowledgeRepository {
create(input: CreateKnowledgeInput): KnowledgeEntry { /* INSERT */ }
get(id: string): KnowledgeEntry | null
list(filter?: { status?: KnowledgeStatus; type?: KnowledgeType }): KnowledgeEntry[]
update(id: string, patch: Partial<...>): KnowledgeEntry
delete(id: string): void
countByStatus(status: KnowledgeStatus): number
}
```
`mapRow` 蛇形 ↔ 驼峰映射。
- [ ] **Step 3: BookPrefsRepository + BridgeDismissRepository**
```typescript
// book-prefs.repo.ts
get(key: string): string | null
set(key: string, value: string): void
// bridge-dismiss.repo.ts
isDismissed(chapterId: string): boolean
dismiss(chapterId: string): void
```
- [ ] **Step 4: ChapterRepository 扩展 publish_status**
`mapRow` 增加 `publishStatus``update` patch 支持 `publishStatus`;新增:
```typescript
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter
countByPublishStatus(status: PublishStatus): number
getPreviousChapter(chapterId: string): Chapter | null // 按 sort_order 找上一章
```
- [ ] **Step 5: 运行 PASS + Commit**
```bash
npm run test -- tests/main/knowledge.repo.test.ts
git commit -m "feat: add knowledge and bridge repositories with publish status"
```
---
## Task 3: KnowledgeService
**Files:**
- Create: `src/main/services/knowledge.service.ts`
- Create: `tests/main/knowledge.service.test.ts`
- [ ] **Step 1: UT-KNOW-02 遗忘判定测试**
```typescript
it('UT-KNOW-02: forgotten when sort_order gap >= 20', () => {
// 创建 25 章,在第 1 章埋 foreshadow approved buried
// latestOrder - mentionOrder >= 20 → forgotten count 1
})
```
- [ ] **Step 2: UT-KNOW-03 bookmark 同步测试**
```typescript
it('UT-KNOW-03: createFromBookmark creates pending entry', () => {
const bm = bookmarkRepo.create(chapterId, 0, '测灵石发热', 'foreshadow')
const entry = service.createFromBookmark(bm.id)
expect(entry.status).toBe('pending')
expect(entry.linkedBookmarkId).toBe(bm.id)
})
```
- [ ] **Step 3: 实现 KnowledgeService**
```typescript
const FORGOTTEN_GAP = 20
export class KnowledgeService {
list(filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean })
create(input: CreateKnowledgeInput): KnowledgeEntry
update(id: string, patch: Partial<CreateKnowledgeInput>): KnowledgeEntry
delete(id: string): void
approve(id: string): KnowledgeEntry
reject(id: string): KnowledgeEntry
batchApprove(ids: string[]): number
createFromBookmark(bookmarkId: string): KnowledgeEntry
getForeshadowStats(): { buried: number; resolved: number; forgotten: number }
countPending(): number
private isForgotten(entry: KnowledgeEntry): boolean {
if (entry.type !== 'foreshadow' || entry.status !== 'approved' || entry.foreshadowStatus !== 'buried') return false
const mentionId = entry.lastMentionChapterId ?? entry.sourceChapterId
if (!mentionId) return false
const mentionOrder = chapterRepo.get(mentionId)?.sortOrder ?? 0
const latestOrder = Math.max(...chapterRepo.list().map((c) => c.sortOrder), 0)
return latestOrder - mentionOrder >= FORGOTTEN_GAP
}
}
```
- [ ] **Step 4: IT-KNOW-04 批量采纳**
```typescript
it('IT-KNOW-04: batchApprove clears pending', () => {
// create 3 pending → batchApprove → countPending === 0
})
```
- [ ] **Step 5: 运行 PASS + Commit**
```bash
npm run test -- tests/main/knowledge.service.test.ts
git commit -m "feat: add KnowledgeService with foreshadow forgotten logic"
```
---
## Task 4: CockpitService + GlobalSettings 扩展
**Files:**
- Create: `src/main/services/cockpit.service.ts`
- Modify: `src/main/services/global-settings.ts`
- Create: `tests/main/cockpit.service.test.ts`
- [ ] **Step 1: UT-COCK-01 测试**
```typescript
it('UT-COCK-01: getSummary counts words and ready stock', () => {
volRepo.create('卷一', 0)
chapterRepo.create(volId, 'A', 0, '<p>hello world</p>')
chapterRepo.setPublishStatus(chId, 'ready')
const summary = service.getSummary(activeVolumeId)
expect(summary.totalWords).toBeGreaterThan(0)
expect(summary.stockReadyCount).toBe(1)
})
```
- [ ] **Step 2: 实现 CockpitService**
```typescript
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(...)
: 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, ...stats }
}
shouldShowOnOpen(): boolean {
return new BookPrefsRepository(this.db).get('cockpitSeen') !== 'true'
}
markSeen(): void {
new BookPrefsRepository(this.db).set('cockpitSeen', 'true')
}
}
```
- [ ] **Step 3: global-settings defaults**
```typescript
updateSchedule: 'none',
stockBufferThreshold: 3,
```
`get()` merge 时补默认值。
- [ ] **Step 4: 运行 PASS + Commit**
```bash
npm run test -- tests/main/cockpit.service.test.ts
git commit -m "feat: add CockpitService and extend global settings"
```
---
## Task 5: ChapterBridgeService
**Files:**
- Create: `src/main/services/chapter-bridge.service.ts`
- Create: `tests/main/chapter-bridge.test.ts`
- [ ] **Step 1: UT-BRIDGE-01 文本提取测试**
```typescript
it('UT-BRIDGE-01: extracts tail and head', () => {
const prev = chapterRepo.create(volId, '上一章', 0, '<p>' + '甲'.repeat(400) + '</p>')
const cur = chapterRepo.create(volId, '本章', 1, '<p>开头内容</p>')
const result = service.getBridgeSync(cur.id) // 无 AI 同步方法
expect(result.previousTail.length).toBeLessThanOrEqual(300)
expect(result.previousTail.length).toBeGreaterThan(50)
expect(result.currentHead).toContain('开头')
})
```
- [ ] **Step 2: 实现 ChapterBridgeService**
```typescript
export class ChapterBridgeService {
shouldPrompt(chapterId: string): boolean {
if (dismissRepo.isDismissed(chapterId)) return false
const ch = chapterRepo.get(chapterId)
if (!ch || stripHtml(ch.content).length >= 200) return false
return chapterRepo.getPreviousChapter(chapterId) !== null
}
extract(chapterId: string): ChapterBridgeResult {
const prev = chapterRepo.getPreviousChapter(chapterId)
const cur = chapterRepo.get(chapterId)!
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 || !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 {
dismissRepo.dismiss(chapterId)
}
}
```
- [ ] **Step 3: IT-BRIDGE-01240s LM Studio**
```typescript
it('IT-BRIDGE-01: getBridge returns aiSuggestion from LM Studio', async () => {
// 两章有内容 → getBridge(id, true) → aiSuggestion.length > 10
}, 240_000)
```
- [ ] **Step 4: 运行 PASS + Commit**
```bash
npm run test -- tests/main/chapter-bridge.test.ts
git commit -m "feat: add ChapterBridgeService with optional AI suggestion"
```
---
## Task 6: IPC + Preload
**Files:**
- Create: `src/main/ipc/handlers/knowledge.handler.ts`
- Create: `src/main/ipc/handlers/cockpit.handler.ts`
- Create: `src/main/ipc/handlers/bridge.handler.ts`
- Modify: `src/main/ipc/handlers/chapter.handler.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- [ ] **Step 1: 实现 handlers**
参照 `auto.handler.ts` 模式,`wrap(() => ...)``BookRegistryService.getDb(bookId)`
`knowledge.handler.ts``list` / `create` / `update` / `delete` / `approve` / `reject` / `batchApprove` / `createFromBookmark` / `stats`
`cockpit.handler.ts``getSummary(bookId, volumeId?)` / `markSeen` / `shouldShowOnOpen`
`bridge.handler.ts``get(bookId, chapterId, withAi?)` / `dismiss` / `shouldPrompt`
`chapter.handler.ts` 新增:
```typescript
IPC.CHAPTER_SET_PUBLISH_STATUS chapterRepo.setPublishStatus(chapterId, status)
```
- [ ] **Step 2: register.ts**
```typescript
registerKnowledgeHandlers(books, settings)
registerCockpitHandlers(books, settings)
registerBridgeHandlers(books, settings, aiClient)
```
- [ ] **Step 3: preload 命名空间**
```typescript
knowledge: { list, create, update, delete, approve, reject, batchApprove, createFromBookmark, stats },
cockpit: { getSummary, markSeen, shouldShowOnOpen },
bridge: { get, dismiss, shouldPrompt },
chapter: { /* 现有 */ setPublishStatus },
```
- [ ] **Step 4: build 验证**
```bash
npm run build
```
- [ ] **Step 5: Commit**
```bash
git commit -m "feat: add knowledge cockpit bridge IPC and preload"
```
---
## Task 7: KnowledgePanel UI
**Files:**
- Create: `src/renderer/stores/useKnowledgeStore.ts`
- Create: `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx`
- Modify: `src/renderer/components/knowledge/KnowledgePanel.tsx`
- Modify: `src/renderer/styles/layout.css`
- [ ] **Step 1: useKnowledgeStore**
```typescript
interface KnowledgeState {
entries: KnowledgeEntry[]
stats: { buried: number; resolved: number; forgotten: number }
tab: 'pending' | 'all' | 'foreshadow'
load: (bookId: string) => Promise<void>
setTab: (tab: ...) => void
create / update / delete / approve / batchApprove
openForgottenFilter: () => void // tab=foreshadow + forgottenOnly
}
```
- [ ] **Step 2: KnowledgeEditorDialog**
Radix Dialog 或现有 `.modal-overlay` 模式;字段:type select、title、content textarea、importance 1-5、foreshadowStatustype=foreshadow 时)、sourceChapter select、「直接采纳」checkbox。
- [ ] **Step 3: 重写 KnowledgePanel**
布局见 spec §6.4;保留 `naming-check-open`Tab 切换过滤列表;`data-testid` 与 spec 一致。
- [ ] **Step 4: CSS `.knowledge-panel` / `.kb-item` 对齐 ui_pc.html**
- [ ] **Step 5: Commit**
```bash
git commit -m "feat: implement KnowledgePanel with review workflow UI"
```
---
## Task 8: CockpitModal + TopBar + 首次打开
**Files:**
- Create: `src/renderer/stores/useCockpitStore.ts`
- Create: `src/renderer/components/cockpit/CockpitModal.tsx`
- Modify: `src/renderer/components/layout/TopBar.tsx`
- Modify: `src/renderer/stores/useBookStore.ts`openBook 后检查 cockpit
- Modify: `src/renderer/App.tsx``EditorLayout.tsx`(挂载 CockpitModal
- [ ] **Step 1: useCockpitStore**
```typescript
loadSummary(bookId, volumeId?)
open / close
markSeen(bookId)
shouldShowOnOpen(bookId): Promise<boolean>
```
- [ ] **Step 2: CockpitModal**
加载 summary 展示卡片;按钮:
- `cockpit-resume-edit` → close + 恢复 lastChapterId
- `cockpit-bridge-check` → close + 打开 ChapterBridgeModal
- `cockpit-forgotten-foreshadow` → close + `useKnowledgeStore.openForgottenFilter()` + 切 rightPanel knowledge
- [ ] **Step 3: TopBar**
替换 `comingSoon``data-testid="topbar-cockpit"``useCockpitStore.open()`
- [ ] **Step 4: 首次打开书籍**
`useBookStore.openBook` 成功后:
```typescript
if (await ipcCall(() => window.electronAPI.cockpit.shouldShowOnOpen(bookId))) {
await ipcCall(() => window.electronAPI.cockpit.markSeen(bookId))
useCockpitStore.getState().open()
}
```
- [ ] **Step 5: Commit**
```bash
git commit -m "feat: add CockpitModal and first-open behavior"
```
---
## Task 9: ChapterBridgeModal + EditorLayout 集成
**Files:**
- Create: `src/renderer/components/bridge/ChapterBridgeModal.tsx`
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
- Modify: `src/renderer/stores/useAppStore.ts`bridgeOpen 状态,可选)
- [ ] **Step 1: ChapterBridgeModal**
打开时 `bridge.get(bookId, chapterId, true)`;展示 tail/head/aiSuggestioncheckbox dismiss + `bridge-start-writing` 关闭。
- [ ] **Step 2: EditorLayout 自动触发**
`switchTarget` 到 chapter 后:
```typescript
const should = await ipcCall(() => window.electronAPI.bridge.shouldPrompt(chapterId))
if (should) setTimeout(() => setBridgeOpen(true), 500)
```
- [ ] **Step 3: 驾驶舱手动触发**
CockpitModal `cockpit-bridge-check` 设置 `bridgeChapterId` 为当前章并打开 modal。
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add ChapterBridgeModal with auto prompt on new chapter"
```
---
## Task 10: 发布状态 + 存稿提醒 + 设置
**Files:**
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
- Modify: `src/renderer/stores/useBookStore.ts`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- [ ] **Step 1: 章节 publish 循环按钮**
`chapter-item` 内增加小图标按钮 `data-testid={`chapter-publish-${ch.id}`}`,点击循环 `draft → ready → published`,调用 `chapter.setPublishStatus`,刷新 chapters。
- [ ] **Step 2: StatusBar 存稿警告**
EditorLayout `#editor-statusbar` 内:
```typescript
const summary = await cockpit.getSummary(bookId, activeVolumeId)
if (settings.updateSchedule !== 'none' && summary.stockReadyCount < summary.stockThreshold) {
// 渲染 data-testid="status-stock-warning"
}
```
书籍打开时 load summarypublish 变更后 refresh。
- [ ] **Step 3: SettingsPage**
新增「更新计划」区块:`updateSchedule` select、`stockBufferThreshold` number input (1-20)。
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add publish status UI stock warning and update schedule settings"
```
---
## Task 11: 地标同步 + i18n
**Files:**
- Modify: `src/renderer/components/editor/TipTapEditor.tsx` 或 landmark 插入流程
- Modify: `public/locales/zh-CN/translation.json`
- Modify: `public/locales/en/translation.json`
- [ ] **Step 1: 插入 foreshadow 地标时 checkbox**
在 insert landmark 对话框(或插入后 toast 行动)增加「同步到知识库」;勾选则 `knowledge.createFromBookmark(bookmarkId)`
若当前无独立 insert UI,在 `LandmarkModal` 或 TipTap landmark 命令回调中加 post-create hook。
- [ ] **Step 2: i18n 键**
`cockpit.*``bridge.*``knowledge.*``publish.status.draft/ready/published``settings.updateSchedule.*``stock.warning`
- [ ] **Step 3: Commit**
```bash
git commit -m "feat: add foreshadow landmark sync and P5 i18n strings"
```
---
## Task 12: E2E 测试
**Files:**
- Create: `e2e/cockpit-knowledge.spec.ts`
- [ ] **Step 1: 无 AI 门槛测试**
```typescript
test('E2E-COCK-01: cockpit modal shows stats', async () => { /* openBookWithContent → topbar-cockpit → cockpit-total-words visible */ })
test('E2E-COCK-02: first open shows cockpit once', async () => { /* 开书两次 */ })
test('E2E-PUB-01: publish ready increases stock count', async () => { /* chapter-publish click → cockpit-stock-count */ })
test('E2E-KNOW-01: create approve knowledge entry', async () => { /* knowledge-new → pending tab → approve */ })
test('E2E-BRIDGE-01: new chapter triggers bridge modal', async () => { /* 新章 → bridge-previous-tail visible */ })
test('E2E-STOCK-01: low stock warning in statusbar', async () => { /* set threshold + schedule in settings, 0 ready chapters */ })
```
- [ ] **Step 2: LM Studio 测试(300s**
```typescript
test('E2E-BRIDGE-02: bridge shows AI suggestion', async () => {
test.setTimeout(300_000)
// 两章有内容 → 手动打开 bridge → bridge-ai-suggestion 非空
})
test('E2E-KNOW-02: foreshadow landmark sync', async () => { /* 插入 foreshadow + sync */ })
```
- [ ] **Step 3: 运行 E2E**
```bash
npm run test:e2e -- e2e/cockpit-knowledge.spec.ts
```
- [ ] **Step 4: Commit**
```bash
git commit -m "test: add P5 cockpit knowledge and bridge E2E specs"
```
---
## Task 13: v0.6.0 交付
**Files:**
- Modify: `package.json`
- Modify: `README.md`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`(版本号展示)
- Modify: `docs/superpowers/plans/2026-07-06-bilin-p5-workflow-knowledge.md`(勾选完成项)
- [ ] **Step 1: 版本号 → `0.6.0`**
- [ ] **Step 2: README 功能概览追加 P5**
```markdown
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5)
- 章间衔接:上下章片段 + 可选 AI 建议(P5)
- 更新计划:发布状态、存稿阈值提醒(P5)
```
- [ ] **Step 3: 全量测试**
```bash
npm run build
npm run test
npm run test:e2e
```
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: ship v0.6.0 with cockpit knowledge and chapter bridge"
```
---
## Spec Coverage Checklist
| Spec 要求 | Task |
|-----------|------|
| schema v6 三表 | Task 1 |
| Knowledge CRUD / 审核 / 遗忘 | Task 23, 7 |
| Cockpit 聚合 + 首次打开 | Task 4, 8 |
| ChapterBridge + AI | Task 5, 9 |
| publish_status UI | Task 10 |
| updateSchedule / 存稿提醒 | Task 4, 10 |
| 地标 foreshadow 同步 | Task 11 |
| IPC / preload | Task 6 |
| i18n | Task 11 |
| 全部测试 ID | Task 15, 12 |
| v0.6.0 DoD | Task 13 |
@@ -0,0 +1,927 @@
# 笔临 P6 AI 知识上下文整合 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** 交付 v0.7.0:已审核知识库条目半自动推荐 + 用户勾选确认 + 注入对话/交互/自动/向导四种 AI 模式;修复写作流 `contextJson` 仅存标题摘要的问题。
**Architecture:** 新增 `KnowledgeRetrievalService``approved` 知识条目综合打分;扩展 `AiContextBinding.knowledgeIds``AiContextBuilderService` 合并知识块;`FlowContextPayload` 统一写作流上下文存储;`KnowledgeSuggestList` + `ContextEditorModal` 知识 Tab 供四种模式共用。测试 **禁止 Mock** AI 集成/E2E 打真实 LM Studio。
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、i18next、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-07-bilin-p6-ai-context-design.md`
**LM Studio 默认:** `baseUrl=http://127.0.0.1:1234``model=gemma-4-e4b-it`
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/types.ts` | `KnowledgeSuggestOptions``ScoredKnowledge``FlowContextPayload`;扩展 `AiContextBinding` |
| `src/shared/ipc-channels.ts` | `KNOWLEDGE_SUGGEST_CONTEXT` |
| `src/main/services/knowledge-retrieval.service.ts` | 打分 + TopN 推荐 |
| `src/main/services/ai-context-builder.service.ts` | knowledge 块 + 预算裁剪 |
| `src/main/services/flow-context.util.ts` | `buildFlowContextPayload()` 共享 |
| `src/main/services/interactive-writing.service.ts` | `readContextText``systemPrompt` |
| `src/main/db/repositories/ai-session.repo.ts` | `parseContext``knowledgeIds` |
| `src/main/ipc/handlers/knowledge.handler.ts` | `suggestContext` |
| `src/main/ipc/handlers/interactive.handler.ts` | `FlowContextPayload` |
| `src/main/ipc/handlers/auto.handler.ts` | `readContextText` 修复 |
| `src/main/ipc/handlers/wizard.handler.ts` | 同上 + preview 知识列表 |
| `src/main/ipc/handlers/ai.handler.ts` | binding 默认 `knowledgeIds: []` |
| `src/main/services/global-settings.ts` | `knowledgeAutoSuggest` / `knowledgeSuggestTopN` 默认值 |
| `src/preload/index.ts` | `knowledge.suggestContext` |
| `src/shared/electron-api.d.ts` | 类型 |
| `src/renderer/components/ai/KnowledgeSuggestList.tsx` | 共享勾选 UI |
| `src/renderer/components/ai/ContextEditorModal.tsx` | 知识 Tab + 推荐区 |
| `src/renderer/components/ai/InteractivePanel.tsx` | context_confirm 知识区 |
| `src/renderer/components/ai/AutoPanel.tsx` | goal 后知识确认 |
| `src/renderer/components/ai/WizardPanel.tsx` | wizard_context 知识区 |
| `src/renderer/lib/ai-context-summary.ts` | 摘要含知识计数 |
| `src/renderer/stores/useInteractiveStore.ts` | binding 含 knowledgeIds |
| `public/locales/zh-CN/translation.json` | P6 文案 |
| `public/locales/en/translation.json` | P6 文案 |
| `tests/main/knowledge-retrieval.test.ts` | UT-CTX-01/04 |
| `tests/main/ai-context-builder.test.ts` | UT-CTX-02/03(扩展) |
| `tests/main/flow-context.test.ts` | FlowContextPayload 单元 |
| `tests/main/ai-context-integration.test.ts` | IT-CTX-01/02/03 |
| `e2e/ai-context.spec.ts` | E2E-CTX-* |
---
## Task 1: 类型 + IPC 通道 + 默认 binding
**Files:**
- Modify: `src/shared/types.ts`
- Modify: `src/shared/ipc-channels.ts`
- Modify: `src/main/db/repositories/ai-session.repo.ts`
- [x] **Step 1: 扩展 types**
`src/shared/types.ts` 追加:
```typescript
export interface KnowledgeSuggestOptions {
currentChapterId?: string
goalText?: string
topN?: number
}
export interface ScoredKnowledge {
entry: KnowledgeEntry
score: number
reasons: string[]
}
export interface FlowContextPayload {
binding: AiContextBinding
systemPrompt: string
contextSummary: string
}
```
扩展 `AiContextBinding`
```typescript
export interface AiContextBinding {
chapterIds: string[]
outlineIds: string[]
settingIds: string[]
inspirationIds: string[]
knowledgeIds: string[]
}
```
扩展 `ContextPreviewKind``| 'knowledge'`
扩展 `ContextPreviewItem``knowledgeType?: KnowledgeType`
扩展 `GlobalSettings`(可选,本 Task 仅类型,Task 2 实现默认值):
```typescript
knowledgeAutoSuggest: boolean
knowledgeSuggestTopN: number
```
- [x] **Step 2: IPC 通道**
`src/shared/ipc-channels.ts`
```typescript
KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext',
```
- [x] **Step 3: ai-session.repo 默认 knowledgeIds**
```typescript
const EMPTY_CONTEXT: AiContextBinding = {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
}
function parseContext(json: string): AiContextBinding {
try {
const parsed = JSON.parse(json) as Partial<AiContextBinding>
return {
chapterIds: parsed.chapterIds ?? [],
outlineIds: parsed.outlineIds ?? [],
settingIds: parsed.settingIds ?? [],
inspirationIds: parsed.inspirationIds ?? [],
knowledgeIds: parsed.knowledgeIds ?? []
}
} catch {
return { ...EMPTY_CONTEXT }
}
}
```
- [x] **Step 4: 全局修补 EMPTY_BINDING**
搜索 `inspirationIds: []` 且无 `knowledgeIds``AiContextBinding` 字面量(`ContextEditorModal.tsx``useInteractiveStore.ts` 等),全部补上 `knowledgeIds: []`
- [x] **Step 5: build 验证**
```bash
npm run build
```
- [x] **Step 6: Commit**
```bash
git add src/shared/types.ts src/shared/ipc-channels.ts src/main/db/repositories/ai-session.repo.ts
git commit -m "feat: extend AiContextBinding with knowledgeIds for P6"
```
---
## Task 2: KnowledgeRetrievalService
**Files:**
- Create: `src/main/services/knowledge-retrieval.service.ts`
- Create: `tests/main/knowledge-retrieval.test.ts`
- [x] **Step 1: UT-CTX-01 / UT-CTX-04 失败测试**
`tests/main/knowledge-retrieval.test.ts`
```typescript
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 { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('KnowledgeRetrievalService', () => {
let db: SqliteDb
afterEach(() => db?.close())
it('UT-CTX-01: ranks by importance + chapter proximity + goal match', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const volId = new VolumeRepository(db).create('卷一', 0).id
const chapterRepo = new ChapterRepository(db)
const cur = chapterRepo.create(volId, '当前章', 5, '<p>x</p>')
const near = chapterRepo.create(volId, '近章', 4, '<p>x</p>')
chapterRepo.create(volId, '远章', 0, '<p>x</p>')
const repo = new KnowledgeRepository(db)
const low = repo.create({
type: 'fact',
title: '无关事实',
content: '普通内容',
importance: 2,
status: 'approved'
})
const high = repo.create({
type: 'foreshadow',
title: '测灵石异常',
content: '测灵石在入门考核中发热',
importance: 5,
status: 'approved',
foreshadowStatus: 'buried',
sourceChapterId: near.id
})
const service = new KnowledgeRetrievalService(db)
const result = service.suggest({
currentChapterId: cur.id,
goalText: '测灵石入门考核',
topN: 5
})
expect(result[0].entry.id).toBe(high.id)
expect(result[0].score).toBeGreaterThan(
result.find((r) => r.entry.id === low.id)!.score
)
expect(result[0].reasons.length).toBeGreaterThan(0)
})
it('UT-CTX-04: resolved foreshadow scores lower than buried', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const volId = new VolumeRepository(db).create('卷一', 0).id
const chId = new ChapterRepository(db).create(volId, '章', 0).id
const repo = new KnowledgeRepository(db)
const buried = repo.update(
repo.create({
type: 'foreshadow',
title: '埋设伏笔',
importance: 3,
status: 'approved',
foreshadowStatus: 'buried',
sourceChapterId: chId
}).id,
{ status: 'approved' }
)
const resolved = repo.create({
type: 'foreshadow',
title: '已回收伏笔',
importance: 3,
status: 'approved',
foreshadowStatus: 'resolved',
sourceChapterId: chId
})
const service = new KnowledgeRetrievalService(db)
const scores = service.suggest({ currentChapterId: chId, topN: 10 })
const buriedScore = scores.find((s) => s.entry.id === buried.id)!.score
const resolvedScore = scores.find((s) => s.entry.id === resolved.id)!.score
expect(buriedScore).toBeGreaterThan(resolvedScore)
})
})
```
- [x] **Step 2: 运行确认 FAIL**
```bash
npm run test -- tests/main/knowledge-retrieval.test.ts
```
- [x] **Step 3: 实现 KnowledgeRetrievalService**
`src/main/services/knowledge-retrieval.service.ts` 核心:
```typescript
import type { KnowledgeEntry, KnowledgeSuggestOptions, ScoredKnowledge } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
const STOP_WORDS = new Set(['的', '了', '在', '是', '与', '和', 'the', 'a', 'an'])
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[\s,,。!?、;;:]+/)
.map((t) => t.trim())
.filter((t) => t.length >= 2 && !STOP_WORDS.has(t))
}
function overlapRatio(a: string, b: string): number {
const ta = new Set(tokenize(a))
const tb = tokenize(b)
if (ta.size === 0 || tb.length === 0) return 0
let hit = 0
for (const t of tb) if (ta.has(t)) hit++
return hit / tb.length
}
function chapterProximityBonus(
entry: KnowledgeEntry,
currentSort: number | null,
chapterSort: Map<string, number>
): number {
if (currentSort == null) return 0
const ids = [entry.sourceChapterId, entry.lastMentionChapterId].filter(Boolean) as string[]
if (ids.length === 0) return 0
let best = Infinity
for (const id of ids) {
const order = chapterSort.get(id)
if (order != null) best = Math.min(best, Math.abs(currentSort - order))
}
if (best === Infinity) return 0
if (best === 0) return 30
if (best <= 3) return 20
if (best <= 10) return 10
return 0
}
export class KnowledgeRetrievalService {
constructor(private db: SqliteDb) {}
suggest(opts: KnowledgeSuggestOptions = {}): ScoredKnowledge[] {
const topN = opts.topN ?? 8
const entries = new KnowledgeRepository(this.db).list({ status: 'approved' })
if (entries.length === 0) return []
const chapters = new ChapterRepository(this.db).list()
const sortMap = new Map(chapters.map((c) => [c.id, c.sortOrder]))
const currentSort = opts.currentChapterId
? (sortMap.get(opts.currentChapterId) ?? null)
: null
const goalText = opts.goalText ?? ''
const scored: ScoredKnowledge[] = entries.map((entry) => {
let score = entry.importance * 20
const reasons: string[] = []
const prox = chapterProximityBonus(entry, currentSort, sortMap)
score += prox
if (prox > 0) reasons.push('knowledge.reason.chapterRelevant')
if (entry.type === 'foreshadow') {
const fs = entry.foreshadowStatus ?? 'buried'
if (fs === 'buried') {
score += 25
reasons.push('knowledge.reason.foreshadowBuried')
} else if (fs === 'partial') score += 15
}
if (goalText) {
const text = `${entry.title} ${entry.content}`
const ratio = overlapRatio(goalText, text)
score += Math.round(ratio * 30)
if (ratio > 0.2) reasons.push('knowledge.reason.goalMatch')
}
if (entry.importance >= 4) reasons.push('knowledge.reason.highImportance')
return { entry, score, reasons: [...new Set(reasons)] }
})
return scored.sort((a, b) => b.score - a.score).slice(0, topN)
}
}
```
- [x] **Step 4: 运行 PASS**
```bash
npm run test -- tests/main/knowledge-retrieval.test.ts
```
- [x] **Step 5: Commit**
```bash
git add src/main/services/knowledge-retrieval.service.ts tests/main/knowledge-retrieval.test.ts
git commit -m "feat: add KnowledgeRetrievalService with scoring for context suggest"
```
---
## Task 3: 扩展 AiContextBuilder + flow-context 工具
**Files:**
- Modify: `src/main/services/ai-context-builder.service.ts`
- Create: `src/main/services/flow-context.util.ts`
- Modify: `tests/main/ai-context-builder.test.ts`
- Create: `tests/main/flow-context.test.ts`
- [x] **Step 1: UT-CTX-02 / UT-CTX-03 测试**
`tests/main/ai-context-builder.test.ts` 追加:
```typescript
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
it('UT-CTX-02: build includes knowledge preview items', () => {
const know = new KnowledgeRepository(db)
const entry = know.create({
type: 'foreshadow',
title: '测灵石',
content: '石头发热',
status: 'approved'
})
const result = aiContextBuilder.build(
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [entry.id] },
registry,
bookId,
'作者'
)
expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true)
expect(result.systemPrompt).toContain('【知识·伏笔】')
expect(result.systemPrompt).toContain('测灵石')
})
it('UT-CTX-03: caps knowledge section at 4000 chars', () => {
const know = new KnowledgeRepository(db)
const ids: string[] = []
for (let i = 0; i < 20; i++) {
const e = know.create({
type: 'fact',
title: `条目${i}`,
content: '长'.repeat(400),
importance: 5,
status: 'approved'
})
ids.push(e.id)
}
const result = aiContextBuilder.build(
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: ids },
registry,
bookId,
'作者'
)
const knowledgeChars = result.preview
.filter((p) => p.kind === 'knowledge')
.reduce((s, p) => s + p.charCount, 0)
expect(knowledgeChars).toBeLessThanOrEqual(4000)
})
```
`tests/main/flow-context.test.ts`
```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { BookRegistryService } from '../../src/main/services/book-registry'
import { buildFlowContextPayload } from '../../src/main/services/flow-context.util'
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
describe('flow-context.util', () => {
let userDir: string
let registry: BookRegistryService
let bookId: string
beforeEach(() => {
userDir = mkdtempSync(join(tmpdir(), 'bilin-flow-ctx-'))
registry = new BookRegistryService(userDir)
bookId = registry.create({ name: '书', category: '玄幻' }).id
})
afterEach(() => rmSync(userDir, { recursive: true, force: true }))
it('buildFlowContextPayload stores systemPrompt and summary', () => {
const db = registry.getDb(bookId)
const entry = new KnowledgeRepository(db).create({
type: 'fact',
title: '测试知识',
content: '内容',
status: 'approved'
})
const payload = buildFlowContextPayload(
{
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: [entry.id]
},
registry,
bookId,
'作者'
)
expect(payload.systemPrompt).toContain('测试知识')
expect(payload.contextSummary).toContain('1')
db.close()
})
})
```
- [x] **Step 2: 运行确认 FAIL**
```bash
npm run test -- tests/main/ai-context-builder.test.ts tests/main/flow-context.test.ts
```
- [x] **Step 3: 扩展 ai-context-builder**
要点:
- `KNOWLEDGE_CHAR_LIMIT = 500``KNOWLEDGE_TOTAL_LIMIT = 4000`
- `knowledgeTypeLabel()` 映射五种类型
-`candidates` 循环后追加 knowledge 块,带 `sortKey: entry.importance * 1000`(高 important 先保留)
- 知识区单独裁剪至 4000 后再参与总 16KB 裁剪
- 所有现有 `build()` 调用 binding 需含 `knowledgeIds`(测试已补)
`src/main/services/flow-context.util.ts`
```typescript
import type { AiContextBinding, FlowContextPayload } from '../../shared/types'
import type { BookRegistryService } from './book-registry'
import { aiContextBuilder } from './ai-context-builder.service'
export function buildFlowContextPayload(
binding: AiContextBinding,
registry: BookRegistryService,
bookId: string,
penName: string
): FlowContextPayload {
const built = aiContextBuilder.build(binding, registry, bookId, penName)
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 }
for (const p of built.preview) {
if (p.kind in counts) counts[p.kind as keyof typeof counts]++
}
const contextSummary = `${counts.chapter}章·${counts.outline}纲·${counts.setting}设·${counts.inspiration}感·${counts.knowledge}`
return { binding, systemPrompt: built.systemPrompt, contextSummary }
}
```
- [x] **Step 4: 运行 PASS**
```bash
npm run test -- tests/main/ai-context-builder.test.ts tests/main/flow-context.test.ts
```
- [x] **Step 5: Commit**
```bash
git add src/main/services/ai-context-builder.service.ts src/main/services/flow-context.util.ts tests/main/
git commit -m "feat: extend AiContextBuilder with knowledge blocks and flow payload helper"
```
---
## Task 4: IPC suggestContext + preload
**Files:**
- Modify: `src/main/ipc/handlers/knowledge.handler.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- Modify: `src/main/services/global-settings.ts`
- Modify: `src/renderer/stores/useSettingsStore.ts`
- [x] **Step 1: knowledge.handler 追加**
```typescript
ipcMain.handle(
IPC.KNOWLEDGE_SUGGEST_CONTEXT,
(
_e,
{ bookId, opts }: { bookId: string; opts?: KnowledgeSuggestOptions }
) => wrap(() => {
const settings = /* inject via handler param or read topN from settings */
new KnowledgeRetrievalService(registry.getDb(bookId)).suggest(opts)
})
)
```
`registerKnowledgeHandlers` 签名改为接收 `GlobalSettingsService``topN` 默认 `settings.get().knowledgeSuggestTopN ?? 8`
- [x] **Step 2: global-settings 默认值**
```typescript
knowledgeAutoSuggest: true,
knowledgeSuggestTopN: 8,
```
`get()` merge 补默认。
- [x] **Step 3: preload + electron-api.d.ts**
```typescript
suggestContext: (
bookId: string,
opts?: KnowledgeSuggestOptions
): Promise<IpcResult<ScoredKnowledge[]>>
```
- [x] **Step 4: build**
```bash
npm run build
```
- [x] **Step 5: Commit**
```bash
git commit -m "feat: add knowledge suggestContext IPC and global settings defaults"
```
---
## Task 5: 写作流 systemPrompt 修复
**Files:**
- Modify: `src/main/services/interactive-writing.service.ts`
- Modify: `src/main/ipc/handlers/interactive.handler.ts`
- Modify: `src/main/ipc/handlers/auto.handler.ts`
- Modify: `src/main/ipc/handlers/wizard.handler.ts`
- Modify: `src/main/services/wizard-writing.service.ts``buildPreview` 追加知识列表)
- [x] **Step 1: interactive confirmContext 存 FlowContextPayload**
`interactive.handler.ts` `INTERACTIVE_CONFIRM_CONTEXT`
```typescript
const payload = buildFlowContextPayload(binding, registry, bookId, settings.get().penName)
const flow = service.confirmContext(flowId, payload)
```
`InteractiveWritingService.confirmContext` 改为:
```typescript
confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow {
this.repo.updateStep(flowId, 'plot_suggest', {
contextJson: JSON.stringify(payload)
})
return this.repo.get(flowId)!
}
private readContextText(flowId: string): string {
try {
const parsed = JSON.parse(this.repo.getContextJson(flowId)) as FlowContextPayload
return parsed.systemPrompt || '(无上下文)'
} catch {
return '(无上下文)'
}
}
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
try {
const parsed = JSON.parse(this.repo.getContextJson(flow.id)) as FlowContextPayload
return { ...flow, contextSummary: parsed.contextSummary }
} catch {
return flow
}
}
```
- [x] **Step 2: auto.handler / wizard.handler readContextText**
替换本地 `readContextText` 为读 `systemPrompt`
```typescript
function readContextText(registry, bookId, flowId): string {
const row = registry.getDb(bookId)
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { context_json: string } | undefined
try {
const parsed = JSON.parse(row?.context_json ?? '{}') as FlowContextPayload
return parsed.systemPrompt ?? '(无上下文)'
} catch {
return '(无上下文)'
}
}
```
- [x] **Step 3: wizard confirmContext 同 interactive**
`wizard.handler.ts` `WIZARD_CONFIRM_CONTEXT` 使用 `buildFlowContextPayload`
- [x] **Step 4: buildPreview 追加知识标题**
`WizardWritingService.buildPreview` 返回前,从 `payload.binding.knowledgeIds` 查标题,append `\n将注入知识:A、B、C`
- [x] **Step 5: auto planScenes 前自动 confirm**
`AutoPanel``planScenes` 调用前,若 flow 无 `systemPrompt`,用 session binding + 默认推荐 knowledgeIds 调 `interactive.confirmContext` 等价逻辑(可新增 `auto:confirmContext` IPC 或复用 `buildFlowContextPayload` + `setContextSummary` 扩展为存完整 payload)。
推荐:新增 `auto.handler` `AUTO_CONFIRM_CONTEXT` 与 interactive 对称。
- [x] **Step 6: Commit**
```bash
git commit -m "fix: store full systemPrompt in writing flow contextJson for P6"
```
---
## Task 6: KnowledgeSuggestList + ContextEditorModal
**Files:**
- Create: `src/renderer/components/ai/KnowledgeSuggestList.tsx`
- Modify: `src/renderer/components/ai/ContextEditorModal.tsx`
- Modify: `src/renderer/lib/ai-context-summary.ts`
- [x] **Step 1: KnowledgeSuggestList**
Props
```typescript
interface KnowledgeSuggestListProps {
items: ScoredKnowledge[]
selectedIds: string[]
onToggle: (id: string) => void
}
```
渲染 checkbox + title + `t(\`knowledge.type.${entry.type}\`)` + reason badges `t(reason)`。
- [x] **Step 2: ContextEditorModal 扩展**
新增 props`goalText?: string`
打开时:
```typescript
const chapterId = useBookStore.getState().selectedChapterId ?? undefined
const suggested = await ipcCall(() =>
window.electronAPI.knowledge.suggestContext(bookId, { currentChapterId: chapterId, goalText })
)
// 默认 merge suggested ids into binding.knowledgeIds
```
新增 Tab `knowledge`;顶部推荐区 + `context-refresh-suggest`;下方 approved 全列表(`knowledge.list(bookId, { status: 'approved' })`)可勾选。
- [x] **Step 3: formatContextSummary 含 knowledge**
```typescript
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 }
// ...
return t('ai.contextSummary', { ...counts })
```
更新 `ai.contextSummary` i18n 为 `{{chapters}}章·{{outlines}}纲·{{settings}}设·{{inspirations}}感·{{knowledge}}知`。
- [x] **Step 4: Commit**
```bash
git commit -m "feat: add KnowledgeSuggestList and knowledge tab in context editor"
```
---
## Task 7: 交互 / 自动 / 向导 Panel 集成
**Files:**
- Modify: `src/renderer/components/ai/InteractivePanel.tsx`
- Modify: `src/renderer/components/ai/AutoPanel.tsx`
- Modify: `src/renderer/components/ai/WizardPanel.tsx`
- Modify: `src/renderer/stores/useInteractiveStore.ts`
- Modify: `src/renderer/stores/useAutoStore.ts`confirmContext
- Modify: `src/renderer/components/ai/AiPanel.tsx`(传 goalText 空)
- [x] **Step 1: InteractivePanel context_confirm**
- mount 时 `suggestContext` → state `suggested`
- 内联 `<KnowledgeSuggestList>``data-testid="interactive-knowledge-suggest"`
- 勾选同步到 session `context.knowledgeIds``ai.sessionUpdate`
- `confirmContextAndStart` 传含 `knowledgeIds` 的 binding
- [x] **Step 2: AutoPanel**
- `goal_setup` 区域下方折叠「知识上下文」`data-testid="auto-knowledge-suggest"`
- `goalText` = `chapterGoal` + 选中大纲 description
- `planScenes` 前调用 `auto.confirmContext`Task 5 新增 IPC
- [x] **Step 3: WizardPanel wizard_context**
- 同 Interactive 内联推荐列表 `data-testid="wizard-knowledge-suggest"`
- `handleContextClose` 传 `goalText` = goal + styleNote
- [x] **Step 4: Commit**
```bash
git commit -m "feat: integrate knowledge suggest into interactive auto wizard panels"
```
---
## Task 8: i18n
**Files:**
- Modify: `public/locales/zh-CN/translation.json`
- Modify: `public/locales/en/translation.json`
- [x] **Step 1: 追加 spec §9 全部键 + `ai.contextSummary` 更新**
- [x] **Step 2: Commit**
```bash
git commit -m "feat: add P6 AI context i18n strings"
```
---
## Task 9: 集成测试
**Files:**
- Create: `tests/main/ai-context-integration.test.ts`
- [x] **Step 1: IT-CTX-01**
```typescript
it('IT-CTX-01: suggestContext returns entries when approved exist', () => {
// setup approved entry → suggest → length >= 1
})
```
- [x] **Step 2: IT-CTX-02**
通过 `aiContextBuilder.build` 含 knowledgeIds 断言 `systemPrompt` 含 `【知识·`(无需 Electron IPC)。
- [x] **Step 3: IT-CTX-03240s**
```typescript
it('IT-CTX-03: interactive confirm then suggestPlots uses knowledge context', async () => {
// setup book + knowledge + interactive flow
// confirmContext with knowledgeIds
// suggestPlots returns 3 options
}, 240_000)
```
- [x] **Step 4: 运行**
```bash
npm run test -- tests/main/ai-context-integration.test.ts
```
- [x] **Step 5: Commit**
```bash
git commit -m "test: add P6 AI context integration tests"
```
---
## Task 10: E2E
**Files:**
- Create: `e2e/ai-context.spec.ts`
- [x] **Step 1: E2E-CTX-01**
```typescript
test('E2E-CTX-01: context editor knowledge tab shows suggestions', async () => {
// create book → add approved knowledge via panel → open AI context editor → context-tab-knowledge visible
})
```
- [x] **Step 2: E2E-CTX-02**
```typescript
test('E2E-CTX-02: interactive context_confirm shows knowledge suggest', async () => {
// open interactive → interactive-knowledge-suggest visible
})
```
- [x] **Step 3: E2E-CTX-03300s**
```typescript
test('E2E-CTX-03: chat with knowledge context gets AI reply', async () => {
test.setTimeout(300_000)
// add knowledge → select in context → send chat → non-empty reply
})
```
- [x] **Step 4: 运行**
```bash
npm run test:e2e -- e2e/ai-context.spec.ts
```
- [x] **Step 5: Commit**
```bash
git commit -m "test: add P6 AI context E2E specs"
```
---
## Task 11: v0.7.0 交付
**Files:**
- Modify: `package.json`
- Modify: `README.md`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`about 版本号)
- Modify: `docs/superpowers/plans/2026-07-07-bilin-p6-ai-context.md`(勾选完成项)
- [x] **Step 1: 版本号 `0.7.0`**
- [x] **Step 2: README 追加**
```markdown
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
```
- [x] **Step 3: 全量测试**
```bash
npm run build
npm run test
npm run test:e2e
```
- [x] **Step 4: Commit**
```bash
git commit -m "feat: ship v0.7.0 with AI knowledge context integration"
```
---
## Spec Coverage Checklist
| Spec 要求 | Task |
|-----------|------|
| KnowledgeRetrievalService 打分 | Task 2 |
| AiContextBinding.knowledgeIds | Task 1 |
| AiContextBuilder 知识块 | Task 3 |
| FlowContextPayload / systemPrompt 修复 | Task 3, 5 |
| knowledge:suggestContext IPC | Task 4 |
| ContextEditorModal 知识 Tab | Task 6 |
| 对话静默注入 | Task 5AI_CHAT 已用 build+ Task 6 |
| 交互 context_confirm | Task 7 |
| 自动写作知识确认 | Task 5, 7 |
| 向导 wizard_context | Task 7 |
| global-settings 默认值 | Task 4 |
| UT-CTX-01~04 | Task 2, 3 |
| IT-CTX-01~03 | Task 9 |
| E2E-CTX-01~03 | Task 10 |
| v0.7.0 DoD | Task 11 |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,454 @@
# 笔临 P5 作家工作流 + 知识库 MVP 设计规格
**版本**1.0
**日期**2026-07-06
**范围**:P5 — 驾驶舱 + 更新计划/存稿 + 章间衔接 + 知识库 MVP(手动 CRUD / 审核 / 伏笔追踪)
**前置**:P4.1 自动/向导写作已交付(v0.5.0,`5d3e264`
**参考**`design/readme.md` v1.1 §4.3 / §6.7 / §20.1 / §20.4、`design/ui_pc.html` `#dashboardModal` / `#bridgeModal` / `#knowledge`
**目标版本**v0.6.0
---
## 1. 背景与目标
### 1.1 阶段定位
v0.5.0 已交付四种 AI 写作模式;知识库 Tab 仍为占位(仅命名检查入口),顶栏驾驶舱/章间衔接为 `comingSoon`。本阶段交付 **作家日更工作流****知识库 MVP**,对齐 `design/readme.md` §20 作家专项能力,为后续 AI 知识抽取(P5.2+)与 AI 上下文注入(P6)打基础。
### 1.2 已确认决策(Brainstorming 2026-07-06
| 维度 | 决策 |
|------|------|
| 总范围 | **A 作家工作流 + B 知识库 MVP**,统一 v0.6.0 交付(方案 1) |
| 知识库 | **MVP B**`knowledge_entries` 手动 CRUD + 审核流 + 地标 `foreshadow` 同步;**不做** AI 自动抽取 |
| 驾驶舱 | **MVP B**:现有字数聚合 + `publish_status` 存稿缓冲 + 快速入口;**不含** `writing_logs` / 热力图 / 预计完本 |
| 章间衔接 | **B**:上一章末 + 本章开头文本;LM Studio 可用时追加 AI 建议;离线降级纯文本 |
| 架构 | schema **v6**`CockpitService` / `KnowledgeService` / `ChapterBridgeService` 分层 |
| 首次打开书籍 | 展示驾驶舱一次;日常打开恢复上次编辑位(§4.3) |
| AI 后端 | 衔接建议走现有 `AiClientService`(LM Studio);集成测试禁止 Mock |
| 测试超时 | 衔接 AI 集成 **240s**E2E 含 AI **300s** |
### 1.3 验收标准
用户能完成:
1. 顶栏打开**驾驶舱**,查看全书/本卷字数、存稿缓冲、伏笔摘要(已埋/已回收/疑似遗忘)
2. 在设置中配置**更新频率**与**存稿阈值**;章节列表修改 `publish_status`(草稿/待发布/已发布);存稿不足时状态栏软性提醒
3. 新章字数 < 200 时自动弹出**章间衔接**卡片(可「本章不再提示」);或从驾驶舱手动触发;LM Studio 可用时看到 AI 衔接建议
4. 在**知识库**手动新建/编辑条目,在「待审核」Tab 采纳或拒绝;伏笔类型可标记状态;从地标创建 `foreshadow` 时可同步为待审核条目
5. 驾驶舱「处理遗忘伏笔」跳转知识库并筛选疑似遗忘项;命名一致性检查入口保留
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| AI 每章自动知识抽取(§6.7 全自动) | P5.2+ |
| 知识条目注入 AI 写作上下文 | P6 |
| `writing_logs`、365 天热力图、预计完本、番茄钟(§8) | P5.2 / §8 |
| 角色弧线简报、Cytoscape 关系图谱(§7 / §20.3 | P6+ |
| 导入导出、平台投稿预设(§11 / §20.2) | P7 |
| 书籍设置面板「文风控制」(ui 原型占位) | 后续 |
---
## 2. 架构
### 2.1 进程模型
```
┌─────────────────────────────────────────────────────────────┐
│ 主进程(schema v6
│ · KnowledgeRepository / KnowledgeService │
│ · CockpitService(只读聚合) │
│ · ChapterBridgeService(文本提取 + 可选 AiClient
│ · ChapterRepository 扩展 publish_status 读写 │
│ · GlobalSettings 扩展 updateSchedule / stockBufferThreshold │
│ · book_preferencescockpitSeen、bridge 免打扰) │
└───────────────────────────┬─────────────────────────────────┘
│ IPC
┌───────────────────────────▼─────────────────────────────────┐
│ 渲染进程 │
│ · CockpitModal / ChapterBridgeModal │
│ · KnowledgePanel(待审核 | 全部 | 伏笔) │
│ · ChapterList 发布状态 + StatusBar 存稿提醒 │
│ · SettingsPage 更新计划 │
│ · TopBar 驾驶舱按钮(替换 comingSoon
└─────────────────────────────────────────────────────────────┘
```
### 2.2 模块边界
| 模块 | 职责 | 依赖 |
|------|------|------|
| `KnowledgeService` | CRUD、审核、批量操作、伏笔统计、遗忘判定 | `KnowledgeRepository`, `ChapterRepository` |
| `CockpitService` | 聚合字数、存稿数、伏笔摘要、卷占比 | `ChapterRepository`, `KnowledgeService` |
| `ChapterBridgeService` | 提取相邻章片段、生成/缓存 AI 建议 | `ChapterRepository`, `AiClientService` |
| `KnowledgePanel` | 列表 UI、编辑对话框、审核操作 | `useKnowledgeStore` |
| `useKnowledgeStore` | IPC 封装、Tab 筛选状态 | preload `knowledge.*` |
**原则**:驾驶舱只读不写;知识审核后才计入伏笔统计;衔接 AI 失败不阻断文本展示。
---
## 3. 数据模型(schema v6
### 3.1 迁移
- `CURRENT_VERSION = 6`
- 新文件 `schema-v6.sql`
```sql
CREATE TABLE knowledge_entries (
id TEXT PRIMARY KEY,
type TEXT NOT NULL, -- character/foreshadow/location/relationship/fact
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
importance INTEGER NOT NULL DEFAULT 3, -- 1-5
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected
foreshadow_status TEXT, -- buried/partial/resolved (type=foreshadow)
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 idx_knowledge_status ON knowledge_entries(status);
CREATE INDEX idx_knowledge_type ON knowledge_entries(type);
CREATE TABLE book_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL -- JSON
);
CREATE TABLE 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
);
```
- v5→v6 迁移测试:`UT-MIG-06`
### 3.2 TypeScript 类型(`src/shared/types.ts`
```typescript
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 { /* 字段与表一致 */ }
export interface CockpitSummary {
totalWords: number
volumeWords: number
stockReadyCount: number
stockThreshold: number
foreshadowBuried: number
foreshadowResolved: number
foreshadowForgotten: number
volumeBreakdown: { volumeId: string; name: string; words: number }[]
}
export interface ChapterBridgeResult {
previousChapterId: string | null
previousTail: string
currentHead: string
aiSuggestion?: string
}
```
### 3.3 GlobalSettings 扩展
```typescript
updateSchedule: UpdateSchedule // default 'none'
stockBufferThreshold: number // default 3
```
`publish_status` 列已在 migrate v2 尝试添加;本阶段确保 `ChapterRepository` 读写并在 UI 暴露。
### 3.4 书籍偏好(`book_preferences`
| key | value | 说明 |
|-----|-------|------|
| `cockpitSeen` | `"true"` | 首次驾驶舱已展示 |
| `openCockpitEveryTime` | `"true"` | 可选:书籍级「每次打开显示驾驶舱」(默认 false,全局可在后续扩展) |
### 3.5 伏笔「疑似遗忘」判定(MVP)
同时满足:
1. `type = 'foreshadow'``status = 'approved'``foreshadow_status = 'buried'`
2.`last_mention_chapter_id ?? source_chapter_id` 对应章节的 `sort_order``mentionOrder`
3. 全书最大 `sort_order``latestOrder`
4. `latestOrder - mentionOrder >= 20` → 计入 `foreshadowForgotten`
用户可在编辑条目时更新 `lastMentionChapterId` 或改为 `resolved` 以清除遗忘标记。
### 3.6 地标同步
创建或编辑 `bookmark.landmarkType = 'foreshadow'` 时,UI 提供 checkbox「同步到知识库」:
- 调用 `knowledge.createFromBookmark(bookmarkId)` → 新建 `pending` 条目,`linked_bookmark_id` 关联,`title` 取自地标 label
---
## 4. IPC 与 Preload
### 4.1 通道(`src/shared/ipc-channels.ts`
```typescript
// knowledge
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:foreshadowStats'
// cockpit
COCKPIT_SUMMARY: 'cockpit:summary'
COCKPIT_MARK_SEEN: 'cockpit:markSeen'
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen'
// bridge
BRIDGE_GET: 'bridge:get'
BRIDGE_DISMISS: 'bridge:dismiss'
BRIDGE_SHOULD_PROMPT: 'bridge:shouldPrompt'
// chapter 扩展
CHAPTER_SET_PUBLISH_STATUS: 'chapter:setPublishStatus' // 或合并进现有 update
```
### 4.2 Preload 命名空间
```typescript
knowledge: { list, create, update, delete, approve, reject, batchApprove, createFromBookmark, stats }
cockpit: { getSummary, markSeen, shouldShowOnOpen }
bridge: { get, dismiss, shouldPrompt }
```
---
## 5. 服务层
### 5.1 KnowledgeService
```typescript
list(filter?: { status?: KnowledgeStatus; type?: KnowledgeType; forgottenOnly?: boolean })
create(input: CreateKnowledgeInput): KnowledgeEntry
update(id, patch): KnowledgeEntry
delete(id): void
approve(id) / reject(id) / batchApprove(ids[])
createFromBookmark(bookmarkId): KnowledgeEntry
getForeshadowStats(): { buried, resolved, forgotten }
countPending(): number
```
新建条目默认 `status='pending'`;用户可在对话框勾选「直接采纳」→ `approved`
### 5.2 CockpitService
```typescript
getSummary(bookId, activeVolumeId?): CockpitSummary
shouldShowOnOpen(bookId): boolean // !cockpitSeen
markSeen(bookId): void
```
字数:`stripHtml` 后求和现有 `chapters.content`;不引入 `writing_logs`
### 5.3 ChapterBridgeService
```typescript
shouldPrompt(chapterId): boolean
// 本章字数 < 200 && 存在上一章 && 未 dismiss
getBridge(chapterId, withAi?: boolean): Promise<ChapterBridgeResult>
// previousTail: 上一章 stripHtml 末 300 字
// currentHead: 本章 stripHtml 前 200 字(有则展示)
// withAi && AiClient 可用 → chat 单轮建议(240s timeout
dismiss(chapterId): void
```
**AI Prompt 模板**(简体):
> 你是连载小说编辑。上一章末尾:「{tail}」。本章开头:「{head}」。用 2–3 句话指出衔接是否顺畅,并给出一个开头方向建议。不要续写正文。
失败时返回无 `aiSuggestion`UI 隐藏 AI 区块。
---
## 6. 渲染进程 UI
### 6.1 TopBar
- 驾驶舱按钮(`data-testid="topbar-cockpit"`)打开 `CockpitModal`;移除 `comingSoon`
### 6.2 CockpitModal(对齐 `#dashboardModal`
| data-testid | 元素 |
|-------------|------|
| `cockpit-modal` | 根 overlay |
| `cockpit-total-words` | 全书总字数 |
| `cockpit-volume-words` | 本卷字数 |
| `cockpit-stock-count` | 存稿 ready 数 |
| `cockpit-foreshadow-summary` | 伏笔摘要行 |
| `cockpit-resume-edit` | 回到上次编辑 |
| `cockpit-bridge-check` | 检查章间衔接 |
| `cockpit-forgotten-foreshadow` | 跳转知识库筛选 |
打开书籍时:若 `cockpit.shouldShowOnOpen` → 自动弹出并 `markSeen`
### 6.3 ChapterBridgeModal(对齐 `#bridgeModal`
| data-testid | 元素 |
|-------------|------|
| `bridge-modal` | 根 |
| `bridge-previous-tail` | 上一章末尾 |
| `bridge-current-head` | 本章开头 |
| `bridge-ai-suggestion` | AI 建议(可选) |
| `bridge-dismiss-checkbox` | 本章不再提示 |
| `bridge-start-writing` | 开始写作 |
触发:`EditorLayout``openChapter` 后若 `bridge.shouldPrompt` → 延迟 500ms 弹出(避免与加载冲突)。
### 6.4 KnowledgePanel(替换 placeholder
布局:
1. 工具栏:「新建条目」「命名一致性检查」(保留)
2. 摘要条:伏笔统计(同 cockpit)
3. Tab`待审核` | `全部` | `伏笔`
4. 列表项:类型图标、标题、重要度星、来源章、状态标签
5. 点击 → `KnowledgeEditorDialog`(标题、类型、内容、重要度、伏笔状态、关联章)
| data-testid | 元素 |
|-------------|------|
| `knowledge-panel` | 根 |
| `knowledge-tab-pending` / `-all` / `-foreshadow` | Tab |
| `knowledge-new` | 新建 |
| `knowledge-item-{id}` | 列表项 |
| `knowledge-approve-{id}` | 单条采纳 |
| `knowledge-batch-approve` | 批量采纳待审核 |
### 6.5 章节发布状态
- 章节列表项右键或状态图标循环:`draft``ready``published`
- `data-testid="chapter-publish-{id}"`
### 6.6 StatusBar 存稿提醒
-`stockReadyCount < stockBufferThreshold``updateSchedule !== 'none'` → 显示橙色提示文案(不阻断)
- `data-testid="status-stock-warning"`
### 6.7 SettingsPage
- 更新频率下拉:`updateSchedule`
- 存稿阈值数字输入:`stockBufferThreshold`120,默认 3
### 6.8 i18n
新增键:`cockpit.*``bridge.*``knowledge.*``publish.status.*``settings.updateSchedule.*``stock.warning`
---
## 7. 错误处理
| 场景 | 行为 |
|------|------|
| 无上一章时 bridge | `shouldPrompt=false`;手动打开显示「无上一章」 |
| AI 建议超时/失败 | 仅展示文本片段;toast 可选「AI 建议不可用」 |
| 删除已关联 bookmark 的 knowledge | 保留条目,`linked_bookmark_id` 置 null |
| 空知识库 cockpit 伏笔 | 显示 0,不报错 |
| publish 更新失败 | IPC 错误 toast |
---
## 8. 测试
### 8.1 单元 / 集成(Vitest
| ID | 场景 | AI |
|----|------|-----|
| UT-MIG-06 | v5→v6`knowledge_entries` 存在 | — |
| UT-KNOW-01 | CRUD + approve 后 status 正确 | — |
| UT-KNOW-02 | 遗忘判定:sort_order 差 ≥20 | — |
| UT-KNOW-03 | createFromBookmark 创建 pending | — |
| UT-COCK-01 | getSummary 字数与存稿计数 | — |
| UT-BRIDGE-01 | 提取 tail/head 长度正确 | — |
| IT-BRIDGE-01 | getBridge 含非空 aiSuggestion | 240s LM Studio |
| IT-KNOW-04 | 批量 approve 后 pending 清零 | — |
`vitest.config.ts` 保持 `fileParallelism: false`
### 8.2 E2EPlaywright
| ID | 场景 | 超时 |
|----|------|------|
| E2E-COCK-01 | 打开驾驶舱,统计卡片可见 | 30s |
| E2E-COCK-02 | 首次开书自动弹驾驶舱,二次不再自动 | 30s |
| E2E-PUB-01 | 章节改 publish_status 为 readycockpit 存稿 +1 | 30s |
| E2E-KNOW-01 | 新建 → 待审核 → 采纳 → 列表可见 | 30s |
| E2E-KNOW-02 | 地标 foreshadow 同步知识库 | 30s |
| E2E-BRIDGE-01 | 新章触发衔接 modal,文本非空 | 30s |
| E2E-BRIDGE-02 | 衔接含 AI 建议(LM Studio | 300s |
| E2E-STOCK-01 | 存稿低于阈值,status bar 警告可见 | 30s |
---
## 9. 文件清单(实现参考)
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v6.sql` | 新表 |
| `src/main/db/repositories/knowledge.repo.ts` | 数据访问 |
| `src/main/services/knowledge.service.ts` | 业务 + 遗忘判定 |
| `src/main/services/cockpit.service.ts` | 聚合 |
| `src/main/services/chapter-bridge.service.ts` | 衔接 + AI |
| `src/main/ipc/handlers/knowledge.handler.ts` | IPC |
| `src/main/ipc/handlers/cockpit.handler.ts` | IPC |
| `src/main/ipc/handlers/bridge.handler.ts` | IPC |
| `src/renderer/components/cockpit/CockpitModal.tsx` | UI |
| `src/renderer/components/bridge/ChapterBridgeModal.tsx` | UI |
| `src/renderer/components/knowledge/KnowledgePanel.tsx` | 重写 |
| `src/renderer/components/knowledge/KnowledgeEditorDialog.tsx` | 编辑 |
| `src/renderer/stores/useKnowledgeStore.ts` | 状态 |
| `tests/main/migrate-v6.test.ts` | 迁移 |
| `tests/main/knowledge.repo.test.ts` | CRUD |
| `tests/main/cockpit.service.test.ts` | 聚合 |
| `e2e/cockpit-knowledge.spec.ts` | E2E |
---
## 10. Definition of Donev0.6.0
- [ ] schema v6 迁移 + 类型 + IPC + preload
- [ ] KnowledgeService + Panel 完整 CRUD / 审核
- [ ] CockpitModal + 首次打开逻辑
- [ ] ChapterBridgeModal + 自动/手动触发 + AI 建议
- [ ] publish_status UI + 存稿提醒 + updateSchedule 设置
- [ ] 全量测试通过(含 LM Studio IT-BRIDGE-01 / E2E-BRIDGE-02
- [ ] README / SettingsPage 版本号 → **0.6.0**
---
## 11. Spec Self-Review
| 检查项 | 结果 |
|--------|------|
| Placeholder 扫描 | 无 TBD;排除项见 §1.4 |
| 内部一致性 | 驾驶舱不依赖 writing_logs;伏笔统计仅 approved |
| 范围 | 单 spec 可支撑一份 implementation plan |
| 歧义 | 遗忘阈值固定 20 章;存稿阈值默认 3;MVP 不做 AI 抽取 |
---
*本规格为笔临 P5 子项目。AI 知识抽取、writing_logs 热力图等在 P5.2+ spec 中增量修订。*
@@ -0,0 +1,389 @@
# 笔临 P6 AI 知识上下文整合 设计规格
**版本**1.0
**日期**2026-07-07
**范围**:P6 — 已审核知识库条目半自动推荐 + 用户确认 + 注入四种 AI 入口(对话 / 交互 / 自动 / 向导)
**前置**:P5 作家工作流 + 知识库 MVP 已交付(v0.6.0`b33d2e7`
**参考**`design/readme.md` v1.1 §6.1 / §6.5 / §6.7、`docs/superpowers/specs/2026-07-06-bilin-p5-workflow-knowledge-design.md`
**目标版本**v0.7.0
---
## 1. 背景与目标
### 1.1 阶段定位
v0.6.0 已交付知识库 MVP(手动 CRUD、审核、伏笔追踪)与作家工作流,但 `AiContextBuilder` 仍仅支持章节/大纲/设定/灵感四类手动勾选,**知识条目未参与任何 AI 调用**。交互/自动/向导写作流还存在技术债:`contextJson` 只存标题拼接的短摘要,而非完整 `systemPrompt`
本阶段(P6)交付 **知识上下文整合**:半自动检索推荐 + 用户勾选确认 + 统一注入四种 AI 模式,对齐 `design/readme.md` §6.1「在每轮对话前检索高重要度知识条目注入」与 §6.5 各写作模式的上下文确认步骤。
### 1.2 已确认决策(Brainstorming 2026-07-07
| 维度 | 决策 |
|------|------|
| 总范围 | **A AI 上下文整合**v0.7.0 单阶段交付 |
| 注入策略 | **半自动 + 确认**:系统推荐,用户勾选后注入 |
| AI 入口 | **四种全做**:对话、交互写作、自动写作、向导模式 |
| 检索排序 | **重要度 + 章节相关性 + 目标文本匹配**(方案 C |
| 对话 UX | **会话级确认 + 发送静默注入**(不在每条消息前弹窗) |
| 架构 | **方案 1**`KnowledgeRetrievalService` + 扩展 `AiContextBuilder` |
| schema | **无 v7 迁移**(纯服务层与 UI 扩展) |
| AI 测试 | 集成/E2E 含 AI 时禁止 Mock,走 LM Studio |
### 1.3 验收标准
用户能完成:
1.**AI 上下文编辑器** 看到系统推荐的知识条目(带推荐理由),默认勾选 Top 推荐,可手动增删
2. **对话模式**:保存上下文后发送消息,AI 回复能体现已选知识(如伏笔、角色状态)
3. **交互写作**`context_confirm` 步骤展示知识推荐列表;确认后剧情建议/场景生成使用完整上下文
4. **自动写作**:设定章节目标后确认知识上下文;场景规划与生成携带知识块
5. **向导模式**:第 4 步「确认知识条目与伏笔」展示推荐列表;策略预览列出将注入的知识标题
6.**`approved`** 知识条目参与推荐与注入;`pending`/`rejected` 不可选
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| 注入历史 / 反向链接(§5.2「被 AI 用作上下文注入的轮次」) | P6.1+ |
| AI 语义检索 / embedding | 后续 |
| 知识自动抽取(§6.7 全自动) | P5.2+ |
| 文风学习 / 风格控制面板(§6.3) | 后续 |
| `writing_logs`、热力图(§8 | P5.2 |
| 角色关系图谱 Cytoscape(§7 | P6+ |
| schema v7 迁移 | 不需要 |
---
## 2. 架构
### 2.1 进程模型
```
┌──────────────────────────────────────────────────────────────┐
│ 主进程 │
│ · KnowledgeRetrievalService(打分 + 推荐) │
│ · AiContextBuilderService(扩展 knowledgeIds 块) │
│ · KnowledgeService(只读 list approved
│ · 各 handlerAI_CHAT / INTERACTIVE_* / AUTO_* / WIZARD_* │
│ confirmContext 时 build() → 存 systemPrompt 全文 │
└───────────────────────────┬──────────────────────────────────┘
│ IPC
│ knowledge.suggestContext
│ ai.buildContext(扩展 preview
┌───────────────────────────▼──────────────────────────────────┐
│ 渲染进程 │
│ · ContextEditorModal+知识 Tab + 推荐区) │
│ · KnowledgeSuggestList(共享勾选组件) │
│ · InteractivePanel context_confirm │
│ · AutoPanel goal 后知识确认区 │
│ · WizardPanel wizard_context 步骤 │
└──────────────────────────────────────────────────────────────┘
```
### 2.2 模块边界
| 模块 | 职责 | 依赖 |
|------|------|------|
| `KnowledgeRetrievalService` | 对 `approved` 条目综合打分,返回 `ScoredKnowledge[]` | `KnowledgeRepository`, `ChapterRepository` |
| `AiContextBuilderService` | 合并传统四类 + 知识块 → `systemPrompt` + `preview` | `BookRegistryService` |
| `KnowledgeSuggestList` | 推荐列表 UI、checkbox、reason badge | 无 IPC,由父组件传 props |
| `ContextEditorModal` | 五 Tab 编辑 binding;打开时拉 suggest | preload |
**原则**
- 推荐可忽略,注入以用户最终勾选为准
- 知识块与传统上下文共享总字符预算
- 写作流修复:`contextJson` 必须存 `systemPrompt` 全文,禁止仅存标题摘要
---
## 3. 检索算法
### 3.1 输入
```typescript
interface KnowledgeSuggestOptions {
currentChapterId?: string
goalText?: string // 章节目标、大纲描述拼接、用户备注
topN?: number // default 8
}
```
### 3.2 候选集
- `status = 'approved'` 的全部 `knowledge_entries`
- 无 approved 条目时返回 `[]`
### 3.3 打分公式
对每条 entry 计算 `score`(整数,越高越优先):
| 因子 | 计算 | 上限 |
|------|------|------|
| 重要度 | `importance × 20` | 100 |
| 章节接近度 | 取 `sourceChapterId``lastMentionChapterId` 中距当前章 `sortOrder` 差值的最小值 `d``d=0→30``d≤3→20``d≤10→10`,否则 0 | 30 |
| 伏笔加权 | `foreshadow``buried→25``partial→15``resolved→0`;非伏笔 0 | 25 |
| 目标匹配 | `goalText``title+content` 做简单分词重合(去停用词后),`overlapRatio × 30` | 30 |
`reasons` 数组(供 UI badgei18n key):
- `importance ≥ 4``knowledge.reason.highImportance`
- 章节接近度 > 0 → `knowledge.reason.chapterRelevant`
- `foreshadow``buried``knowledge.reason.foreshadowBuried`
- 目标匹配 > 0.2 → `knowledge.reason.goalMatch`
`score` 降序取 Top `topN`(默认 8)。
### 3.4 默认勾选策略
- 打开上下文编辑器时,推荐区 **默认全选** Top N
- 合并进 `binding.knowledgeIds`(与用户已有手动选择取并集,去重)
- 用户取消勾选后立即从 `knowledgeIds` 移除
---
## 4. 上下文构建
### 4.1 `AiContextBinding` 扩展
```typescript
export interface AiContextBinding {
chapterIds: string[]
outlineIds: string[]
settingIds: string[]
inspirationIds: string[]
knowledgeIds: string[] // 新增,默认 []
}
```
持久化位置:
- AI 会话:`ai_sessions.context` JSON
- 写作流:`interactive_flows.context_json``binding` 字段
### 4.2 知识块格式
```
【知识·{类型标签}】{title}
{content 截断至 500 字}
```
类型标签映射:`character→角色``foreshadow→伏笔``location→地点``relationship→关系``fact→事实`
### 4.3 字符预算
| 限制 | 值 |
|------|-----|
| 单条知识 content | 500 字 |
| 知识区合计 | 4000 字 |
| 章节块 | 2000 字(现有) |
| 其它块 | 500 字(现有) |
| **总 systemPrompt** | 16 × 1024 字(现有) |
裁剪顺序:候选块按 score(知识)或 sortKey(其它)排序,超出总预算时从低优先级移除。
### 4.4 `ContextPreviewItem` 扩展
```typescript
export type ContextPreviewKind =
| 'chapter' | 'outline' | 'setting' | 'inspiration' | 'knowledge'
export interface ContextPreviewItem {
kind: ContextPreviewKind
id: string
title: string
excerpt: string
charCount: number
knowledgeType?: KnowledgeType // kind=knowledge 时
}
```
### 4.5 写作流 `contextJson` 结构(统一)
```typescript
interface FlowContextPayload {
binding: AiContextBinding
systemPrompt: string // build() 完整输出,供 AI 调用
contextSummary: string // UI 短摘要,如「3章·2设定·5知识」
}
```
**修复范围**`InteractiveWritingService.readContextText()``auto.handler``readContextText()` 改为返回 `systemPrompt`,不再仅返回标题拼接。
---
## 5. IPC
### 5.1 新增通道
| 通道 | 参数 | 返回 |
|------|------|------|
| `knowledge:suggestContext` | `{ bookId, opts: KnowledgeSuggestOptions }` | `ScoredKnowledge[]` |
### 5.2 变更通道
| 通道 | 变更 |
|------|------|
| `ai:buildContext` | binding 含 `knowledgeIds`preview 含 knowledge 项 |
| `interactive:confirmContext` | 存 `FlowContextPayload``suggestPlots``systemPrompt` |
| `wizard:confirmContext` | 同上 |
| `auto:*`plan/generate | 读 `systemPrompt` |
preload / `electron-api.d.ts` 同步扩展。
---
## 6. UI 规格
### 6.1 共享组件 `KnowledgeSuggestList`
- `data-testid="knowledge-suggest-list"`
- 每行:`checkbox` + 标题 + 类型 + `reason` badge
- 单项 `data-testid={`knowledge-suggest-${id}`}`
### 6.2 `ContextEditorModal` 扩展
- 新增 Tab`knowledge``data-testid="context-tab-knowledge"`
- 顶部 **推荐区**:调用 `suggestContext`;展示 `KnowledgeSuggestList`
- 下方 **全部 approved 列表**:可手动搜索/勾选(简单 filter 即可)
- 「刷新推荐」按钮:`data-testid="context-refresh-suggest"`
- 保存时写入 `session.context``knowledgeIds`
**goalText 来源**(打开 Modal 时传入 props):
| 调用方 | goalText |
|--------|----------|
| AiPanel | 空(仅章节相关性) |
| InteractivePanel | 空 |
| AutoPanel | `chapterGoal` + 勾选大纲 `description` 拼接 |
| WizardPanel | `chapterGoal` + `styleNote` |
**currentChapterId**`useBookStore.selectedChapterId`
### 6.3 对话模式(AiPanel
- 上下文摘要行点击 → Modal(现有)
- 发送消息:不弹窗;`AI_CHAT` 用最新 binding build 的 `systemPrompt`
- 摘要行展示知识数量,如「2章·3知识」
### 6.4 交互写作(InteractivePanel
- `context_confirm` 步骤:
- 展示 `KnowledgeSuggestList`(内联,非必须开 Modal
- 「编辑完整上下文」→ Modal
- 「确认并开始」→ `confirmContext(binding)``suggestPlots`
- `data-testid="interactive-knowledge-suggest"`
### 6.5 自动写作(AutoPanel
- `goal_setup` 提交目标后,或 goal 区域下方展示折叠「知识上下文」
- 展示推荐 + 勾选;`planScenes` 前确保 `confirmContext` 已执行(可自动用 session binding + 推荐默认勾选)
- `data-testid="auto-knowledge-suggest"`
### 6.6 向导模式(WizardPanel
- `wizard_context` 步骤:展示推荐列表 + 「编辑上下文」
- `buildPreview` 返回的 preview 文本追加「将注入知识:…」列表
- `data-testid="wizard-knowledge-suggest"`
---
## 7. 全局设置(可选扩展)
```typescript
// GlobalSettings 新增,均有默认值
knowledgeAutoSuggest: boolean // default true
knowledgeSuggestTopN: number // default 8,范围 315
```
SettingsPage AI 分区或通用分区增加开关(本阶段可仅后端默认值,UI 设置项为 Nice-to-have;若时间紧可 Phase 2 补 UI)。
---
## 8. 测试
### 8.1 测试 ID
| ID | 层级 | 场景 | 预期 |
|----|------|------|------|
| UT-CTX-01 | 单元 | 打分:高重要度 + 近章 + 目标匹配 | 得分最高条目排第一 |
| UT-CTX-02 | 单元 | `build()` 含 knowledgeIds | preview 有 `kind=knowledge` |
| UT-CTX-03 | 单元 | 知识区超 4000 字 | 按 score 裁剪 |
| UT-CTX-04 | 单元 | resolved 伏笔 | 分数低于 buried 同重要度条目 |
| IT-CTX-01 | 集成 | `suggestContext` 有 approved 数据 | 返回 ≥1 条 |
| IT-CTX-02 | 集成 | `AI_CHAT` | systemPrompt 含 `【知识·` |
| IT-CTX-03 | 集成 | 交互 confirm → suggestPlots | 使用完整 prompt240s LM Studio |
| E2E-CTX-01 | E2E | 打开上下文编辑器知识 Tab | 推荐列表可见 |
| E2E-CTX-02 | E2E | 交互 context_confirm | 知识勾选区可见 |
| E2E-CTX-03 | E2E | 对话发送含知识上下文 | AI 回复非空(300s) |
### 8.2 超时
- IT-CTX-03**240s**
- E2E-CTX-03**300s**
---
## 9. i18n 键(节选)
```json
"context.tab.knowledge": "知识库",
"context.refreshSuggest": "刷新推荐",
"context.knowledgeCount": "{{count}} 条知识",
"knowledge.reason.highImportance": "高重要度",
"knowledge.reason.chapterRelevant": "与当前章相关",
"knowledge.reason.foreshadowBuried": "伏笔待回收",
"knowledge.reason.goalMatch": "匹配章节目标",
"interactive.confirmKnowledge": "确认知识上下文",
"wizard.knowledgeList": "将注入知识"
```
---
## 10. 文件映射
| 文件 | 职责 |
|------|------|
| `src/main/services/knowledge-retrieval.service.ts` | 检索打分 |
| `src/main/services/ai-context-builder.service.ts` | 扩展 knowledge 块 |
| `src/main/services/interactive-writing.service.ts` | readContextText 修复 |
| `src/main/ipc/handlers/knowledge.handler.ts` | suggestContext |
| `src/main/ipc/handlers/interactive.handler.ts` | FlowContextPayload |
| `src/main/ipc/handlers/auto.handler.ts` | systemPrompt 读取 |
| `src/main/ipc/handlers/wizard.handler.ts` | 同上 |
| `src/main/ipc/handlers/ai.handler.ts` | binding 默认 knowledgeIds |
| `src/shared/types.ts` | 类型扩展 |
| `src/shared/ipc-channels.ts` | KNOWLEDGE_SUGGEST_CONTEXT |
| `src/preload/index.ts` | knowledge.suggestContext |
| `src/renderer/components/ai/KnowledgeSuggestList.tsx` | 共享 UI |
| `src/renderer/components/ai/ContextEditorModal.tsx` | 知识 Tab + 推荐 |
| `src/renderer/components/ai/InteractivePanel.tsx` | context_confirm 知识区 |
| `src/renderer/components/ai/AutoPanel.tsx` | 知识确认区 |
| `src/renderer/components/ai/WizardPanel.tsx` | wizard_context 知识区 |
| `tests/main/knowledge-retrieval.test.ts` | UT-CTX-* |
| `tests/main/ai-context-builder.test.ts` | UT-CTX-02/03 |
| `e2e/ai-context.spec.ts` | E2E-CTX-* |
---
## 11. Spec Coverage Checklist
| design/readme 要求 | 本规格 |
|--------------------|--------|
| §6.1 知识条目按重要度检索注入 | §3 检索 + §4 构建 |
| §6.1 上下文预览可删减 | ContextEditorModal 勾选 |
| §6.5.1 交互上下文确认 | §6.4 |
| §6.5.3 向导确认知识条目 | §6.6 |
| §6.7 重要度影响注入优先级 | §3.3 打分 |
| P5 延后项「知识注入 AI 上下文」 | 本阶段交付 |
---
## 12. 版本与 DoD
- `package.json`**0.7.0**
- `README.md` 功能概览追加 P6 一行
- `npm run build && npm run test && npm run test:e2e` 全绿
- LM Studio 集成测试按 §8.2 超时执行
@@ -0,0 +1,482 @@
# 笔临 P4.1 自动写作 + 向导式写作设计规格
**版本**1.0
**日期**2026-07-09
**范围**P4.1 — 实装 §6.5.2 自动写作 + §6.5.3 向导式写作(统一写作流引擎)
**前置**:P4 交互式写作已交付(v0.4.0,`a8e0ba9`
**参考**`design/readme.md` v1.1 §6.5.2–§6.5.3、`design/ui_pc.html` AI 面板模式 Tab
**目标版本**v0.5.0
---
## 1. 背景与目标
### 1.1 阶段定位
v0.4.0 已交付交互式写作(§6.5.1);「自动写作」「向导」Tab 仍为 `feature.comingSoon` 占位。本阶段在同一 AI 会话体系下实装:
- **自动写作**:用户设定章节目标与字数范围 → AI 规划场景顺序 → 连续流式生成 → 达标或手动停止 → 成章落盘
- **向导式写作**:5 步引导收集参数 → AI 策略预览 → 自动生成 → **handoff 至交互面板**做场景微调
两模式共享 P4 的场景存储、命名暂停、流式推送与成章逻辑,并通过 **handoff** 与交互写作衔接。
### 1.2 已确认决策(Brainstorming 2026-07-09
| 维度 | 决策 |
|------|------|
| 范围 | **P4.1**:§6.5.2 + §6.5.3 同时交付;§6.3 / §6.6 / §6.7 仍排除 |
| 架构 | **方案 1**:统一写作流引擎,扩展 P4 表与 Service,不重写 |
| 向导第 4 步 | **选项 A**:复用 `ContextEditorModal` 确认上下文(含大纲/设定中的伏笔线索),不引入知识库表 |
| 向导风格 | 节奏三选一(舒缓/紧张/混合)+ 可选自由文本 `styleNote`**不做**文风滑杆 |
| 向导生成后 | 自动切入 **InteractivePanel**toast「已进入交互微调」) |
| 自动暂停 | 可「继续自动」或「转入交互微调」(handoff,保留 scenes |
| 成章落盘 | 活跃卷新建章节;`origin=auto``origin=wizard`;合并全部场景 HTML |
| AI 快照 | 成章时创建 `type='ai'` 快照(标签「自动写作成章」/「向导写作成章」;**顺带补齐** P4 interactive 成章快照) |
| 状态持久化 | **schema v5**`interactive_flows``flow_mode` / `mode_config_json``interactive_scenes` 不变 |
| 启动门槛 | **硬拦截**:复用 P4 `checkInteractiveGate` 逻辑(无正文且无大纲/设定时 Tab 禁用) |
| 同会话 flow | 每 `session_id` 仅一个 `status=in_progress` flow;按 `flow_mode` 区分模式 |
| AI 后端 | **LM Studio**(延续 P3/P4),`http://127.0.0.1:1234``gemma-4-e4b-it` |
| 测试原则 | **禁止 Mock**;集成 / E2E 打真实 LM Studio |
| 测试超时 | Vitest AI 用例 **240s**Playwright 全局 **180s**auto/wizard E2E **300s** |
### 1.3 验收标准
用户能完成:
1. 在有足够上下文的书籍中进入「自动写作」或「向导」Tab(门槛不满足时 Tab 禁用并 tooltip 提示)
2. **自动写作**:设定本章目标(大纲选取和/或手写)+ 目标字数范围 → AI 返回场景规划 → 连续流式生成场景(支持命名暂停)→ 字数达范围或点击「结束本章」→ 新建章节 `origin=auto`,编辑器自动打开
3. **自动写作干预**:生成中点击「暂停」→ flow 进入 `paused`;可「继续自动」或「转入交互微调」→ 交互面板可见已生成场景并可微调/续写
4. **向导写作**:依次完成 5 步(目标 → 节奏/风格 → 视角人物 → 上下文确认 → 策略预览)→ 确认后开始生成 → 生成完成后自动显示交互面板,可微调场景
5. 向导成章后章节 `origin=wizard`;自动成章 `origin=auto`
6. 关闭应用并重启后,未完成的 auto/wizard flow 恢复到上次步骤
7. 切换回「对话」模式时同会话聊天历史仍可见;再次进入 auto/wizard/interactive 时 **恢复已有 flow**(不无条件 reset
8. 所有成章路径均创建 `type='ai'` 快照
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| §6.3 文风学习与风格滑杆 | P4.2+ |
| §6.6 大纲生成章节骨架 | P4.2+(可独立 spec |
| §6.7 知识自动抽取与审核工作流 | P4.2+ |
| §6.4 Skill / MCP | P4+ |
| 知识库伏笔追踪 UI | P4.2+ / P5 |
| 导入导出、驾驶舱等 | P5+ |
| 命名结果自动写入设定库 | 本阶段仅写入正文 |
---
## 2. 架构
### 2.1 进程模型
```
┌──────────────────────────────────────────────────────────────────┐
│ 主进程 │
│ · schema v5interactive_flows.flow_mode / mode_config_json │
│ · chapters.originmanual | interactive | auto | wizard │
│ · SceneGenerationCore(从 P4 抽取:流式、命名暂停、HTML 规范化) │
│ · AutoWritingService(目标→规划→连续生成→暂停/成章/handoff) │
│ · WizardWritingService5 步→策略预览→委托 Auto→handoff
│ · InteractiveWritingServiceP4 不变;handoff 目标) │
│ · checkWritingGate(复用 interactive-gate 逻辑,三模式共用) │
│ · AiClientService / AiContextBuilder(复用) │
│ · IPCauto:* / wizard:* + interactive:streamChunk(增 flowMode
└────────────────────────────┬─────────────────────────────────────┘
│ contextBridge
┌────────────────────────────▼─────────────────────────────────────┐
│ 渲染进程 │
│ · AiPanelwritingMode=auto|wizard 时挂载 AutoPanel/WizardPanel │
│ · handoff 后 writingMode 保持或切 interactive,显示 InteractivePanel│
│ · useAutoStore / useWizardStore + useInteractiveStore │
│ · ContextEditorModal(向导步骤 4 + 自动可选编辑上下文) │
└────────────────────────────────────────────────────────────────────┘
```
**约束**(延续 P0P4):
- 渲染进程禁止直接 `fetch` AI API
- 流式通过 `webContents.send` 推送,不阻塞 IPC invoke
- 写作 flow 绑定 `ai_session_id`;关键轮次可写入 `ai_messages` 作审计(可选,非阻塞)
### 2.2 Handoff 规则
`auto``wizard` 转入交互微调时:
1. `interactive_scenes` 列表 **不变**
2. `flow_mode` 更新为 `'interactive'`
3. `step` 设为:
-`scene_draft_html` 非空 → `'scene_review'`
- 否则若已有 scenes → `'plot_suggest'`(进入下一场景循环)
- 否则 → `'context_confirm'`
4. `mode_config_json` 保留(只读参考),不删除
5. 渲染进程 `setWritingMode('interactive')` + toast
自动模式 **暂停**(非 handoff)时:`step='paused'``flow_mode` 仍为 `'auto'`
### 2.3 步骤机
#### 2.3.1 interactiveP4 不变)
| Step | 名称 |
|------|------|
| `context_confirm` | 上下文确认 |
| `plot_suggest` | 剧情走向 |
| `plot_select` | 用户选择 |
| `scene_generate` | 场景生成 |
| `naming_pause` | 命名暂停 |
| `scene_review` | 场景确认/微调 |
| `completed` | 成章完成 |
#### 2.3.2 auto
| Step | 名称 | AI |
|------|------|-----|
| `goal_setup` | 目标/字数/大纲 | 无 |
| `scene_plan` | 等待用户触发规划 | JSON 场景列表 |
| `auto_generate` | 连续生成(含 naming_pause 子状态) | 流式 |
| `paused` | 用户暂停 | 无 |
| `completed` | 成章完成 | 无 |
生成循环:按 `scenePlan` 顺序逐场景生成;每场景完成后追加 `interactive_scenes`;累计字数(stripHtml 计)≥ `wordMin` 且 AI 判定目标达成或用户点击「结束本章」时停止。
#### 2.3.3 wizard
| Step | 名称 | AI |
|------|------|-----|
| `wizard_goal` | 步骤 1:本章目标 | 无 |
| `wizard_rhythm` | 步骤 2:节奏 + styleNote | 无 |
| `wizard_pov` | 步骤 3:视角人物(设定 character | 无 |
| `wizard_context` | 步骤 4ContextEditorModal | 无 |
| `wizard_preview` | 步骤 5:策略预览 | 非流式摘要 |
| `wizard_generating` | 委托 Auto 引擎生成 | 流式 |
| → handoff | 见 §2.2 | — |
---
## 3. Schema v5
### 3.1 DDL(每本书 `.sqlite`
```sql
-- schema-v5.sql
ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive';
ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}';
-- chapters.origin 在应用层扩展为 manual | interactive | auto | wizard
-- v4 已有 origin 列,v5 迁移不 ALTER(字符串枚举扩展)
```
`migrate.ts``CURRENT_VERSION=5`v4→v5 应用上述 ALTER。
### 3.2 mode_config_json
```typescript
export type WritingFlowMode = 'interactive' | 'auto' | 'wizard'
export type AutoRhythm = 'relaxed' | 'tense' | 'mixed'
export interface ScenePlanItem {
label: string
summary: string
}
export interface AutoWritingConfig {
chapterGoal: string
outlineItemIds?: string[]
wordMin: number // 默认 2000
wordMax: number // 默认 4000
scenePlan?: ScenePlanItem[]
currentPlanIndex?: number // auto_generate 进度
}
export interface WizardWritingConfig extends AutoWritingConfig {
rhythm: AutoRhythm
styleNote?: string
povSettingId?: string
strategyPreview?: string // HTML
contextBinding?: AiContextBinding // 步骤 4 确认结果
}
```
### 3.3 共享类型扩展
```typescript
export type ChapterOrigin = 'manual' | 'interactive' | 'auto' | 'wizard'
export type AutoStep =
| 'goal_setup'
| 'scene_plan'
| 'auto_generate'
| 'paused'
| 'completed'
export type WizardStep =
| 'wizard_goal'
| 'wizard_rhythm'
| 'wizard_pov'
| 'wizard_context'
| 'wizard_preview'
| 'wizard_generating'
// InteractiveFlow 扩展:
// flowMode: WritingFlowMode
// modeConfig: AutoWritingConfig | WizardWritingConfig | Record<string, never>
// step: InteractiveStep | AutoStep | WizardStep
```
---
## 4. 服务层
### 4.1 SceneGenerationCore
`InteractiveWritingService` 抽取(或内部组合):
- `generateSceneStream(flowId, planItem, config, onChunk, signal)`
- `resolveNaming(flowId, chosenName, ...)`
- `normalizeSceneHtml(raw: string): string`
- 命名暂停检测:复用 `tryParseNamingFromBuffer`
### 4.2 AutoWritingService
| 方法 | 说明 |
|------|------|
| `start(sessionId)` | 创建 `flow_mode=auto`, `step=goal_setup` |
| `getFlow(sessionId)` | 按 session 查 in_progress |
| `setGoal(flowId, config: Partial<AutoWritingConfig>)` | 写入 mode_config`step=scene_plan` |
| `planScenes(flowId)` | AI JSON → `scenePlan``step=auto_generate` |
| `generateNext(flowId, onChunk, signal)` | 生成当前 plan 项;处理 naming;追加 scene;更新 index |
| `pause(flowId)` | `step=paused`abort 进行中的生成 |
| `resume(flowId)` | `step=auto_generate`,继续下一项 |
| `handoffToInteractive(flowId)` | §2.2 |
| `finishChapter(flowId, volumeId, title?)` | 合并 scenes → 新建章 `origin=auto` + AI 快照 |
| `shouldStop(flowId)` | 字数 ≥ wordMin 且(最后一项 plan 完成 或 AI 返回 stop 标记) |
### 4.3 WizardWritingService
| 方法 | 说明 |
|------|------|
| `start(sessionId)` | `flow_mode=wizard`, `step=wizard_goal` |
| `setGoal` / `setRhythm` / `setPov` | 逐步写入 mode_config,推进 step |
| `confirmContext(flowId, binding)` | 步骤 4`step=wizard_preview` |
| `buildPreview(flowId)` | AI 生成 strategyPreview HTML |
| `startGenerate(flowId, onChunk, signal)` | `step=wizard_generating`;构建 AutoWritingConfig 并调用 AutoWritingService 全量生成 |
| `handoffToInteractive(flowId)` | 生成完成后自动调用 §2.2 |
### 4.4 成章与快照(三模式统一)
`finishChapter` 路径均:
1. 合并 `interactive_scenes.content_html`
2. `chapters.create(..., origin)`
3. `snapshots.create(chapterId, merged, type='ai', label=...)`
4. `flow.status=completed`, `step=completed`
---
## 5. IPC
### 5.1 通道(`ipc-channels.ts` 追加)
```typescript
// auto
AUTO_CHECK_GATE: 'auto:checkGate' // 实际复用 gate service
AUTO_GET_FLOW: 'auto:getFlow'
AUTO_START: 'auto:start'
AUTO_SET_GOAL: 'auto:setGoal'
AUTO_PLAN_SCENES: 'auto:planScenes'
AUTO_GENERATE: 'auto:generate' // 生成下一场景或批量至 pause/stop
AUTO_PAUSE: 'auto:pause'
AUTO_RESUME: 'auto:resume'
AUTO_HANDOFF_INTERACTIVE: 'auto:handoffInteractive'
AUTO_FINISH_CHAPTER: 'auto:finishChapter'
AUTO_GET_SCENE_DRAFT: 'auto:getSceneDraft'
AUTO_ABORT: 'auto:abort'
// wizard
WIZARD_GET_FLOW: 'wizard:getFlow'
WIZARD_START: 'wizard:start'
WIZARD_SET_GOAL: 'wizard:setGoal'
WIZARD_SET_RHYTHM: 'wizard:setRhythm'
WIZARD_SET_POV: 'wizard:setPov'
WIZARD_CONFIRM_CONTEXT: 'wizard:confirmContext'
WIZARD_BUILD_PREVIEW: 'wizard:buildPreview'
WIZARD_START_GENERATE: 'wizard:startGenerate'
WIZARD_HANDOFF_INTERACTIVE: 'wizard:handoffInteractive'
WIZARD_ABORT: 'wizard:abort'
```
流式事件 `interactive:streamChunk` payload 扩展:
```typescript
{ flowId, flowMode: WritingFlowMode, phase: 'scene' | 'naming' | 'refine', delta: string, done: boolean }
```
### 5.2 Preload / electron-api.d.ts
暴露 `window.electronAPI.auto.*``window.electronAPI.wizard.*` 命名空间。
---
## 6. Prompt 设计
### 6.1 场景规划(auto planScenes
输入:章节目标、大纲条目摘要、已有上下文摘要、wordMin/wordMax。
输出 JSON
```json
{
"scenes": [
{ "label": "场景1", "summary": "…≥80字…" },
{ "label": "场景2", "summary": "…" }
],
"estimatedWords": 3200
}
```
约束:3–6 个场景;总预估字数在 [wordMin, wordMax] 内。
### 6.2 自动场景生成
输入:当前 plan item、累计 scenes 摘要、AutoWritingConfig。
输出:HTML 段落(600–1500 字/场景);命名暂停格式同 P4。
### 6.3 向导策略预览(wizard buildPreview
输入:WizardWritingConfig 全量 + AiContextBuilder 摘要。
输出 HTML 一段,模板语义:
> 我将以 **[视角人物]** 视角,用 **[节奏]** 节奏,写一段达成 **[目标]** 的剧情。风格说明:…。重点上下文:…。
---
## 7. 渲染进程 UI
### 7.1 AiPanel
- 移除 `auto` / `wizard``comingSoon`
- `handleModeClick('auto'|'wizard')`checkGate → loadFlow → 无 flow 则 start**不**无条件 reset 已有 flow
- `writingMode === 'auto'``<AutoPanel />`
- `writingMode === 'wizard'``<WizardPanel />`handoff 后 `writingMode='interactive'``<InteractivePanel />`
### 7.2 AutoPanel
| data-testid | 元素 |
|-------------|------|
| `auto-panel` | 根容器 |
| `auto-goal-input` | 本章目标 |
| `auto-outline-picker` | 大纲多选 |
| `auto-word-min` / `auto-word-max` | 字数范围 |
| `auto-plan-scenes` | 触发规划 |
| `auto-start-generate` | 开始/继续生成 |
| `auto-scene-preview` | 流式预览 |
| `auto-pause` | 暂停 |
| `auto-handoff-interactive` | 转入交互 |
| `auto-finish-chapter` | 结束本章 |
步骤条:`目标 → 规划 → 生成 → 完成`
### 7.3 WizardPanel
| data-testid | 元素 |
|-------------|------|
| `wizard-panel` | 根容器 |
| `wizard-step-indicator` | 15 步指示 |
| `wizard-goal-input` | 步骤 1 |
| `wizard-rhythm-*` | 步骤 2 节奏选项 |
| `wizard-style-note` | 步骤 2 风格说明 |
| `wizard-pov-select` | 步骤 3 |
| `wizard-context-edit` | 步骤 4 打开 ContextEditorModal |
| `wizard-preview` | 步骤 5 策略摘要 |
| `wizard-start-generate` | 开始生成 |
生成完成后隐藏 WizardPanel,显示 InteractivePanel(同 session)。
### 7.4 i18n 键(zh-CN / en
`auto.gateBlocked``auto.gateTooltip``auto.step.*``auto.pause``auto.resume``auto.handoffInteractive``auto.finishChapter``auto.chapterCreated`
`wizard.step.*``wizard.rhythm.*``wizard.previewTitle``wizard.startGenerate``wizard.handoffToast`
及步骤条、placeholder、错误 toast 文案。
---
## 8. 错误处理
| 场景 | 行为 |
|------|------|
| planScenes JSON 解析失败 | 重试一次(追加「仅返回 JSON」);仍失败 → toast + 保持 `scene_plan` |
| 生成中用户 pause | AbortController abort`step=paused`;已生成 scenes 保留 |
| 无活跃卷 finishChapter | IPC 错误码;toast「请先创建分卷」 |
| 离线 / 非 lmstudio 且无网络 | 同 P3/P4disabled + offline banner |
| wizard 某步必填缺失 | 下一步按钮 disabled |
| handoff 时无 scenes 且无 draft | 仍 handoff 到 `context_confirm`toast 提示可手动续写 |
---
## 9. 测试
### 9.1 单元 / 集成(Vitest,真实 LM Studio
| ID | 场景 | 超时 |
|----|------|------|
| UT-MIG-05 | v4 DB 迁移到 v5flow_mode 列存在 | — |
| IT-AUTO-01 | planScenes 返回 ≥2 个 scene | 240s |
| IT-AUTO-02 | generateNext 写入非空 scene | 240s |
| IT-AUTO-03 | pause 后 step=pausedresume 继续 | 240s |
| IT-AUTO-04 | handoff 后 flow_mode=interactive | — |
| IT-WIZ-01 | buildPreview 非空 HTML | 240s |
| IT-WIZ-02 | startGenerate 完成后 scenes.length ≥ 1 | 300s |
| IT-FIN-01 | auto finishChapter → origin=auto + ai 快照 | — |
| IT-FIN-02 | interactive finishChapter → ai 快照(P4 补齐) | — |
### 9.2 E2EPlaywright300s
| ID | 场景 |
|----|------|
| E2E-AUTO-01 | 自动:设目标 → 规划 → 生成 → 成章 → 编辑器非空 |
| E2E-AUTO-02 | 自动:暂停 → 转交互 → accept scene 可见 |
| E2E-WIZ-01 | 向导 5 步 → 生成 → InteractivePanel 可见 |
| E2E-AUTO-03 | 空书 auto tab disabled(门槛,无 AI |
---
## 10. 文件清单(实现参考)
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v5.sql` | v5 DDL |
| `src/main/db/migrate.ts` | v4→v5 |
| `src/main/db/repositories/interactive.repo.ts` | flow_mode / mode_config CRUD |
| `src/main/services/scene-generation-core.service.ts` | 共享生成逻辑 |
| `src/main/services/auto-writing.service.ts` | 自动写作步骤机 |
| `src/main/services/wizard-writing.service.ts` | 向导步骤机 |
| `src/main/services/auto-prompt-builder.service.ts` | plan / scene / preview prompt |
| `src/main/ipc/handlers/auto.handler.ts` | auto IPC |
| `src/main/ipc/handlers/wizard.handler.ts` | wizard IPC |
| `src/renderer/components/ai/AutoPanel.tsx` | 自动 UI |
| `src/renderer/components/ai/WizardPanel.tsx` | 向导 UI |
| `src/renderer/stores/useAutoStore.ts` | auto 状态 |
| `src/renderer/stores/useWizardStore.ts` | wizard 状态 |
| `tests/main/migrate-v5.test.ts` | 迁移 |
| `tests/main/auto-*.test.ts` | 集成 |
| `tests/main/wizard-*.test.ts` | 集成 |
| `e2e/auto-writing.spec.ts` | E2E |
| `e2e/wizard-writing.spec.ts` | E2E |
---
## 11. Spec 自检
| 检查项 | 结果 |
|--------|------|
| Placeholder 扫描 | 无 TBD/TODO;排除项已列 §1.4 |
| 内部一致性 | handoff / 步骤机 / IPC / UI testid 对齐 |
| 范围 | 单阶段可交付 v0.5.0;§6.7 已通过 ContextEditor 降级 |
| 歧义 | 成章 origin 三分;同 session 单 in_progress flow;恢复 flow 不 reset |
---
*本规格为笔临 P4.1 子项目。知识抽取、大纲骨架等各自在后续 spec 中增量修订。*
+135
View File
@@ -0,0 +1,135 @@
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 })
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) {
await page.locator('[data-testid="cockpit-modal"] .modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
async function ensureChapterEditor(page: Page): Promise<void> {
const items = page.locator('.chapter-item')
if ((await items.count()) === 0) {
await page.getByRole('button', { name: '+ 新章' }).click()
} else {
await items.first().click()
}
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
async function addApprovedKnowledge(page: Page, title: string): Promise<void> {
await page.locator('[data-testid="panel-tab-knowledge"]').click()
await page.locator('[data-testid="knowledge-new"]').click()
await page.locator('[data-testid="knowledge-title-input"]').fill(title)
await page.locator('[data-testid="knowledge-save"]').click()
await page.locator('[data-testid="knowledge-tab-pending"]').click()
await page.locator('[data-testid^="knowledge-approve-"]').first().click()
}
test.describe('AI knowledge context P6', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-ctx-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-CTX-01: context editor knowledge tab shows suggestions', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '上下文书')
await ensureChapterEditor(page)
await addApprovedKnowledge(page, '测灵石异常')
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await page.getByTestId('ai-context-preview').click()
await expect(page.getByTestId('context-editor-modal')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('context-tab-knowledge').click()
await expect(page.getByTestId('context-knowledge-suggest')).toBeVisible({ timeout: 10_000 })
await app.close()
})
test('E2E-CTX-02: interactive context_confirm shows knowledge suggest', 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 addApprovedKnowledge(page, '测灵石异常')
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await expect(page.getByTestId('ai-mode-tab-interactive')).toBeEnabled({ timeout: 10_000 })
await page.getByTestId('ai-mode-tab-interactive').click()
await expect(page.getByTestId('interactive-knowledge-suggest')).toBeVisible({ timeout: 15_000 })
await app.close()
})
test('E2E-CTX-03: chat with knowledge context gets AI reply', async () => {
test.setTimeout(300_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '对话知识书')
await ensureChapterEditor(page)
await addApprovedKnowledge(page, '测灵石异常')
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await page.getByTestId('ai-context-preview').click()
await page.getByTestId('context-tab-knowledge').click()
await page.getByTestId('context-save').click()
await page.getByTestId('ai-chat-input').fill('根据知识库,本章可能发生什么?')
await page.getByTestId('ai-send').click()
await expect(page.getByTestId('chat-message-assistant').last()).not.toBeEmpty({ timeout: 240_000 })
await app.close()
})
})
+136
View File
@@ -0,0 +1,136 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir }
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
}
}
async function ensureChapterEditor(page: Page): Promise<void> {
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
async function openBookWithContent(page: Page): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('自动测试书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
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 })
}
async function openAutoPanel(page: Page): Promise<void> {
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await expect(page.getByTestId('ai-mode-tab-auto')).toBeEnabled({ timeout: 10_000 })
await page.getByTestId('ai-mode-tab-auto').click()
await expect(page.getByTestId('auto-panel')).toBeVisible({ timeout: 10_000 })
}
async function planAutoScenes(page: Page): Promise<void> {
await page.getByTestId('auto-goal-input').fill('林远通过入门考核,展现天赋震惊众人')
await page.getByTestId('auto-plan-scenes').click()
await expect(page.getByTestId('auto-start-generate')).toBeVisible({ timeout: 240_000 })
}
async function generateOneAutoScene(page: Page): Promise<void> {
await page.getByTestId('auto-start-generate').click()
const naming0 = page.getByTestId('naming-option-0')
const finishBtn = page.getByTestId('auto-finish-chapter')
await expect(naming0.or(finishBtn).or(page.getByTestId('auto-start-generate'))).toBeVisible({
timeout: 240_000
})
if (await naming0.isVisible()) {
await naming0.click()
await expect(finishBtn.or(page.getByTestId('auto-start-generate'))).toBeVisible({
timeout: 240_000
})
}
}
test.describe('Auto writing', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-auto-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-AUTO-03: auto tab disabled on empty book', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('空书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('panel-tab-ai').click()
await expect(page.getByTestId('ai-mode-tab-auto')).toBeDisabled({ timeout: 10_000 })
await app.close()
})
test('E2E-AUTO-01: auto flow creates chapter', async () => {
test.setTimeout(300_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await openBookWithContent(page)
await openAutoPanel(page)
await planAutoScenes(page)
await generateOneAutoScene(page)
await expect(page.getByTestId('auto-finish-chapter')).toBeVisible({ timeout: 30_000 })
await page.getByTestId('auto-finish-chapter').click()
await expect(page.locator('.ProseMirror')).not.toBeEmpty({ timeout: 30_000 })
await app.close()
})
test('E2E-AUTO-02: pause and handoff to interactive', async () => {
test.setTimeout(300_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await openBookWithContent(page)
await openAutoPanel(page)
await planAutoScenes(page)
await page.getByTestId('auto-start-generate').click()
await expect(page.getByTestId('auto-pause')).toBeEnabled({ timeout: 15_000 })
await page.waitForTimeout(2_000)
await page.getByTestId('auto-pause').click()
await expect(page.getByTestId('auto-handoff-interactive')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('auto-handoff-interactive').click()
await expect(page.getByTestId('interactive-panel')).toBeVisible({ timeout: 15_000 })
const acceptBtn = page.getByTestId('interactive-accept-scene')
const plotA = page.getByTestId('plot-option-A')
const startBtn = page.getByTestId('interactive-start')
await expect(acceptBtn.or(plotA).or(startBtn)).toBeVisible({ timeout: 120_000 })
await app.close()
})
})
+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()
})
})
+126
View File
@@ -0,0 +1,126 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir }
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
}
}
async function ensureChapterEditor(page: Page): Promise<void> {
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
async function openBookWithContent(page: Page): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('向导测试书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
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 })
}
async function openWizardPanel(page: Page): Promise<void> {
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await expect(page.getByTestId('ai-mode-tab-wizard')).toBeEnabled({ timeout: 10_000 })
await page.getByTestId('ai-mode-tab-wizard').click()
await expect(page.getByTestId('wizard-panel')).toBeVisible({ timeout: 10_000 })
}
async function confirmWizardContext(page: Page): Promise<void> {
await page.getByTestId('wizard-context-edit').click()
await expect(page.getByTestId('context-editor-modal')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('context-tab-chapter').click()
await page.locator('[data-testid^="context-item-"]').first().check()
await page.getByTestId('context-refresh').click()
await page.getByTestId('context-save').click()
await expect(page.getByTestId('wizard-preview')).toBeVisible({ timeout: 15_000 })
}
test.describe('Wizard writing', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-wiz-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-WIZ-GATE: wizard tab disabled on empty book', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('空书')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('panel-tab-ai').click()
await expect(page.getByTestId('ai-mode-tab-wizard')).toBeDisabled({ timeout: 10_000 })
await app.close()
})
test('E2E-WIZ-01: wizard flow reaches interactive panel', async () => {
test.setTimeout(600_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await openBookWithContent(page)
await openWizardPanel(page)
await page.getByTestId('wizard-goal-input').fill('林远通过入门考核,展现天赋震惊众人')
await page.getByRole('button', { name: '下一步' }).click()
await page.getByTestId('wizard-rhythm-mixed').click()
await page.getByRole('button', { name: '下一步' }).click()
await page.getByRole('button', { name: '下一步' }).click()
await confirmWizardContext(page)
await page.getByRole('button', { name: '刷新预览' }).click()
await expect(page.getByTestId('wizard-preview')).not.toContainText('…', { timeout: 240_000 })
await page.getByTestId('wizard-start-generate').click()
await expect(page.getByTestId('ai-mode-tab-interactive')).toHaveClass(/active/, { timeout: 360_000 })
const acceptBtn = page.getByTestId('interactive-accept-scene')
const plotA = page.getByTestId('plot-option-A')
const naming0 = page.getByTestId('naming-option-0')
await expect(acceptBtn.or(plotA).or(naming0)).toBeVisible({ timeout: 120_000 })
if (await naming0.isVisible()) {
await naming0.click()
}
await expect(page.getByTestId('interactive-panel')).toBeVisible({ timeout: 30_000 })
await expect(acceptBtn.or(plotA)).toBeVisible({ timeout: 120_000 })
await app.close()
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bilin", "name": "bilin",
"version": "0.4.0", "version": "0.7.0",
"description": "笔临 - 长篇创作智能协作平台", "description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"type": "module", "type": "module",
+109 -1
View File
@@ -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",
@@ -71,12 +72,17 @@
"ai.untitledSession": "New chat", "ai.untitledSession": "New chat",
"ai.contextPlaceholder": "📎 Context: not configured yet", "ai.contextPlaceholder": "📎 Context: not configured yet",
"ai.contextEmpty": "📎 Context: nothing selected", "ai.contextEmpty": "📎 Context: nothing selected",
"ai.contextSummary": "📎 Context: {{chapters}} ch. · {{outlines}} outline · {{settings}} settings · {{inspirations}} ideas", "ai.contextSummary": "📎 Context: {{chapters}} ch. · {{outlines}} outline · {{settings}} settings · {{inspirations}} ideas · {{knowledge}} knowledge",
"ai.contextEditorTitle": "Edit AI context", "ai.contextEditorTitle": "Edit AI context",
"ai.contextTab.chapter": "Chapters", "ai.contextTab.chapter": "Chapters",
"ai.contextTab.outline": "Outline", "ai.contextTab.outline": "Outline",
"ai.contextTab.setting": "Settings", "ai.contextTab.setting": "Settings",
"ai.contextTab.inspiration": "Ideas", "ai.contextTab.inspiration": "Ideas",
"ai.contextTab.knowledge": "Knowledge",
"ai.contextRefreshSuggest": "Refresh suggestions",
"ai.contextKnowledgeSuggestTitle": "Suggested knowledge",
"ai.contextKnowledgeAllTitle": "All approved knowledge",
"ai.contextKnowledgeEmpty": "No approved knowledge entries",
"ai.contextEmptyList": "No items", "ai.contextEmptyList": "No items",
"ai.contextRefresh": "Refresh context", "ai.contextRefresh": "Refresh context",
"ai.contextSave": "Save", "ai.contextSave": "Save",
@@ -99,6 +105,7 @@
"interactive.step.scene": "Scene", "interactive.step.scene": "Scene",
"interactive.step.review": "Review", "interactive.step.review": "Review",
"interactive.start": "Start interactive", "interactive.start": "Start interactive",
"interactive.confirmKnowledge": "Confirm knowledge context",
"interactive.loadingPlots": "Generating plot options…", "interactive.loadingPlots": "Generating plot options…",
"interactive.plotNotePlaceholder": "Additional instructions (optional)", "interactive.plotNotePlaceholder": "Additional instructions (optional)",
"interactive.generateScene": "Generate scene", "interactive.generateScene": "Generate scene",
@@ -112,6 +119,41 @@
"interactive.chapterTitlePlaceholder": "Chapter title (optional)", "interactive.chapterTitlePlaceholder": "Chapter title (optional)",
"interactive.chapterCreated": "Interactive draft chapter created", "interactive.chapterCreated": "Interactive draft chapter created",
"interactive.sceneCount": "{{count}} scene(s) confirmed", "interactive.sceneCount": "{{count}} scene(s) confirmed",
"auto.gateBlocked": "Add chapter content, outline, or settings before auto writing",
"auto.gateTooltip": "Requires chapter text or outline/settings",
"auto.step.goal": "Goal",
"auto.step.plan": "Plan",
"auto.step.generate": "Generate",
"auto.step.done": "Done",
"auto.goalPlaceholder": "Plot points for this chapter…",
"auto.planScenes": "Plan scenes",
"auto.startGenerate": "Generate next scene",
"auto.sceneProgress": "Scene {{current}} / {{total}}",
"auto.pause": "Pause",
"auto.resume": "Resume auto",
"auto.handoffInteractive": "Switch to interactive",
"auto.finishChapter": "Finish chapter",
"auto.chapterCreated": "Auto-written chapter created",
"wizard.gateBlocked": "Add chapter content, outline, or settings before wizard mode",
"wizard.gateTooltip": "Requires chapter text or outline/settings",
"wizard.step.goal": "Goal",
"wizard.step.rhythm": "Rhythm",
"wizard.step.pov": "POV",
"wizard.step.context": "Context",
"wizard.step.preview": "Preview",
"wizard.rhythm.relaxed": "Relaxed",
"wizard.rhythm.tense": "Tense",
"wizard.rhythm.mixed": "Mixed",
"wizard.styleNotePlaceholder": "Optional style note",
"wizard.contextHint": "Confirm context including outline/settings clues",
"wizard.knowledgeList": "Knowledge to inject",
"wizard.previewTitle": "Strategy preview",
"wizard.refreshPreview": "Refresh preview",
"wizard.startGenerate": "Start generating",
"wizard.handoffToast": "Switched to interactive refinement",
"wizard.next": "Next",
"wizard.povDefault": "Default POV",
"wizard.generating": "Generating chapter…",
"snapshot.ai": "Before AI insert", "snapshot.ai": "Before AI insert",
"naming.open": "🔍 Naming consistency check", "naming.open": "🔍 Naming consistency check",
"naming.title": "Naming consistency check", "naming.title": "Naming consistency check",
@@ -125,6 +167,72 @@
"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.reason.highImportance": "High importance",
"knowledge.reason.chapterRelevant": "Chapter relevant",
"knowledge.reason.foreshadowBuried": "Foreshadow pending",
"knowledge.reason.goalMatch": "Goal match",
"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",
+109 -1
View File
@@ -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": "通用",
@@ -71,12 +72,17 @@
"ai.untitledSession": "新对话", "ai.untitledSession": "新对话",
"ai.contextPlaceholder": "📎 上下文:尚未配置", "ai.contextPlaceholder": "📎 上下文:尚未配置",
"ai.contextEmpty": "📎 上下文:尚未勾选", "ai.contextEmpty": "📎 上下文:尚未勾选",
"ai.contextSummary": "📎 上下文:{{chapters}}章 · 大纲{{outlines}}条 · 设定{{settings}}个 · 灵感{{inspirations}}条", "ai.contextSummary": "📎 上下文:{{chapters}}章 · 大纲{{outlines}}条 · 设定{{settings}}个 · 灵感{{inspirations}}条 · 知识{{knowledge}}条",
"ai.contextEditorTitle": "编辑 AI 上下文", "ai.contextEditorTitle": "编辑 AI 上下文",
"ai.contextTab.chapter": "章节", "ai.contextTab.chapter": "章节",
"ai.contextTab.outline": "大纲", "ai.contextTab.outline": "大纲",
"ai.contextTab.setting": "设定", "ai.contextTab.setting": "设定",
"ai.contextTab.inspiration": "灵感", "ai.contextTab.inspiration": "灵感",
"ai.contextTab.knowledge": "知识库",
"ai.contextRefreshSuggest": "刷新推荐",
"ai.contextKnowledgeSuggestTitle": "推荐知识",
"ai.contextKnowledgeAllTitle": "全部已审核知识",
"ai.contextKnowledgeEmpty": "暂无已审核知识条目",
"ai.contextEmptyList": "暂无可选项", "ai.contextEmptyList": "暂无可选项",
"ai.contextRefresh": "更新上下文", "ai.contextRefresh": "更新上下文",
"ai.contextSave": "保存", "ai.contextSave": "保存",
@@ -99,6 +105,7 @@
"interactive.step.scene": "场景", "interactive.step.scene": "场景",
"interactive.step.review": "确认", "interactive.step.review": "确认",
"interactive.start": "开始交互", "interactive.start": "开始交互",
"interactive.confirmKnowledge": "确认知识上下文",
"interactive.loadingPlots": "正在生成剧情走向…", "interactive.loadingPlots": "正在生成剧情走向…",
"interactive.plotNotePlaceholder": "补充指令(可选)", "interactive.plotNotePlaceholder": "补充指令(可选)",
"interactive.generateScene": "生成场景", "interactive.generateScene": "生成场景",
@@ -112,6 +119,41 @@
"interactive.chapterTitlePlaceholder": "章节标题(可选)", "interactive.chapterTitlePlaceholder": "章节标题(可选)",
"interactive.chapterCreated": "已生成交互初稿章节", "interactive.chapterCreated": "已生成交互初稿章节",
"interactive.sceneCount": "已确认 {{count}} 个场景", "interactive.sceneCount": "已确认 {{count}} 个场景",
"auto.gateBlocked": "请先创建章节正文、大纲或设定后再使用自动写作",
"auto.gateTooltip": "需要至少一章正文,或存在大纲/设定",
"auto.step.goal": "目标",
"auto.step.plan": "规划",
"auto.step.generate": "生成",
"auto.step.done": "完成",
"auto.goalPlaceholder": "本章需达成的剧情点…",
"auto.planScenes": "规划场景",
"auto.startGenerate": "生成下一场景",
"auto.sceneProgress": "场景 {{current}} / {{total}}",
"auto.pause": "暂停",
"auto.resume": "继续自动",
"auto.handoffInteractive": "转入交互微调",
"auto.finishChapter": "结束本章",
"auto.chapterCreated": "已生成自动写作章节",
"wizard.gateBlocked": "请先创建章节正文、大纲或设定后再使用向导写作",
"wizard.gateTooltip": "需要至少一章正文,或存在大纲/设定",
"wizard.step.goal": "目标",
"wizard.step.rhythm": "节奏",
"wizard.step.pov": "视角",
"wizard.step.context": "上下文",
"wizard.step.preview": "预览",
"wizard.rhythm.relaxed": "舒缓",
"wizard.rhythm.tense": "紧张",
"wizard.rhythm.mixed": "混合",
"wizard.styleNotePlaceholder": "可选:本章风格说明",
"wizard.contextHint": "确认与本章相关的上下文(含大纲/设定中的伏笔线索)",
"wizard.knowledgeList": "将注入知识",
"wizard.previewTitle": "生成策略预览",
"wizard.refreshPreview": "刷新预览",
"wizard.startGenerate": "开始生成",
"wizard.handoffToast": "已进入交互微调",
"wizard.next": "下一步",
"wizard.povDefault": "默认视角",
"wizard.generating": "正在生成章节…",
"snapshot.ai": "AI 生成前", "snapshot.ai": "AI 生成前",
"naming.open": "🔍 命名一致性检查", "naming.open": "🔍 命名一致性检查",
"naming.title": "命名一致性检查", "naming.title": "命名一致性检查",
@@ -125,6 +167,72 @@
"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.reason.highImportance": "高重要度",
"knowledge.reason.chapterRelevant": "与当前章相关",
"knowledge.reason.foreshadowBuried": "伏笔待回收",
"knowledge.reason.goalMatch": "匹配章节目标",
"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",
+16 -1
View File
@@ -3,8 +3,10 @@ import schemaV1 from './schema-v1.sql?raw'
import schemaV2 from './schema-v2.sql?raw' 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 schemaV6 from './schema-v6.sql?raw'
const CURRENT_VERSION = 4 const CURRENT_VERSION = 6
function tryAlter(db: SqliteDb, sql: string): void { function tryAlter(db: SqliteDb, sql: string): void {
try { try {
@@ -56,5 +58,18 @@ export function migrate(db: SqliteDb): void {
tryAlter(db, "ALTER TABLE chapters ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'") tryAlter(db, "ALTER TABLE chapters ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'")
db.exec(schemaV4) db.exec(schemaV4)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(4, 'P4 interactive schema') db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(4, 'P4 interactive schema')
current = 4
}
if (current < 5) {
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')
} }
} }
+4 -2
View File
@@ -6,7 +6,8 @@ const EMPTY_CONTEXT: AiContextBinding = {
chapterIds: [], chapterIds: [],
outlineIds: [], outlineIds: [],
settingIds: [], settingIds: [],
inspirationIds: [] inspirationIds: [],
knowledgeIds: []
} }
function parseContext(json: string): AiContextBinding { function parseContext(json: string): AiContextBinding {
@@ -16,7 +17,8 @@ function parseContext(json: string): AiContextBinding {
chapterIds: parsed.chapterIds ?? [], chapterIds: parsed.chapterIds ?? [],
outlineIds: parsed.outlineIds ?? [], outlineIds: parsed.outlineIds ?? [],
settingIds: parsed.settingIds ?? [], settingIds: parsed.settingIds ?? [],
inspirationIds: parsed.inspirationIds ?? [] inspirationIds: parsed.inspirationIds ?? [],
knowledgeIds: parsed.knowledgeIds ?? []
} }
} catch { } catch {
return { ...EMPTY_CONTEXT } return { ...EMPTY_CONTEXT }
@@ -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 { 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)
} }
+63 -9
View File
@@ -1,10 +1,13 @@
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import type { import type {
AutoWritingConfig,
InteractiveFlow, InteractiveFlow,
InteractiveFlowStatus, InteractiveFlowStatus,
InteractiveScene, InteractiveScene,
InteractiveStep, NamingPause,
NamingPause WizardWritingConfig,
WritingFlowMode,
WritingStep
} from '../../../shared/types' } from '../../../shared/types'
import type { SqliteDb } from '../types' import type { SqliteDb } from '../types'
@@ -27,33 +30,47 @@ export class InteractiveRepository {
return row ? this.mapFlow(row) : null return row ? this.mapFlow(row) : null
} }
create(sessionId: string): InteractiveFlow { create(
sessionId: string,
flowMode: WritingFlowMode = 'interactive',
initialStep?: WritingStep
): InteractiveFlow {
const id = randomUUID() const id = randomUUID()
const step =
initialStep ??
(flowMode === 'auto'
? 'goal_setup'
: flowMode === 'wizard'
? 'wizard_goal'
: 'context_confirm')
this.db this.db
.prepare( .prepare(
`INSERT INTO interactive_flows (id, session_id, step, status, context_json) VALUES (?, ?, 'context_confirm', 'in_progress', '{}')` `INSERT INTO interactive_flows (id, session_id, step, status, context_json, flow_mode, mode_config_json)
VALUES (?, ?, ?, 'in_progress', '{}', ?, '{}')`
) )
.run(id, sessionId) .run(id, sessionId, step, flowMode)
return this.get(id)! return this.get(id)!
} }
resetInProgress(sessionId: string): InteractiveFlow { resetInProgress(sessionId: string, flowMode: WritingFlowMode = 'interactive'): InteractiveFlow {
this.db this.db
.prepare( .prepare(
`UPDATE interactive_flows SET status = 'abandoned', updated_at = datetime('now') WHERE session_id = ? AND status = 'in_progress'` `UPDATE interactive_flows SET status = 'abandoned', updated_at = datetime('now') WHERE session_id = ? AND status = 'in_progress'`
) )
.run(sessionId) .run(sessionId)
return this.create(sessionId) return this.create(sessionId, flowMode)
} }
updateStep( updateStep(
flowId: string, flowId: string,
step: InteractiveStep, step: WritingStep,
patch: { patch: {
contextJson?: string contextJson?: string
plotSelectionJson?: string | null plotSelectionJson?: string | null
namingPendingJson?: string | null namingPendingJson?: string | null
sceneDraftHtml?: string | null sceneDraftHtml?: string | null
modeConfigJson?: string
flowMode?: WritingFlowMode
status?: InteractiveFlowStatus status?: InteractiveFlowStatus
} = {} } = {}
): InteractiveFlow { ): InteractiveFlow {
@@ -75,6 +92,14 @@ export class InteractiveRepository {
sets.push('scene_draft_html = ?') sets.push('scene_draft_html = ?')
vals.push(patch.sceneDraftHtml) vals.push(patch.sceneDraftHtml)
} }
if (patch.modeConfigJson !== undefined) {
sets.push('mode_config_json = ?')
vals.push(patch.modeConfigJson)
}
if (patch.flowMode !== undefined) {
sets.push('flow_mode = ?')
vals.push(patch.flowMode)
}
if (patch.status !== undefined) { if (patch.status !== undefined) {
sets.push('status = ?') sets.push('status = ?')
vals.push(patch.status) vals.push(patch.status)
@@ -84,6 +109,27 @@ export class InteractiveRepository {
return this.get(flowId)! return this.get(flowId)!
} }
getModeConfig<T extends AutoWritingConfig | WizardWritingConfig>(
flowId: string
): T {
const row = this.db
.prepare('SELECT mode_config_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { mode_config_json: string } | undefined
try {
return JSON.parse(row?.mode_config_json ?? '{}') as T
} catch {
return {} as T
}
}
setModeConfig(flowId: string, config: AutoWritingConfig | WizardWritingConfig): void {
this.db
.prepare(
`UPDATE interactive_flows SET mode_config_json = ?, updated_at = datetime('now') WHERE id = ?`
)
.run(JSON.stringify(config), flowId)
}
listScenes(flowId: string): InteractiveScene[] { listScenes(flowId: string): InteractiveScene[] {
const rows = this.db const rows = this.db
.prepare('SELECT * FROM interactive_scenes WHERE flow_id = ? ORDER BY sort_order') .prepare('SELECT * FROM interactive_scenes WHERE flow_id = ? ORDER BY sort_order')
@@ -156,11 +202,19 @@ export class InteractiveRepository {
namingPending = null namingPending = null
} }
} }
let modeConfig: AutoWritingConfig | WizardWritingConfig | Record<string, never> = {}
try {
modeConfig = JSON.parse((row.mode_config_json as string) ?? '{}')
} catch {
modeConfig = {}
}
return { return {
id: flowId, id: flowId,
sessionId: row.session_id as string, sessionId: row.session_id as string,
step: row.step as InteractiveStep, flowMode: (row.flow_mode as WritingFlowMode) ?? 'interactive',
step: row.step as WritingStep,
status: row.status as InteractiveFlowStatus, status: row.status as InteractiveFlowStatus,
modeConfig,
scenes: this.listScenes(flowId), scenes: this.listScenes(flowId),
namingPending, namingPending,
contextSummary: undefined contextSummary: undefined
+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
}
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE interactive_flows ADD COLUMN flow_mode TEXT NOT NULL DEFAULT 'interactive';
ALTER TABLE interactive_flows ADD COLUMN mode_config_json TEXT NOT NULL DEFAULT '{}';
+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
);
+221
View File
@@ -0,0 +1,221 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AiContextBinding, AutoWritingConfig } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings'
import { AiClientService } from '../../services/ai-client.service'
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
import { checkInteractiveGate } from '../../services/interactive-gate.service'
import { AutoWritingService } from '../../services/auto-writing.service'
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result'
const activeAuto = new Map<string, AbortController>()
function serviceFor(registry: BookRegistryService, bookId: string, aiClient: AiClientService) {
return new AutoWritingService(registry.getDb(bookId), aiClient)
}
function enrich(registry: BookRegistryService, bookId: string, aiClient: AiClientService, flowId: string) {
const svc = serviceFor(registry, bookId, aiClient)
const flow = new InteractiveRepository(registry.getDb(bookId)).get(flowId)
return flow ? svc.enrichFlow(flow) : null
}
function readContextText(registry: BookRegistryService, bookId: string, flowId: string): string {
const row = registry
.getDb(bookId)
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { context_json: string } | undefined
return readFlowSystemPrompt(row?.context_json ?? '{}')
}
export function registerAutoHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService
): void {
void settings
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
)
ipcMain.handle(
IPC.AUTO_GET_FLOW,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).getFlow(sessionId)
return flow ? serviceFor(registry, bookId, aiClient).enrichFlow(flow) : null
})
)
ipcMain.handle(
IPC.AUTO_START,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const flow = serviceFor(registry, bookId, aiClient).start(sessionId)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
})
)
ipcMain.handle(
IPC.AUTO_SET_GOAL,
(
_e,
{ bookId, flowId, config }: { bookId: string; flowId: string; config: Partial<AutoWritingConfig> }
) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
const flow = svc.setGoal(flowId, config)
return svc.enrichFlow(flow)
})
)
ipcMain.handle(
IPC.AUTO_CONFIRM_CONTEXT,
(
_e,
{ bookId, flowId, binding }: { bookId: string; flowId: string; binding: AiContextBinding }
) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
const payload = buildFlowContextPayload(
binding,
registry,
bookId,
settings.get().penName
)
return svc.enrichFlow(svc.confirmContext(flowId, payload))
})
)
ipcMain.handle(
IPC.AUTO_PLAN_SCENES,
(_e, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
wrap(async () => {
const svc = serviceFor(registry, bookId, aiClient)
if (contextSummary) svc.setContextSummary(flowId, contextSummary)
const text = contextSummary ?? readContextText(registry, bookId, flowId)
await svc.planScenes(flowId, text)
return enrich(registry, bookId, aiClient, flowId)
})
)
ipcMain.handle(
IPC.AUTO_GENERATE,
(event, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
wrap(async () => {
const controller = new AbortController()
activeAuto.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
const svc = serviceFor(registry, bookId, aiClient)
const text = contextSummary ?? readContextText(registry, bookId, flowId)
try {
const flow = await svc.generateNext(
flowId,
text,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
flowMode: 'auto',
phase: 'scene',
delta,
done
})
},
controller.signal
)
return svc.enrichFlow(flow)
} finally {
activeAuto.delete(flowId)
}
})
)
ipcMain.handle(
IPC.AUTO_RESOLVE_NAMING,
(
event,
{
bookId,
flowId,
chosenName,
contextSummary
}: { bookId: string; flowId: string; chosenName: string; contextSummary?: string }
) =>
wrap(async () => {
const controller = new AbortController()
activeAuto.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
const svc = serviceFor(registry, bookId, aiClient)
const text = contextSummary ?? readContextText(registry, bookId, flowId)
try {
const flow = await svc.resolveNaming(
flowId,
chosenName,
text,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
flowMode: 'auto',
phase: 'naming_resume',
delta,
done
})
},
controller.signal
)
return svc.enrichFlow(flow)
} finally {
activeAuto.delete(flowId)
}
})
)
ipcMain.handle(IPC.AUTO_PAUSE, (_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
activeAuto.get(flowId)?.abort()
activeAuto.delete(flowId)
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.pause(flowId))
})
)
ipcMain.handle(IPC.AUTO_RESUME, (_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.resume(flowId))
})
)
ipcMain.handle(
IPC.AUTO_HANDOFF_INTERACTIVE,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.handoffToInteractive(flowId))
})
)
ipcMain.handle(
IPC.AUTO_FINISH_CHAPTER,
(
_e,
{ bookId, flowId, volumeId, title }: { bookId: string; flowId: string; volumeId: string; title?: string }
) => wrap(() => serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title))
)
ipcMain.handle(IPC.AUTO_ABORT, (_e, { flowId }: { flowId: string }) =>
wrap(() => {
activeAuto.get(flowId)?.abort()
activeAuto.delete(flowId)
})
)
ipcMain.handle(
IPC.AUTO_GET_SCENE_DRAFT,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => serviceFor(registry, bookId, aiClient).getSceneDraft(flowId))
)
}
+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 { 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))
)
} }
+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())
)
}
+6 -6
View File
@@ -4,7 +4,7 @@ import type { AiContextBinding, PlotSelection } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry' import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings' import { GlobalSettingsService } from '../../services/global-settings'
import { AiClientService } from '../../services/ai-client.service' import { AiClientService } from '../../services/ai-client.service'
import { aiContextBuilder } from '../../services/ai-context-builder.service' import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
import { checkInteractiveGate } from '../../services/interactive-gate.service' import { checkInteractiveGate } from '../../services/interactive-gate.service'
import { InteractiveWritingService } from '../../services/interactive-writing.service' import { InteractiveWritingService } from '../../services/interactive-writing.service'
import { wrap } from '../result' import { wrap } from '../result'
@@ -56,13 +56,13 @@ export function registerInteractiveHandlers(
}: { bookId: string; flowId: string; binding: AiContextBinding } }: { bookId: string; flowId: string; binding: AiContextBinding }
) => ) =>
wrap(() => { wrap(() => {
const built = aiContextBuilder.build(binding, registry, bookId, settings.get().penName) const payload = buildFlowContextPayload(
const summary = built.preview.map((p) => p.title).join('、') || '(空)'
const flow = serviceFor(registry, bookId, aiClient).confirmContext(
flowId,
binding, binding,
summary registry,
bookId,
settings.get().penName
) )
const flow = serviceFor(registry, bookId, aiClient).confirmContext(flowId, payload)
return serviceFor(registry, bookId, aiClient).enrichFlow(flow) return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
}) })
) )
@@ -0,0 +1,85 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { CreateKnowledgeInput, KnowledgeStatus, KnowledgeType, KnowledgeSuggestOptions } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings'
import { KnowledgeRetrievalService } from '../../services/knowledge-retrieval.service'
import { KnowledgeService } from '../../services/knowledge.service'
import { wrap } from '../result'
export function registerKnowledgeHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): 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())
)
ipcMain.handle(
IPC.KNOWLEDGE_SUGGEST_CONTEXT,
(_e, { bookId, opts }: { bookId: string; opts?: KnowledgeSuggestOptions }) =>
wrap(() => {
const topN = opts?.topN ?? settings.get().knowledgeSuggestTopN ?? 8
return new KnowledgeRetrievalService(registry.getDb(bookId)).suggest({
...opts,
topN
})
})
)
}
+169
View File
@@ -0,0 +1,169 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AiContextBinding, AutoRhythm, AutoWritingConfig, WizardWritingConfig } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings'
import { AiClientService } from '../../services/ai-client.service'
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
import { WizardWritingService } from '../../services/wizard-writing.service'
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result'
const activeWizard = new Map<string, AbortController>()
function serviceFor(registry: BookRegistryService, bookId: string, aiClient: AiClientService) {
return new WizardWritingService(registry.getDb(bookId), aiClient)
}
function readContextText(registry: BookRegistryService, bookId: string, flowId: string): string {
const row = registry
.getDb(bookId)
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
.get(flowId) as { context_json: string } | undefined
return readFlowSystemPrompt(row?.context_json ?? '{}')
}
export function registerWizardHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService
): void {
ipcMain.handle(
IPC.WIZARD_GET_FLOW,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
const flow = svc.getFlow(sessionId)
return flow ? svc.enrichFlow(flow) : null
})
)
ipcMain.handle(
IPC.WIZARD_START,
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
const flow = svc.start(sessionId)
return svc.enrichFlow(flow)
})
)
ipcMain.handle(
IPC.WIZARD_SET_GOAL,
(
_e,
{ bookId, flowId, config }: { bookId: string; flowId: string; config: Partial<AutoWritingConfig> }
) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.setGoal(flowId, config))
})
)
ipcMain.handle(
IPC.WIZARD_SET_RHYTHM,
(
_e,
{
bookId,
flowId,
rhythm,
styleNote
}: { bookId: string; flowId: string; rhythm: AutoRhythm; styleNote?: string }
) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.setRhythm(flowId, rhythm, styleNote))
})
)
ipcMain.handle(
IPC.WIZARD_SET_POV,
(_e, { bookId, flowId, povSettingId }: { bookId: string; flowId: string; povSettingId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.setPov(flowId, povSettingId))
})
)
ipcMain.handle(
IPC.WIZARD_CONFIRM_CONTEXT,
(
_e,
{ bookId, flowId, binding }: { bookId: string; flowId: string; binding: AiContextBinding }
) =>
wrap(() => {
const payload = buildFlowContextPayload(
binding,
registry,
bookId,
settings.get().penName
)
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.confirmContext(flowId, payload))
})
)
ipcMain.handle(
IPC.WIZARD_BUILD_PREVIEW,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(async () => {
const svc = serviceFor(registry, bookId, aiClient)
const config = new InteractiveRepository(registry.getDb(bookId)).getModeConfig<WizardWritingConfig>(
flowId
)
const text = readContextText(registry, bookId, flowId)
const povName = svc.getPovName(config.povSettingId)
const preview = await svc.buildPreview(flowId, text, povName)
const flow = new InteractiveRepository(registry.getDb(bookId)).get(flowId)!
return { preview, flow: svc.enrichFlow(flow) }
})
)
ipcMain.handle(
IPC.WIZARD_START_GENERATE,
(event, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(async () => {
const controller = new AbortController()
activeWizard.set(flowId, controller)
const win = BrowserWindow.fromWebContents(event.sender)
const svc = serviceFor(registry, bookId, aiClient)
const text = readContextText(registry, bookId, flowId)
try {
const flow = await svc.startGenerate(
flowId,
text,
(delta, done) => {
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
flowId,
flowMode: 'wizard',
phase: 'scene',
delta,
done
})
},
controller.signal
)
return svc.enrichFlow(flow)
} finally {
activeWizard.delete(flowId)
}
})
)
ipcMain.handle(
IPC.WIZARD_HANDOFF_INTERACTIVE,
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
wrap(() => {
const svc = serviceFor(registry, bookId, aiClient)
return svc.enrichFlow(svc.handoffToInteractive(flowId))
})
)
ipcMain.handle(IPC.WIZARD_ABORT, (_e, { flowId }: { flowId: string }) =>
wrap(() => {
activeWizard.get(flowId)?.abort()
activeWizard.delete(flowId)
})
)
}
+13 -2
View File
@@ -16,6 +16,11 @@ import { registerBookmarkHandlers } from './handlers/bookmark.handler'
import { registerSearchHandlers } from './handlers/search.handler' import { registerSearchHandlers } from './handlers/search.handler'
import { registerAiHandlers } from './handlers/ai.handler' 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 { 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'
@@ -42,8 +47,14 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerSnapshotHandlers(books, snapshotService!) registerSnapshotHandlers(books, snapshotService!)
registerBookmarkHandlers(books) registerBookmarkHandlers(books)
registerSearchHandlers(books, settings) registerSearchHandlers(books, settings)
registerAiHandlers(books, settings, new AiClientService(() => settings.get().aiConfig)) const aiClient = new AiClientService(() => settings.get().aiConfig)
registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig)) registerAiHandlers(books, settings, aiClient)
registerInteractiveHandlers(books, settings, aiClient)
registerAutoHandlers(books, settings, aiClient)
registerWizardHandlers(books, settings, aiClient)
registerKnowledgeHandlers(books, settings)
registerCockpitHandlers(books, settings)
registerBridgeHandlers(books, aiClient)
return { settings, books, shortcuts: shortcutManager } return { settings, books, shortcuts: shortcutManager }
} }
@@ -1,15 +1,24 @@
import { stripHtml } from './word-count' import { stripHtml } from './word-count'
import type { AiContextBinding, AiContextBuildResult, ContextPreviewItem } from '../../shared/types' import type {
AiContextBinding,
AiContextBuildResult,
ContextPreviewItem,
KnowledgeType
} from '../../shared/types'
import type { BookRegistryService } from './book-registry' import type { BookRegistryService } from './book-registry'
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
const CHAPTER_CHAR_LIMIT = 2000 const CHAPTER_CHAR_LIMIT = 2000
const OTHER_CHAR_LIMIT = 500 const OTHER_CHAR_LIMIT = 500
const KNOWLEDGE_CHAR_LIMIT = 500
const KNOWLEDGE_TOTAL_LIMIT = 4000
const TOTAL_CHAR_LIMIT = 16 * 1024 const TOTAL_CHAR_LIMIT = 16 * 1024
interface BlockCandidate { interface BlockCandidate {
sortKey: number sortKey: number
text: string text: string
preview: ContextPreviewItem preview: ContextPreviewItem
isKnowledge?: boolean
} }
function truncate(text: string, max: number): string { function truncate(text: string, max: number): string {
@@ -17,12 +26,33 @@ function truncate(text: string, max: number): string {
return `${text.slice(0, max)}` return `${text.slice(0, max)}`
} }
function knowledgeTypeLabel(type: KnowledgeType): string {
const map: Record<KnowledgeType, string> = {
character: '角色',
foreshadow: '伏笔',
location: '地点',
relationship: '关系',
fact: '事实'
}
return map[type]
}
function formatBlock(kind: ContextPreviewItem['kind'], title: string, body: string): string { function formatBlock(kind: ContextPreviewItem['kind'], title: string, body: string): string {
const label = const label =
kind === 'chapter' ? '章节' : kind === 'outline' ? '大纲' : kind === 'setting' ? '设定' : '灵感' kind === 'chapter'
? '章节'
: kind === 'outline'
? '大纲'
: kind === 'setting'
? '设定'
: '灵感'
return `${label}${title}\n${body}` return `${label}${title}\n${body}`
} }
function formatKnowledgeBlock(type: KnowledgeType, title: string, body: string): string {
return `【知识·${knowledgeTypeLabel(type)}${title}\n${body}`
}
export class AiContextBuilderService { export class AiContextBuilderService {
build( build(
binding: AiContextBinding, binding: AiContextBinding,
@@ -34,6 +64,7 @@ export class AiContextBuilderService {
if (!meta) throw new Error('Book not found') if (!meta) throw new Error('Book not found')
const candidates: BlockCandidate[] = [] const candidates: BlockCandidate[] = []
const knowledgeIds = binding.knowledgeIds ?? []
for (const id of binding.chapterIds) { for (const id of binding.chapterIds) {
const chapter = registry.getChapterRepo(bookId).get(id) const chapter = registry.getChapterRepo(bookId).get(id)
@@ -104,6 +135,38 @@ export class AiContextBuilderService {
}) })
} }
const knowledgeRepo = new KnowledgeRepository(registry.getDb(bookId))
const knowledgeCandidates: BlockCandidate[] = []
for (const id of knowledgeIds) {
const entry = knowledgeRepo.get(id)
if (!entry || entry.status !== 'approved') continue
const body = truncate(stripHtml(entry.content || entry.title), KNOWLEDGE_CHAR_LIMIT)
knowledgeCandidates.push({
sortKey: entry.importance * 1000,
isKnowledge: true,
text: formatKnowledgeBlock(entry.type, entry.title, body),
preview: {
kind: 'knowledge',
id,
title: entry.title,
excerpt: body,
charCount: body.length,
knowledgeType: entry.type
}
})
}
knowledgeCandidates.sort((a, b) => b.sortKey - a.sortKey)
let knowledgeChars = 0
const trimmedKnowledge: BlockCandidate[] = []
for (const k of knowledgeCandidates) {
const next = knowledgeChars + k.text.length + (trimmedKnowledge.length > 0 ? 2 : 0)
if (next > KNOWLEDGE_TOTAL_LIMIT) break
trimmedKnowledge.push(k)
knowledgeChars = next
}
candidates.push(...trimmedKnowledge)
candidates.sort((a, b) => b.sortKey - a.sortKey) candidates.sort((a, b) => b.sortKey - a.sortKey)
const selected: BlockCandidate[] = [] const selected: BlockCandidate[] = []
+31
View File
@@ -0,0 +1,31 @@
import type { ScenePlanItem } from '../../shared/types'
export interface ScenePlanResult {
scenes: ScenePlanItem[]
estimatedWords: number
}
export function parseScenePlanJson(response: string): ScenePlanResult {
const trimmed = response.trim()
const start = trimmed.indexOf('{')
const end = trimmed.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('scene plan JSON not found')
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as {
scenes?: unknown
estimatedWords?: unknown
}
if (!Array.isArray(parsed.scenes) || parsed.scenes.length < 2) {
throw new Error('scenes array invalid')
}
const scenes = parsed.scenes.map((s, i) => {
const row = s as Record<string, unknown>
return {
label: String(row.label ?? `场景${i + 1}`),
summary: String(row.summary ?? '')
}
})
return {
scenes,
estimatedWords: Number(parsed.estimatedWords ?? 3000)
}
}
@@ -0,0 +1,75 @@
import type { AutoWritingConfig, ScenePlanItem, WizardWritingConfig } from '../../shared/types'
import type { ChatMessage } from './ai-client.service'
export function buildScenePlanMessages(
chapterGoal: string,
outlineSummaries: string[],
contextText: string,
wordMin: number,
wordMax: number
): ChatMessage[] {
const outline =
outlineSummaries.length > 0
? `关联大纲:\n${outlineSummaries.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n`
: ''
return [
{
role: 'system',
content:
'你是长篇小说章节规划助手。根据本章目标规划 3-6 个连续场景。仅返回 JSON{"scenes":[{"label":"场景名","summary":"至少80字的场景要点"},...],"estimatedWords":数字}。estimatedWords 须在用户给定字数范围内。'
},
{
role: 'user',
content: `${outline}本章目标:${chapterGoal}\n\n上下文:\n${contextText}\n\n目标字数:${wordMin}-${wordMax} 字。请规划场景顺序。`
}
]
}
export function buildAutoSceneMessages(
config: AutoWritingConfig,
planItem: ScenePlanItem,
priorSceneSummaries: string[],
contextText: string
): ChatMessage[] {
const prior =
priorSceneSummaries.length > 0
? `已写场景摘要:\n${priorSceneSummaries.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n`
: ''
return [
{
role: 'system',
content:
'你是小说场景写作助手。若需为新元素命名且尚无定论,先仅返回 JSON{"type":"naming_required","elementType":"类型","context":"说明","options":["名1","名2","名3","名4","名5"]}。否则输出 600-1500 字场景 HTML <p> 段落。'
},
{
role: 'user',
content: `${prior}上下文:\n${contextText}\n\n本章目标:${config.chapterGoal}\n\n当前场景:${planItem.label}\n场景要点:${planItem.summary}\n\n请生成本场景。`
}
]
}
const RHYTHM_LABEL: Record<string, string> = {
relaxed: '舒缓',
tense: '紧张',
mixed: '混合'
}
export function buildWizardPreviewMessages(
config: WizardWritingConfig,
contextText: string,
povName: string
): ChatMessage[] {
const rhythm = RHYTHM_LABEL[config.rhythm] ?? config.rhythm
const style = config.styleNote ? `\n风格说明:${config.styleNote}` : ''
return [
{
role: 'system',
content:
'你是写作策略助手。根据用户配置生成一段策略预览,使用 HTML <p> 包裹,中文,150-300 字,说明视角、节奏、目标与重点上下文。'
},
{
role: 'user',
content: `视角人物:${povName}\n节奏:${rhythm}${style}\n本章目标:${config.chapterGoal}\n\n上下文摘要:\n${contextText}\n\n请生成策略预览。`
}
]
}
+226
View File
@@ -0,0 +1,226 @@
import type {
AutoWritingConfig,
FlowContextPayload,
InteractiveFlow,
ScenePlanItem
} from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { InteractiveRepository } from '../db/repositories/interactive.repo'
import { OutlineRepository } from '../db/repositories/outline.repo'
import type { AiClientService } from './ai-client.service'
import {
buildAutoSceneMessages,
buildScenePlanMessages
} from './auto-prompt-builder.service'
import { parseScenePlanJson, type ScenePlanResult } from './auto-parse.service'
import { ChapterFinishService } from './chapter-finish.service'
import { SceneGenerationCore } from './scene-generation-core.service'
import { handoffToInteractive } from './writing-flow-handoff.service'
import { stripHtml } from './word-count'
export class AutoWritingService {
private repo: InteractiveRepository
private core: SceneGenerationCore
constructor(
private db: SqliteDb,
private aiClient: AiClientService
) {
this.repo = new InteractiveRepository(db)
this.core = new SceneGenerationCore(this.repo, aiClient)
}
start(sessionId: string): InteractiveFlow {
return this.repo.resetInProgress(sessionId, 'auto')
}
getFlow(sessionId: string): InteractiveFlow | null {
return this.repo.getBySession(sessionId)
}
setGoal(flowId: string, partial: Partial<AutoWritingConfig>): InteractiveFlow {
const existing = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const config: AutoWritingConfig = {
chapterGoal: partial.chapterGoal ?? existing.chapterGoal ?? '',
outlineItemIds: partial.outlineItemIds ?? existing.outlineItemIds,
wordMin: partial.wordMin ?? existing.wordMin ?? 2000,
wordMax: partial.wordMax ?? existing.wordMax ?? 4000,
scenePlan: existing.scenePlan,
currentPlanIndex: existing.currentPlanIndex ?? 0
}
this.repo.setModeConfig(flowId, config)
return this.repo.updateStep(flowId, 'scene_plan')
}
async planScenes(flowId: string, contextText: string): Promise<ScenePlanResult> {
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const outlines = new OutlineRepository(this.db)
const outlineSummaries = (config.outlineItemIds ?? [])
.map((id) => outlines.get(id))
.filter(Boolean)
.map((o) => `${o!.title}${stripHtml(o!.description).slice(0, 120)}`)
const messages = buildScenePlanMessages(
config.chapterGoal,
outlineSummaries,
contextText,
config.wordMin,
config.wordMax
)
let response = await this.aiClient.chat(messages)
let plan: ScenePlanResult
try {
plan = parseScenePlanJson(response)
} catch {
response = await this.aiClient.chat([
...messages,
{ role: 'user', content: '仅返回 JSON,不要 markdown 或其它说明。' }
])
plan = parseScenePlanJson(response)
}
const updated: AutoWritingConfig = {
...config,
scenePlan: plan.scenes,
currentPlanIndex: 0
}
this.repo.setModeConfig(flowId, updated)
this.repo.updateStep(flowId, 'auto_generate')
return plan
}
async generateNext(
flowId: string,
contextText: string,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal
): Promise<InteractiveFlow> {
const flow = this.repo.get(flowId)
if (!flow) throw new Error('flow not found')
if (flow.step === 'naming_pause') throw new Error('naming pause active')
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const index = config.currentPlanIndex ?? 0
const plan = config.scenePlan ?? []
if (index >= plan.length) return flow
const planItem = plan[index]!
const prior = this.repo
.listScenes(flowId)
.map((s) => stripHtml(s.contentHtml).slice(0, 200))
const messages = buildAutoSceneMessages(config, planItem, prior, contextText)
const { naming } = await this.core.generateStream(
flowId,
messages,
onChunk,
abortSignal,
'auto_generate'
)
if (naming) return this.repo.get(flowId)!
await this.commitCurrentScene(flowId, planItem)
return this.repo.get(flowId)!
}
async resolveNaming(
flowId: string,
chosenName: string,
contextText: string,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal
): Promise<InteractiveFlow> {
const flow = this.repo.get(flowId)
if (!flow?.namingPending) throw new Error('no naming pause')
await this.core.resolveNaming(
flowId,
chosenName,
flow.namingPending,
onChunk,
abortSignal,
'auto_generate'
)
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const index = config.currentPlanIndex ?? 0
const planItem = config.scenePlan?.[index]
if (planItem) await this.commitCurrentScene(flowId, planItem)
return this.repo.get(flowId)!
}
pause(flowId: string): InteractiveFlow {
return this.repo.updateStep(flowId, 'paused')
}
resume(flowId: string): InteractiveFlow {
return this.repo.updateStep(flowId, 'auto_generate')
}
handoffToInteractive(flowId: string): InteractiveFlow {
return handoffToInteractive(this.repo, flowId)
}
finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: string } {
return new ChapterFinishService(this.db).finishFromFlow(
flowId,
volumeId,
title ?? '自动初稿',
'auto',
'自动写作成章'
)
}
getSceneDraft(flowId: string): string {
return this.repo.getSceneDraft(flowId)
}
totalWordCount(flowId: string): number {
return this.repo
.listScenes(flowId)
.reduce((sum, s) => sum + stripHtml(s.contentHtml).length, 0)
}
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
try {
const parsed = JSON.parse(this.repo.getContextJson(flow.id)) as { contextSummary?: string }
return { ...flow, contextSummary: parsed.contextSummary }
} catch {
return flow
}
}
confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow {
this.repo.updateStep(flowId, this.repo.get(flowId)!.step, {
contextJson: JSON.stringify(payload)
})
return this.repo.get(flowId)!
}
setContextSummary(flowId: string, contextSummary: string): void {
this.repo.updateStep(flowId, this.repo.get(flowId)!.step, {
contextJson: JSON.stringify({
binding: {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
},
systemPrompt: contextSummary,
contextSummary
})
})
}
private async commitCurrentScene(flowId: string, planItem: ScenePlanItem): Promise<void> {
const draft = this.repo.getSceneDraft(flowId).trim()
if (!draft) return
this.repo.addScene(flowId, draft, planItem.label)
this.repo.setSceneDraft(flowId, '')
const config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const nextIndex = (config.currentPlanIndex ?? 0) + 1
this.repo.setModeConfig(flowId, { ...config, currentPlanIndex: nextIndex })
}
}
@@ -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,34 @@
import type { ChapterOrigin } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { InteractiveRepository } from '../db/repositories/interactive.repo'
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
export class ChapterFinishService {
constructor(private db: SqliteDb) {}
finishFromFlow(
flowId: string,
volumeId: string,
title: string,
origin: ChapterOrigin,
snapshotLabel: string
): { chapterId: string } {
const repo = new InteractiveRepository(this.db)
const draft = repo.getSceneDraft(flowId).trim()
if (draft) repo.addScene(flowId, draft, 'draft')
const scenes = repo.listScenes(flowId)
if (scenes.length === 0) throw new Error('no scenes to merge')
const merged = scenes.map((s) => s.contentHtml).join('\n')
const chapters = new ChapterRepository(this.db)
const sortOrder = chapters.listByVolume(volumeId).length
const ch = chapters.create(volumeId, title, sortOrder, merged, origin)
new SnapshotRepository(this.db).create(ch.id, 'ai', merged, snapshotLabel)
repo.updateStep(flowId, 'completed', {
status: 'completed',
sceneDraftHtml: '',
namingPendingJson: null
})
return { chapterId: ch.id }
}
}
+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')
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { AiContextBinding, FlowContextPayload } from '../../shared/types'
import type { BookRegistryService } from './book-registry'
import { aiContextBuilder } from './ai-context-builder.service'
const EMPTY_BINDING: AiContextBinding = {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
}
export function buildFlowContextPayload(
binding: AiContextBinding,
registry: BookRegistryService,
bookId: string,
penName: string
): FlowContextPayload {
const built = aiContextBuilder.build(binding, registry, bookId, penName)
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 }
for (const p of built.preview) {
if (p.kind in counts) counts[p.kind as keyof typeof counts]++
}
const contextSummary = `${counts.chapter}章·${counts.outline}纲·${counts.setting}设·${counts.inspiration}感·${counts.knowledge}`
return { binding, systemPrompt: built.systemPrompt, contextSummary }
}
export function parseFlowContextPayload(contextJson: string): FlowContextPayload | null {
try {
const parsed = JSON.parse(contextJson) as Partial<FlowContextPayload>
if (parsed.systemPrompt) {
return {
binding: { ...EMPTY_BINDING, ...parsed.binding },
systemPrompt: parsed.systemPrompt,
contextSummary: parsed.contextSummary ?? ''
}
}
if (parsed.contextSummary) {
return {
binding: { ...EMPTY_BINDING, ...parsed.binding },
systemPrompt: parsed.contextSummary,
contextSummary: parsed.contextSummary
}
}
return null
} catch {
return null
}
}
export function readFlowSystemPrompt(contextJson: string): string {
return parseFlowContextPayload(contextJson)?.systemPrompt ?? '(无上下文)'
}
+9 -1
View File
@@ -20,13 +20,17 @@ 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,
snapshotMaxPersist: 3, snapshotMaxPersist: 3,
searchHistory: [], searchHistory: [],
aiConfig: defaultAiConfig(), aiConfig: defaultAiConfig(),
namingIgnoreWords: [] namingIgnoreWords: [],
knowledgeAutoSuggest: true,
knowledgeSuggestTopN: 8
} }
} }
@@ -45,6 +49,10 @@ export class GlobalSettingsService {
return { return {
...defaults(), ...defaults(),
...raw, ...raw,
updateSchedule: raw.updateSchedule ?? 'none',
stockBufferThreshold: raw.stockBufferThreshold ?? 3,
knowledgeAutoSuggest: raw.knowledgeAutoSuggest ?? true,
knowledgeSuggestTopN: raw.knowledgeSuggestTopN ?? 8,
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts }, shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig } aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
} }
@@ -1,30 +1,34 @@
import type { import type {
AiContextBinding, AiContextBinding,
FlowContextPayload,
InteractiveFlow, InteractiveFlow,
PlotOption, PlotOption,
PlotSelection PlotSelection
} from '../../shared/types' } from '../../shared/types'
import type { SqliteDb } from '../db/types' import type { SqliteDb } from '../db/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { InteractiveRepository } from '../db/repositories/interactive.repo' import { InteractiveRepository } from '../db/repositories/interactive.repo'
import type { AiClientService } from './ai-client.service' import type { AiClientService } from './ai-client.service'
import { ChapterFinishService } from './chapter-finish.service'
import { SceneGenerationCore } from './scene-generation-core.service'
import { parsePlotsJson } from './interactive-parse.service'
import { stripHtml } from './word-count'
import { import {
buildNamingResumeMessages,
buildPlotSuggestMessages, buildPlotSuggestMessages,
buildRefineMessages, buildRefineMessages,
buildSceneGenerateMessages buildSceneGenerateMessages
} from './interactive-prompt-builder.service' } from './interactive-prompt-builder.service'
import { parsePlotsJson, tryParseNamingFromBuffer } from './interactive-parse.service' import { readFlowSystemPrompt } from './flow-context.util'
import { stripHtml } from './word-count'
export class InteractiveWritingService { export class InteractiveWritingService {
private repo: InteractiveRepository private repo: InteractiveRepository
private core: SceneGenerationCore
constructor( constructor(
private db: SqliteDb, private db: SqliteDb,
private aiClient: AiClientService private aiClient: AiClientService
) { ) {
this.repo = new InteractiveRepository(db) this.repo = new InteractiveRepository(db)
this.core = new SceneGenerationCore(this.repo, aiClient)
} }
start(sessionId: string): InteractiveFlow { start(sessionId: string): InteractiveFlow {
@@ -35,9 +39,9 @@ export class InteractiveWritingService {
return this.repo.getBySession(sessionId) return this.repo.getBySession(sessionId)
} }
confirmContext(flowId: string, binding: AiContextBinding, contextSummary: string): InteractiveFlow { confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow {
this.repo.updateStep(flowId, 'plot_suggest', { this.repo.updateStep(flowId, 'plot_suggest', {
contextJson: JSON.stringify({ binding, contextSummary }) contextJson: JSON.stringify(payload)
}) })
return this.repo.get(flowId)! return this.repo.get(flowId)!
} }
@@ -87,32 +91,7 @@ export class InteractiveWritingService {
const summaries = scenes.map((s) => stripHtml(s.contentHtml).slice(0, 200)) const summaries = scenes.map((s) => stripHtml(s.contentHtml).slice(0, 200))
const messages = buildSceneGenerateMessages(contextText, selection, summaries) const messages = buildSceneGenerateMessages(contextText, selection, summaries)
let buffer = '' await this.core.generateStream(flowId, messages, onChunk, abortSignal, 'scene_review')
const full = await this.aiClient.chat(
messages,
(delta) => {
buffer += delta
const naming = tryParseNamingFromBuffer(buffer)
if (naming) return
onChunk(delta, false)
},
abortSignal
)
const naming = tryParseNamingFromBuffer(full || buffer)
if (naming) {
this.repo.updateStep(flowId, 'naming_pause', {
namingPendingJson: JSON.stringify(naming),
sceneDraftHtml: ''
})
onChunk('', true)
return this.repo.get(flowId)!
}
const html = this.normalizeSceneHtml(full || buffer)
this.repo.setSceneDraft(flowId, html)
this.repo.updateStep(flowId, 'scene_review', { namingPendingJson: null })
onChunk('', true)
return this.repo.get(flowId)! return this.repo.get(flowId)!
} }
@@ -125,21 +104,14 @@ export class InteractiveWritingService {
const flow = this.repo.get(flowId) const flow = this.repo.get(flowId)
if (!flow?.namingPending) throw new Error('no naming pause') if (!flow?.namingPending) throw new Error('no naming pause')
const messages = buildNamingResumeMessages( await this.core.resolveNaming(
flowId,
chosenName, chosenName,
this.repo.getSceneDraft(flowId), flow.namingPending,
flow.namingPending.context onChunk,
abortSignal,
'scene_review'
) )
const full = await this.aiClient.chat(
messages,
(delta) => onChunk(delta, false),
abortSignal
)
const html = this.normalizeSceneHtml(full)
this.repo.setSceneDraft(flowId, html)
this.repo.updateStep(flowId, 'scene_review', { namingPendingJson: null })
onChunk('', true)
return this.repo.get(flowId)! return this.repo.get(flowId)!
} }
@@ -160,7 +132,7 @@ export class InteractiveWritingService {
}, },
abortSignal abortSignal
) )
const html = this.normalizeSceneHtml(full || buffer) const html = this.core.normalizeSceneHtml(full || buffer)
this.repo.setSceneDraft(flowId, html) this.repo.setSceneDraft(flowId, html)
onChunk('', true) onChunk('', true)
return this.repo.get(flowId)! return this.repo.get(flowId)!
@@ -181,26 +153,13 @@ export class InteractiveWritingService {
} }
finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: string } { finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: string } {
const draft = this.repo.getSceneDraft(flowId).trim() return new ChapterFinishService(this.db).finishFromFlow(
if (draft) { flowId,
const selection = JSON.parse(this.repo.getPlotSelection(flowId) || '{}') as PlotSelection volumeId,
this.repo.addScene(flowId, draft, selection.optionIds?.join('+') ?? '') title ?? '交互初稿',
this.repo.setSceneDraft(flowId, '') 'interactive',
} '交互写作成章'
)
const scenes = this.repo.listScenes(flowId)
if (scenes.length === 0) throw new Error('no scenes to merge')
const merged = scenes.map((s) => s.contentHtml).join('\n')
const chapters = new ChapterRepository(this.db)
const sortOrder = chapters.listByVolume(volumeId).length
const ch = chapters.create(volumeId, title ?? '交互初稿', sortOrder, merged, 'interactive')
this.repo.updateStep(flowId, 'completed', {
status: 'completed',
namingPendingJson: null,
sceneDraftHtml: ''
})
return { chapterId: ch.id }
} }
getSceneDraft(flowId: string): string { getSceneDraft(flowId: string): string {
@@ -208,8 +167,8 @@ export class InteractiveWritingService {
} }
enrichFlow(flow: InteractiveFlow): InteractiveFlow { enrichFlow(flow: InteractiveFlow): InteractiveFlow {
const raw = this.repo.getContextJson(flow.id)
try { try {
const raw = this.repo.getContextJson(flow.id)
const parsed = JSON.parse(raw) as { contextSummary?: string } const parsed = JSON.parse(raw) as { contextSummary?: string }
return { ...flow, contextSummary: parsed.contextSummary } return { ...flow, contextSummary: parsed.contextSummary }
} catch { } catch {
@@ -218,24 +177,6 @@ export class InteractiveWritingService {
} }
private readContextText(flowId: string): string { private readContextText(flowId: string): string {
try { return readFlowSystemPrompt(this.repo.getContextJson(flowId))
const parsed = JSON.parse(this.repo.getContextJson(flowId)) as {
contextSummary?: string
binding?: AiContextBinding
}
return parsed.contextSummary ?? '(无上下文摘要)'
} catch {
return '(无上下文摘要)'
}
}
private normalizeSceneHtml(text: string): string {
const t = text.trim()
if (!t) return ''
if (t.includes('<p>')) return t
return t
.split(/\n\n+/)
.map((p) => `<p>${p.trim()}</p>`)
.join('')
} }
} }
@@ -0,0 +1,90 @@
import type { KnowledgeEntry, KnowledgeSuggestOptions, ScoredKnowledge } from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
const STOP_WORDS = new Set(['的', '了', '在', '是', '与', '和', 'the', 'a', 'an'])
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[\s,,。!?、;;:]+/)
.map((t) => t.trim())
.filter((t) => t.length >= 2 && !STOP_WORDS.has(t))
}
function overlapRatio(a: string, b: string): number {
const ta = new Set(tokenize(a))
const tb = tokenize(b)
if (ta.size === 0 || tb.length === 0) return 0
let hit = 0
for (const t of tb) if (ta.has(t)) hit++
return hit / tb.length
}
function chapterProximityBonus(
entry: KnowledgeEntry,
currentSort: number | null,
chapterSort: Map<string, number>
): number {
if (currentSort == null) return 0
const ids = [entry.sourceChapterId, entry.lastMentionChapterId].filter(Boolean) as string[]
if (ids.length === 0) return 0
let best = Infinity
for (const id of ids) {
const order = chapterSort.get(id)
if (order != null) best = Math.min(best, Math.abs(currentSort - order))
}
if (best === Infinity) return 0
if (best === 0) return 30
if (best <= 3) return 20
if (best <= 10) return 10
return 0
}
export class KnowledgeRetrievalService {
constructor(private db: SqliteDb) {}
suggest(opts: KnowledgeSuggestOptions = {}): ScoredKnowledge[] {
const topN = opts.topN ?? 8
const entries = new KnowledgeRepository(this.db).list({ status: 'approved' })
if (entries.length === 0) return []
const chapters = new ChapterRepository(this.db).list()
const sortMap = new Map(chapters.map((c) => [c.id, c.sortOrder]))
const currentSort = opts.currentChapterId
? (sortMap.get(opts.currentChapterId) ?? null)
: null
const goalText = opts.goalText ?? ''
const scored: ScoredKnowledge[] = entries.map((entry) => {
let score = entry.importance * 20
const reasons: string[] = []
const prox = chapterProximityBonus(entry, currentSort, sortMap)
score += prox
if (prox > 0) reasons.push('knowledge.reason.chapterRelevant')
if (entry.type === 'foreshadow') {
const fs = entry.foreshadowStatus ?? 'buried'
if (fs === 'buried') {
score += 25
reasons.push('knowledge.reason.foreshadowBuried')
} else if (fs === 'partial') score += 15
}
if (goalText) {
const text = `${entry.title} ${entry.content}`
const ratio = overlapRatio(goalText, text)
score += Math.round(ratio * 30)
if (ratio > 0.2) reasons.push('knowledge.reason.goalMatch')
}
if (entry.importance >= 4) reasons.push('knowledge.reason.highImportance')
return { entry, score, reasons: [...new Set(reasons)] }
})
return scored.sort((a, b) => b.score - a.score).slice(0, topN)
}
}
+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
}
}
@@ -0,0 +1,83 @@
import type { InteractiveFlow, NamingPause } from '../../shared/types'
import type { InteractiveRepository } from '../db/repositories/interactive.repo'
import type { AiClientService, ChatMessage } from './ai-client.service'
import { buildNamingResumeMessages } from './interactive-prompt-builder.service'
import { tryParseNamingFromBuffer } from './interactive-parse.service'
export class SceneGenerationCore {
constructor(
private repo: InteractiveRepository,
private aiClient: AiClientService
) {}
normalizeSceneHtml(text: string): string {
const t = text.trim()
if (!t) return ''
if (t.includes('<p>')) return t
return t
.split(/\n\n+/)
.map((p) => `<p>${p.trim()}</p>`)
.join('')
}
async generateStream(
flowId: string,
messages: ChatMessage[],
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal,
reviewStep: 'scene_review' | 'auto_generate' = 'scene_review'
): Promise<{ flow: InteractiveFlow; html: string; naming: NamingPause | null }> {
let buffer = ''
const full = await this.aiClient.chat(
messages,
(delta) => {
buffer += delta
const naming = tryParseNamingFromBuffer(buffer)
if (naming) return
onChunk(delta, false)
},
abortSignal
)
const naming = tryParseNamingFromBuffer(full || buffer)
if (naming) {
this.repo.updateStep(flowId, 'naming_pause', {
namingPendingJson: JSON.stringify(naming),
sceneDraftHtml: ''
})
onChunk('', true)
return { flow: this.repo.get(flowId)!, html: '', naming }
}
const html = this.normalizeSceneHtml(full || buffer)
this.repo.setSceneDraft(flowId, html)
this.repo.updateStep(flowId, reviewStep, { namingPendingJson: null })
onChunk('', true)
return { flow: this.repo.get(flowId)!, html, naming: null }
}
async resolveNaming(
flowId: string,
chosenName: string,
namingPending: NamingPause,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal,
reviewStep: 'scene_review' | 'auto_generate' = 'scene_review'
): Promise<InteractiveFlow> {
const messages = buildNamingResumeMessages(
chosenName,
this.repo.getSceneDraft(flowId),
namingPending.context
)
const full = await this.aiClient.chat(
messages,
(delta) => onChunk(delta, false),
abortSignal
)
const html = this.normalizeSceneHtml(full)
this.repo.setSceneDraft(flowId, html)
this.repo.updateStep(flowId, reviewStep, { namingPendingJson: null })
onChunk('', true)
return this.repo.get(flowId)!
}
}
+138
View File
@@ -0,0 +1,138 @@
import type {
AiContextBinding,
AutoRhythm,
AutoWritingConfig,
FlowContextPayload,
InteractiveFlow,
WizardWritingConfig
} from '../../shared/types'
import type { SqliteDb } from '../db/types'
import { InteractiveRepository } from '../db/repositories/interactive.repo'
import { SettingRepository } from '../db/repositories/setting.repo'
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
import type { AiClientService } from './ai-client.service'
import { buildWizardPreviewMessages } from './auto-prompt-builder.service'
import { AutoWritingService } from './auto-writing.service'
import { handoffToInteractive } from './writing-flow-handoff.service'
export class WizardWritingService {
private repo: InteractiveRepository
private autoService: AutoWritingService
constructor(
private db: SqliteDb,
private aiClient: AiClientService
) {
this.repo = new InteractiveRepository(db)
this.autoService = new AutoWritingService(db, aiClient)
}
start(sessionId: string): InteractiveFlow {
return this.repo.resetInProgress(sessionId, 'wizard')
}
getFlow(sessionId: string): InteractiveFlow | null {
return this.repo.getBySession(sessionId)
}
setGoal(flowId: string, partial: Partial<AutoWritingConfig>): InteractiveFlow {
const existing = this.repo.getModeConfig<WizardWritingConfig>(flowId)
const config: WizardWritingConfig = {
rhythm: existing.rhythm ?? 'mixed',
chapterGoal: partial.chapterGoal ?? existing.chapterGoal ?? '',
outlineItemIds: partial.outlineItemIds ?? existing.outlineItemIds,
wordMin: partial.wordMin ?? existing.wordMin ?? 2000,
wordMax: partial.wordMax ?? existing.wordMax ?? 4000
}
this.repo.setModeConfig(flowId, config)
return this.repo.updateStep(flowId, 'wizard_rhythm')
}
setRhythm(flowId: string, rhythm: AutoRhythm, styleNote?: string): InteractiveFlow {
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
this.repo.setModeConfig(flowId, { ...config, rhythm, styleNote: styleNote?.trim() || undefined })
return this.repo.updateStep(flowId, 'wizard_pov')
}
setPov(flowId: string, povSettingId?: string): InteractiveFlow {
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
this.repo.setModeConfig(flowId, { ...config, povSettingId: povSettingId || undefined })
return this.repo.updateStep(flowId, 'wizard_context')
}
confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow {
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
this.repo.setModeConfig(flowId, { ...config, contextBinding: payload.binding })
this.repo.updateStep(flowId, 'wizard_preview', {
contextJson: JSON.stringify(payload)
})
return this.repo.get(flowId)!
}
async buildPreview(flowId: string, contextText: string, povName: string): Promise<string> {
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
const messages = buildWizardPreviewMessages(config, contextText, povName)
const html = await this.aiClient.chat(messages)
let preview = html.trim().includes('<p>') ? html.trim() : `<p>${html.trim()}</p>`
const binding = config.contextBinding
if (binding?.knowledgeIds?.length) {
const repo = new KnowledgeRepository(this.db)
const titles = binding.knowledgeIds
.map((id) => repo.get(id)?.title)
.filter(Boolean)
if (titles.length) {
preview += `<p><strong>将注入知识:</strong>${titles.join('、')}</p>`
}
}
this.repo.setModeConfig(flowId, { ...config, strategyPreview: preview })
return preview
}
async startGenerate(
flowId: string,
contextText: string,
onChunk: (delta: string, done: boolean) => void,
abortSignal?: AbortSignal
): Promise<InteractiveFlow> {
this.repo.updateStep(flowId, 'wizard_generating')
const wizardConfig = this.repo.getModeConfig<WizardWritingConfig>(flowId)
const autoConfig: AutoWritingConfig = {
chapterGoal: wizardConfig.chapterGoal,
outlineItemIds: wizardConfig.outlineItemIds,
wordMin: wizardConfig.wordMin ?? 2000,
wordMax: wizardConfig.wordMax ?? 4000,
currentPlanIndex: 0
}
this.repo.setModeConfig(flowId, { ...wizardConfig, ...autoConfig })
this.autoService.setContextSummary(flowId, contextText)
await this.autoService.planScenes(flowId, contextText)
let flow = this.repo.get(flowId)!
let config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
const planLen = config.scenePlan?.length ?? 0
while ((config.currentPlanIndex ?? 0) < planLen) {
if (abortSignal?.aborted) break
flow = await this.autoService.generateNext(flowId, contextText, onChunk, abortSignal)
if (flow.step === 'naming_pause') return flow
config = this.repo.getModeConfig<AutoWritingConfig>(flowId)
}
return handoffToInteractive(this.repo, flowId)
}
handoffToInteractive(flowId: string): InteractiveFlow {
return handoffToInteractive(this.repo, flowId)
}
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
return this.autoService.enrichFlow(flow)
}
getPovName(povSettingId?: string): string {
if (!povSettingId) return '主角'
const setting = new SettingRepository(this.db).get(povSettingId)
return setting?.name ?? '主角'
}
}
@@ -0,0 +1,11 @@
import type { InteractiveFlow, WritingStep } from '../../shared/types'
import type { InteractiveRepository } from '../db/repositories/interactive.repo'
export function handoffToInteractive(repo: InteractiveRepository, flowId: string): InteractiveFlow {
const draft = repo.getSceneDraft(flowId)
const scenes = repo.listScenes(flowId)
let step: WritingStep = 'context_confirm'
if (draft.trim()) step = 'scene_review'
else if (scenes.length > 0) step = 'plot_suggest'
return repo.updateStep(flowId, step, { flowMode: 'interactive' })
}
+167 -2
View File
@@ -1,6 +1,8 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron' import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
import { IPC } from '../shared/ipc-channels' import { IPC } from '../shared/ipc-channels'
import type { import type {
KnowledgeSuggestOptions,
ScoredKnowledge,
ActionId, ActionId,
Bookmark, Bookmark,
BookMeta, BookMeta,
@@ -17,6 +19,8 @@ import type {
NamingConflict, NamingConflict,
AiStreamChunkEvent, AiStreamChunkEvent,
AiNetworkStatusEvent, AiNetworkStatusEvent,
AutoRhythm,
AutoWritingConfig,
InteractiveFlow, InteractiveFlow,
InteractiveGateResult, InteractiveGateResult,
InteractiveStreamChunkEvent, InteractiveStreamChunkEvent,
@@ -33,7 +37,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 = {
@@ -89,7 +100,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[]>> =>
@@ -318,6 +335,154 @@ const electronAPI = {
getSceneDraft: (bookId: string, flowId: string): Promise<IpcResult<string>> => getSceneDraft: (bookId: string, flowId: string): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.INTERACTIVE_GET_SCENE_DRAFT, { bookId, flowId }) ipcRenderer.invoke(IPC.INTERACTIVE_GET_SCENE_DRAFT, { bookId, flowId })
}, },
auto: {
checkGate: (bookId: string): Promise<IpcResult<InteractiveGateResult>> =>
ipcRenderer.invoke(IPC.AUTO_CHECK_GATE, { bookId }),
getFlow: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow | null>> =>
ipcRenderer.invoke(IPC.AUTO_GET_FLOW, { bookId, sessionId }),
start: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_START, { bookId, sessionId }),
setGoal: (
bookId: string,
flowId: string,
config: Partial<AutoWritingConfig>
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_SET_GOAL, { bookId, flowId, config }),
confirmContext: (
bookId: string,
flowId: string,
binding: AiContextBinding
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_CONFIRM_CONTEXT, { bookId, flowId, binding }),
planScenes: (
bookId: string,
flowId: string,
contextSummary?: string
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_PLAN_SCENES, { bookId, flowId, contextSummary }),
generate: (
bookId: string,
flowId: string,
contextSummary?: string
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_GENERATE, { bookId, flowId, contextSummary }),
resolveNaming: (
bookId: string,
flowId: string,
chosenName: string,
contextSummary?: string
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_RESOLVE_NAMING, { bookId, flowId, chosenName, contextSummary }),
pause: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_PAUSE, { bookId, flowId }),
resume: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_RESUME, { bookId, flowId }),
handoffInteractive: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.AUTO_HANDOFF_INTERACTIVE, { bookId, flowId }),
finishChapter: (
bookId: string,
flowId: string,
volumeId: string,
title?: string
): Promise<IpcResult<{ chapterId: string }>> =>
ipcRenderer.invoke(IPC.AUTO_FINISH_CHAPTER, { bookId, flowId, volumeId, title }),
abort: (flowId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.AUTO_ABORT, { flowId }),
getSceneDraft: (bookId: string, flowId: string): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.AUTO_GET_SCENE_DRAFT, { bookId, flowId })
},
wizard: {
getFlow: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow | null>> =>
ipcRenderer.invoke(IPC.WIZARD_GET_FLOW, { bookId, sessionId }),
start: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_START, { bookId, sessionId }),
setGoal: (
bookId: string,
flowId: string,
config: Partial<AutoWritingConfig>
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_SET_GOAL, { bookId, flowId, config }),
setRhythm: (
bookId: string,
flowId: string,
rhythm: AutoRhythm,
styleNote?: string
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_SET_RHYTHM, { bookId, flowId, rhythm, styleNote }),
setPov: (bookId: string, flowId: string, povSettingId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_SET_POV, { bookId, flowId, povSettingId }),
confirmContext: (
bookId: string,
flowId: string,
binding: AiContextBinding
): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_CONFIRM_CONTEXT, { bookId, flowId, binding }),
buildPreview: (
bookId: string,
flowId: string
): Promise<IpcResult<{ preview: string; flow: InteractiveFlow }>> =>
ipcRenderer.invoke(IPC.WIZARD_BUILD_PREVIEW, { bookId, flowId }),
startGenerate: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_START_GENERATE, { bookId, flowId }),
handoffInteractive: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
ipcRenderer.invoke(IPC.WIZARD_HANDOFF_INTERACTIVE, { bookId, flowId }),
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 }),
suggestContext: (
bookId: string,
opts?: KnowledgeSuggestOptions
): Promise<IpcResult<ScoredKnowledge[]>> =>
ipcRenderer.invoke(IPC.KNOWLEDGE_SUGGEST_CONTEXT, { bookId, opts })
},
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,
+75 -17
View File
@@ -10,7 +10,11 @@ import { insertToEditor } from '@renderer/lib/editor-commands'
import { ipcCall } from '@renderer/lib/ipc-client' import { ipcCall } from '@renderer/lib/ipc-client'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal' import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel' import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
import { AutoPanel } from '@renderer/components/ai/AutoPanel'
import { WizardPanel } from '@renderer/components/ai/WizardPanel'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore' import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { useAutoStore } from '@renderer/stores/useAutoStore'
import { useWizardStore } from '@renderer/stores/useWizardStore'
const MODES: { id: AiWritingMode; labelKey: string }[] = [ const MODES: { id: AiWritingMode; labelKey: string }[] = [
{ id: 'chat', labelKey: 'ai.mode.chat' }, { id: 'chat', labelKey: 'ai.mode.chat' },
@@ -42,8 +46,16 @@ export function AiPanel(): React.JSX.Element {
const sendMessage = useAiStore((s) => s.sendMessage) const sendMessage = useAiStore((s) => s.sendMessage)
const setWritingMode = useAiStore((s) => s.setWritingMode) const setWritingMode = useAiStore((s) => s.setWritingMode)
const gateEligible = useInteractiveStore((s) => s.gateEligible) const gateEligible = useInteractiveStore((s) => s.gateEligible)
const autoGateEligible = useAutoStore((s) => s.gateEligible)
const wizardGateEligible = useWizardStore((s) => s.gateEligible)
const checkGate = useInteractiveStore((s) => s.checkGate) const checkGate = useInteractiveStore((s) => s.checkGate)
const checkAutoGate = useAutoStore((s) => s.checkGate)
const checkWizardGate = useWizardStore((s) => s.checkGate)
const startInteractive = useInteractiveStore((s) => s.start) const startInteractive = useInteractiveStore((s) => s.start)
const startAuto = useAutoStore((s) => s.start)
const startWizard = useWizardStore((s) => s.start)
const loadAutoFlow = useAutoStore((s) => s.loadFlow)
const loadWizardFlow = useWizardStore((s) => s.loadFlow)
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [contextOpen, setContextOpen] = useState(false) const [contextOpen, setContextOpen] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -65,8 +77,10 @@ export function AiPanel(): React.JSX.Element {
if (bookId) { if (bookId) {
void loadSessions(bookId) void loadSessions(bookId)
void checkGate(bookId) void checkGate(bookId)
void checkAutoGate(bookId)
void checkWizardGate(bookId)
} }
}, [bookId, loadSessions, checkGate]) }, [bookId, loadSessions, checkGate, checkAutoGate, checkWizardGate])
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -93,31 +107,62 @@ export function AiPanel(): React.JSX.Element {
} }
} }
const handleModeClick = (mode: AiWritingMode): void => { const enterWritingMode = (mode: 'interactive' | 'auto' | 'wizard'): void => {
if (mode === 'auto' || mode === 'wizard') {
showToast(t('feature.comingSoon'))
return
}
if (mode === 'interactive') {
void (async () => { void (async () => {
if (bookId) await checkGate(bookId) const gateCheck =
if (!useInteractiveStore.getState().gateEligible) { mode === 'interactive'
showToast(t('interactive.gateBlocked')) ? checkGate
: mode === 'auto'
? checkAutoGate
: checkWizardGate
const gateKey =
mode === 'interactive'
? 'interactive'
: mode === 'auto'
? 'auto'
: 'wizard'
if (bookId) await gateCheck(bookId)
const eligible =
mode === 'interactive'
? useInteractiveStore.getState().gateEligible
: mode === 'auto'
? useAutoStore.getState().gateEligible
: useWizardStore.getState().gateEligible
if (!eligible) {
showToast(t(`${gateKey}.gateBlocked`))
return return
} }
setWritingMode(mode) setWritingMode(mode)
const sid = activeSessionId ?? useAiStore.getState().activeSessionId
if (!bookId) return if (!bookId) return
if (!sid) { if (!useAiStore.getState().activeSessionId) {
await createSession(bookId) await createSession(bookId)
} }
const sessionId = useAiStore.getState().activeSessionId const sessionId = useAiStore.getState().activeSessionId
if (!sessionId) return if (!sessionId) return
if (mode === 'interactive') {
await useInteractiveStore.getState().loadFlow(bookId, sessionId) await useInteractiveStore.getState().loadFlow(bookId, sessionId)
if (!useInteractiveStore.getState().flow) { if (!useInteractiveStore.getState().flow) await startInteractive(bookId, sessionId)
await startInteractive(bookId, sessionId) } else if (mode === 'auto') {
await loadAutoFlow(bookId, sessionId)
if (!useAutoStore.getState().flow) await startAuto(bookId, sessionId)
} else {
await loadWizardFlow(bookId, sessionId)
if (!useWizardStore.getState().flow) await startWizard(bookId, sessionId)
} }
})() })()
}
const handleModeClick = (mode: AiWritingMode): void => {
if (mode === 'interactive') {
enterWritingMode('interactive')
return
}
if (mode === 'auto') {
enterWritingMode('auto')
return
}
if (mode === 'wizard') {
enterWritingMode('wizard')
return return
} }
setWritingMode(mode) setWritingMode(mode)
@@ -159,15 +204,24 @@ export function AiPanel(): React.JSX.Element {
<div className="ai-mode-tabs" role="tablist"> <div className="ai-mode-tabs" role="tablist">
{MODES.map((mode) => { {MODES.map((mode) => {
const interactiveDisabled = mode.id === 'interactive' && !gateEligible const modeGate =
mode.id === 'interactive'
? gateEligible
: mode.id === 'auto'
? autoGateEligible
: mode.id === 'wizard'
? wizardGateEligible
: true
const writingDisabled =
(mode.id === 'interactive' || mode.id === 'auto' || mode.id === 'wizard') && !modeGate
return ( return (
<button <button
key={mode.id} key={mode.id}
type="button" type="button"
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`} className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
data-testid={`ai-mode-tab-${mode.id}`} data-testid={`ai-mode-tab-${mode.id}`}
disabled={interactiveDisabled} disabled={writingDisabled}
title={interactiveDisabled ? t('interactive.gateTooltip') : undefined} title={writingDisabled ? t(`${mode.id}.gateTooltip`) : undefined}
onClick={() => handleModeClick(mode.id)} onClick={() => handleModeClick(mode.id)}
> >
{t(mode.labelKey)} {t(mode.labelKey)}
@@ -178,6 +232,10 @@ export function AiPanel(): React.JSX.Element {
{writingMode === 'interactive' ? ( {writingMode === 'interactive' ? (
<InteractivePanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} /> <InteractivePanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
) : writingMode === 'auto' ? (
<AutoPanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
) : writingMode === 'wizard' ? (
<WizardPanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
) : ( ) : (
<> <>
<button <button
+314
View File
@@ -0,0 +1,314 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AutoWritingConfig, OutlineItem, ScoredKnowledge } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAutoStore } from '@renderer/stores/useAutoStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
interface AutoPanelProps {
bookId: string
sessionId: string | null
disabled: boolean
}
export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): React.JSX.Element {
const { t } = useTranslation()
const {
flow,
streaming,
streamBuffer,
sceneDraft,
loadFlow,
start,
setGoal,
planScenes,
generateNext,
resolveNaming,
pause,
resume,
handoffInteractive,
finishChapter,
mount,
unmount
} = useAutoStore()
const [goal, setGoalText] = useState('')
const [wordMin, setWordMin] = useState(2000)
const [wordMax, setWordMax] = useState(4000)
const [outlineIds, setOutlineIds] = useState<string[]>([])
const [outlines, setOutlines] = useState<OutlineItem[]>([])
const [chapterTitle, setChapterTitle] = useState('')
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([])
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
const sessions = useAiStore((s) => s.sessions)
const activeSessionId = useAiStore((s) => s.activeSessionId)
const goalText = useMemo(() => {
const outlineText = outlines
.filter((o) => outlineIds.includes(o.id))
.map((o) => o.description ?? '')
.join(' ')
return [goal.trim(), outlineText].filter(Boolean).join(' ')
}, [goal, outlineIds, outlines])
useEffect(() => {
mount()
return () => unmount()
}, [mount, unmount])
useEffect(() => {
if (bookId && sessionId) void loadFlow(bookId, sessionId)
}, [bookId, sessionId, loadFlow])
useEffect(() => {
if (!bookId) return
void ipcCall(() => window.electronAPI.outline.list(bookId)).then(setOutlines)
}, [bookId])
useEffect(() => {
if (!bookId || !sessionId || !knowledgeAutoSuggest) return
const session = sessions.find((s) => s.id === sessionId)
const existing = session?.context.knowledgeIds ?? []
setKnowledgeIds(existing)
void ipcCall(() =>
window.electronAPI.knowledge.suggestContext(bookId, {
currentChapterId: selectedChapterId ?? undefined,
goalText: goalText || undefined
})
).then((items) => {
setSuggested(items)
const merged = [...new Set([...existing, ...items.map((x) => x.entry.id)])]
setKnowledgeIds(merged)
if (!session) return
void ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
context: { ...session.context, knowledgeIds: merged }
})
).then((updated) => {
useAiStore.setState((s) => ({
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
}))
})
})
}, [bookId, sessionId, knowledgeAutoSuggest, selectedChapterId, goalText, sessions])
const toggleKnowledge = (id: string): void => {
if (!bookId || !activeSessionId) return
const session = sessions.find((s) => s.id === activeSessionId)
if (!session) return
const next = knowledgeIds.includes(id)
? knowledgeIds.filter((x) => x !== id)
: [...knowledgeIds, id]
setKnowledgeIds(next)
void ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, activeSessionId, {
context: { ...session.context, knowledgeIds: next }
})
).then((updated) => {
useAiStore.setState((s) => ({
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
}))
})
}
if (!sessionId || !flow) {
return (
<div className="auto-panel" data-testid="auto-panel">
<p className="placeholder-box">{t('ai.noSession')}</p>
</div>
)
}
const step = flow.step
const previewHtml = streaming ? streamBuffer : sceneDraft
const config = flow.modeConfig as AutoWritingConfig
const submitGoal = (): void => {
const payload: Partial<AutoWritingConfig> = {
chapterGoal: goal.trim(),
outlineItemIds: outlineIds,
wordMin,
wordMax
}
void setGoal(bookId, payload)
}
const toggleOutline = (id: string): void => {
setOutlineIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
}
return (
<div className="auto-panel interactive-panel" data-testid="auto-panel">
<div className="interactive-step-bar" data-testid="auto-step-indicator">
{t('auto.step.goal')} {t('auto.step.plan')} {t('auto.step.generate')} {t('auto.step.done')}
</div>
{(step === 'goal_setup' || step === 'scene_plan') && (
<div className="interactive-section">
<textarea
className="form-control"
data-testid="auto-goal-input"
placeholder={t('auto.goalPlaceholder')}
value={goal}
onChange={(e) => setGoalText(e.target.value)}
rows={3}
/>
<div className="auto-outline-picker" data-testid="auto-outline-picker">
{outlines.map((o) => (
<label key={o.id} className="auto-outline-option">
<input
type="checkbox"
checked={outlineIds.includes(o.id)}
onChange={() => toggleOutline(o.id)}
/>
{o.title}
</label>
))}
</div>
<div className="auto-word-range">
<input
type="number"
data-testid="auto-word-min"
value={wordMin}
onChange={(e) => setWordMin(Number(e.target.value))}
/>
<span></span>
<input
type="number"
data-testid="auto-word-max"
value={wordMax}
onChange={(e) => setWordMax(Number(e.target.value))}
/>
</div>
{knowledgeAutoSuggest && (
<details className="auto-knowledge-section" data-testid="auto-knowledge-suggest">
<summary>{t('interactive.confirmKnowledge')}</summary>
<KnowledgeSuggestList
items={suggested}
selectedIds={knowledgeIds}
onToggle={toggleKnowledge}
/>
</details>
)}
<button
type="button"
className="btn primary"
data-testid="auto-plan-scenes"
disabled={disabled || streaming || !goal.trim()}
onClick={() => {
void (async () => {
await setGoal(bookId, { chapterGoal: goal.trim(), outlineItemIds: outlineIds, wordMin, wordMax })
await planScenes(bookId)
})()
}}
>
{t('auto.planScenes')}
</button>
</div>
)}
{(step === 'auto_generate' || step === 'paused' || step === 'naming_pause') && (
<div className="interactive-section">
{config.scenePlan && (
<p className="auto-plan-summary">
{t('auto.sceneProgress', {
current: config.currentPlanIndex ?? 0,
total: config.scenePlan.length
})}
</p>
)}
<div
className="interactive-scene-preview"
data-testid="auto-scene-preview"
dangerouslySetInnerHTML={{ __html: previewHtml || '<p>…</p>' }}
/>
{step === 'naming_pause' && flow.namingPending && (
<div className="naming-options">
{flow.namingPending.options.map((name, i) => (
<button
key={name}
type="button"
className="btn"
data-testid={`naming-option-${i}`}
disabled={disabled || streaming}
onClick={() => void resolveNaming(bookId, name)}
>
{name}
</button>
))}
</div>
)}
<div className="interactive-actions">
{step === 'auto_generate' && (
<>
<button
type="button"
className="btn"
data-testid="auto-pause"
disabled={disabled}
onClick={() => void pause(bookId)}
>
{t('auto.pause')}
</button>
<button
type="button"
className="btn primary"
data-testid="auto-start-generate"
disabled={disabled || streaming}
onClick={() => void generateNext(bookId)}
>
{t('auto.startGenerate')}
</button>
</>
)}
{step === 'paused' && (
<>
<button
type="button"
className="btn"
disabled={disabled || streaming}
onClick={() => void resume(bookId)}
>
{t('auto.resume')}
</button>
<button
type="button"
className="btn"
data-testid="auto-handoff-interactive"
disabled={disabled}
onClick={() => void handoffInteractive(bookId)}
>
{t('auto.handoffInteractive')}
</button>
</>
)}
</div>
{flow.scenes.length > 0 && (
<div className="interactive-finish">
<input
className="form-control"
value={chapterTitle}
onChange={(e) => setChapterTitle(e.target.value)}
placeholder={t('interactive.chapterTitlePlaceholder')}
/>
<button
type="button"
className="btn primary"
data-testid="auto-finish-chapter"
disabled={disabled || streaming}
onClick={() => void finishChapter(bookId, chapterTitle.trim() || undefined)}
>
{t('auto.finishChapter')}
</button>
</div>
)}
</div>
)}
</div>
)
}
+106 -11
View File
@@ -1,45 +1,80 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { AiContextBinding } from '@shared/types' import type { AiContextBinding, KnowledgeEntry, ScoredKnowledge } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore' import { useBookStore } from '@renderer/stores/useBookStore'
import { useAiStore } from '@renderer/stores/useAiStore' import { useAiStore } from '@renderer/stores/useAiStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { ipcCall } from '@renderer/lib/ipc-client' import { ipcCall } from '@renderer/lib/ipc-client'
import { formatContextSummary } from '@renderer/lib/ai-context-summary' import { formatContextSummary } from '@renderer/lib/ai-context-summary'
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
type ContextTab = 'chapter' | 'outline' | 'setting' | 'inspiration' type ContextTab = 'chapter' | 'outline' | 'setting' | 'inspiration' | 'knowledge'
interface ContextEditorModalProps { interface ContextEditorModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
goalText?: string
} }
const EMPTY_BINDING: AiContextBinding = { const EMPTY_BINDING: AiContextBinding = {
chapterIds: [], chapterIds: [],
outlineIds: [], outlineIds: [],
settingIds: [], settingIds: [],
inspirationIds: [] inspirationIds: [],
knowledgeIds: []
} }
export function ContextEditorModal({ open, onClose }: ContextEditorModalProps): React.JSX.Element | null { export function ContextEditorModal({
open,
onClose,
goalText = ''
}: ContextEditorModalProps): React.JSX.Element | null {
const { t } = useTranslation() const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId) const bookId = useBookStore((s) => s.currentBookId)
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
const chapters = useBookStore((s) => s.chapters) const chapters = useBookStore((s) => s.chapters)
const outlines = useBookStore((s) => s.outlines) const outlines = useBookStore((s) => s.outlines)
const settings = useBookStore((s) => s.settings) const settings = useBookStore((s) => s.settings)
const inspirations = useBookStore((s) => s.inspirations) const inspirations = useBookStore((s) => s.inspirations)
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
const activeSessionId = useAiStore((s) => s.activeSessionId) const activeSessionId = useAiStore((s) => s.activeSessionId)
const sessions = useAiStore((s) => s.sessions) const sessions = useAiStore((s) => s.sessions)
const [tab, setTab] = useState<ContextTab>('setting') const [tab, setTab] = useState<ContextTab>('setting')
const [binding, setBinding] = useState<AiContextBinding>(EMPTY_BINDING) const [binding, setBinding] = useState<AiContextBinding>(EMPTY_BINDING)
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
const [approvedKnowledge, setApprovedKnowledge] = useState<KnowledgeEntry[]>([])
const loadSuggestions = useCallback(async (): Promise<void> => {
if (!bookId || !knowledgeAutoSuggest) {
setSuggested([])
return
}
const items = await ipcCall(() =>
window.electronAPI.knowledge.suggestContext(bookId, {
currentChapterId: selectedChapterId ?? undefined,
goalText: goalText || undefined
})
)
setSuggested(items)
setBinding((prev) => {
const merged = new Set([...prev.knowledgeIds, ...items.map((x) => x.entry.id)])
return { ...prev, knowledgeIds: [...merged] }
})
}, [bookId, goalText, knowledgeAutoSuggest, selectedChapterId])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const session = sessions.find((s) => s.id === activeSessionId) const session = sessions.find((s) => s.id === activeSessionId)
setBinding(session?.context ?? EMPTY_BINDING) setBinding(session?.context ?? EMPTY_BINDING)
}, [open, activeSessionId, sessions]) if (!bookId) return
void ipcCall(() =>
window.electronAPI.knowledge.list(bookId, { status: 'approved' })
).then(setApprovedKnowledge)
void loadSuggestions()
}, [open, activeSessionId, sessions, bookId, loadSuggestions])
const toggle = (kind: ContextTab, id: string): void => { const toggle = (kind: Exclude<ContextTab, 'knowledge'>, id: string): void => {
const key = const key =
kind === 'chapter' kind === 'chapter'
? 'chapterIds' ? 'chapterIds'
@@ -57,6 +92,15 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
}) })
} }
const toggleKnowledge = (id: string): void => {
setBinding((prev) => ({
...prev,
knowledgeIds: prev.knowledgeIds.includes(id)
? prev.knowledgeIds.filter((x) => x !== id)
: [...prev.knowledgeIds, id]
}))
}
const handleSave = async (): Promise<void> => { const handleSave = async (): Promise<void> => {
if (!bookId || !activeSessionId) return if (!bookId || !activeSessionId) return
const updated = await ipcCall(() => const updated = await ipcCall(() =>
@@ -87,7 +131,9 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
? outlines.map((o) => ({ id: o.id, title: o.title })) ? outlines.map((o) => ({ id: o.id, title: o.title }))
: tab === 'setting' : tab === 'setting'
? settings.map((s) => ({ id: s.id, title: s.name })) ? settings.map((s) => ({ id: s.id, title: s.name }))
: inspirations.map((i) => ({ id: i.id, title: i.title || t('inspiration.untitled') })) : tab === 'inspiration'
? inspirations.map((i) => ({ id: i.id, title: i.title || t('inspiration.untitled') }))
: approvedKnowledge.map((k) => ({ id: k.id, title: k.title }))
const selectedIds = const selectedIds =
tab === 'chapter' tab === 'chapter'
@@ -96,7 +142,9 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
? binding.outlineIds ? binding.outlineIds
: tab === 'setting' : tab === 'setting'
? binding.settingIds ? binding.settingIds
: binding.inspirationIds : tab === 'inspiration'
? binding.inspirationIds
: binding.knowledgeIds
return ( return (
<div className="modal-overlay" data-testid="context-editor-modal"> <div className="modal-overlay" data-testid="context-editor-modal">
@@ -108,7 +156,7 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
</button> </button>
</div> </div>
<div className="context-editor-tabs"> <div className="context-editor-tabs">
{(['chapter', 'outline', 'setting', 'inspiration'] as const).map((id) => ( {(['chapter', 'outline', 'setting', 'inspiration', 'knowledge'] as const).map((id) => (
<button <button
key={id} key={id}
type="button" type="button"
@@ -120,18 +168,65 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
</button> </button>
))} ))}
</div> </div>
{tab === 'knowledge' && (
<div className="context-knowledge-suggest">
<div className="context-knowledge-suggest-header">
<span>{t('ai.contextKnowledgeSuggestTitle')}</span>
<button
type="button"
className="btn btn-sm"
data-testid="context-refresh-suggest"
onClick={() => void loadSuggestions()}
>
{t('ai.contextRefreshSuggest')}
</button>
</div>
<KnowledgeSuggestList
items={suggested}
selectedIds={binding.knowledgeIds}
onToggle={toggleKnowledge}
testId="context-knowledge-suggest"
/>
</div>
)}
<div className="context-editor-list"> <div className="context-editor-list">
{tab === 'knowledge' ? (
<>
<div className="context-knowledge-all-title">{t('ai.contextKnowledgeAllTitle')}</div>
{approvedKnowledge.length === 0 && (
<div className="sidebar-empty">{t('ai.contextKnowledgeEmpty')}</div>
)}
{approvedKnowledge.map((entry) => (
<label
key={entry.id}
className="context-editor-item"
data-testid={`context-knowledge-item-${entry.id}`}
>
<input
type="checkbox"
checked={binding.knowledgeIds.includes(entry.id)}
onChange={() => toggleKnowledge(entry.id)}
/>
<span>{entry.title}</span>
<span className="knowledge-suggest-type">{t(`knowledge.type.${entry.type}`)}</span>
</label>
))}
</>
) : (
<>
{items.length === 0 && <div className="sidebar-empty">{t('ai.contextEmptyList')}</div>} {items.length === 0 && <div className="sidebar-empty">{t('ai.contextEmptyList')}</div>}
{items.map((item) => ( {items.map((item) => (
<label key={item.id} className="context-editor-item" data-testid={`context-item-${item.id}`}> <label key={item.id} className="context-editor-item" data-testid={`context-item-${item.id}`}>
<input <input
type="checkbox" type="checkbox"
checked={selectedIds.includes(item.id)} checked={selectedIds.includes(item.id)}
onChange={() => toggle(tab, item.id)} onChange={() => toggle(tab as Exclude<ContextTab, 'knowledge'>, item.id)}
/> />
<span>{item.title}</span> <span>{item.title}</span>
</label> </label>
))} ))}
</>
)}
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn" data-testid="context-refresh" onClick={() => void handleRefresh()}> <button type="button" className="btn" data-testid="context-refresh" onClick={() => void handleRefresh()}>
@@ -1,10 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { PlotSelection } from '@shared/types' import type { PlotSelection, ScoredKnowledge } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore' import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore' import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal' import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
import { PlotOptionCard } from '@renderer/components/ai/PlotOptionCard' import { PlotOptionCard } from '@renderer/components/ai/PlotOptionCard'
import { ipcCall } from '@renderer/lib/ipc-client'
interface InteractivePanelProps { interface InteractivePanelProps {
bookId: string bookId: string
@@ -19,6 +23,9 @@ export function InteractivePanel({
}: InteractivePanelProps): React.JSX.Element { }: InteractivePanelProps): React.JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const backend = useSettingsStore((s) => s.aiConfig.backend) const backend = useSettingsStore((s) => s.aiConfig.backend)
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
const sessions = useAiStore((s) => s.sessions)
const { const {
flow, flow,
plots, plots,
@@ -37,6 +44,8 @@ export function InteractivePanel({
} = useInteractiveStore() } = useInteractiveStore()
const [contextOpen, setContextOpen] = useState(false) const [contextOpen, setContextOpen] = useState(false)
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([])
const [selectedPlots, setSelectedPlots] = useState<Array<'A' | 'B' | 'C'>>([]) const [selectedPlots, setSelectedPlots] = useState<Array<'A' | 'B' | 'C'>>([])
const [userNote, setUserNote] = useState('') const [userNote, setUserNote] = useState('')
const [refineInput, setRefineInput] = useState('') const [refineInput, setRefineInput] = useState('')
@@ -52,6 +61,33 @@ export function InteractivePanel({
if (bookId && sessionId) void loadFlow(bookId, sessionId) if (bookId && sessionId) void loadFlow(bookId, sessionId)
}, [bookId, sessionId, loadFlow]) }, [bookId, sessionId, loadFlow])
const step = flow?.step
useEffect(() => {
if (!bookId || !sessionId || step !== 'context_confirm' || !knowledgeAutoSuggest) return
const session = sessions.find((s) => s.id === sessionId)
const existing = session?.context.knowledgeIds ?? []
setKnowledgeIds(existing)
void ipcCall(() =>
window.electronAPI.knowledge.suggestContext(bookId, {
currentChapterId: selectedChapterId ?? undefined
})
).then((items) => {
setSuggested(items)
const merged = [...new Set([...existing, ...items.map((x) => x.entry.id)])]
setKnowledgeIds(merged)
if (!session) return
void ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
context: { ...session.context, knowledgeIds: merged }
})
).then((updated) => {
useAiStore.setState((s) => ({
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
}))
})
})
}, [bookId, sessionId, step, knowledgeAutoSuggest, selectedChapterId, sessions])
if (!sessionId || !flow) { if (!sessionId || !flow) {
return ( return (
<div className="interactive-panel" data-testid="interactive-panel"> <div className="interactive-panel" data-testid="interactive-panel">
@@ -60,13 +96,31 @@ export function InteractivePanel({
) )
} }
const step = flow.step
const previewHtml = streaming ? streamBuffer : sceneDraft const previewHtml = streaming ? streamBuffer : sceneDraft
const showReview = const showReview =
step === 'scene_review' || step === 'scene_review' ||
(!streaming && sceneDraft.trim().length > 0 && step === 'scene_generate') (!streaming && sceneDraft.trim().length > 0 && step === 'scene_generate')
const showFinishChapter = flow.scenes.length > 0 && flow.status !== 'completed' const showFinishChapter = flow.scenes.length > 0 && flow.status !== 'completed'
const toggleKnowledge = (id: string): void => {
setKnowledgeIds((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
const session = sessions.find((s) => s.id === sessionId)
if (bookId && sessionId && session) {
void ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
context: { ...session.context, knowledgeIds: next }
})
).then((updated) => {
useAiStore.setState((s) => ({
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
}))
})
}
return next
})
}
const handleStart = (): void => { const handleStart = (): void => {
void confirmContextAndStart(bookId, flow.id) void confirmContextAndStart(bookId, flow.id)
} }
@@ -94,6 +148,16 @@ export function InteractivePanel({
{step === 'context_confirm' && ( {step === 'context_confirm' && (
<div className="interactive-section"> <div className="interactive-section">
<p>{flow.contextSummary ?? t('ai.contextEmpty')}</p> <p>{flow.contextSummary ?? t('ai.contextEmpty')}</p>
{knowledgeAutoSuggest && (
<div className="interactive-knowledge-section" data-testid="interactive-knowledge-suggest">
<h4>{t('interactive.confirmKnowledge')}</h4>
<KnowledgeSuggestList
items={suggested}
selectedIds={knowledgeIds}
onToggle={toggleKnowledge}
/>
</div>
)}
<button type="button" className="btn" onClick={() => setContextOpen(true)}> <button type="button" className="btn" onClick={() => setContextOpen(true)}>
{t('ai.contextEditorTitle')} {t('ai.contextEditorTitle')}
</button> </button>
@@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next'
import type { ScoredKnowledge } from '@shared/types'
interface KnowledgeSuggestListProps {
items: ScoredKnowledge[]
selectedIds: string[]
onToggle: (id: string) => void
testId?: string
}
export function KnowledgeSuggestList({
items,
selectedIds,
onToggle,
testId = 'knowledge-suggest-list'
}: KnowledgeSuggestListProps): React.JSX.Element {
const { t } = useTranslation()
if (items.length === 0) {
return <div className="sidebar-empty">{t('ai.contextKnowledgeEmpty')}</div>
}
return (
<div className="knowledge-suggest-list" data-testid={testId}>
{items.map(({ entry, reasons }) => (
<label
key={entry.id}
className="context-editor-item knowledge-suggest-item"
data-testid={`knowledge-suggest-item-${entry.id}`}
>
<input
type="checkbox"
checked={selectedIds.includes(entry.id)}
onChange={() => onToggle(entry.id)}
/>
<span className="knowledge-suggest-title">{entry.title}</span>
<span className="knowledge-suggest-type">{t(`knowledge.type.${entry.type}`)}</span>
{reasons.length > 0 && (
<span className="knowledge-suggest-reasons">
{reasons.map((reason) => (
<span key={reason} className="knowledge-reason-badge">
{t(reason)}
</span>
))}
</span>
)}
</label>
))}
</div>
)
}
+318
View File
@@ -0,0 +1,318 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AutoRhythm, AutoWritingConfig, ScoredKnowledge, SettingEntry, WizardWritingConfig } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useWizardStore } from '@renderer/stores/useWizardStore'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
interface WizardPanelProps {
bookId: string
sessionId: string | null
disabled: boolean
}
const RHYTHMS: AutoRhythm[] = ['relaxed', 'tense', 'mixed']
export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps): React.JSX.Element {
const { t } = useTranslation()
const {
flow,
previewHtml,
streaming,
streamBuffer,
loadFlow,
start,
setGoal,
setRhythm,
setPov,
confirmContext,
buildPreview,
startGenerate,
mount,
unmount
} = useWizardStore()
const [goal, setGoalText] = useState('')
const [wordMin, setWordMin] = useState(2000)
const [wordMax, setWordMax] = useState(4000)
const [rhythm, setRhythmVal] = useState<AutoRhythm>('mixed')
const [styleNote, setStyleNote] = useState('')
const [characters, setCharacters] = useState<SettingEntry[]>([])
const [povId, setPovId] = useState('')
const [contextOpen, setContextOpen] = useState(false)
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([])
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
const sessions = useAiStore((s) => s.sessions)
const goalText = useMemo(
() => [goal.trim(), styleNote.trim()].filter(Boolean).join(' '),
[goal, styleNote]
)
const step = flow?.step
useEffect(() => {
if (!bookId || !sessionId || step !== 'wizard_context' || !knowledgeAutoSuggest) return
const session = sessions.find((s) => s.id === sessionId)
const existing = session?.context.knowledgeIds ?? []
setKnowledgeIds(existing)
void ipcCall(() =>
window.electronAPI.knowledge.suggestContext(bookId, {
currentChapterId: selectedChapterId ?? undefined,
goalText: goalText || undefined
})
).then((items) => {
setSuggested(items)
const merged = [...new Set([...existing, ...items.map((x) => x.entry.id)])]
setKnowledgeIds(merged)
if (!session) return
void ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
context: { ...session.context, knowledgeIds: merged }
})
).then((updated) => {
useAiStore.setState((s) => ({
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
}))
})
})
}, [bookId, sessionId, step, knowledgeAutoSuggest, selectedChapterId, goalText, sessions])
useEffect(() => {
mount()
return () => unmount()
}, [mount, unmount])
useEffect(() => {
if (bookId && sessionId) void loadFlow(bookId, sessionId)
}, [bookId, sessionId, loadFlow])
useEffect(() => {
if (!bookId) return
void ipcCall(() => window.electronAPI.setting.list(bookId)).then((list) => {
setCharacters(list.filter((s) => s.type === 'character'))
})
}, [bookId])
if (!sessionId || !flow) {
return (
<div className="wizard-panel" data-testid="wizard-panel">
<p className="placeholder-box">{t('ai.noSession')}</p>
</div>
)
}
const config = flow.modeConfig as WizardWritingConfig
const toggleKnowledge = (id: string): void => {
if (!bookId || !sessionId) return
const session = sessions.find((s) => s.id === sessionId)
if (!session) return
const next = knowledgeIds.includes(id)
? knowledgeIds.filter((x) => x !== id)
: [...knowledgeIds, id]
setKnowledgeIds(next)
void ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
context: { ...session.context, knowledgeIds: next }
})
).then((updated) => {
useAiStore.setState((s) => ({
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
}))
})
}
const handleConfirmContext = (): void => {
const session = sessions.find((s) => s.id === sessionId)
if (session) void confirmContext(bookId, session.context)
}
const handleContextClose = (): void => {
setContextOpen(false)
const session = useAiStore.getState().sessions.find(
(s) => s.id === useAiStore.getState().activeSessionId
)
if (session) void confirmContext(bookId, session.context)
}
return (
<>
<div className="wizard-panel interactive-panel" data-testid="wizard-panel">
<div className="wizard-step-indicator" data-testid="wizard-step-indicator">
{t('wizard.step.goal')} {t('wizard.step.rhythm')} {t('wizard.step.pov')} {' '}
{t('wizard.step.context')} {t('wizard.step.preview')}
</div>
{step === 'wizard_goal' && (
<div className="interactive-section">
<textarea
className="form-control"
data-testid="wizard-goal-input"
placeholder={t('auto.goalPlaceholder')}
value={goal}
onChange={(e) => setGoalText(e.target.value)}
rows={3}
/>
<div className="auto-word-range">
<input type="number" value={wordMin} onChange={(e) => setWordMin(Number(e.target.value))} />
<span></span>
<input type="number" value={wordMax} onChange={(e) => setWordMax(Number(e.target.value))} />
</div>
<button
type="button"
className="btn primary"
disabled={disabled || !goal.trim()}
onClick={() =>
void setGoal(bookId, {
chapterGoal: goal.trim(),
wordMin,
wordMax
} as Partial<AutoWritingConfig>)
}
>
{t('wizard.next')}
</button>
</div>
)}
{step === 'wizard_rhythm' && (
<div className="interactive-section">
{RHYTHMS.map((r) => (
<button
key={r}
type="button"
className={`btn ${rhythm === r ? 'primary' : ''}`}
data-testid={`wizard-rhythm-${r}`}
disabled={disabled}
onClick={() => setRhythmVal(r)}
>
{t(`wizard.rhythm.${r}`)}
</button>
))}
<input
className="form-control"
data-testid="wizard-style-note"
placeholder={t('wizard.styleNotePlaceholder')}
value={styleNote}
onChange={(e) => setStyleNote(e.target.value)}
/>
<button
type="button"
className="btn primary"
disabled={disabled}
onClick={() => void setRhythm(bookId, rhythm, styleNote)}
>
{t('wizard.next')}
</button>
</div>
)}
{step === 'wizard_pov' && (
<div className="interactive-section">
<select
className="form-control"
data-testid="wizard-pov-select"
value={povId}
onChange={(e) => setPovId(e.target.value)}
>
<option value="">{t('wizard.povDefault')}</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button
type="button"
className="btn primary"
disabled={disabled}
onClick={() => void setPov(bookId, povId || characters[0]?.id || '')}
>
{t('wizard.next')}
</button>
</div>
)}
{step === 'wizard_context' && (
<div className="interactive-section">
<p>{t('wizard.contextHint')}</p>
{knowledgeAutoSuggest && (
<div className="wizard-knowledge-section" data-testid="wizard-knowledge-suggest">
<h4>{t('interactive.confirmKnowledge')}</h4>
<KnowledgeSuggestList
items={suggested}
selectedIds={knowledgeIds}
onToggle={toggleKnowledge}
/>
</div>
)}
<button
type="button"
className="btn"
data-testid="wizard-context-edit"
disabled={disabled}
onClick={() => setContextOpen(true)}
>
{t('ai.contextEditorTitle')}
</button>
<button
type="button"
className="btn primary"
disabled={disabled}
onClick={handleConfirmContext}
>
{t('wizard.next')}
</button>
</div>
)}
{step === 'wizard_preview' && (
<div className="interactive-section">
<h4>{t('wizard.previewTitle')}</h4>
<div
className="wizard-preview"
data-testid="wizard-preview"
dangerouslySetInnerHTML={{
__html: previewHtml || config.strategyPreview || '<p>…</p>'
}}
/>
<button
type="button"
className="btn"
disabled={disabled || streaming}
onClick={() => void buildPreview(bookId)}
>
{t('wizard.refreshPreview')}
</button>
<button
type="button"
className="btn primary"
data-testid="wizard-start-generate"
disabled={disabled || streaming}
onClick={() => void startGenerate(bookId)}
>
{t('wizard.startGenerate')}
</button>
</div>
)}
{step === 'wizard_generating' && (
<div className="interactive-section">
<p>{streaming ? t('ai.sending') : t('wizard.generating')}</p>
<div
className="interactive-scene-preview"
dangerouslySetInnerHTML={{ __html: streamBuffer || '<p>…</p>' }}
/>
</div>
)}
</div>
<ContextEditorModal open={contextOpen} onClose={handleContextClose} goalText={goalText} />
</>
)
}
@@ -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)} />
</> </>
) )
+108 -3
View File
@@ -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,8 @@ import { useTranslation } from 'react-i18next'
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 { useInteractiveStore } from '@renderer/stores/useInteractiveStore' import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { useAutoStore } from '@renderer/stores/useAutoStore'
import { useWizardStore } from '@renderer/stores/useWizardStore'
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel' import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
import { AiPanel } from '@renderer/components/ai/AiPanel' import { AiPanel } from '@renderer/components/ai/AiPanel'
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel' import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
@@ -29,7 +31,11 @@ export function RightPanel(): React.JSX.Element {
onClick={() => { onClick={() => {
if (tab.id === 'ai') { if (tab.id === 'ai') {
const bookId = useBookStore.getState().currentBookId const bookId = useBookStore.getState().currentBookId
if (bookId) void useInteractiveStore.getState().checkGate(bookId) if (bookId) {
void useInteractiveStore.getState().checkGate(bookId)
void useAutoStore.getState().checkGate(bookId)
void useWizardStore.getState().checkGate(bookId)
}
} }
setRightPanel(tab.id) setRightPanel(tab.id)
}} }}
+16 -1
View File
@@ -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.4.0 {t('app.name')} v0.7.0
<br /> <br />
{t('app.tagline')} {t('app.tagline')}
</p> </p>
+3 -2
View File
@@ -4,13 +4,14 @@ import type { ContextPreviewItem } from '@shared/types'
export function formatContextSummary(preview: ContextPreviewItem[], t: TFunction): string { export function formatContextSummary(preview: ContextPreviewItem[], t: TFunction): string {
if (preview.length === 0) return t('ai.contextEmpty') if (preview.length === 0) return t('ai.contextEmpty')
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0 } const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 }
for (const item of preview) counts[item.kind]++ for (const item of preview) counts[item.kind]++
return t('ai.contextSummary', { return t('ai.contextSummary', {
chapters: counts.chapter, chapters: counts.chapter,
outlines: counts.outline, outlines: counts.outline,
settings: counts.setting, settings: counts.setting,
inspirations: counts.inspiration inspirations: counts.inspiration,
knowledge: counts.knowledge
}) })
} }
+196
View File
@@ -0,0 +1,196 @@
import { create } from 'zustand'
import type { AutoWritingConfig, InteractiveFlow } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import i18n from '@renderer/i18n'
interface AutoState {
gateEligible: boolean
flow: InteractiveFlow | null
streamBuffer: string
streaming: boolean
sceneDraft: string
listenersAttached: boolean
checkGate: (bookId: string) => Promise<void>
loadFlow: (bookId: string, sessionId: string) => Promise<void>
start: (bookId: string, sessionId: string) => Promise<void>
setGoal: (bookId: string, config: Partial<AutoWritingConfig>) => Promise<void>
confirmContext: (bookId: string) => Promise<void>
planScenes: (bookId: string) => Promise<void>
generateNext: (bookId: string) => Promise<void>
resolveNaming: (bookId: string, chosenName: string) => Promise<void>
pause: (bookId: string) => Promise<void>
resume: (bookId: string) => Promise<void>
handoffInteractive: (bookId: string) => Promise<void>
finishChapter: (bookId: string, title?: string) => Promise<void>
refreshSceneDraft: (bookId: string) => Promise<void>
mount: () => void
unmount: () => void
}
let unsubStream: (() => void) | null = null
export const useAutoStore = create<AutoState>((set, get) => ({
gateEligible: false,
flow: null,
streamBuffer: '',
streaming: false,
sceneDraft: '',
listenersAttached: false,
checkGate: async (bookId) => {
const r = await ipcCall(() => window.electronAPI.auto.checkGate(bookId))
set({ gateEligible: r.eligible })
},
loadFlow: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.auto.getFlow(bookId, sessionId))
set({ flow })
if (flow) await get().refreshSceneDraft(bookId)
},
start: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.auto.start(bookId, sessionId))
set({ flow, streamBuffer: '', sceneDraft: '' })
},
setGoal: async (bookId, config) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.auto.setGoal(bookId, flowId, config))
set({ flow })
},
confirmContext: async (bookId) => {
const flowId = get().flow?.id
const session = useAiStore.getState().sessions.find(
(s) => s.id === useAiStore.getState().activeSessionId
)
if (!flowId || !session) return
const flow = await ipcCall(() =>
window.electronAPI.auto.confirmContext(bookId, flowId, session.context)
)
set({ flow })
},
planScenes: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true })
try {
await get().confirmContext(bookId)
const summary = useAiStore.getState().contextSummary
const flow = await ipcCall(() =>
window.electronAPI.auto.planScenes(bookId, flowId, summary)
)
set({ flow })
} finally {
set({ streaming: false })
}
},
generateNext: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true, streamBuffer: '' })
try {
const summary = useAiStore.getState().contextSummary
const flow = await ipcCall(() =>
window.electronAPI.auto.generate(bookId, flowId, summary)
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
resolveNaming: async (bookId, chosenName) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true, streamBuffer: '' })
try {
const summary = useAiStore.getState().contextSummary
const flow = await ipcCall(() =>
window.electronAPI.auto.resolveNaming(bookId, flowId, chosenName, summary)
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
pause: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.auto.pause(bookId, flowId))
set({ flow, streaming: false, streamBuffer: '' })
},
resume: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.auto.resume(bookId, flowId))
set({ flow })
},
handoffInteractive: async (bookId) => {
const flowId = get().flow?.id
const sessionId = useAiStore.getState().activeSessionId
if (!flowId || !sessionId) return
const flow = await ipcCall(() => window.electronAPI.auto.handoffInteractive(bookId, flowId))
set({ flow, streaming: false, streamBuffer: '' })
useAiStore.getState().setWritingMode('interactive')
useAppStore.getState().showToast(i18n.t('wizard.handoffToast'))
await useInteractiveStore.getState().loadFlow(bookId, sessionId)
const interactiveFlow = useInteractiveStore.getState().flow
if (interactiveFlow?.step === 'plot_suggest') {
await useInteractiveStore.getState().suggestPlots(bookId)
}
},
finishChapter: async (bookId, title) => {
const flowId = get().flow?.id
const volumeId = useBookStore.getState().activeVolumeId
if (!flowId || !volumeId) throw new Error('no volume')
const { chapterId } = await ipcCall(() =>
window.electronAPI.auto.finishChapter(bookId, flowId, volumeId, title)
)
await useBookStore.getState().refreshChapters()
await useBookStore.getState().openBook(bookId)
useBookStore.getState().setSelectedChapter(chapterId)
useEditStore.getState().setTarget({ kind: 'chapter', id: chapterId })
useAppStore.getState().setView('editor')
useAppStore.getState().showToast(i18n.t('auto.chapterCreated'))
},
refreshSceneDraft: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const draft = await ipcCall(() => window.electronAPI.auto.getSceneDraft(bookId, flowId))
set({ sceneDraft: draft })
},
mount: () => {
if (get().listenersAttached) return
unsubStream = window.electronAPI.onInteractiveStreamChunk((payload) => {
if (payload.flowMode !== 'auto' || payload.done) return
set((s) => ({
streaming: true,
streamBuffer: s.streamBuffer + payload.delta
}))
})
set({ listenersAttached: true })
},
unmount: () => {
unsubStream?.()
unsubStream = null
set({ listenersAttached: false })
}
}))
+11
View File
@@ -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)
+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 })
}))
+2 -1
View File
@@ -67,7 +67,8 @@ export const useInteractiveStore = create<InteractiveState>((set, get) => ({
chapterIds: [], chapterIds: [],
outlineIds: [], outlineIds: [],
settingIds: [], settingIds: [],
inspirationIds: [] inspirationIds: [],
knowledgeIds: []
} }
const flow = await ipcCall(() => const flow = await ipcCall(() =>
window.electronAPI.interactive.confirmContext(bookId, flowId, binding) window.electronAPI.interactive.confirmContext(bookId, flowId, binding)
+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
}
}))
+4
View File
@@ -17,9 +17,13 @@ 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[],
knowledgeAutoSuggest: true,
knowledgeSuggestTopN: 8,
loaded: false, loaded: false,
load: async () => { load: async () => {
const data = await ipcCall(() => window.electronAPI.settings.get()) const data = await ipcCall(() => window.electronAPI.settings.get())
+144
View File
@@ -0,0 +1,144 @@
import { create } from 'zustand'
import type {
AiContextBinding,
AutoRhythm,
AutoWritingConfig,
InteractiveFlow,
WizardWritingConfig
} from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import i18n from '@renderer/i18n'
interface WizardState {
gateEligible: boolean
flow: InteractiveFlow | null
previewHtml: string
streaming: boolean
streamBuffer: string
listenersAttached: boolean
checkGate: (bookId: string) => Promise<void>
loadFlow: (bookId: string, sessionId: string) => Promise<void>
start: (bookId: string, sessionId: string) => Promise<void>
setGoal: (bookId: string, config: Partial<AutoWritingConfig>) => Promise<void>
setRhythm: (bookId: string, rhythm: AutoRhythm, styleNote?: string) => Promise<void>
setPov: (bookId: string, povSettingId: string) => Promise<void>
confirmContext: (bookId: string, binding: AiContextBinding) => Promise<void>
buildPreview: (bookId: string) => Promise<void>
startGenerate: (bookId: string) => Promise<void>
mount: () => void
unmount: () => void
}
let unsubStream: (() => void) | null = null
export const useWizardStore = create<WizardState>((set, get) => ({
gateEligible: false,
flow: null,
previewHtml: '',
streaming: false,
streamBuffer: '',
listenersAttached: false,
checkGate: async (bookId) => {
const r = await ipcCall(() => window.electronAPI.auto.checkGate(bookId))
set({ gateEligible: r.eligible })
},
loadFlow: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.wizard.getFlow(bookId, sessionId))
const config = flow?.modeConfig as WizardWritingConfig | undefined
set({
flow,
previewHtml: config?.strategyPreview ?? ''
})
},
start: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.wizard.start(bookId, sessionId))
set({ flow, previewHtml: '', streamBuffer: '' })
},
setGoal: async (bookId, config) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.wizard.setGoal(bookId, flowId, config))
set({ flow })
},
setRhythm: async (bookId, rhythm, styleNote) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() =>
window.electronAPI.wizard.setRhythm(bookId, flowId, rhythm, styleNote)
)
set({ flow })
},
setPov: async (bookId, povSettingId) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.wizard.setPov(bookId, flowId, povSettingId))
set({ flow })
},
confirmContext: async (bookId, binding) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() =>
window.electronAPI.wizard.confirmContext(bookId, flowId, binding)
)
set({ flow })
},
buildPreview: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true })
try {
const { preview, flow } = await ipcCall(() =>
window.electronAPI.wizard.buildPreview(bookId, flowId)
)
set({ previewHtml: preview, flow })
} finally {
set({ streaming: false })
}
},
startGenerate: async (bookId) => {
const flowId = get().flow?.id
const sessionId = useAiStore.getState().activeSessionId
if (!flowId || !sessionId) return
set({ streaming: true, streamBuffer: '' })
try {
const flow = await ipcCall(() => window.electronAPI.wizard.startGenerate(bookId, flowId))
set({ flow })
useAiStore.getState().setWritingMode('interactive')
useAppStore.getState().showToast(i18n.t('wizard.handoffToast'))
await useInteractiveStore.getState().loadFlow(bookId, sessionId)
const interactiveFlow = useInteractiveStore.getState().flow
if (interactiveFlow?.step === 'plot_suggest') {
await useInteractiveStore.getState().suggestPlots(bookId)
}
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
mount: () => {
if (get().listenersAttached) return
unsubStream = window.electronAPI.onInteractiveStreamChunk((payload) => {
if (payload.flowMode !== 'wizard' || payload.done) return
set((s) => ({ streaming: true, streamBuffer: s.streamBuffer + payload.delta }))
})
set({ listenersAttached: true })
},
unmount: () => {
unsubStream?.()
unsubStream = null
set({ listenersAttached: false })
}
}))
+214
View File
@@ -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 {
@@ -1399,6 +1573,46 @@
gap: 8px; gap: 8px;
} }
.auto-outline-picker {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: auto;
font-size: 12px;
}
.auto-outline-option {
display: flex;
align-items: center;
gap: 6px;
}
.auto-word-range {
display: flex;
align-items: center;
gap: 8px;
}
.auto-word-range input {
width: 80px;
}
.wizard-step-indicator {
font-size: 11px;
color: var(--text-muted);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.wizard-preview {
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
}
.plot-options { .plot-options {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+125 -1
View File
@@ -18,6 +18,8 @@ import type {
InteractiveFlow, InteractiveFlow,
InteractiveGateResult, InteractiveGateResult,
InteractiveStreamChunkEvent, InteractiveStreamChunkEvent,
AutoRhythm,
AutoWritingConfig,
PlotOption, PlotOption,
PlotSelection, PlotSelection,
IpcResult, IpcResult,
@@ -31,7 +33,16 @@ import type {
SnapshotType, SnapshotType,
UpdateChapterParams, UpdateChapterParams,
Volume, Volume,
WordFreqResult WordFreqResult,
KnowledgeEntry,
KnowledgeSuggestOptions,
ScoredKnowledge,
CreateKnowledgeInput,
KnowledgeStatus,
KnowledgeType,
CockpitSummary,
ChapterBridgeResult,
PublishStatus
} from './types' } from './types'
export interface ElectronAPI { export interface ElectronAPI {
@@ -75,6 +86,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[]>>
@@ -240,6 +256,114 @@ export interface ElectronAPI {
abort: (flowId: string) => Promise<IpcResult<void>> abort: (flowId: string) => Promise<IpcResult<void>>
getSceneDraft: (bookId: string, flowId: string) => Promise<IpcResult<string>> getSceneDraft: (bookId: string, flowId: string) => Promise<IpcResult<string>>
} }
auto: {
checkGate: (bookId: string) => Promise<IpcResult<InteractiveGateResult>>
getFlow: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow | null>>
start: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow>>
setGoal: (
bookId: string,
flowId: string,
config: Partial<AutoWritingConfig>
) => Promise<IpcResult<InteractiveFlow>>
confirmContext: (
bookId: string,
flowId: string,
binding: AiContextBinding
) => Promise<IpcResult<InteractiveFlow>>
planScenes: (
bookId: string,
flowId: string,
contextSummary?: string
) => Promise<IpcResult<InteractiveFlow>>
generate: (
bookId: string,
flowId: string,
contextSummary?: string
) => Promise<IpcResult<InteractiveFlow>>
resolveNaming: (
bookId: string,
flowId: string,
chosenName: string,
contextSummary?: string
) => Promise<IpcResult<InteractiveFlow>>
pause: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
resume: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
handoffInteractive: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
finishChapter: (
bookId: string,
flowId: string,
volumeId: string,
title?: string
) => Promise<IpcResult<{ chapterId: string }>>
abort: (flowId: string) => Promise<IpcResult<void>>
getSceneDraft: (bookId: string, flowId: string) => Promise<IpcResult<string>>
}
wizard: {
getFlow: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow | null>>
start: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow>>
setGoal: (
bookId: string,
flowId: string,
config: Partial<AutoWritingConfig>
) => Promise<IpcResult<InteractiveFlow>>
setRhythm: (
bookId: string,
flowId: string,
rhythm: AutoRhythm,
styleNote?: string
) => Promise<IpcResult<InteractiveFlow>>
setPov: (bookId: string, flowId: string, povSettingId: string) => Promise<IpcResult<InteractiveFlow>>
confirmContext: (
bookId: string,
flowId: string,
binding: AiContextBinding
) => Promise<IpcResult<InteractiveFlow>>
buildPreview: (
bookId: string,
flowId: string
) => Promise<IpcResult<{ preview: string; flow: InteractiveFlow }>>
startGenerate: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
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 }>>
suggestContext: (
bookId: string,
opts?: KnowledgeSuggestOptions
) => Promise<IpcResult<ScoredKnowledge[]>>
}
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,
+42 -1
View File
@@ -77,5 +77,46 @@ export const IPC = {
INTERACTIVE_FINISH_CHAPTER: 'interactive:finishChapter', INTERACTIVE_FINISH_CHAPTER: 'interactive:finishChapter',
INTERACTIVE_ABORT: 'interactive:abort', INTERACTIVE_ABORT: 'interactive:abort',
INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk', INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk',
INTERACTIVE_GET_SCENE_DRAFT: 'interactive:getSceneDraft' INTERACTIVE_GET_SCENE_DRAFT: 'interactive:getSceneDraft',
AUTO_CHECK_GATE: 'auto:checkGate',
AUTO_GET_FLOW: 'auto:getFlow',
AUTO_START: 'auto:start',
AUTO_SET_GOAL: 'auto:setGoal',
AUTO_CONFIRM_CONTEXT: 'auto:confirmContext',
AUTO_PLAN_SCENES: 'auto:planScenes',
AUTO_GENERATE: 'auto:generate',
AUTO_RESOLVE_NAMING: 'auto:resolveNaming',
AUTO_PAUSE: 'auto:pause',
AUTO_RESUME: 'auto:resume',
AUTO_HANDOFF_INTERACTIVE: 'auto:handoffInteractive',
AUTO_FINISH_CHAPTER: 'auto:finishChapter',
AUTO_GET_SCENE_DRAFT: 'auto:getSceneDraft',
AUTO_ABORT: 'auto:abort',
WIZARD_GET_FLOW: 'wizard:getFlow',
WIZARD_START: 'wizard:start',
WIZARD_SET_GOAL: 'wizard:setGoal',
WIZARD_SET_RHYTHM: 'wizard:setRhythm',
WIZARD_SET_POV: 'wizard:setPov',
WIZARD_CONFIRM_CONTEXT: 'wizard:confirmContext',
WIZARD_BUILD_PREVIEW: 'wizard:buildPreview',
WIZARD_START_GENERATE: 'wizard:startGenerate',
WIZARD_HANDOFF_INTERACTIVE: 'wizard:handoffInteractive',
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',
KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext',
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
+123 -4
View File
@@ -9,7 +9,7 @@ export type ThemeId =
export type Language = 'zh-CN' | 'en' export type Language = 'zh-CN' | 'en'
export type ChapterStatus = 'draft' | 'review' | 'done' export type ChapterStatus = 'draft' | 'review' | 'done'
export type ChapterOrigin = 'manual' | 'interactive' export type ChapterOrigin = 'manual' | 'interactive' | 'auto' | 'wizard'
export type BookStatus = 'draft' | 'ongoing' | 'done' export type BookStatus = 'draft' | 'ongoing' | 'done'
export type PublishStatus = 'draft' | 'ready' | 'published' export type PublishStatus = 'draft' | 'ready' | 'published'
export type OutlineStatus = 'pending' | 'writing' | 'done' | 'abandoned' export type OutlineStatus = 'pending' | 'writing' | 'done' | 'abandoned'
@@ -50,12 +50,81 @@ 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 KnowledgeSuggestOptions {
currentChapterId?: string
goalText?: string
topN?: number
}
export interface ScoredKnowledge {
entry: KnowledgeEntry
score: number
reasons: string[]
}
export interface FlowContextPayload {
binding: AiContextBinding
systemPrompt: string
contextSummary: 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
@@ -63,6 +132,8 @@ export interface GlobalSettings {
searchHistory: string[] searchHistory: string[]
aiConfig: AiConfig aiConfig: AiConfig
namingIgnoreWords: string[] namingIgnoreWords: string[]
knowledgeAutoSuggest?: boolean
knowledgeSuggestTopN?: number
} }
export interface BookMeta { export interface BookMeta {
@@ -223,6 +294,7 @@ export interface AiContextBinding {
outlineIds: string[] outlineIds: string[]
settingIds: string[] settingIds: string[]
inspirationIds: string[] inspirationIds: string[]
knowledgeIds: string[]
} }
export interface AiSession { export interface AiSession {
@@ -261,7 +333,7 @@ export interface AiNetworkStatusEvent {
online: boolean online: boolean
} }
export type ContextPreviewKind = 'chapter' | 'outline' | 'setting' | 'inspiration' export type ContextPreviewKind = 'chapter' | 'outline' | 'setting' | 'inspiration' | 'knowledge'
export interface ContextPreviewItem { export interface ContextPreviewItem {
kind: ContextPreviewKind kind: ContextPreviewKind
@@ -269,6 +341,7 @@ export interface ContextPreviewItem {
title: string title: string
excerpt: string excerpt: string
charCount: number charCount: number
knowledgeType?: KnowledgeType
} }
export interface AiContextBuildResult { export interface AiContextBuildResult {
@@ -285,6 +358,49 @@ export type InteractiveStep =
| 'scene_review' | 'scene_review'
| 'completed' | 'completed'
export type WritingFlowMode = 'interactive' | 'auto' | 'wizard'
export type AutoRhythm = 'relaxed' | 'tense' | 'mixed'
export type AutoStep =
| 'goal_setup'
| 'scene_plan'
| 'auto_generate'
| 'paused'
| 'completed'
export type WizardStep =
| 'wizard_goal'
| 'wizard_rhythm'
| 'wizard_pov'
| 'wizard_context'
| 'wizard_preview'
| 'wizard_generating'
export type WritingStep = InteractiveStep | AutoStep | WizardStep
export interface ScenePlanItem {
label: string
summary: string
}
export interface AutoWritingConfig {
chapterGoal: string
outlineItemIds?: string[]
wordMin: number
wordMax: number
scenePlan?: ScenePlanItem[]
currentPlanIndex?: number
}
export interface WizardWritingConfig extends AutoWritingConfig {
rhythm: AutoRhythm
styleNote?: string
povSettingId?: string
strategyPreview?: string
contextBinding?: AiContextBinding
}
export type InteractiveFlowStatus = 'in_progress' | 'completed' | 'abandoned' export type InteractiveFlowStatus = 'in_progress' | 'completed' | 'abandoned'
export interface PlotOption { export interface PlotOption {
@@ -315,8 +431,10 @@ export interface InteractiveScene {
export interface InteractiveFlow { export interface InteractiveFlow {
id: string id: string
sessionId: string sessionId: string
step: InteractiveStep flowMode: WritingFlowMode
step: WritingStep
status: InteractiveFlowStatus status: InteractiveFlowStatus
modeConfig: AutoWritingConfig | WizardWritingConfig | Record<string, never>
scenes: InteractiveScene[] scenes: InteractiveScene[]
namingPending?: NamingPause | null namingPending?: NamingPause | null
contextSummary?: string contextSummary?: string
@@ -329,7 +447,8 @@ export interface InteractiveGateResult {
export interface InteractiveStreamChunkEvent { export interface InteractiveStreamChunkEvent {
flowId: string flowId: string
phase: 'scene' | 'refine' | 'naming_resume' flowMode?: WritingFlowMode
phase: 'scene' | 'refine' | 'naming_resume' | 'plan'
delta: string delta: string
done: boolean done: boolean
} }
+60 -3
View File
@@ -7,6 +7,7 @@ import { migrate } from '../../src/main/db/migrate'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo' import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo' import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
import { SettingRepository } from '../../src/main/db/repositories/setting.repo' import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
import { BookRegistryService } from '../../src/main/services/book-registry' import { BookRegistryService } from '../../src/main/services/book-registry'
import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service' import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service'
import type { SqliteDb } from '../../src/main/db/types' import type { SqliteDb } from '../../src/main/db/types'
@@ -38,7 +39,7 @@ describe('AiContextBuilderService', () => {
const chapter = chapters.create(volId, '长章节', 0, longHtml) const chapter = chapters.create(volId, '长章节', 0, longHtml)
const result = aiContextBuilder.build( const result = aiContextBuilder.build(
{ chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [] }, { chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [] },
registry, registry,
bookId, bookId,
'作者' '作者'
@@ -58,7 +59,7 @@ describe('AiContextBuilderService', () => {
} }
const result = aiContextBuilder.build( const result = aiContextBuilder.build(
{ chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [] }, { chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [] },
registry, registry,
bookId, bookId,
'作者' '作者'
@@ -74,7 +75,7 @@ describe('AiContextBuilderService', () => {
const setting = settings.create('character', '主角林远', '少年修士') const setting = settings.create('character', '主角林远', '少年修士')
const result = aiContextBuilder.build( const result = aiContextBuilder.build(
{ chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [] }, { chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [], knowledgeIds: [] },
registry, registry,
bookId, bookId,
'作者' '作者'
@@ -83,4 +84,60 @@ describe('AiContextBuilderService', () => {
expect(result.preview[0].title).toBe('主角林远') expect(result.preview[0].title).toBe('主角林远')
expect(result.systemPrompt).toContain('主角林远') expect(result.systemPrompt).toContain('主角林远')
}) })
it('UT-CTX-02: build includes knowledge preview items', () => {
const know = new KnowledgeRepository(db)
const entry = know.create({
type: 'foreshadow',
title: '测灵石',
content: '石头发热',
status: 'approved'
})
const result = aiContextBuilder.build(
{
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: [entry.id]
},
registry,
bookId,
'作者'
)
expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true)
expect(result.systemPrompt).toContain('【知识·伏笔】')
expect(result.systemPrompt).toContain('测灵石')
})
it('UT-CTX-03: caps knowledge section at 4000 chars', () => {
const know = new KnowledgeRepository(db)
const ids: string[] = []
for (let i = 0; i < 20; i++) {
const e = know.create({
type: 'fact',
title: `条目${i}`,
content: '长'.repeat(400),
importance: 5,
status: 'approved'
})
ids.push(e.id)
}
const result = aiContextBuilder.build(
{
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: ids
},
registry,
bookId,
'作者'
)
const knowledgeChars = result.preview
.filter((p) => p.kind === 'knowledge')
.reduce((s, p) => s + p.charCount, 0)
expect(knowledgeChars).toBeLessThanOrEqual(4000)
})
}) })
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service'
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { BookRegistryService } from '../../src/main/services/book-registry'
import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service'
import { DEFAULT_AI_CONFIG, type AiContextBinding } from '../../src/shared/types'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
const EMPTY_BINDING: AiContextBinding = {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
}
describe('AI context integration', () => {
it('IT-CTX-01: suggestContext returns entries when approved exist', () => {
const db = new DatabaseSync(':memory:')
migrate(db)
new KnowledgeRepository(db).create({
type: 'fact',
title: '测灵石异常',
content: '石头发热',
status: 'approved',
importance: 4
})
const result = new KnowledgeRetrievalService(db).suggest({ topN: 5 })
expect(result.length).toBeGreaterThanOrEqual(1)
expect(result[0].entry.title).toBe('测灵石异常')
db.close()
})
it('IT-CTX-02: build with knowledgeIds includes knowledge block in systemPrompt', () => {
const userDir = mkdtempSync(join(tmpdir(), 'bilin-ctx-it-'))
const registry = new BookRegistryService(userDir)
const meta = registry.create({ name: '测试书', category: '玄幻' })
const bookDb = registry.getDb(meta.id)
const entry = new KnowledgeRepository(bookDb).create({
type: 'foreshadow',
title: '测灵石',
content: '石头发热',
status: 'approved'
})
const result = aiContextBuilder.build(
{ ...EMPTY_BINDING, knowledgeIds: [entry.id] },
registry,
meta.id,
'作者'
)
expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true)
expect(result.systemPrompt).toContain('【知识·')
bookDb.close()
rmSync(userDir, { recursive: true, force: true })
})
it('IT-CTX-03: interactive confirm then suggestPlots uses knowledge context', async () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
const know = new KnowledgeRepository(db).create({
type: 'foreshadow',
title: '测灵石异常',
content: '入门考核时测灵石发热,暗示主角特殊灵根',
status: 'approved',
importance: 5
})
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
const flow = service.start(session.id)
service.confirmContext(flow.id, {
binding: { ...EMPTY_BINDING, knowledgeIds: [know.id] },
systemPrompt: `【知识·伏笔·测灵石异常】入门考核时测灵石发热,暗示主角特殊灵根\n\n林远参加宗门入门考核,演武场测灵石前众人围观。`,
contextSummary: '0章·0纲·0设·0感·1知'
})
const plots = await service.suggestPlots(flow.id)
expect(plots).toHaveLength(3)
db.close()
}, 240_000)
})
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { InteractiveRepository } from '../../src/main/db/repositories/interactive.repo'
import { AutoWritingService } from '../../src/main/services/auto-writing.service'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
const CONTEXT = '林远站在演武场中央,众人目光汇聚,等待入门考核开始。'
describe('AutoWritingService generate', () => {
const service = (db: DatabaseSync) =>
new AutoWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
async function planFirstScene(db: DatabaseSync, svc: AutoWritingService, flowId: string): Promise<void> {
await svc.planScenes(flowId, CONTEXT)
}
it('IT-AUTO-02: generateNext writes non-empty scene', async () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
const svc = service(db)
const flow = svc.start(session.id)
svc.setGoal(flow.id, {
chapterGoal: '林远通过入门考核,展现天赋震惊众人',
wordMin: 800,
wordMax: 1500
})
await planFirstScene(db, svc, flow.id)
let afterGen = await svc.generateNext(flow.id, CONTEXT, () => {})
if (afterGen.step === 'naming_pause' && afterGen.namingPending) {
afterGen = await svc.resolveNaming(
flow.id,
afterGen.namingPending.options[0]!,
CONTEXT,
() => {}
)
}
const scenes = new InteractiveRepository(db).listScenes(flow.id)
expect(scenes.length).toBeGreaterThanOrEqual(1)
expect(scenes[0]!.contentHtml.replace(/<[^>]+>/g, '').trim().length).toBeGreaterThan(20)
db.close()
}, 240_000)
it('IT-AUTO-03: pause and resume update step', async () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
const svc = service(db)
const flow = svc.start(session.id)
svc.setGoal(flow.id, {
chapterGoal: '林远通过入门考核',
wordMin: 800,
wordMax: 1500
})
await planFirstScene(db, svc, flow.id)
const paused = svc.pause(flow.id)
expect(paused.step).toBe('paused')
const resumed = svc.resume(flow.id)
expect(resumed.step).toBe('auto_generate')
db.close()
}, 240_000)
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { parseScenePlanJson } from '../../src/main/services/auto-parse.service'
describe('parseScenePlanJson', () => {
it('parses valid scene plan', () => {
const json = JSON.stringify({
scenes: [
{
label: '开场',
summary: '林远步入演武场,众人围观,测灵石前气氛紧张,他感受到体内灵力涌动。'
},
{
label: '异变',
summary: '测灵石突然碎裂,长老震惊,同门哗然,林远意识到自己的天赋远超预期。'
}
],
estimatedWords: 3000
})
const plan = parseScenePlanJson(json)
expect(plan.scenes).toHaveLength(2)
expect(plan.estimatedWords).toBe(3000)
})
it('throws on invalid JSON', () => {
expect(() => parseScenePlanJson('not json')).toThrow()
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { AutoWritingService } from '../../src/main/services/auto-writing.service'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
const CONTEXT = '林远站在演武场中央,众人目光汇聚,等待入门考核开始。'
describe('AutoWritingService.planScenes', () => {
const service = (db: DatabaseSync) =>
new AutoWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
it('IT-AUTO-01: planScenes returns >=2 scenes', async () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
const svc = service(db)
const flow = svc.start(session.id)
svc.setGoal(flow.id, {
chapterGoal: '林远通过入门考核,展现天赋震惊众人',
wordMin: 800,
wordMax: 1500
})
const plan = await svc.planScenes(flow.id, CONTEXT)
expect(plan.scenes.length).toBeGreaterThanOrEqual(2)
db.close()
}, 360_000)
})
+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)
})
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach, 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 { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { InteractiveRepository } from '../../src/main/db/repositories/interactive.repo'
import { SnapshotRepository } from '../../src/main/db/repositories/snapshot.repo'
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
import { ChapterFinishService } from '../../src/main/services/chapter-finish.service'
import { AutoWritingService } from '../../src/main/services/auto-writing.service'
import { handoffToInteractive } from '../../src/main/services/writing-flow-handoff.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('ChapterFinishService', () => {
let db: SqliteDb
let volId: string
let flowId: string
beforeEach(() => {
db = new DatabaseSync(':memory:')
migrate(db)
volId = new VolumeRepository(db).create('卷一', 0).id
const session = new AiSessionRepository(db).create('t', 'm')
flowId = new InteractiveRepository(db).create(session.id).id
new InteractiveRepository(db).addScene(flowId, '<p>场景</p>', 'A')
})
afterEach(() => db.close())
it('IT-FIN-02: creates ai snapshot on interactive finish', () => {
const service = new ChapterFinishService(db)
const { chapterId } = service.finishFromFlow(flowId, volId, '交互初稿', 'interactive', '交互写作成章')
const snaps = new SnapshotRepository(db).list(chapterId)
expect(snaps.some((s) => s.type === 'ai' && s.name.includes('交互'))).toBe(true)
})
it('IT-FIN-01: auto origin and snapshot label', () => {
const service = new ChapterFinishService(db)
const { chapterId } = service.finishFromFlow(flowId, volId, '自动初稿', 'auto', '自动写作成章')
const ch = new ChapterRepository(db).get(chapterId)!
expect(ch.origin).toBe('auto')
const snaps = new SnapshotRepository(db).list(chapterId)
expect(snaps.some((s) => s.name.includes('自动'))).toBe(true)
})
})
describe('auto handoff', () => {
it('IT-AUTO-04: handoff sets flow_mode interactive', () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', 'm')
const repo = new InteractiveRepository(db)
const flow = repo.create(session.id, 'auto', 'auto_generate')
repo.addScene(flow.id, '<p>已生成</p>', '场景1')
const result = handoffToInteractive(repo, flow.id)
expect(result.flowMode).toBe('interactive')
expect(result.step).toBe('plot_suggest')
db.close()
})
})
describe('AutoWritingService.setGoal', () => {
it('advances to scene_plan', () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', 'm')
const service = new AutoWritingService(db, { chat: async () => '' } as never)
const flow = service.start(session.id)
const updated = service.setGoal(flow.id, { chapterGoal: '林远通过入门考核', wordMin: 2000, wordMax: 4000 })
expect(updated.step).toBe('scene_plan')
db.close()
})
})
+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)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { BookRegistryService } from '../../src/main/services/book-registry'
import { buildFlowContextPayload } from '../../src/main/services/flow-context.util'
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
describe('flow-context.util', () => {
let userDir: string
let registry: BookRegistryService
let bookId: string
beforeEach(() => {
userDir = mkdtempSync(join(tmpdir(), 'bilin-flow-ctx-'))
registry = new BookRegistryService(userDir)
bookId = registry.create({ name: '书', category: '玄幻' }).id
})
afterEach(() => rmSync(userDir, { recursive: true, force: true }))
it('buildFlowContextPayload stores systemPrompt and summary', () => {
const db = registry.getDb(bookId)
const entry = new KnowledgeRepository(db).create({
type: 'fact',
title: '测试知识',
content: '内容',
status: 'approved'
})
const payload = buildFlowContextPayload(
{
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: [entry.id]
},
registry,
bookId,
'作者'
)
expect(payload.systemPrompt).toContain('测试知识')
expect(payload.contextSummary).toContain('1')
db.close()
})
})
+11 -5
View File
@@ -15,11 +15,17 @@ describe('interactive plots integration', () => {
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model) const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG)) const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
const flow = service.start(session.id) const flow = service.start(session.id)
service.confirmContext( service.confirmContext(flow.id, {
flow.id, binding: {
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] }, chapterIds: [],
'林远参加宗门入门考核,演武场测灵石前众人围观。' outlineIds: [],
) settingIds: [],
inspirationIds: [],
knowledgeIds: []
},
systemPrompt: '林远参加宗门入门考核,演武场测灵石前众人围观。',
contextSummary: '0章·0纲·0设·0感·0知'
})
const plots = await service.suggestPlots(flow.id) const plots = await service.suggestPlots(flow.id)
expect(plots).toHaveLength(3) expect(plots).toHaveLength(3)
db.close() db.close()
+11 -5
View File
@@ -18,11 +18,17 @@ describe('interactive scene integration', () => {
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model) const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
const svc = service(db) const svc = service(db)
const flow = svc.start(session.id) const flow = svc.start(session.id)
svc.confirmContext( svc.confirmContext(flow.id, {
flow.id, binding: {
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] }, chapterIds: [],
'林远在演武场测试灵根,众人围观。' outlineIds: [],
) settingIds: [],
inspirationIds: [],
knowledgeIds: []
},
systemPrompt: '林远在演武场测试灵根,众人围观。',
contextSummary: '0章·0纲·0设·0感·0知'
})
const plots = await svc.suggestPlots(flow.id) const plots = await svc.suggestPlots(flow.id)
svc.selectPlot(flow.id, { optionIds: [plots[0].id] }) svc.selectPlot(flow.id, { optionIds: [plots[0].id] })
const afterGen = await svc.generateScene(flow.id, () => {}) const afterGen = await svc.generateScene(flow.id, () => {})
+80
View File
@@ -0,0 +1,80 @@
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 { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service'
import type { SqliteDb } from '../../src/main/db/types'
describe('KnowledgeRetrievalService', () => {
let db: SqliteDb
afterEach(() => db?.close())
it('UT-CTX-01: ranks by importance + chapter proximity + goal match', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const volId = new VolumeRepository(db).create('卷一', 0).id
const chapterRepo = new ChapterRepository(db)
const cur = chapterRepo.create(volId, '当前章', 5, '<p>x</p>')
const near = chapterRepo.create(volId, '近章', 4, '<p>x</p>')
chapterRepo.create(volId, '远章', 0, '<p>x</p>')
const repo = new KnowledgeRepository(db)
const low = repo.create({
type: 'fact',
title: '无关事实',
content: '普通内容',
importance: 2,
status: 'approved'
})
const high = repo.create({
type: 'foreshadow',
title: '测灵石异常',
content: '测灵石在入门考核中发热',
importance: 5,
status: 'approved',
foreshadowStatus: 'buried',
sourceChapterId: near.id
})
const service = new KnowledgeRetrievalService(db)
const result = service.suggest({
currentChapterId: cur.id,
goalText: '测灵石入门考核',
topN: 5
})
expect(result[0].entry.id).toBe(high.id)
expect(result[0].score).toBeGreaterThan(
result.find((r) => r.entry.id === low.id)!.score
)
expect(result[0].reasons.length).toBeGreaterThan(0)
})
it('UT-CTX-04: resolved foreshadow scores lower than buried', () => {
db = new DatabaseSync(':memory:')
migrate(db)
const volId = new VolumeRepository(db).create('卷一', 0).id
const chId = new ChapterRepository(db).create(volId, '章', 0).id
const repo = new KnowledgeRepository(db)
const buried = repo.create({
type: 'foreshadow',
title: '埋设伏笔',
importance: 3,
status: 'approved',
foreshadowStatus: 'buried',
sourceChapterId: chId
})
const resolved = repo.create({
type: 'foreshadow',
title: '已回收伏笔',
importance: 3,
status: 'approved',
foreshadowStatus: 'resolved',
sourceChapterId: chId
})
const service = new KnowledgeRetrievalService(db)
const scores = service.suggest({ currentChapterId: chId, topN: 10 })
const buriedScore = scores.find((s) => s.entry.id === buried.id)!.score
const resolvedScore = scores.find((s) => s.entry.id === resolved.id)!.score
expect(buriedScore).toBeGreaterThan(resolvedScore)
})
})
+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) 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(4) 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(4) 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)
+2 -2
View File
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
expect(tableExists(db, 'ai_messages')).toBe(true) expect(tableExists(db, 'ai_messages')).toBe(true)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(4) 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(4) expect(version.v).toBe(6)
}) })
}) })
+2 -2
View File
@@ -23,7 +23,7 @@ describe('migrate v4', () => {
migrate(db) migrate(db)
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
expect(version.v).toBe(4) 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(4) expect(version.v).toBe(6)
expect(tableExists(db, 'interactive_flows')).toBe(true) expect(tableExists(db, 'interactive_flows')).toBe(true)
}) })
}) })
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import schemaV1 from '../../src/main/db/schema-v1.sql?raw'
import schemaV2 from '../../src/main/db/schema-v2.sql?raw'
import schemaV3 from '../../src/main/db/schema-v3.sql?raw'
import schemaV4 from '../../src/main/db/schema-v4.sql?raw'
import type { SqliteDb } from '../../src/main/db/types'
describe('migrate v5', () => {
let db: SqliteDb
afterEach(() => db?.close())
it('UT-MIG-05: applies v5 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 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)
})
it('migrates v4 database to v5', () => {
db = new DatabaseSync(':memory:')
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')), description TEXT
)`)
db.exec(schemaV1)
db.exec(schemaV2)
db.exec(schemaV3)
db.prepare('INSERT INTO schema_version (version) VALUES (1),(2),(3)').run()
try {
db.exec("ALTER TABLE chapters ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'")
} catch {
/* duplicate ok */
}
db.exec(schemaV4)
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(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)
})
})
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { InteractiveRepository } from '../../src/main/db/repositories/interactive.repo'
import { WizardWritingService } from '../../src/main/services/wizard-writing.service'
import { AutoWritingService } from '../../src/main/services/auto-writing.service'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG, type AutoWritingConfig, type InteractiveFlow } from '../../src/shared/types'
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 300_000, maxTokens: 4096 }
const CONTEXT = '林远站在演武场中央,众人目光汇聚,等待入门考核开始。'
describe('WizardWritingService.startGenerate', () => {
const wizard = (db: DatabaseSync) =>
new WizardWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
const auto = (db: DatabaseSync) =>
new AutoWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
async function setupWizardFlow(svc: WizardWritingService, sessionId: string): Promise<string> {
let flow = svc.start(sessionId)
flow = svc.setGoal(flow.id, {
chapterGoal: '林远通过入门考核,展现天赋',
wordMin: 600,
wordMax: 1200
})
flow = svc.setRhythm(flow.id, 'tense')
flow = svc.setPov(flow.id)
flow = svc.confirmContext(flow.id, {
binding: {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
},
systemPrompt: CONTEXT,
contextSummary: '0章·0纲·0设·0感·0知'
})
return flow.id
}
async function continueAfterPartialGenerate(
wizSvc: WizardWritingService,
autoSvc: AutoWritingService,
repo: InteractiveRepository,
flowId: string,
initial: InteractiveFlow
): Promise<InteractiveFlow> {
let flow = initial
for (let guard = 0; guard < 8 && flow.flowMode !== 'interactive'; guard++) {
if (flow.step === 'naming_pause' && flow.namingPending) {
flow = await autoSvc.resolveNaming(
flowId,
flow.namingPending.options[0]!,
CONTEXT,
() => {}
)
continue
}
const config = repo.getModeConfig<AutoWritingConfig>(flowId)
const planLen = config.scenePlan?.length ?? 0
const idx = config.currentPlanIndex ?? 0
if (idx < planLen) {
flow = await autoSvc.generateNext(flowId, CONTEXT, () => {})
continue
}
if (flow.scenes.length > 0) {
return wizSvc.handoffToInteractive(flowId)
}
break
}
return flow
}
it('IT-WIZ-02: startGenerate handoffs to interactive with scenes', async () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const repo = new InteractiveRepository(db)
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
const wizSvc = wizard(db)
const autoSvc = auto(db)
const flowId = await setupWizardFlow(wizSvc, session.id)
const initial = await wizSvc.startGenerate(flowId, CONTEXT, () => {})
const flow =
initial.flowMode === 'interactive'
? initial
: await continueAfterPartialGenerate(wizSvc, autoSvc, repo, flowId, initial)
expect(flow.flowMode).toBe('interactive')
expect(flow.scenes.length).toBeGreaterThanOrEqual(1)
db.close()
}, 600_000)
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { migrate } from '../../src/main/db/migrate'
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
import { WizardWritingService } from '../../src/main/services/wizard-writing.service'
import { AiClientService } from '../../src/main/services/ai-client.service'
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
const CONTEXT = '林远站在演武场中央,众人目光汇聚,等待入门考核开始。'
describe('WizardWritingService.buildPreview', () => {
const service = (db: DatabaseSync) =>
new WizardWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
it('IT-WIZ-01: buildPreview returns non-empty HTML', async () => {
const db = new DatabaseSync(':memory:')
migrate(db)
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
const svc = service(db)
let flow = svc.start(session.id)
flow = svc.setGoal(flow.id, {
chapterGoal: '林远通过入门考核,展现天赋震惊众人',
wordMin: 800,
wordMax: 1500
})
flow = svc.setRhythm(flow.id, 'mixed')
flow = svc.setPov(flow.id)
flow = svc.confirmContext(flow.id, {
binding: {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: [],
knowledgeIds: []
},
systemPrompt: CONTEXT,
contextSummary: '0章·0纲·0设·0感·0知'
})
const preview = await svc.buildPreview(flow.id, CONTEXT, '林远')
expect(preview).toMatch(/<p>/)
expect(preview.replace(/<[^>]+>/g, '').trim().length).toBeGreaterThan(10)
db.close()
}, 240_000)
})
+2 -1
View File
@@ -5,7 +5,8 @@ export default defineConfig({
test: { test: {
environment: 'node', environment: 'node',
include: ['tests/**/*.test.ts'], include: ['tests/**/*.test.ts'],
testTimeout: 240_000 testTimeout: 240_000,
fileParallelism: false
}, },
resolve: { resolve: {
alias: { alias: {