Compare commits
10 Commits
a8e0ba9ac9
...
aa2c7dfed3
| Author | SHA1 | Date | |
|---|---|---|---|
| aa2c7dfed3 | |||
| 2afbb83c43 | |||
| b5ae04af18 | |||
| b33d2e7b34 | |||
| 78f046890d | |||
| a02a665b0f | |||
| 5d3e264cf3 | |||
| 6a949b54ba | |||
| 86b66a311a | |||
| ab33213f99 |
@@ -1,9 +1,16 @@
|
|||||||
# 笔临 (Bilin)
|
# 笔临 (Bilin)
|
||||||
|
|
||||||
长篇创作智能协作平台 — Electron 桌面客户端 v0.4.0(P4 交互式写作)
|
长篇创作智能协作平台 — Electron 桌面客户端 v0.7.0(P6 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-01(240s 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、foreshadowStatus(type=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/aiSuggestion;checkbox 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 summary;publish 变更后 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 2–3, 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 1–5, 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-03(240s)**
|
||||||
|
|
||||||
|
```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-03(300s)**
|
||||||
|
|
||||||
|
```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 5(AI_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_preferences(cockpitSeen、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`(1–20,默认 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 E2E(Playwright)
|
||||||
|
|
||||||
|
| ID | 场景 | 超时 |
|
||||||
|
|----|------|------|
|
||||||
|
| E2E-COCK-01 | 打开驾驶舱,统计卡片可见 | 30s |
|
||||||
|
| E2E-COCK-02 | 首次开书自动弹驾驶舱,二次不再自动 | 30s |
|
||||||
|
| E2E-PUB-01 | 章节改 publish_status 为 ready,cockpit 存稿 +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 Done(v0.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) │
|
||||||
|
│ · 各 handler:AI_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 badge,i18n 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,范围 3–15
|
||||||
|
```
|
||||||
|
|
||||||
|
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 | 使用完整 prompt(240s 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 v5:interactive_flows.flow_mode / mode_config_json │
|
||||||
|
│ · chapters.origin:manual | interactive | auto | wizard │
|
||||||
|
│ · SceneGenerationCore(从 P4 抽取:流式、命名暂停、HTML 规范化) │
|
||||||
|
│ · AutoWritingService(目标→规划→连续生成→暂停/成章/handoff) │
|
||||||
|
│ · WizardWritingService(5 步→策略预览→委托 Auto→handoff) │
|
||||||
|
│ · InteractiveWritingService(P4 不变;handoff 目标) │
|
||||||
|
│ · checkWritingGate(复用 interactive-gate 逻辑,三模式共用) │
|
||||||
|
│ · AiClientService / AiContextBuilder(复用) │
|
||||||
|
│ · IPC:auto:* / wizard:* + interactive:streamChunk(增 flowMode) │
|
||||||
|
└────────────────────────────┬─────────────────────────────────────┘
|
||||||
|
│ contextBridge
|
||||||
|
┌────────────────────────────▼─────────────────────────────────────┐
|
||||||
|
│ 渲染进程 │
|
||||||
|
│ · AiPanel:writingMode=auto|wizard 时挂载 AutoPanel/WizardPanel │
|
||||||
|
│ · handoff 后 writingMode 保持或切 interactive,显示 InteractivePanel│
|
||||||
|
│ · useAutoStore / useWizardStore + useInteractiveStore │
|
||||||
|
│ · ContextEditorModal(向导步骤 4 + 自动可选编辑上下文) │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**约束**(延续 P0–P4):
|
||||||
|
|
||||||
|
- 渲染进程禁止直接 `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 interactive(P4 不变)
|
||||||
|
|
||||||
|
| 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` | 步骤 4:ContextEditorModal | 无 |
|
||||||
|
| `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` | 1–5 步指示 |
|
||||||
|
| `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/P4:disabled + offline banner |
|
||||||
|
| wizard 某步必填缺失 | 下一步按钮 disabled |
|
||||||
|
| handoff 时无 scenes 且无 draft | 仍 handoff 到 `context_confirm`,toast 提示可手动续写 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 测试
|
||||||
|
|
||||||
|
### 9.1 单元 / 集成(Vitest,真实 LM Studio)
|
||||||
|
|
||||||
|
| ID | 场景 | 超时 |
|
||||||
|
|----|------|------|
|
||||||
|
| UT-MIG-05 | v4 DB 迁移到 v5,flow_mode 列存在 | — |
|
||||||
|
| IT-AUTO-01 | planScenes 返回 ≥2 个 scene | 240s |
|
||||||
|
| IT-AUTO-02 | generateNext 写入非空 scene | 240s |
|
||||||
|
| IT-AUTO-03 | pause 后 step=paused;resume 继续 | 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 E2E(Playwright,300s)
|
||||||
|
|
||||||
|
| 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 中增量修订。*
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type {
|
||||||
|
CreateKnowledgeInput,
|
||||||
|
ForeshadowStatus,
|
||||||
|
KnowledgeEntry,
|
||||||
|
KnowledgeStatus,
|
||||||
|
KnowledgeType
|
||||||
|
} from '../../../shared/types'
|
||||||
|
import type { SqliteDb } from '../types'
|
||||||
|
|
||||||
|
function mapRow(row: Record<string, unknown>): KnowledgeEntry {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
type: row.type as KnowledgeType,
|
||||||
|
title: row.title as string,
|
||||||
|
content: row.content as string,
|
||||||
|
importance: row.importance as number,
|
||||||
|
status: row.status as KnowledgeStatus,
|
||||||
|
foreshadowStatus: (row.foreshadow_status as ForeshadowStatus) || undefined,
|
||||||
|
sourceChapterId: (row.source_chapter_id as string) || undefined,
|
||||||
|
lastMentionChapterId: (row.last_mention_chapter_id as string) || undefined,
|
||||||
|
linkedBookmarkId: (row.linked_bookmark_id as string) || undefined,
|
||||||
|
createdAt: row.created_at as string,
|
||||||
|
updatedAt: row.updated_at as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KnowledgeRepository {
|
||||||
|
constructor(private db: SqliteDb) {}
|
||||||
|
|
||||||
|
create(input: CreateKnowledgeInput): KnowledgeEntry {
|
||||||
|
const id = randomUUID()
|
||||||
|
const status = input.status ?? 'pending'
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO knowledge_entries (
|
||||||
|
id, type, title, content, importance, status, foreshadow_status,
|
||||||
|
source_chapter_id, last_mention_chapter_id, linked_bookmark_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
id,
|
||||||
|
input.type,
|
||||||
|
input.title,
|
||||||
|
input.content ?? '',
|
||||||
|
input.importance ?? 3,
|
||||||
|
status,
|
||||||
|
input.foreshadowStatus ?? null,
|
||||||
|
input.sourceChapterId ?? null,
|
||||||
|
input.lastMentionChapterId ?? null,
|
||||||
|
input.linkedBookmarkId ?? null
|
||||||
|
)
|
||||||
|
return this.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): KnowledgeEntry | null {
|
||||||
|
const row = this.db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(id) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined
|
||||||
|
return row ? mapRow(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
list(filter?: { status?: KnowledgeStatus; type?: KnowledgeType }): KnowledgeEntry[] {
|
||||||
|
let sql = 'SELECT * FROM knowledge_entries WHERE 1=1'
|
||||||
|
const params: unknown[] = []
|
||||||
|
if (filter?.status) {
|
||||||
|
sql += ' AND status = ?'
|
||||||
|
params.push(filter.status)
|
||||||
|
}
|
||||||
|
if (filter?.type) {
|
||||||
|
sql += ' AND type = ?'
|
||||||
|
params.push(filter.type)
|
||||||
|
}
|
||||||
|
sql += ' ORDER BY updated_at DESC'
|
||||||
|
return (this.db.prepare(sql).all(...params) as Record<string, unknown>[]).map(mapRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>): KnowledgeEntry {
|
||||||
|
const existing = this.get(id)
|
||||||
|
if (!existing) throw new Error('Knowledge entry not found')
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE knowledge_entries SET
|
||||||
|
type = ?, title = ?, content = ?, importance = ?, status = ?,
|
||||||
|
foreshadow_status = ?, source_chapter_id = ?, last_mention_chapter_id = ?,
|
||||||
|
linked_bookmark_id = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
patch.type ?? existing.type,
|
||||||
|
patch.title ?? existing.title,
|
||||||
|
patch.content ?? existing.content,
|
||||||
|
patch.importance ?? existing.importance,
|
||||||
|
patch.status ?? existing.status,
|
||||||
|
patch.foreshadowStatus ?? existing.foreshadowStatus ?? null,
|
||||||
|
patch.sourceChapterId ?? existing.sourceChapterId ?? null,
|
||||||
|
patch.lastMentionChapterId ?? existing.lastMentionChapterId ?? null,
|
||||||
|
patch.linkedBookmarkId ?? existing.linkedBookmarkId ?? null,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return this.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): void {
|
||||||
|
this.db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
countByStatus(status: KnowledgeStatus): number {
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT COUNT(*) AS c FROM knowledge_entries WHERE status = ?')
|
||||||
|
.get(status) as { c: number }
|
||||||
|
return row.c
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 '{}';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS knowledge_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
importance INTEGER NOT NULL DEFAULT 3,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
foreshadow_status TEXT,
|
||||||
|
source_chapter_id TEXT,
|
||||||
|
last_mention_chapter_id TEXT,
|
||||||
|
linked_bookmark_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (source_chapter_id) REFERENCES chapters(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (last_mention_chapter_id) REFERENCES chapters(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_status ON knowledge_entries(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_type ON knowledge_entries(type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS book_preferences (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chapter_bridge_dismiss (
|
||||||
|
chapter_id TEXT PRIMARY KEY,
|
||||||
|
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -0,0 +1,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))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import { BookRegistryService } from '../../services/book-registry'
|
||||||
|
import { ChapterBridgeService } from '../../services/chapter-bridge.service'
|
||||||
|
import { AiClientService } from '../../services/ai-client.service'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
export function registerBridgeHandlers(
|
||||||
|
registry: BookRegistryService,
|
||||||
|
aiClient: AiClientService
|
||||||
|
): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.BRIDGE_GET,
|
||||||
|
(
|
||||||
|
_e,
|
||||||
|
{ bookId, chapterId, withAi }: { bookId: string; chapterId: string; withAi?: boolean }
|
||||||
|
) =>
|
||||||
|
wrap(() =>
|
||||||
|
new ChapterBridgeService(registry.getDb(bookId), aiClient).getBridge(
|
||||||
|
chapterId,
|
||||||
|
withAi ?? false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.BRIDGE_DISMISS, (_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
|
||||||
|
wrap(() => new ChapterBridgeService(registry.getDb(bookId)).dismiss(chapterId))
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.BRIDGE_SHOULD_PROMPT,
|
||||||
|
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
|
||||||
|
wrap(() => new ChapterBridgeService(registry.getDb(bookId)).shouldPrompt(chapterId))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import { IPC } from '../../../shared/ipc-channels'
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
import type { ChapterStatus } from '../../../shared/types'
|
import type { ChapterStatus, PublishStatus } from '../../../shared/types'
|
||||||
import { BookRegistryService } from '../../services/book-registry'
|
import { BookRegistryService } from '../../services/book-registry'
|
||||||
import { ftsSync } from '../../services/fts-sync.service'
|
import { ftsSync } from '../../services/fts-sync.service'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
@@ -124,4 +124,12 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
|
|||||||
) =>
|
) =>
|
||||||
wrap(() => registry.getChapterRepo(bookId).moveToVolume(chapterId, targetVolumeId, targetIndex))
|
wrap(() => registry.getChapterRepo(bookId).moveToVolume(chapterId, targetVolumeId, targetIndex))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.CHAPTER_SET_PUBLISH_STATUS,
|
||||||
|
(
|
||||||
|
_event,
|
||||||
|
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
|
||||||
|
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, publishStatus))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import { BookRegistryService } from '../../services/book-registry'
|
||||||
|
import { CockpitService } from '../../services/cockpit.service'
|
||||||
|
import type { GlobalSettingsService } from '../../services/global-settings'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
export function registerCockpitHandlers(
|
||||||
|
registry: BookRegistryService,
|
||||||
|
settings: GlobalSettingsService
|
||||||
|
): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.COCKPIT_SUMMARY,
|
||||||
|
(_e, { bookId, volumeId }: { bookId: string; volumeId?: string }) =>
|
||||||
|
wrap(() => new CockpitService(registry.getDb(bookId), settings).getSummary(volumeId))
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.COCKPIT_MARK_SEEN, (_e, { bookId }: { bookId: string }) =>
|
||||||
|
wrap(() => {
|
||||||
|
new CockpitService(registry.getDb(bookId), settings).markSeen()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.COCKPIT_SHOULD_SHOW, (_e, { bookId }: { bookId: string }) =>
|
||||||
|
wrap(() => new CockpitService(registry.getDb(bookId), settings).shouldShowOnOpen())
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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[] = []
|
||||||
|
|||||||
@@ -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请生成策略预览。`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? '(无上下文)'
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { BookMeta, Volume, Chapter, OutlineItem, SettingEntry, InspirationEntry } from '@shared/types'
|
import type { BookMeta, Volume, Chapter, OutlineItem, SettingEntry, InspirationEntry } from '@shared/types'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||||
|
|
||||||
interface BookState {
|
interface BookState {
|
||||||
books: BookMeta[]
|
books: BookMeta[]
|
||||||
@@ -62,6 +63,16 @@ export const useBookStore = create<BookState>((set, get) => ({
|
|||||||
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
|
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
|
||||||
selectedChapterId: lastChapter
|
selectedChapterId: lastChapter
|
||||||
})
|
})
|
||||||
|
const cockpit = useCockpitStore.getState()
|
||||||
|
const shouldShow = await cockpit.shouldShowOnOpen(bookId)
|
||||||
|
if (shouldShow) {
|
||||||
|
await cockpit.markSeen(bookId)
|
||||||
|
cockpit.openModal()
|
||||||
|
void cockpit.loadSummary(
|
||||||
|
bookId,
|
||||||
|
result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume ?? undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setSelectedChapter: (chapterId) => {
|
setSelectedChapter: (chapterId) => {
|
||||||
const ch = get().chapters.find((c) => c.id === chapterId)
|
const ch = get().chapters.find((c) => c.id === chapterId)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { CockpitSummary } from '@shared/types'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
interface CockpitState {
|
||||||
|
open: boolean
|
||||||
|
summary: CockpitSummary | null
|
||||||
|
loading: boolean
|
||||||
|
bridgeChapterId: string | null
|
||||||
|
loadSummary: (bookId: string, volumeId?: string) => Promise<void>
|
||||||
|
openModal: () => void
|
||||||
|
close: () => void
|
||||||
|
markSeen: (bookId: string) => Promise<void>
|
||||||
|
shouldShowOnOpen: (bookId: string) => Promise<boolean>
|
||||||
|
openBridgeForChapter: (chapterId: string) => void
|
||||||
|
clearBridgeChapter: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCockpitStore = create<CockpitState>((set) => ({
|
||||||
|
open: false,
|
||||||
|
summary: null,
|
||||||
|
loading: false,
|
||||||
|
bridgeChapterId: null,
|
||||||
|
loadSummary: async (bookId, volumeId) => {
|
||||||
|
set({ loading: true })
|
||||||
|
try {
|
||||||
|
const summary = await ipcCall(() => window.electronAPI.cockpit.getSummary(bookId, volumeId))
|
||||||
|
set({ summary })
|
||||||
|
} finally {
|
||||||
|
set({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openModal: () => set({ open: true }),
|
||||||
|
close: () => set({ open: false }),
|
||||||
|
markSeen: async (bookId) => {
|
||||||
|
await ipcCall(() => window.electronAPI.cockpit.markSeen(bookId))
|
||||||
|
},
|
||||||
|
shouldShowOnOpen: async (bookId) =>
|
||||||
|
ipcCall(() => window.electronAPI.cockpit.shouldShowOnOpen(bookId)),
|
||||||
|
openBridgeForChapter: (chapterId) => set({ bridgeChapterId: chapterId, open: false }),
|
||||||
|
clearBridgeChapter: () => set({ bridgeChapterId: null })
|
||||||
|
}))
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type {
|
||||||
|
CreateKnowledgeInput,
|
||||||
|
KnowledgeEntry,
|
||||||
|
KnowledgeStatus,
|
||||||
|
KnowledgeType
|
||||||
|
} from '@shared/types'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
|
||||||
|
export type KnowledgeTab = 'pending' | 'all' | 'foreshadow'
|
||||||
|
|
||||||
|
interface KnowledgeState {
|
||||||
|
entries: KnowledgeEntry[]
|
||||||
|
stats: { buried: number; resolved: number; forgotten: number }
|
||||||
|
tab: KnowledgeTab
|
||||||
|
forgottenOnly: boolean
|
||||||
|
editorOpen: boolean
|
||||||
|
editingId: string | null
|
||||||
|
load: (bookId: string) => Promise<void>
|
||||||
|
setTab: (tab: KnowledgeTab) => void
|
||||||
|
openForgottenFilter: () => void
|
||||||
|
openEditor: (id?: string | null) => void
|
||||||
|
closeEditor: () => void
|
||||||
|
create: (bookId: string, input: CreateKnowledgeInput) => Promise<KnowledgeEntry>
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>
|
||||||
|
) => Promise<KnowledgeEntry>
|
||||||
|
deleteEntry: (bookId: string, id: string) => Promise<void>
|
||||||
|
approve: (bookId: string, id: string) => Promise<void>
|
||||||
|
reject: (bookId: string, id: string) => Promise<void>
|
||||||
|
batchApprove: (bookId: string, ids: string[]) => Promise<void>
|
||||||
|
filteredEntries: () => KnowledgeEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useKnowledgeStore = create<KnowledgeState>((set, get) => ({
|
||||||
|
entries: [],
|
||||||
|
stats: { buried: 0, resolved: 0, forgotten: 0 },
|
||||||
|
tab: 'all',
|
||||||
|
forgottenOnly: false,
|
||||||
|
editorOpen: false,
|
||||||
|
editingId: null,
|
||||||
|
load: async (bookId) => {
|
||||||
|
const [entries, stats] = await Promise.all([
|
||||||
|
ipcCall(() => window.electronAPI.knowledge.list(bookId)),
|
||||||
|
ipcCall(() => window.electronAPI.knowledge.stats(bookId))
|
||||||
|
])
|
||||||
|
set({ entries, stats })
|
||||||
|
},
|
||||||
|
setTab: (tab) => set({ tab, forgottenOnly: tab === 'foreshadow' ? get().forgottenOnly : false }),
|
||||||
|
openForgottenFilter: () => {
|
||||||
|
useAppStore.getState().setRightPanel('knowledge')
|
||||||
|
set({ tab: 'foreshadow', forgottenOnly: true })
|
||||||
|
},
|
||||||
|
openEditor: (id = null) => set({ editorOpen: true, editingId: id }),
|
||||||
|
closeEditor: () => set({ editorOpen: false, editingId: null }),
|
||||||
|
create: async (bookId, input) => {
|
||||||
|
const entry = await ipcCall(() => window.electronAPI.knowledge.create(bookId, input))
|
||||||
|
await get().load(bookId)
|
||||||
|
return entry
|
||||||
|
},
|
||||||
|
update: async (bookId, id, patch) => {
|
||||||
|
const entry = await ipcCall(() => window.electronAPI.knowledge.update(bookId, id, patch))
|
||||||
|
await get().load(bookId)
|
||||||
|
return entry
|
||||||
|
},
|
||||||
|
deleteEntry: async (bookId, id) => {
|
||||||
|
await ipcCall(() => window.electronAPI.knowledge.delete(bookId, id))
|
||||||
|
await get().load(bookId)
|
||||||
|
},
|
||||||
|
approve: async (bookId, id) => {
|
||||||
|
await ipcCall(() => window.electronAPI.knowledge.approve(bookId, id))
|
||||||
|
await get().load(bookId)
|
||||||
|
},
|
||||||
|
reject: async (bookId, id) => {
|
||||||
|
await ipcCall(() => window.electronAPI.knowledge.reject(bookId, id))
|
||||||
|
await get().load(bookId)
|
||||||
|
},
|
||||||
|
batchApprove: async (bookId, ids) => {
|
||||||
|
await ipcCall(() => window.electronAPI.knowledge.batchApprove(bookId, ids))
|
||||||
|
await get().load(bookId)
|
||||||
|
},
|
||||||
|
filteredEntries: () => {
|
||||||
|
const { entries, tab, forgottenOnly } = get()
|
||||||
|
let list = entries
|
||||||
|
if (tab === 'pending') list = list.filter((e) => e.status === 'pending')
|
||||||
|
else if (tab === 'foreshadow') {
|
||||||
|
list = list.filter((e) => e.type === 'foreshadow')
|
||||||
|
if (forgottenOnly) {
|
||||||
|
// forgotten filter applied client-side via reload with filter in panel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -17,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())
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -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;
|
||||||
|
|||||||
Vendored
+125
-1
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import { CockpitService } from '../../src/main/services/cockpit.service'
|
||||||
|
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
|
||||||
|
describe('CockpitService', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
let settingsDir: string
|
||||||
|
afterEach(() => {
|
||||||
|
db?.close()
|
||||||
|
if (settingsDir) rmSync(settingsDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-COCK-01: getSummary counts words and ready stock', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
settingsDir = mkdtempSync(join(tmpdir(), 'bilin-cockpit-'))
|
||||||
|
const settings = new GlobalSettingsService(settingsDir)
|
||||||
|
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||||
|
const chapterRepo = new ChapterRepository(db)
|
||||||
|
const ch = chapterRepo.create(volId, 'A', 0, '<p>hello world</p>')
|
||||||
|
chapterRepo.setPublishStatus(ch.id, 'ready')
|
||||||
|
const service = new CockpitService(db, settings)
|
||||||
|
const summary = service.getSummary(volId)
|
||||||
|
expect(summary.totalWords).toBeGreaterThan(0)
|
||||||
|
expect(summary.stockReadyCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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, () => {})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('KnowledgeRepository', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-KNOW-01: CRUD and approve updates status', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const repo = new KnowledgeRepository(db)
|
||||||
|
const entry = repo.create({
|
||||||
|
type: 'foreshadow',
|
||||||
|
title: '测灵石异常',
|
||||||
|
foreshadowStatus: 'buried'
|
||||||
|
})
|
||||||
|
expect(entry.status).toBe('pending')
|
||||||
|
const approved = repo.update(entry.id, { status: 'approved' })
|
||||||
|
expect(approved.status).toBe('approved')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import { BookmarkRepository } from '../../src/main/db/repositories/bookmark.repo'
|
||||||
|
import { KnowledgeService } from '../../src/main/services/knowledge.service'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('KnowledgeService', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-KNOW-02: forgotten when sort_order gap >= 20', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||||
|
const chapterRepo = new ChapterRepository(db)
|
||||||
|
const firstId = chapterRepo.create(volId, '第1章', 0, '<p>伏笔</p>').id
|
||||||
|
for (let i = 1; i < 25; i++) {
|
||||||
|
chapterRepo.create(volId, `第${i + 1}章`, i, '<p>x</p>')
|
||||||
|
}
|
||||||
|
const service = new KnowledgeService(db)
|
||||||
|
const entry = service.create({
|
||||||
|
type: 'foreshadow',
|
||||||
|
title: '测灵石',
|
||||||
|
status: 'approved',
|
||||||
|
foreshadowStatus: 'buried',
|
||||||
|
sourceChapterId: firstId
|
||||||
|
})
|
||||||
|
expect(service.isForgotten(entry)).toBe(true)
|
||||||
|
const stats = service.getForeshadowStats()
|
||||||
|
expect(stats.forgotten).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-KNOW-03: createFromBookmark creates pending entry', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||||
|
const chId = new ChapterRepository(db).create(volId, '章', 0).id
|
||||||
|
const bm = new BookmarkRepository(db).create(chId, 0, '测灵石发热', 'foreshadow')
|
||||||
|
const service = new KnowledgeService(db)
|
||||||
|
const entry = service.createFromBookmark(bm.id)
|
||||||
|
expect(entry.status).toBe('pending')
|
||||||
|
expect(entry.linkedBookmarkId).toBe(bm.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IT-KNOW-04: batchApprove clears pending', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const service = new KnowledgeService(db)
|
||||||
|
const ids = [
|
||||||
|
service.create({ type: 'fact', title: 'A' }).id,
|
||||||
|
service.create({ type: 'fact', title: 'B' }).id,
|
||||||
|
service.create({ type: 'fact', title: 'C' }).id
|
||||||
|
]
|
||||||
|
expect(service.countPending()).toBe(3)
|
||||||
|
expect(service.batchApprove(ids)).toBe(3)
|
||||||
|
expect(service.countPending()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,7 +30,7 @@ describe('migrate v2', () => {
|
|||||||
expect(tableExists(db, 'search_fts')).toBe(true)
|
expect(tableExists(db, 'search_fts')).toBe(true)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(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)
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user