feat: ship v0.4.0 with interactive writing mode
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
# 笔临 (Bilin)
|
||||
|
||||
长篇创作智能协作平台 — Electron 桌面客户端 v0.3.0(P2.1 + P3)
|
||||
长篇创作智能协作平台 — Electron 桌面客户端 v0.4.0(P4 交互式写作)
|
||||
|
||||
## 功能概览(v0.3.0)
|
||||
## 功能概览(v0.4.0)
|
||||
|
||||
- 交互式写作:剧情走向建议、场景生成、命名暂停、微调确认、成章落盘(P4)
|
||||
- 章节拖拽、全局搜索替换、灵感捕获、`@` 跳转、引用钉住(P2.1)
|
||||
- AI 对话助手:会话持久化、流式输出、上下文编辑、快捷命令(P3)
|
||||
- AI 设置页、命名一致性检查、离线降级、AI 快照(P3)
|
||||
@@ -29,6 +30,8 @@ npm run test:e2e # E2E 测试(需先 build)
|
||||
|
||||
## 文档
|
||||
|
||||
- P4 交互式写作设计规格:`docs/superpowers/specs/2026-07-08-bilin-p4-interactive-design.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/plans/2026-07-07-bilin-p21-p3.md`
|
||||
- P2 设计规格:`docs/superpowers/specs/2026-07-06-bilin-p2-design.md`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 笔临 P4 交互式写作 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.
|
||||
> **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.4.0:实装交互式写作全流程(剧情走向 → 场景生成 → 命名暂停 → 微调 → 成章落盘),schema v4 持久化,工程项含 `test-results/` gitignore 与 LM Studio 测试超时调整。
|
||||
|
||||
@@ -59,13 +59,13 @@
|
||||
- Modify: `tests/main/ai-client.test.ts`
|
||||
- Modify: `e2e/ai-chat.spec.ts`
|
||||
|
||||
- [ ] **Step 1: `.gitignore` 追加**
|
||||
- [x] **Step 1: `.gitignore` 追加**
|
||||
|
||||
```
|
||||
test-results/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Playwright 全局超时**
|
||||
- [x] **Step 2: Playwright 全局超时**
|
||||
|
||||
`playwright.config.ts`:
|
||||
|
||||
@@ -78,13 +78,13 @@ export default defineConfig({
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 现有 AI 用例超时上调(可选但推荐)**
|
||||
- [x] **Step 3: 现有 AI 用例超时上调(可选但推荐)**
|
||||
|
||||
`tests/main/ai-client.test.ts`:`}, 180_000)`
|
||||
|
||||
`e2e/ai-chat.spec.ts`:AI 用例 `test.setTimeout(180_000)`;助手 `expect` 保持或升至 `120_000`。
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
- [x] **Step 4: 验证**
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
@@ -92,7 +92,7 @@ npm run test
|
||||
|
||||
Expected: 全部 PASS(需 LM Studio 运行)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitignore playwright.config.ts tests/main/ai-client.test.ts e2e/ai-chat.spec.ts
|
||||
@@ -110,7 +110,7 @@ git commit -m "chore: ignore test-results and raise AI test timeouts"
|
||||
- Modify: `src/shared/ipc-channels.ts`
|
||||
- Create: `tests/main/migrate-v4.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写迁移失败测试**
|
||||
- [x] **Step 1: 写迁移失败测试**
|
||||
|
||||
`tests/main/migrate-v4.test.ts`:
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('migrate v4', () => {
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认 FAIL**
|
||||
- [x] **Step 2: 运行确认 FAIL**
|
||||
|
||||
```bash
|
||||
npm run test -- tests/main/migrate-v4.test.ts
|
||||
@@ -161,11 +161,11 @@ npm run test -- tests/main/migrate-v4.test.ts
|
||||
|
||||
Expected: FAIL(version 仍为 3 或表不存在)
|
||||
|
||||
- [ ] **Step 3: 创建 `schema-v4.sql`**
|
||||
- [x] **Step 3: 创建 `schema-v4.sql`**
|
||||
|
||||
内容同 spec §2.3(`chapters.origin`、`interactive_flows`、`interactive_scenes` 及索引)。
|
||||
|
||||
- [ ] **Step 4: 更新 `migrate.ts`**
|
||||
- [x] **Step 4: 更新 `migrate.ts`**
|
||||
|
||||
```typescript
|
||||
import schemaV4 from './schema-v4.sql?raw'
|
||||
@@ -184,13 +184,13 @@ if (current < 4) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `types.ts` 追加类型**
|
||||
- [x] **Step 5: `types.ts` 追加类型**
|
||||
|
||||
按 spec §3 追加:`InteractiveStep`、`InteractiveFlowStatus`、`ChapterOrigin`、`PlotOption`、`NamingPause`、`PlotSelection`、`InteractiveScene`、`InteractiveFlow`、`InteractiveGateResult`、`InteractiveStreamChunkEvent`。
|
||||
|
||||
`Chapter` 接口增加 `origin?: ChapterOrigin`。
|
||||
|
||||
- [ ] **Step 6: `ipc-channels.ts` 追加**
|
||||
- [x] **Step 6: `ipc-channels.ts` 追加**
|
||||
|
||||
```typescript
|
||||
INTERACTIVE_CHECK_GATE: 'interactive:checkGate',
|
||||
@@ -208,13 +208,13 @@ INTERACTIVE_ABORT: 'interactive:abort',
|
||||
INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk',
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 运行测试 PASS**
|
||||
- [x] **Step 7: 运行测试 PASS**
|
||||
|
||||
```bash
|
||||
npm run test -- tests/main/migrate-v4.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
- [x] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/db/schema-v4.sql src/main/db/migrate.ts src/shared/types.ts src/shared/ipc-channels.ts tests/main/migrate-v4.test.ts
|
||||
@@ -229,7 +229,7 @@ git commit -m "feat: add schema v4 and interactive shared types"
|
||||
- Create: `src/main/db/repositories/interactive.repo.ts`
|
||||
- Modify: `src/main/services/book-registry.ts`
|
||||
|
||||
- [ ] **Step 1: 实现 `InteractiveRepository`**
|
||||
- [x] **Step 1: 实现 `InteractiveRepository`**
|
||||
|
||||
```typescript
|
||||
import { randomUUID } from 'crypto'
|
||||
@@ -358,7 +358,7 @@ export class InteractiveRepository {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `book-registry.ts` 注册**
|
||||
- [x] **Step 2: `book-registry.ts` 注册**
|
||||
|
||||
```typescript
|
||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
@@ -368,7 +368,7 @@ getInteractiveRepo(bookId: string): InteractiveRepository {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/db/repositories/interactive.repo.ts src/main/services/book-registry.ts
|
||||
@@ -385,7 +385,7 @@ git commit -m "feat: add InteractiveRepository for flow and scene persistence"
|
||||
- Modify: `src/main/db/repositories/chapter.repo.ts`
|
||||
- Modify: `src/main/services/word-count.ts`(若需导出 `stripHtml`,或从 naming-check 复用)
|
||||
|
||||
- [ ] **Step 1: 写门槛失败测试**
|
||||
- [x] **Step 1: 写门槛失败测试**
|
||||
|
||||
`tests/main/interactive-gate.test.ts`:
|
||||
|
||||
@@ -427,7 +427,7 @@ describe('checkInteractiveGate', () => {
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 `interactive-gate.service.ts`**
|
||||
- [x] **Step 2: 实现 `interactive-gate.service.ts`**
|
||||
|
||||
```typescript
|
||||
import type { InteractiveGateResult } from '../../shared/types'
|
||||
@@ -454,7 +454,7 @@ export function checkInteractiveGate(db: SqliteDb): InteractiveGateResult {
|
||||
|
||||
(实现时可用 `stripHtml` 遍历章节列表,与 spec 语义一致即可。)
|
||||
|
||||
- [ ] **Step 3: `chapter.repo.ts` 支持 `origin`**
|
||||
- [x] **Step 3: `chapter.repo.ts` 支持 `origin`**
|
||||
|
||||
`mapRow` 增加 `origin: (row.origin as ChapterOrigin) ?? 'manual'`。
|
||||
|
||||
@@ -462,7 +462,7 @@ export function checkInteractiveGate(db: SqliteDb): InteractiveGateResult {
|
||||
|
||||
INSERT 增加 `origin` 列。
|
||||
|
||||
- [ ] **Step 4: 运行 PASS + Commit**
|
||||
- [x] **Step 4: 运行 PASS + Commit**
|
||||
|
||||
```bash
|
||||
npm run test -- tests/main/interactive-gate.test.ts
|
||||
@@ -478,7 +478,7 @@ git commit -m "feat: add interactive gate check and chapter origin column"
|
||||
- Create: `src/main/services/interactive-prompt-builder.service.ts`
|
||||
- Create: `tests/main/interactive-parse.test.ts`
|
||||
|
||||
- [ ] **Step 1: 解析单元测试(无 AI)**
|
||||
- [x] **Step 1: 解析单元测试(无 AI)**
|
||||
|
||||
`tests/main/interactive-parse.test.ts`:
|
||||
|
||||
@@ -502,7 +502,7 @@ describe('interactive-parse', () => {
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 `interactive-parse.service.ts`**
|
||||
- [x] **Step 2: 实现 `interactive-parse.service.ts`**
|
||||
|
||||
```typescript
|
||||
import type { NamingPause, PlotOption } from '../../shared/types'
|
||||
@@ -553,7 +553,7 @@ export function tryParseNamingFromStream(buffer: string): NamingPause | null {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现 `interactive-prompt-builder.service.ts`**
|
||||
- [x] **Step 3: 实现 `interactive-prompt-builder.service.ts`**
|
||||
|
||||
导出函数:
|
||||
|
||||
@@ -564,7 +564,7 @@ export function tryParseNamingFromStream(buffer: string): NamingPause | null {
|
||||
|
||||
System prompt 明确要求 JSON 格式或 HTML 段落;走向步要求恰好 3 条。
|
||||
|
||||
- [ ] **Step 4: 运行 PASS + Commit**
|
||||
- [x] **Step 4: 运行 PASS + Commit**
|
||||
|
||||
```bash
|
||||
npm run test -- tests/main/interactive-parse.test.ts
|
||||
@@ -579,7 +579,7 @@ git commit -m "feat: add interactive prompt builder and JSON parsers"
|
||||
- Create: `src/main/services/interactive-writing.service.ts`
|
||||
- Create: `tests/main/interactive-finish.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写无章 AI 的 finish 测试**
|
||||
- [x] **Step 1: 写无章 AI 的 finish 测试**
|
||||
|
||||
`tests/main/interactive-finish.test.ts`(IT-INT-04,无 LM Studio):
|
||||
|
||||
@@ -633,7 +633,7 @@ describe('InteractiveWritingService.finishChapter', () => {
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 `InteractiveWritingService`**
|
||||
- [x] **Step 2: 实现 `InteractiveWritingService`**
|
||||
|
||||
构造函数注入 `db` + `AiClientService`(或窄接口 `{ chat, chatStream }`)。
|
||||
|
||||
@@ -656,13 +656,13 @@ finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: st
|
||||
|
||||
其余方法按 spec 步骤机实现;`suggestPlots` 调 `aiClient.chat` + `parsePlotsJson`(失败重试 1 次);`generateScene` 流式累积 buffer,开头检测 `tryParseNamingFromStream` → 写 `naming_pending_json` 并切 `naming_pause`。
|
||||
|
||||
- [ ] **Step 3: 运行 PASS**
|
||||
- [x] **Step 3: 运行 PASS**
|
||||
|
||||
```bash
|
||||
npm run test -- tests/main/interactive-finish.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add InteractiveWritingService with finishChapter"
|
||||
@@ -678,7 +678,7 @@ git commit -m "feat: add InteractiveWritingService with finishChapter"
|
||||
- Modify: `src/preload/index.ts`
|
||||
- Modify: `src/shared/electron-api.d.ts`
|
||||
|
||||
- [ ] **Step 1: 实现 `registerInteractiveHandlers`**
|
||||
- [x] **Step 1: 实现 `registerInteractiveHandlers`**
|
||||
|
||||
模式参照 `ai.handler.ts`:
|
||||
|
||||
@@ -687,7 +687,7 @@ git commit -m "feat: add InteractiveWritingService with finishChapter"
|
||||
- `generateScene` / `refineScene` / `resolveNaming`:注册 `activeInteractive` AbortController Map;流式时 `win.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, payload)`
|
||||
- `finishChapter`:需 `{ bookId, flowId, volumeId, title? }`
|
||||
|
||||
- [ ] **Step 2: `register.ts` 注册**
|
||||
- [x] **Step 2: `register.ts` 注册**
|
||||
|
||||
```typescript
|
||||
import { registerInteractiveHandlers } from './handlers/interactive.handler'
|
||||
@@ -695,7 +695,7 @@ import { registerInteractiveHandlers } from './handlers/interactive.handler'
|
||||
registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: preload 暴露 `interactive` 命名空间**
|
||||
- [x] **Step 3: preload 暴露 `interactive` 命名空间**
|
||||
|
||||
```typescript
|
||||
interactive: {
|
||||
@@ -707,7 +707,7 @@ interactive: {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 手动 smoke**
|
||||
- [x] **Step 4: 手动 smoke**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -715,7 +715,7 @@ npm run build
|
||||
|
||||
Expected: 编译无 TS 错误
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: wire interactive IPC handlers and preload API"
|
||||
@@ -729,7 +729,7 @@ git commit -m "feat: wire interactive IPC handlers and preload API"
|
||||
- Create: `tests/main/interactive-plots.test.ts`
|
||||
- Create: `tests/main/interactive-scene.test.ts`
|
||||
|
||||
- [ ] **Step 1: IT-INT-01**
|
||||
- [x] **Step 1: IT-INT-01**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
@@ -755,14 +755,14 @@ describe('interactive plots integration', () => {
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: IT-INT-02 / IT-INT-03**(`interactive-scene.test.ts`)
|
||||
- [x] **Step 2: IT-INT-02 / IT-INT-03**(`interactive-scene.test.ts`)
|
||||
|
||||
- 先 `selectPlot`,再 `generateScene` 流式收集,断言正文长度 > 100
|
||||
- 若模型返回 naming JSON,走 `resolveNaming` 后续写
|
||||
|
||||
超时:`240_000`
|
||||
|
||||
- [ ] **Step 3: 全量单元测试**
|
||||
- [x] **Step 3: 全量单元测试**
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
@@ -770,7 +770,7 @@ npm run test
|
||||
|
||||
Expected: 50+ tests PASS(需 LM Studio)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "test: add interactive LM Studio integration tests"
|
||||
@@ -787,7 +787,7 @@ git commit -m "test: add interactive LM Studio integration tests"
|
||||
- Modify: `src/renderer/components/ai/AiPanel.tsx`
|
||||
- Modify: `src/renderer/styles/layout.css`
|
||||
|
||||
- [ ] **Step 1: `useInteractiveStore`**
|
||||
- [x] **Step 1: `useInteractiveStore`**
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
@@ -832,11 +832,11 @@ export const useInteractiveStore = create<InteractiveState>((set, get) => ({
|
||||
}))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `PlotOptionCard.tsx`**
|
||||
- [x] **Step 2: `PlotOptionCard.tsx`**
|
||||
|
||||
可点击卡片,`data-testid={`plot-option-${id}`}`,展示 label + summary。
|
||||
|
||||
- [ ] **Step 3: `InteractivePanel.tsx`**
|
||||
- [x] **Step 3: `InteractivePanel.tsx`**
|
||||
|
||||
按 `flow.step` 条件渲染;关键 testid:
|
||||
|
||||
@@ -850,7 +850,7 @@ export const useInteractiveStore = create<InteractiveState>((set, get) => ({
|
||||
- `interactive-accept-scene`
|
||||
- `interactive-finish-chapter`
|
||||
|
||||
- [ ] **Step 4: 修改 `AiPanel.tsx`**
|
||||
- [x] **Step 4: 修改 `AiPanel.tsx`**
|
||||
|
||||
```typescript
|
||||
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
|
||||
@@ -882,11 +882,11 @@ const handleModeClick = (mode: AiWritingMode): void => {
|
||||
|
||||
模式 Tab:`interactive` 在 `!gateEligible` 时 `disabled` + `title={t('interactive.gateTooltip')}`。
|
||||
|
||||
- [ ] **Step 5: CSS**
|
||||
- [x] **Step 5: CSS**
|
||||
|
||||
在 `layout.css` 追加 `.interactive-panel`、`.plot-option-card`、`.naming-options`、`.interactive-step-bar`(参考 `ui_pc.html` `.plot-opt`)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add InteractivePanel UI and wire AiPanel mode switch"
|
||||
@@ -901,11 +901,11 @@ git commit -m "feat: add InteractivePanel UI and wire AiPanel mode switch"
|
||||
- Modify: `public/locales/en/translation.json`
|
||||
- Modify: `src/renderer/stores/useInteractiveStore.ts`(finish 后导航)
|
||||
|
||||
- [ ] **Step 1: 追加文案键**
|
||||
- [x] **Step 1: 追加文案键**
|
||||
|
||||
`interactive.gateBlocked`、`interactive.gateTooltip`、`interactive.step.context`、`interactive.step.plot`、`interactive.step.scene`、`interactive.step.review`、`interactive.start`、`interactive.acceptScene`、`interactive.finishChapter`、`interactive.chapterCreated`、`interactive.namingTitle`、`interactive.refinePlaceholder`
|
||||
|
||||
- [ ] **Step 2: `finishChapter` 成功后**
|
||||
- [x] **Step 2: `finishChapter` 成功后**
|
||||
|
||||
```typescript
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
@@ -922,7 +922,7 @@ useAppStore.getState().setView('editor')
|
||||
showToast(t('interactive.chapterCreated'))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add interactive i18n and chapter navigation after finish"
|
||||
@@ -935,7 +935,7 @@ git commit -m "feat: add interactive i18n and chapter navigation after finish"
|
||||
**Files:**
|
||||
- Create: `e2e/interactive-writing.spec.ts`
|
||||
|
||||
- [ ] **Step 1: E2E-INT-02(门槛,无 AI)**
|
||||
- [x] **Step 1: E2E-INT-02(门槛,无 AI)**
|
||||
|
||||
```typescript
|
||||
test('E2E-INT-02: interactive tab disabled on empty book', async () => {
|
||||
@@ -945,7 +945,7 @@ test('E2E-INT-02: interactive tab disabled on empty book', async () => {
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: E2E-INT-01(完整流程,300s)**
|
||||
- [x] **Step 2: E2E-INT-01(完整流程,300s)**
|
||||
|
||||
```typescript
|
||||
test('E2E-INT-01: interactive flow creates chapter', async () => {
|
||||
@@ -963,17 +963,17 @@ test('E2E-INT-01: interactive flow creates chapter', async () => {
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: E2E-INT-03(重启恢复,300s)**
|
||||
- [x] **Step 3: E2E-INT-03(重启恢复,300s)**
|
||||
|
||||
进入交互 mid-flow → 关闭 app → 重开 → `getFlow` 恢复步骤 UI。
|
||||
|
||||
- [ ] **Step 4: 运行 E2E**
|
||||
- [x] **Step 4: 运行 E2E**
|
||||
|
||||
```bash
|
||||
npm run test:e2e -- e2e/interactive-writing.spec.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "test: add interactive writing E2E specs"
|
||||
@@ -989,11 +989,11 @@ git commit -m "test: add interactive writing E2E specs"
|
||||
- Modify: `src/renderer/components/settings/SettingsPage.tsx`(about 版本号)
|
||||
- Modify: `docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md`(勾选完成项)
|
||||
|
||||
- [ ] **Step 1: 版本号 → `0.4.0`**
|
||||
- [x] **Step 1: 版本号 → `0.4.0`**
|
||||
|
||||
- [ ] **Step 2: README 功能概览追加 P4 交互写作**
|
||||
- [x] **Step 2: README 功能概览追加 P4 交互写作**
|
||||
|
||||
- [ ] **Step 3: 全量测试**
|
||||
- [x] **Step 3: 全量测试**
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
@@ -1003,7 +1003,7 @@ npm run test:e2e
|
||||
|
||||
Expected: 单元全部 PASS;E2E 全部 PASS(LM Studio 运行中)
|
||||
|
||||
- [ ] **Step 4: Commit + tag(可选)**
|
||||
- [x] **Step 4: Commit + tag(可选)**
|
||||
|
||||
```bash
|
||||
git commit -m "chore: release v0.4.0 with interactive writing mode"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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 })
|
||||
}
|
||||
|
||||
test.describe('Interactive writing', () => {
|
||||
let userDataDir: string
|
||||
|
||||
test.beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-int-'))
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
if (userDataDir && existsSync(userDataDir)) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('E2E-INT-02: interactive 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-interactive')).toBeDisabled({ timeout: 10_000 })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('E2E-INT-01: interactive 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 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-panel')).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByTestId('interactive-start').click()
|
||||
await expect(page.getByTestId('plot-option-A')).toBeVisible({ timeout: 120_000 })
|
||||
await page.getByTestId('plot-option-A').click()
|
||||
await page.getByTestId('interactive-confirm-plot').click()
|
||||
const naming0 = page.getByTestId('naming-option-0')
|
||||
const acceptBtn = page.getByTestId('interactive-accept-scene')
|
||||
await expect(naming0.or(acceptBtn)).toBeVisible({ timeout: 120_000 })
|
||||
if (await naming0.isVisible()) {
|
||||
await naming0.click()
|
||||
await expect(acceptBtn).toBeVisible({ timeout: 120_000 })
|
||||
}
|
||||
await acceptBtn.click()
|
||||
const finishBtn = page.getByTestId('interactive-finish-chapter')
|
||||
await expect(finishBtn).toBeVisible({ timeout: 120_000 })
|
||||
await finishBtn.click()
|
||||
await expect(page.locator('.ProseMirror')).not.toBeEmpty({ timeout: 30_000 })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('E2E-INT-03: restores flow after restart', async () => {
|
||||
test.setTimeout(300_000)
|
||||
const app1 = await launchApp(userDataDir)
|
||||
const page1 = await app1.firstWindow()
|
||||
await skipOnboarding(page1)
|
||||
await openBookWithContent(page1)
|
||||
await page1.getByTestId('panel-tab-ai').click()
|
||||
await page1.getByTestId('ai-session-new').click()
|
||||
const sessionValue = await page1.getByTestId('ai-session-select').inputValue()
|
||||
expect(sessionValue).not.toBe('')
|
||||
await page1.getByTestId('ai-mode-tab-interactive').click()
|
||||
await page1.getByTestId('interactive-start').click()
|
||||
await expect(page1.getByTestId('plot-option-A')).toBeVisible({ timeout: 120_000 })
|
||||
await page1.getByTestId('plot-option-A').click()
|
||||
await page1.getByTestId('interactive-confirm-plot').click()
|
||||
const naming0 = page1.getByTestId('naming-option-0')
|
||||
const acceptBtn = page1.getByTestId('interactive-accept-scene')
|
||||
await expect(naming0.or(acceptBtn)).toBeVisible({ timeout: 120_000 })
|
||||
if (await naming0.isVisible()) {
|
||||
await naming0.click()
|
||||
await expect(acceptBtn).toBeVisible({ timeout: 120_000 })
|
||||
}
|
||||
await expect(acceptBtn).toBeVisible()
|
||||
|
||||
await app1.close()
|
||||
|
||||
const app2 = await launchApp(userDataDir)
|
||||
const page2 = await app2.firstWindow()
|
||||
await skipOnboarding(page2)
|
||||
await page2.getByText('交互测试书').click()
|
||||
await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||
await page2.getByTestId('panel-tab-ai').click()
|
||||
await expect(page2.getByTestId('ai-session-select')).not.toHaveValue('', { timeout: 10_000 })
|
||||
await page2.getByTestId('ai-session-select').selectOption(sessionValue)
|
||||
await page2.getByTestId('ai-mode-tab-interactive').click()
|
||||
await expect(page2.getByTestId('interactive-accept-scene')).toBeVisible({ timeout: 30_000 })
|
||||
await app2.close()
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bilin",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "笔临 - 长篇创作智能协作平台",
|
||||
"main": "./out/main/index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -91,6 +91,27 @@
|
||||
"ai.networkRestored": "Network restored. Cloud AI is available again.",
|
||||
"ai.insertToEditor": "Insert into editor",
|
||||
"ai.inserted": "Inserted into editor",
|
||||
"ai.sending": "AI is thinking…",
|
||||
"interactive.gateBlocked": "Add chapter content, outline, or settings before interactive writing",
|
||||
"interactive.gateTooltip": "Requires chapter text or outline/settings",
|
||||
"interactive.step.context": "Context",
|
||||
"interactive.step.plot": "Plot",
|
||||
"interactive.step.scene": "Scene",
|
||||
"interactive.step.review": "Review",
|
||||
"interactive.start": "Start interactive",
|
||||
"interactive.loadingPlots": "Generating plot options…",
|
||||
"interactive.plotNotePlaceholder": "Additional instructions (optional)",
|
||||
"interactive.generateScene": "Generate scene",
|
||||
"interactive.namingTitle": "Name this {{type}}",
|
||||
"interactive.customName": "Custom name",
|
||||
"interactive.useCustomName": "Use this name",
|
||||
"interactive.refinePlaceholder": "Refinement instruction, e.g. make dialogue more hostile",
|
||||
"interactive.refine": "Refine scene",
|
||||
"interactive.acceptScene": "Next scene",
|
||||
"interactive.finishChapter": "Finish chapter",
|
||||
"interactive.chapterTitlePlaceholder": "Chapter title (optional)",
|
||||
"interactive.chapterCreated": "Interactive draft chapter created",
|
||||
"interactive.sceneCount": "{{count}} scene(s) confirmed",
|
||||
"snapshot.ai": "Before AI insert",
|
||||
"naming.open": "🔍 Naming consistency check",
|
||||
"naming.title": "Naming consistency check",
|
||||
|
||||
@@ -91,6 +91,27 @@
|
||||
"ai.networkRestored": "网络已恢复,可继续使用云端 AI",
|
||||
"ai.insertToEditor": "插入到编辑器",
|
||||
"ai.inserted": "已插入到编辑器",
|
||||
"ai.sending": "AI 思考中…",
|
||||
"interactive.gateBlocked": "请先创建章节正文、大纲或设定后再使用交互写作",
|
||||
"interactive.gateTooltip": "需要至少一章正文,或存在大纲/设定",
|
||||
"interactive.step.context": "上下文",
|
||||
"interactive.step.plot": "走向",
|
||||
"interactive.step.scene": "场景",
|
||||
"interactive.step.review": "确认",
|
||||
"interactive.start": "开始交互",
|
||||
"interactive.loadingPlots": "正在生成剧情走向…",
|
||||
"interactive.plotNotePlaceholder": "补充指令(可选)",
|
||||
"interactive.generateScene": "生成场景",
|
||||
"interactive.namingTitle": "请为{{type}}命名",
|
||||
"interactive.customName": "自定义名称",
|
||||
"interactive.useCustomName": "使用此名称",
|
||||
"interactive.refinePlaceholder": "输入微调指令,如:让对话更有敌意",
|
||||
"interactive.refine": "微调场景",
|
||||
"interactive.acceptScene": "满意,下一场景",
|
||||
"interactive.finishChapter": "结束本章",
|
||||
"interactive.chapterTitlePlaceholder": "章节标题(可选)",
|
||||
"interactive.chapterCreated": "已生成交互初稿章节",
|
||||
"interactive.sceneCount": "已确认 {{count}} 个场景",
|
||||
"snapshot.ai": "AI 生成前",
|
||||
"naming.open": "🔍 命名一致性检查",
|
||||
"naming.title": "命名一致性检查",
|
||||
|
||||
@@ -2,8 +2,9 @@ import type { SqliteDb } from './types'
|
||||
import schemaV1 from './schema-v1.sql?raw'
|
||||
import schemaV2 from './schema-v2.sql?raw'
|
||||
import schemaV3 from './schema-v3.sql?raw'
|
||||
import schemaV4 from './schema-v4.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 3
|
||||
const CURRENT_VERSION = 4
|
||||
|
||||
function tryAlter(db: SqliteDb, sql: string): void {
|
||||
try {
|
||||
@@ -48,5 +49,12 @@ export function migrate(db: SqliteDb): void {
|
||||
if (current < 3) {
|
||||
db.exec(schemaV3)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(3, 'P3 AI schema')
|
||||
current = 3
|
||||
}
|
||||
|
||||
if (current < 4) {
|
||||
tryAlter(db, "ALTER TABLE chapters ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'")
|
||||
db.exec(schemaV4)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(4, 'P4 interactive schema')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { countWords } from '../../services/word-count'
|
||||
import type { Chapter, ChapterStatus } from '../../../shared/types'
|
||||
import type { Chapter, ChapterOrigin, ChapterStatus } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
function mapRow(row: Record<string, unknown>): Chapter {
|
||||
return {
|
||||
@@ -11,21 +11,28 @@ function mapRow(row: Record<string, unknown>): Chapter {
|
||||
status: row.status as ChapterStatus,
|
||||
wordCount: row.word_count 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
|
||||
}
|
||||
}
|
||||
|
||||
export class ChapterRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
create(volumeId: string, title: string, sortOrder: number, content = ''): Chapter {
|
||||
create(
|
||||
volumeId: string,
|
||||
title: string,
|
||||
sortOrder: number,
|
||||
content = '',
|
||||
origin: ChapterOrigin = 'manual'
|
||||
): Chapter {
|
||||
const id = randomUUID()
|
||||
const wordCount = countWords(content)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order, origin)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, volumeId, title, content, wordCount, sortOrder)
|
||||
.run(id, volumeId, title, content, wordCount, sortOrder, origin)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type {
|
||||
InteractiveFlow,
|
||||
InteractiveFlowStatus,
|
||||
InteractiveScene,
|
||||
InteractiveStep,
|
||||
NamingPause
|
||||
} from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
export class InteractiveRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
getBySession(sessionId: string): InteractiveFlow | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM interactive_flows WHERE session_id = ? AND status = 'in_progress' ORDER BY updated_at DESC LIMIT 1`
|
||||
)
|
||||
.get(sessionId) as Record<string, unknown> | undefined
|
||||
return row ? this.mapFlow(row) : null
|
||||
}
|
||||
|
||||
get(flowId: string): InteractiveFlow | null {
|
||||
const row = this.db.prepare('SELECT * FROM interactive_flows WHERE id = ?').get(flowId) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? this.mapFlow(row) : null
|
||||
}
|
||||
|
||||
create(sessionId: string): InteractiveFlow {
|
||||
const id = randomUUID()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO interactive_flows (id, session_id, step, status, context_json) VALUES (?, ?, 'context_confirm', 'in_progress', '{}')`
|
||||
)
|
||||
.run(id, sessionId)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
resetInProgress(sessionId: string): InteractiveFlow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE interactive_flows SET status = 'abandoned', updated_at = datetime('now') WHERE session_id = ? AND status = 'in_progress'`
|
||||
)
|
||||
.run(sessionId)
|
||||
return this.create(sessionId)
|
||||
}
|
||||
|
||||
updateStep(
|
||||
flowId: string,
|
||||
step: InteractiveStep,
|
||||
patch: {
|
||||
contextJson?: string
|
||||
plotSelectionJson?: string | null
|
||||
namingPendingJson?: string | null
|
||||
sceneDraftHtml?: string | null
|
||||
status?: InteractiveFlowStatus
|
||||
} = {}
|
||||
): InteractiveFlow {
|
||||
const sets: string[] = ['step = ?', `updated_at = datetime('now')`]
|
||||
const vals: unknown[] = [step]
|
||||
if (patch.contextJson !== undefined) {
|
||||
sets.push('context_json = ?')
|
||||
vals.push(patch.contextJson)
|
||||
}
|
||||
if (patch.plotSelectionJson !== undefined) {
|
||||
sets.push('plot_selection_json = ?')
|
||||
vals.push(patch.plotSelectionJson)
|
||||
}
|
||||
if (patch.namingPendingJson !== undefined) {
|
||||
sets.push('naming_pending_json = ?')
|
||||
vals.push(patch.namingPendingJson)
|
||||
}
|
||||
if (patch.sceneDraftHtml !== undefined) {
|
||||
sets.push('scene_draft_html = ?')
|
||||
vals.push(patch.sceneDraftHtml)
|
||||
}
|
||||
if (patch.status !== undefined) {
|
||||
sets.push('status = ?')
|
||||
vals.push(patch.status)
|
||||
}
|
||||
vals.push(flowId)
|
||||
this.db.prepare(`UPDATE interactive_flows SET ${sets.join(', ')} WHERE id = ?`).run(...vals)
|
||||
return this.get(flowId)!
|
||||
}
|
||||
|
||||
listScenes(flowId: string): InteractiveScene[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM interactive_scenes WHERE flow_id = ? ORDER BY sort_order')
|
||||
.all(flowId) as Record<string, unknown>[]
|
||||
return rows.map((r) => ({
|
||||
id: r.id as string,
|
||||
sortOrder: r.sort_order as number,
|
||||
contentHtml: r.content_html as string,
|
||||
plotLabel: r.plot_label as string,
|
||||
refinedCount: r.refined_count as number
|
||||
}))
|
||||
}
|
||||
|
||||
addScene(flowId: string, contentHtml: string, plotLabel: string): InteractiveScene {
|
||||
const id = randomUUID()
|
||||
const max = this.db
|
||||
.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM interactive_scenes WHERE flow_id = ?')
|
||||
.get(flowId) as { m: number }
|
||||
const sortOrder = max.m + 1
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO interactive_scenes (id, flow_id, sort_order, content_html, plot_label) VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, flowId, sortOrder, contentHtml, plotLabel)
|
||||
return this.listScenes(flowId).find((s) => s.id === id)!
|
||||
}
|
||||
|
||||
updateSceneContent(flowId: string, sceneId: string, contentHtml: string, refinedCount: number): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE interactive_scenes SET content_html = ?, refined_count = ? WHERE id = ? AND flow_id = ?`
|
||||
)
|
||||
.run(contentHtml, refinedCount, sceneId, flowId)
|
||||
}
|
||||
|
||||
getSceneDraft(flowId: string): string {
|
||||
const row = this.db
|
||||
.prepare('SELECT scene_draft_html FROM interactive_flows WHERE id = ?')
|
||||
.get(flowId) as { scene_draft_html: string | null } | undefined
|
||||
return row?.scene_draft_html ?? ''
|
||||
}
|
||||
|
||||
setSceneDraft(flowId: string, html: string): void {
|
||||
this.db
|
||||
.prepare(`UPDATE interactive_flows SET scene_draft_html = ?, updated_at = datetime('now') WHERE id = ?`)
|
||||
.run(html, flowId)
|
||||
}
|
||||
|
||||
getPlotSelection(flowId: string): string {
|
||||
const row = this.db
|
||||
.prepare('SELECT plot_selection_json FROM interactive_flows WHERE id = ?')
|
||||
.get(flowId) as { plot_selection_json: string | null } | undefined
|
||||
return row?.plot_selection_json ?? '{}'
|
||||
}
|
||||
|
||||
getContextJson(flowId: string): string {
|
||||
const row = this.db
|
||||
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
|
||||
.get(flowId) as { context_json: string } | undefined
|
||||
return row?.context_json ?? '{}'
|
||||
}
|
||||
|
||||
private mapFlow(row: Record<string, unknown>): InteractiveFlow {
|
||||
const flowId = row.id as string
|
||||
let namingPending: NamingPause | null = null
|
||||
if (row.naming_pending_json) {
|
||||
try {
|
||||
namingPending = JSON.parse(row.naming_pending_json as string) as NamingPause
|
||||
} catch {
|
||||
namingPending = null
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: flowId,
|
||||
sessionId: row.session_id as string,
|
||||
step: row.step as InteractiveStep,
|
||||
status: row.status as InteractiveFlowStatus,
|
||||
scenes: this.listScenes(flowId),
|
||||
namingPending,
|
||||
contextSummary: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS interactive_flows (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
step TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress',
|
||||
context_json TEXT NOT NULL DEFAULT '{}',
|
||||
plot_selection_json TEXT,
|
||||
naming_pending_json TEXT,
|
||||
scene_draft_html TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (session_id) REFERENCES ai_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS interactive_scenes (
|
||||
id TEXT PRIMARY KEY,
|
||||
flow_id TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
content_html TEXT NOT NULL DEFAULT '',
|
||||
plot_label TEXT NOT NULL DEFAULT '',
|
||||
refined_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (flow_id) REFERENCES interactive_flows(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_interactive_flows_session ON interactive_flows(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactive_scenes_flow ON interactive_scenes(flow_id, sort_order);
|
||||
@@ -0,0 +1,212 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { AiContextBinding, PlotSelection } from '../../../shared/types'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
import { GlobalSettingsService } from '../../services/global-settings'
|
||||
import { AiClientService } from '../../services/ai-client.service'
|
||||
import { aiContextBuilder } from '../../services/ai-context-builder.service'
|
||||
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
||||
import { InteractiveWritingService } from '../../services/interactive-writing.service'
|
||||
import { wrap } from '../result'
|
||||
|
||||
const activeInteractive = new Map<string, AbortController>()
|
||||
|
||||
function serviceFor(registry: BookRegistryService, bookId: string, aiClient: AiClientService) {
|
||||
return new InteractiveWritingService(registry.getDb(bookId), aiClient)
|
||||
}
|
||||
|
||||
function enrich(registry: BookRegistryService, bookId: string, aiClient: AiClientService, sessionId: string) {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
const flow = svc.getFlow(sessionId)
|
||||
return flow ? svc.enrichFlow(flow) : null
|
||||
}
|
||||
|
||||
export function registerInteractiveHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService,
|
||||
aiClient: AiClientService
|
||||
): void {
|
||||
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
|
||||
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INTERACTIVE_GET_FLOW,
|
||||
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
|
||||
wrap(() => enrich(registry, bookId, aiClient, sessionId))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INTERACTIVE_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.INTERACTIVE_CONFIRM_CONTEXT,
|
||||
(
|
||||
_e,
|
||||
{
|
||||
bookId,
|
||||
flowId,
|
||||
binding
|
||||
}: { bookId: string; flowId: string; binding: AiContextBinding }
|
||||
) =>
|
||||
wrap(() => {
|
||||
const built = aiContextBuilder.build(binding, registry, bookId, settings.get().penName)
|
||||
const summary = built.preview.map((p) => p.title).join('、') || '(空)'
|
||||
const flow = serviceFor(registry, bookId, aiClient).confirmContext(
|
||||
flowId,
|
||||
binding,
|
||||
summary
|
||||
)
|
||||
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INTERACTIVE_SUGGEST_PLOTS,
|
||||
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(() => serviceFor(registry, bookId, aiClient).suggestPlots(flowId))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INTERACTIVE_SELECT_PLOT,
|
||||
(
|
||||
_e,
|
||||
{ bookId, flowId, selection }: { bookId: string; flowId: string; selection: PlotSelection }
|
||||
) =>
|
||||
wrap(() => {
|
||||
const flow = serviceFor(registry, bookId, aiClient).selectPlot(flowId, selection)
|
||||
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INTERACTIVE_GENERATE_SCENE,
|
||||
(event, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(async () => {
|
||||
const controller = new AbortController()
|
||||
activeInteractive.set(flowId, controller)
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
try {
|
||||
const flow = await serviceFor(registry, bookId, aiClient).generateScene(
|
||||
flowId,
|
||||
(delta, done) => {
|
||||
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
|
||||
flowId,
|
||||
phase: 'scene',
|
||||
delta,
|
||||
done
|
||||
})
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
||||
} finally {
|
||||
activeInteractive.delete(flowId)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INTERACTIVE_RESOLVE_NAMING,
|
||||
(
|
||||
event,
|
||||
{ bookId, flowId, chosenName }: { bookId: string; flowId: string; chosenName: string }
|
||||
) =>
|
||||
wrap(async () => {
|
||||
const controller = new AbortController()
|
||||
activeInteractive.set(flowId, controller)
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
try {
|
||||
const flow = await serviceFor(registry, bookId, aiClient).resolveNaming(
|
||||
flowId,
|
||||
chosenName,
|
||||
(delta, done) => {
|
||||
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
|
||||
flowId,
|
||||
phase: 'naming_resume',
|
||||
delta,
|
||||
done
|
||||
})
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
||||
} finally {
|
||||
activeInteractive.delete(flowId)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INTERACTIVE_REFINE_SCENE,
|
||||
(
|
||||
event,
|
||||
{ bookId, flowId, instruction }: { bookId: string; flowId: string; instruction: string }
|
||||
) =>
|
||||
wrap(async () => {
|
||||
const controller = new AbortController()
|
||||
activeInteractive.set(flowId, controller)
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
try {
|
||||
const flow = await serviceFor(registry, bookId, aiClient).refineScene(
|
||||
flowId,
|
||||
instruction,
|
||||
(delta, done) => {
|
||||
win?.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, {
|
||||
flowId,
|
||||
phase: 'refine',
|
||||
delta,
|
||||
done
|
||||
})
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
||||
} finally {
|
||||
activeInteractive.delete(flowId)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INTERACTIVE_ACCEPT_SCENE,
|
||||
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(() => {
|
||||
const flow = serviceFor(registry, bookId, aiClient).acceptScene(flowId)
|
||||
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INTERACTIVE_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.INTERACTIVE_ABORT, (_e, { flowId }: { flowId: string }) =>
|
||||
wrap(() => {
|
||||
activeInteractive.get(flowId)?.abort()
|
||||
activeInteractive.delete(flowId)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INTERACTIVE_GET_SCENE_DRAFT,
|
||||
(_e, { bookId, flowId }: { bookId: string; flowId: string }) =>
|
||||
wrap(() => serviceFor(registry, bookId, aiClient).getSceneDraft(flowId))
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { registerSnapshotHandlers } from './handlers/snapshot.handler'
|
||||
import { registerBookmarkHandlers } from './handlers/bookmark.handler'
|
||||
import { registerSearchHandlers } from './handlers/search.handler'
|
||||
import { registerAiHandlers } from './handlers/ai.handler'
|
||||
import { registerInteractiveHandlers } from './handlers/interactive.handler'
|
||||
import { AiClientService } from '../services/ai-client.service'
|
||||
import { NetworkMonitorService } from '../services/network-monitor.service'
|
||||
|
||||
@@ -42,6 +43,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
||||
registerBookmarkHandlers(books)
|
||||
registerSearchHandlers(books, settings)
|
||||
registerAiHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
||||
registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
||||
|
||||
return { settings, books, shortcuts: shortcutManager }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { OutlineRepository } from '../db/repositories/outline.repo'
|
||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||
import { InspirationRepository } from '../db/repositories/inspiration.repo'
|
||||
import { AiSessionRepository } from '../db/repositories/ai-session.repo'
|
||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
|
||||
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
|
||||
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
|
||||
@@ -150,6 +151,10 @@ export class BookRegistryService {
|
||||
return new AiSessionRepository(this.getDb(bookId))
|
||||
}
|
||||
|
||||
getInteractiveRepo(bookId: string): InteractiveRepository {
|
||||
return new InteractiveRepository(this.getDb(bookId))
|
||||
}
|
||||
|
||||
getSnapshotRepo(bookId: string): SnapshotRepository {
|
||||
return new SnapshotRepository(this.getDb(bookId))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { InteractiveGateResult } from '../../shared/types'
|
||||
import type { SqliteDb } from '../db/types'
|
||||
import { stripHtml } from './word-count'
|
||||
|
||||
export function checkInteractiveGate(db: SqliteDb): InteractiveGateResult {
|
||||
const chapters = db.prepare('SELECT content FROM chapters').all() as { content: string }[]
|
||||
for (const ch of chapters) {
|
||||
if (stripHtml(ch.content ?? '').trim().length > 0) {
|
||||
return { eligible: true }
|
||||
}
|
||||
}
|
||||
|
||||
const outlineCount = db.prepare('SELECT COUNT(*) AS c FROM outline_items').get() as { c: number }
|
||||
const settingCount = db.prepare('SELECT COUNT(*) AS c FROM settings').get() as { c: number }
|
||||
if (outlineCount.c > 0 || settingCount.c > 0) {
|
||||
return { eligible: true }
|
||||
}
|
||||
|
||||
const anyChapter = db.prepare('SELECT COUNT(*) AS c FROM chapters').get() as { c: number }
|
||||
return {
|
||||
eligible: false,
|
||||
reason: anyChapter.c === 0 ? 'no_chapter' : 'no_outline_or_setting'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { NamingPause, PlotOption } from '../../shared/types'
|
||||
|
||||
export function parsePlotsJson(response: string): PlotOption[] {
|
||||
const trimmed = response.trim()
|
||||
const start = trimmed.indexOf('{')
|
||||
const end = trimmed.lastIndexOf('}')
|
||||
if (start < 0 || end <= start) throw new Error('plots JSON not found')
|
||||
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as { plots?: unknown }
|
||||
if (!Array.isArray(parsed.plots) || parsed.plots.length < 3) {
|
||||
throw new Error('plots array invalid')
|
||||
}
|
||||
const ids = ['A', 'B', 'C'] as const
|
||||
return parsed.plots.slice(0, 3).map((p, i) => {
|
||||
const row = p as Record<string, unknown>
|
||||
return {
|
||||
id: ids[i],
|
||||
label: String(row.label ?? row.id ?? ids[i]),
|
||||
summary: String(row.summary ?? '')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function parseNamingRequiredJson(response: string): NamingPause {
|
||||
const trimmed = response.trim()
|
||||
const start = trimmed.indexOf('{')
|
||||
const end = trimmed.lastIndexOf('}')
|
||||
if (start < 0 || end <= start) throw new Error('naming JSON not found')
|
||||
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as Record<string, unknown>
|
||||
if (parsed.type !== 'naming_required') throw new Error('not naming_required')
|
||||
const options = Array.isArray(parsed.options) ? parsed.options.map(String) : []
|
||||
if (options.length < 5) throw new Error('naming options < 5')
|
||||
return {
|
||||
elementType: String(parsed.elementType ?? '元素'),
|
||||
context: String(parsed.context ?? ''),
|
||||
options: options.slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
export function tryParseNamingFromBuffer(buffer: string): NamingPause | null {
|
||||
const t = buffer.trim()
|
||||
if (!t.startsWith('{') || !t.includes('naming_required')) return null
|
||||
try {
|
||||
return parseNamingRequiredJson(t)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { PlotSelection } from '../../shared/types'
|
||||
import type { ChatMessage } from './ai-client.service'
|
||||
|
||||
export function buildPlotSuggestMessages(contextText: string, sceneSummaries: string[]): ChatMessage[] {
|
||||
const prior =
|
||||
sceneSummaries.length > 0
|
||||
? `已确认场景摘要:\n${sceneSummaries.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n`
|
||||
: ''
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是长篇小说创作助手。根据上下文给出 3 个互斥的剧情走向建议。仅返回 JSON,不要 markdown。格式:{"plots":[{"id":"A","label":"标题","summary":"至少50字的剧情说明"},...]},恰好 3 条。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `${prior}上下文:\n${contextText}\n\n请给出接下来剧情的 3 个走向(A/B/C)。`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildSceneGenerateMessages(
|
||||
contextText: string,
|
||||
selection: PlotSelection,
|
||||
sceneSummaries: string[]
|
||||
): ChatMessage[] {
|
||||
const plotDesc = selection.optionIds.join('、')
|
||||
const note = selection.userNote ? `\n用户补充:${selection.userNote}` : ''
|
||||
const prior =
|
||||
sceneSummaries.length > 0
|
||||
? `已写场景:\n${sceneSummaries.join('\n---\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选定走向:${plotDesc}${note}\n\n请生成本场景。`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildNamingResumeMessages(
|
||||
chosenName: string,
|
||||
partialScene: string,
|
||||
context: string
|
||||
): ChatMessage[] {
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content: '继续完成小说场景,输出 HTML <p> 段落,将选定名称写入正文。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `命名说明:${context}\n选定名称:${chosenName}\n已有片段:${partialScene}\n\n请续写并完成本场景。`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildRefineMessages(currentSceneHtml: string, instruction: string): ChatMessage[] {
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content: '根据用户指令修订当前场景,输出完整修订后的 HTML <p> 段落,不要解释。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `当前场景:\n${currentSceneHtml}\n\n修改指令:${instruction}`
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type {
|
||||
AiContextBinding,
|
||||
InteractiveFlow,
|
||||
PlotOption,
|
||||
PlotSelection
|
||||
} from '../../shared/types'
|
||||
import type { SqliteDb } from '../db/types'
|
||||
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
import type { AiClientService } from './ai-client.service'
|
||||
import {
|
||||
buildNamingResumeMessages,
|
||||
buildPlotSuggestMessages,
|
||||
buildRefineMessages,
|
||||
buildSceneGenerateMessages
|
||||
} from './interactive-prompt-builder.service'
|
||||
import { parsePlotsJson, tryParseNamingFromBuffer } from './interactive-parse.service'
|
||||
import { stripHtml } from './word-count'
|
||||
|
||||
export class InteractiveWritingService {
|
||||
private repo: InteractiveRepository
|
||||
|
||||
constructor(
|
||||
private db: SqliteDb,
|
||||
private aiClient: AiClientService
|
||||
) {
|
||||
this.repo = new InteractiveRepository(db)
|
||||
}
|
||||
|
||||
start(sessionId: string): InteractiveFlow {
|
||||
return this.repo.resetInProgress(sessionId)
|
||||
}
|
||||
|
||||
getFlow(sessionId: string): InteractiveFlow | null {
|
||||
return this.repo.getBySession(sessionId)
|
||||
}
|
||||
|
||||
confirmContext(flowId: string, binding: AiContextBinding, contextSummary: string): InteractiveFlow {
|
||||
this.repo.updateStep(flowId, 'plot_suggest', {
|
||||
contextJson: JSON.stringify({ binding, contextSummary })
|
||||
})
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
|
||||
async suggestPlots(flowId: string): Promise<PlotOption[]> {
|
||||
const contextText = this.readContextText(flowId)
|
||||
const scenes = this.repo.listScenes(flowId)
|
||||
const summaries = scenes.map((s) => stripHtml(s.contentHtml).slice(0, 120))
|
||||
const messages = buildPlotSuggestMessages(contextText, summaries)
|
||||
|
||||
let response = await this.aiClient.chat(messages)
|
||||
try {
|
||||
const plots = parsePlotsJson(response)
|
||||
this.repo.updateStep(flowId, 'plot_select')
|
||||
return plots
|
||||
} catch {
|
||||
response = await this.aiClient.chat([
|
||||
...messages,
|
||||
{ role: 'user', content: '仅返回 JSON,不要 markdown 或其它说明。' }
|
||||
])
|
||||
const plots = parsePlotsJson(response)
|
||||
this.repo.updateStep(flowId, 'plot_select')
|
||||
return plots
|
||||
}
|
||||
}
|
||||
|
||||
selectPlot(flowId: string, selection: PlotSelection): InteractiveFlow {
|
||||
this.repo.updateStep(flowId, 'scene_generate', {
|
||||
plotSelectionJson: JSON.stringify(selection),
|
||||
sceneDraftHtml: '',
|
||||
namingPendingJson: null
|
||||
})
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
|
||||
async generateScene(
|
||||
flowId: 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')
|
||||
|
||||
const contextText = this.readContextText(flowId)
|
||||
const selection = JSON.parse(this.repo.getPlotSelection(flowId)) as PlotSelection
|
||||
const scenes = this.repo.listScenes(flowId)
|
||||
const summaries = scenes.map((s) => stripHtml(s.contentHtml).slice(0, 200))
|
||||
const messages = buildSceneGenerateMessages(contextText, selection, summaries)
|
||||
|
||||
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 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)!
|
||||
}
|
||||
|
||||
async resolveNaming(
|
||||
flowId: string,
|
||||
chosenName: 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')
|
||||
|
||||
const messages = buildNamingResumeMessages(
|
||||
chosenName,
|
||||
this.repo.getSceneDraft(flowId),
|
||||
flow.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, 'scene_review', { namingPendingJson: null })
|
||||
onChunk('', true)
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
|
||||
async refineScene(
|
||||
flowId: string,
|
||||
instruction: string,
|
||||
onChunk: (delta: string, done: boolean) => void,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<InteractiveFlow> {
|
||||
const draft = this.repo.getSceneDraft(flowId)
|
||||
const messages = buildRefineMessages(draft, instruction)
|
||||
let buffer = ''
|
||||
const full = await this.aiClient.chat(
|
||||
messages,
|
||||
(delta) => {
|
||||
buffer += delta
|
||||
onChunk(delta, false)
|
||||
},
|
||||
abortSignal
|
||||
)
|
||||
const html = this.normalizeSceneHtml(full || buffer)
|
||||
this.repo.setSceneDraft(flowId, html)
|
||||
onChunk('', true)
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
|
||||
acceptScene(flowId: string): InteractiveFlow {
|
||||
const draft = this.repo.getSceneDraft(flowId)
|
||||
if (!draft.trim()) throw new Error('empty scene draft')
|
||||
const selection = JSON.parse(this.repo.getPlotSelection(flowId)) as PlotSelection
|
||||
const plotLabel = selection.optionIds.join('+')
|
||||
this.repo.addScene(flowId, draft, plotLabel)
|
||||
this.repo.setSceneDraft(flowId, '')
|
||||
this.repo.updateStep(flowId, 'plot_suggest', {
|
||||
plotSelectionJson: null,
|
||||
namingPendingJson: null
|
||||
})
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
|
||||
finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: string } {
|
||||
const draft = this.repo.getSceneDraft(flowId).trim()
|
||||
if (draft) {
|
||||
const selection = JSON.parse(this.repo.getPlotSelection(flowId) || '{}') as PlotSelection
|
||||
this.repo.addScene(flowId, draft, selection.optionIds?.join('+') ?? '')
|
||||
this.repo.setSceneDraft(flowId, '')
|
||||
}
|
||||
|
||||
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 {
|
||||
return this.repo.getSceneDraft(flowId)
|
||||
}
|
||||
|
||||
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
|
||||
const raw = this.repo.getContextJson(flow.id)
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { contextSummary?: string }
|
||||
return { ...flow, contextSummary: parsed.contextSummary }
|
||||
} catch {
|
||||
return flow
|
||||
}
|
||||
}
|
||||
|
||||
private readContextText(flowId: string): string {
|
||||
try {
|
||||
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('')
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,11 @@ import type {
|
||||
NamingConflict,
|
||||
AiStreamChunkEvent,
|
||||
AiNetworkStatusEvent,
|
||||
InteractiveFlow,
|
||||
InteractiveGateResult,
|
||||
InteractiveStreamChunkEvent,
|
||||
PlotOption,
|
||||
PlotSelection,
|
||||
IpcResult,
|
||||
LandmarkType,
|
||||
OutlineItem,
|
||||
@@ -264,6 +269,55 @@ const electronAPI = {
|
||||
namingCheck: (bookId: string): Promise<IpcResult<NamingConflict[]>> =>
|
||||
ipcRenderer.invoke(IPC.AI_NAMING_CHECK, { bookId })
|
||||
},
|
||||
interactive: {
|
||||
checkGate: (bookId: string): Promise<IpcResult<InteractiveGateResult>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_CHECK_GATE, { bookId }),
|
||||
getFlow: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow | null>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_GET_FLOW, { bookId, sessionId }),
|
||||
start: (bookId: string, sessionId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_START, { bookId, sessionId }),
|
||||
confirmContext: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
binding: AiContextBinding
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_CONFIRM_CONTEXT, { bookId, flowId, binding }),
|
||||
suggestPlots: (bookId: string, flowId: string): Promise<IpcResult<PlotOption[]>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_SUGGEST_PLOTS, { bookId, flowId }),
|
||||
selectPlot: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
selection: PlotSelection
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_SELECT_PLOT, { bookId, flowId, selection }),
|
||||
generateScene: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_GENERATE_SCENE, { bookId, flowId }),
|
||||
resolveNaming: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
chosenName: string
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_RESOLVE_NAMING, { bookId, flowId, chosenName }),
|
||||
refineScene: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
instruction: string
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_REFINE_SCENE, { bookId, flowId, instruction }),
|
||||
acceptScene: (bookId: string, flowId: string): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_ACCEPT_SCENE, { bookId, flowId }),
|
||||
finishChapter: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
volumeId: string,
|
||||
title?: string
|
||||
): Promise<IpcResult<{ chapterId: string }>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_FINISH_CHAPTER, { bookId, flowId, volumeId, title }),
|
||||
abort: (flowId: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_ABORT, { flowId }),
|
||||
getSceneDraft: (bookId: string, flowId: string): Promise<IpcResult<string>> =>
|
||||
ipcRenderer.invoke(IPC.INTERACTIVE_GET_SCENE_DRAFT, { bookId, flowId })
|
||||
},
|
||||
wordfreq: {
|
||||
analyze: (
|
||||
bookId: string,
|
||||
@@ -297,6 +351,13 @@ const electronAPI = {
|
||||
}
|
||||
ipcRenderer.on(IPC.AI_NETWORK_STATUS, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.AI_NETWORK_STATUS, handler)
|
||||
},
|
||||
onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void): (() => void) => {
|
||||
const handler = (_event: IpcRendererEvent, payload: InteractiveStreamChunkEvent) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.INTERACTIVE_STREAM_CHUNK, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.INTERACTIVE_STREAM_CHUNK, handler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
|
||||
import { insertToEditor } from '@renderer/lib/editor-commands'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
|
||||
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||
|
||||
const MODES: { id: AiWritingMode; labelKey: string }[] = [
|
||||
{ id: 'chat', labelKey: 'ai.mode.chat' },
|
||||
@@ -39,6 +41,9 @@ export function AiPanel(): React.JSX.Element {
|
||||
const createSession = useAiStore((s) => s.createSession)
|
||||
const sendMessage = useAiStore((s) => s.sendMessage)
|
||||
const setWritingMode = useAiStore((s) => s.setWritingMode)
|
||||
const gateEligible = useInteractiveStore((s) => s.gateEligible)
|
||||
const checkGate = useInteractiveStore((s) => s.checkGate)
|
||||
const startInteractive = useInteractiveStore((s) => s.start)
|
||||
const [input, setInput] = useState('')
|
||||
const [contextOpen, setContextOpen] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
@@ -57,8 +62,11 @@ export function AiPanel(): React.JSX.Element {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (bookId) void loadSessions(bookId)
|
||||
}, [bookId, loadSessions])
|
||||
if (bookId) {
|
||||
void loadSessions(bookId)
|
||||
void checkGate(bookId)
|
||||
}
|
||||
}, [bookId, loadSessions, checkGate])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
@@ -86,10 +94,32 @@ export function AiPanel(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const handleModeClick = (mode: AiWritingMode): void => {
|
||||
if (mode !== 'chat') {
|
||||
if (mode === 'auto' || mode === 'wizard') {
|
||||
showToast(t('feature.comingSoon'))
|
||||
return
|
||||
}
|
||||
if (mode === 'interactive') {
|
||||
void (async () => {
|
||||
if (bookId) await checkGate(bookId)
|
||||
if (!useInteractiveStore.getState().gateEligible) {
|
||||
showToast(t('interactive.gateBlocked'))
|
||||
return
|
||||
}
|
||||
setWritingMode(mode)
|
||||
const sid = activeSessionId ?? useAiStore.getState().activeSessionId
|
||||
if (!bookId) return
|
||||
if (!sid) {
|
||||
await createSession(bookId)
|
||||
}
|
||||
const sessionId = useAiStore.getState().activeSessionId
|
||||
if (!sessionId) return
|
||||
await useInteractiveStore.getState().loadFlow(bookId, sessionId)
|
||||
if (!useInteractiveStore.getState().flow) {
|
||||
await startInteractive(bookId, sessionId)
|
||||
}
|
||||
})()
|
||||
return
|
||||
}
|
||||
setWritingMode(mode)
|
||||
}
|
||||
|
||||
@@ -128,18 +158,28 @@ export function AiPanel(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="ai-mode-tabs" role="tablist">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
|
||||
onClick={() => handleModeClick(mode.id)}
|
||||
>
|
||||
{t(mode.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
{MODES.map((mode) => {
|
||||
const interactiveDisabled = mode.id === 'interactive' && !gateEligible
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
|
||||
data-testid={`ai-mode-tab-${mode.id}`}
|
||||
disabled={interactiveDisabled}
|
||||
title={interactiveDisabled ? t('interactive.gateTooltip') : undefined}
|
||||
onClick={() => handleModeClick(mode.id)}
|
||||
>
|
||||
{t(mode.labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{writingMode === 'interactive' ? (
|
||||
<InteractivePanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="context-preview"
|
||||
@@ -222,6 +262,8 @@ export function AiPanel(): React.JSX.Element {
|
||||
{t('ai.send')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ContextEditorModal open={contextOpen} onClose={() => setContextOpen(false)} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PlotSelection } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||
import { PlotOptionCard } from '@renderer/components/ai/PlotOptionCard'
|
||||
|
||||
interface InteractivePanelProps {
|
||||
bookId: string
|
||||
sessionId: string | null
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function InteractivePanel({
|
||||
bookId,
|
||||
sessionId,
|
||||
disabled
|
||||
}: InteractivePanelProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const backend = useSettingsStore((s) => s.aiConfig.backend)
|
||||
const {
|
||||
flow,
|
||||
plots,
|
||||
streamBuffer,
|
||||
streaming,
|
||||
sceneDraft,
|
||||
loadFlow,
|
||||
confirmContextAndStart,
|
||||
selectPlot,
|
||||
resolveNaming,
|
||||
refineScene,
|
||||
acceptScene,
|
||||
finishChapter,
|
||||
mount,
|
||||
unmount
|
||||
} = useInteractiveStore()
|
||||
|
||||
const [contextOpen, setContextOpen] = useState(false)
|
||||
const [selectedPlots, setSelectedPlots] = useState<Array<'A' | 'B' | 'C'>>([])
|
||||
const [userNote, setUserNote] = useState('')
|
||||
const [refineInput, setRefineInput] = useState('')
|
||||
const [customName, setCustomName] = useState('')
|
||||
const [chapterTitle, setChapterTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
mount()
|
||||
return () => unmount()
|
||||
}, [mount, unmount])
|
||||
|
||||
useEffect(() => {
|
||||
if (bookId && sessionId) void loadFlow(bookId, sessionId)
|
||||
}, [bookId, sessionId, loadFlow])
|
||||
|
||||
if (!sessionId || !flow) {
|
||||
return (
|
||||
<div className="interactive-panel" data-testid="interactive-panel">
|
||||
<p className="placeholder-box">{t('ai.noSession')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const step = flow.step
|
||||
const previewHtml = streaming ? streamBuffer : sceneDraft
|
||||
const showReview =
|
||||
step === 'scene_review' ||
|
||||
(!streaming && sceneDraft.trim().length > 0 && step === 'scene_generate')
|
||||
const showFinishChapter = flow.scenes.length > 0 && flow.status !== 'completed'
|
||||
|
||||
const handleStart = (): void => {
|
||||
void confirmContextAndStart(bookId, flow.id)
|
||||
}
|
||||
|
||||
const handleConfirmPlot = (): void => {
|
||||
if (selectedPlots.length === 0) return
|
||||
const selection: PlotSelection = { optionIds: selectedPlots, userNote: userNote.trim() || undefined }
|
||||
void selectPlot(bookId, selection)
|
||||
setSelectedPlots([])
|
||||
setUserNote('')
|
||||
}
|
||||
|
||||
const togglePlot = (id: 'A' | 'B' | 'C'): void => {
|
||||
setSelectedPlots((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="interactive-panel" data-testid="interactive-panel">
|
||||
<div className="interactive-step-bar" data-testid="interactive-step-indicator">
|
||||
{t('interactive.step.context')} → {t('interactive.step.plot')} → {t('interactive.step.scene')} →{' '}
|
||||
{t('interactive.step.review')}
|
||||
</div>
|
||||
|
||||
{step === 'context_confirm' && (
|
||||
<div className="interactive-section">
|
||||
<p>{flow.contextSummary ?? t('ai.contextEmpty')}</p>
|
||||
<button type="button" className="btn" onClick={() => setContextOpen(true)}>
|
||||
{t('ai.contextEditorTitle')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-testid="interactive-start"
|
||||
disabled={disabled || streaming}
|
||||
onClick={handleStart}
|
||||
>
|
||||
{t('interactive.start')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(step === 'plot_suggest' || step === 'plot_select') && (
|
||||
<div className="interactive-section">
|
||||
{plots.length === 0 ? (
|
||||
<p className="placeholder-box">{streaming ? t('ai.sending') : t('interactive.loadingPlots')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="plot-options">
|
||||
{plots.map((p) => (
|
||||
<PlotOptionCard
|
||||
key={p.id}
|
||||
option={p}
|
||||
selected={selectedPlots.includes(p.id)}
|
||||
onSelect={() => togglePlot(p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="form-control"
|
||||
placeholder={t('interactive.plotNotePlaceholder')}
|
||||
value={userNote}
|
||||
onChange={(e) => setUserNote(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-testid="interactive-confirm-plot"
|
||||
disabled={disabled || selectedPlots.length === 0 || streaming}
|
||||
onClick={handleConfirmPlot}
|
||||
>
|
||||
{t('interactive.generateScene')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(step === 'scene_generate' || step === 'scene_review') && !showReview && (
|
||||
<div className="interactive-section">
|
||||
<div
|
||||
className="interactive-scene-preview"
|
||||
data-testid="interactive-scene-preview"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml || '<p>…</p>' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'naming_pause' && flow.namingPending && !showReview && (
|
||||
<div className="interactive-section naming-options">
|
||||
<h4>{t('interactive.namingTitle', { type: flow.namingPending.elementType })}</h4>
|
||||
<p>{flow.namingPending.context}</p>
|
||||
{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 className="naming-custom">
|
||||
<input
|
||||
className="form-control"
|
||||
data-testid="naming-custom"
|
||||
value={customName}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder={t('interactive.customName')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={disabled || streaming || !customName.trim()}
|
||||
onClick={() => void resolveNaming(bookId, customName.trim())}
|
||||
>
|
||||
{t('interactive.useCustomName')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReview && (
|
||||
<div className="interactive-section">
|
||||
<div
|
||||
className="interactive-scene-preview"
|
||||
data-testid="interactive-scene-preview"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml || '<p>…</p>' }}
|
||||
/>
|
||||
<textarea
|
||||
className="form-control"
|
||||
data-testid="interactive-refine-input"
|
||||
placeholder={t('interactive.refinePlaceholder')}
|
||||
value={refineInput}
|
||||
onChange={(e) => setRefineInput(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<div className="interactive-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={disabled || streaming || !refineInput.trim()}
|
||||
onClick={() => {
|
||||
void refineScene(bookId, refineInput)
|
||||
setRefineInput('')
|
||||
}}
|
||||
>
|
||||
{t('interactive.refine')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-testid="interactive-accept-scene"
|
||||
disabled={disabled || streaming || !sceneDraft.trim()}
|
||||
onClick={() => void acceptScene(bookId)}
|
||||
>
|
||||
{t('interactive.acceptScene')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFinishChapter && (
|
||||
<div className="interactive-section interactive-finish-section">
|
||||
<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="interactive-finish-chapter"
|
||||
disabled={disabled || streaming}
|
||||
onClick={() => void finishChapter(bookId, chapterTitle.trim() || undefined)}
|
||||
>
|
||||
{t('interactive.finishChapter')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="interactive-scene-count">
|
||||
{t('interactive.sceneCount', { count: flow.scenes.length })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{disabled && backend !== 'lmstudio' && (
|
||||
<div className="offline-banner show">{t('ai.offlineBanner')}</div>
|
||||
)}
|
||||
</div>
|
||||
<ContextEditorModal open={contextOpen} onClose={() => setContextOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { PlotOption } from '@shared/types'
|
||||
|
||||
interface PlotOptionCardProps {
|
||||
option: PlotOption
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export function PlotOptionCard({ option, selected, onSelect }: PlotOptionCardProps): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`plot-option-card ${selected ? 'selected' : ''}`}
|
||||
data-testid={`plot-option-${option.id}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="opt-label">{option.label}</span>
|
||||
<p className="plot-option-summary">{option.summary}</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
|
||||
import { AiPanel } from '@renderer/components/ai/AiPanel'
|
||||
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
|
||||
@@ -24,7 +26,13 @@ export function RightPanel(): React.JSX.Element {
|
||||
type="button"
|
||||
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
|
||||
data-testid={`panel-tab-${tab.id}`}
|
||||
onClick={() => setRightPanel(tab.id)}
|
||||
onClick={() => {
|
||||
if (tab.id === 'ai') {
|
||||
const bookId = useBookStore.getState().currentBookId
|
||||
if (bookId) void useInteractiveStore.getState().checkGate(bookId)
|
||||
}
|
||||
setRightPanel(tab.id)
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v0.3.0
|
||||
{t('app.name')} v0.4.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { create } from 'zustand'
|
||||
import type { InteractiveFlow, PlotOption, PlotSelection } 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 i18n from '@renderer/i18n'
|
||||
|
||||
interface InteractiveState {
|
||||
gateEligible: boolean
|
||||
gateReason?: string
|
||||
flow: InteractiveFlow | null
|
||||
plots: PlotOption[]
|
||||
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>
|
||||
confirmContextAndStart: (bookId: string, flowId: string) => Promise<void>
|
||||
suggestPlots: (bookId: string) => Promise<void>
|
||||
selectPlot: (bookId: string, selection: PlotSelection) => Promise<void>
|
||||
generateScene: (bookId: string) => Promise<void>
|
||||
resolveNaming: (bookId: string, chosenName: string) => Promise<void>
|
||||
refineScene: (bookId: string, instruction: string) => Promise<void>
|
||||
acceptScene: (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 useInteractiveStore = create<InteractiveState>((set, get) => ({
|
||||
gateEligible: false,
|
||||
flow: null,
|
||||
plots: [],
|
||||
streamBuffer: '',
|
||||
streaming: false,
|
||||
sceneDraft: '',
|
||||
listenersAttached: false,
|
||||
|
||||
checkGate: async (bookId) => {
|
||||
const r = await ipcCall(() => window.electronAPI.interactive.checkGate(bookId))
|
||||
set({ gateEligible: r.eligible, gateReason: r.reason })
|
||||
},
|
||||
|
||||
loadFlow: async (bookId, sessionId) => {
|
||||
const flow = await ipcCall(() => window.electronAPI.interactive.getFlow(bookId, sessionId))
|
||||
set({ flow })
|
||||
if (flow) await get().refreshSceneDraft(bookId)
|
||||
},
|
||||
|
||||
start: async (bookId, sessionId) => {
|
||||
const flow = await ipcCall(() => window.electronAPI.interactive.start(bookId, sessionId))
|
||||
set({ flow, plots: [], streamBuffer: '', sceneDraft: '' })
|
||||
},
|
||||
|
||||
confirmContextAndStart: async (bookId, flowId) => {
|
||||
const session = useAiStore.getState().sessions.find(
|
||||
(s) => s.id === useAiStore.getState().activeSessionId
|
||||
)
|
||||
const binding = session?.context ?? {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: []
|
||||
}
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.interactive.confirmContext(bookId, flowId, binding)
|
||||
)
|
||||
set({ flow })
|
||||
await get().suggestPlots(bookId)
|
||||
},
|
||||
|
||||
suggestPlots: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
set({ streaming: true })
|
||||
try {
|
||||
const plots = await ipcCall(() =>
|
||||
window.electronAPI.interactive.suggestPlots(bookId, flowId)
|
||||
)
|
||||
const flow = await ipcCall(() => window.electronAPI.interactive.getFlow(bookId, get().flow!.sessionId))
|
||||
set({ plots, flow })
|
||||
} finally {
|
||||
set({ streaming: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectPlot: async (bookId, selection) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.interactive.selectPlot(bookId, flowId, selection)
|
||||
)
|
||||
set({ flow, streamBuffer: '', sceneDraft: '' })
|
||||
await get().generateScene(bookId)
|
||||
},
|
||||
|
||||
generateScene: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
set({ streaming: true, streamBuffer: '' })
|
||||
try {
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.interactive.generateScene(bookId, flowId)
|
||||
)
|
||||
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 flow = await ipcCall(() =>
|
||||
window.electronAPI.interactive.resolveNaming(bookId, flowId, chosenName)
|
||||
)
|
||||
set({ flow })
|
||||
await get().refreshSceneDraft(bookId)
|
||||
} finally {
|
||||
set({ streaming: false, streamBuffer: '' })
|
||||
}
|
||||
},
|
||||
|
||||
refineScene: async (bookId, instruction) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId || !instruction.trim()) return
|
||||
set({ streaming: true, streamBuffer: '' })
|
||||
try {
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.interactive.refineScene(bookId, flowId, instruction.trim())
|
||||
)
|
||||
set({ flow })
|
||||
await get().refreshSceneDraft(bookId)
|
||||
} finally {
|
||||
set({ streaming: false, streamBuffer: '' })
|
||||
}
|
||||
},
|
||||
|
||||
acceptScene: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const flow = await ipcCall(() => window.electronAPI.interactive.acceptScene(bookId, flowId))
|
||||
set({ flow, plots: [], sceneDraft: '', streamBuffer: '' })
|
||||
await get().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.interactive.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('interactive.chapterCreated'))
|
||||
},
|
||||
|
||||
refreshSceneDraft: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
const draft = await ipcCall(() =>
|
||||
window.electronAPI.interactive.getSceneDraft(bookId, flowId)
|
||||
)
|
||||
set({ sceneDraft: draft })
|
||||
},
|
||||
|
||||
mount: () => {
|
||||
if (get().listenersAttached) return
|
||||
unsubStream = window.electronAPI.onInteractiveStreamChunk((payload) => {
|
||||
if (payload.done) return
|
||||
set((s) => ({
|
||||
streaming: true,
|
||||
streamBuffer: s.streamBuffer + payload.delta
|
||||
}))
|
||||
})
|
||||
set({ listenersAttached: true })
|
||||
},
|
||||
|
||||
unmount: () => {
|
||||
unsubStream?.()
|
||||
unsubStream = null
|
||||
set({ listenersAttached: false })
|
||||
}
|
||||
}))
|
||||
@@ -1375,3 +1375,100 @@
|
||||
.mention-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.interactive-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.interactive-step-bar {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.interactive-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plot-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plot-option-card {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.plot-option-card.selected {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
|
||||
}
|
||||
|
||||
.plot-option-card .opt-label {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
.plot-option-summary {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.interactive-scene-preview {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.naming-options .btn {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.naming-custom {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.interactive-actions,
|
||||
.interactive-finish {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.interactive-scene-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-mode-tab:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
Vendored
+42
@@ -15,6 +15,11 @@ import type {
|
||||
NamingConflict,
|
||||
AiStreamChunkEvent,
|
||||
AiNetworkStatusEvent,
|
||||
InteractiveFlow,
|
||||
InteractiveGateResult,
|
||||
InteractiveStreamChunkEvent,
|
||||
PlotOption,
|
||||
PlotSelection,
|
||||
IpcResult,
|
||||
LandmarkType,
|
||||
OutlineItem,
|
||||
@@ -199,6 +204,42 @@ export interface ElectronAPI {
|
||||
testConnection: (config?: AiConfig) => Promise<IpcResult<{ ok: true }>>
|
||||
namingCheck: (bookId: string) => Promise<IpcResult<NamingConflict[]>>
|
||||
}
|
||||
interactive: {
|
||||
checkGate: (bookId: string) => Promise<IpcResult<InteractiveGateResult>>
|
||||
getFlow: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow | null>>
|
||||
start: (bookId: string, sessionId: string) => Promise<IpcResult<InteractiveFlow>>
|
||||
confirmContext: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
binding: AiContextBinding
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
suggestPlots: (bookId: string, flowId: string) => Promise<IpcResult<PlotOption[]>>
|
||||
selectPlot: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
selection: PlotSelection
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
generateScene: (bookId: string, flowId: string) => Promise<IpcResult<InteractiveFlow>>
|
||||
resolveNaming: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
chosenName: string
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
refineScene: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
instruction: string
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
acceptScene: (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>>
|
||||
}
|
||||
wordfreq: {
|
||||
analyze: (
|
||||
bookId: string,
|
||||
@@ -212,6 +253,7 @@ export interface ElectronAPI {
|
||||
onShortcutTriggered: (callback: (action: ActionId) => void) => () => void
|
||||
onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void) => () => void
|
||||
onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void) => () => void
|
||||
onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void) => () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -63,5 +63,19 @@ export const IPC = {
|
||||
AI_TEST_CONNECTION: 'ai:testConnection',
|
||||
AI_NAMING_CHECK: 'ai:namingCheck',
|
||||
AI_STREAM_CHUNK: 'ai:streamChunk',
|
||||
AI_NETWORK_STATUS: 'ai:networkStatus'
|
||||
AI_NETWORK_STATUS: 'ai:networkStatus',
|
||||
INTERACTIVE_CHECK_GATE: 'interactive:checkGate',
|
||||
INTERACTIVE_GET_FLOW: 'interactive:getFlow',
|
||||
INTERACTIVE_START: 'interactive:start',
|
||||
INTERACTIVE_CONFIRM_CONTEXT: 'interactive:confirmContext',
|
||||
INTERACTIVE_SUGGEST_PLOTS: 'interactive:suggestPlots',
|
||||
INTERACTIVE_SELECT_PLOT: 'interactive:selectPlot',
|
||||
INTERACTIVE_GENERATE_SCENE: 'interactive:generateScene',
|
||||
INTERACTIVE_RESOLVE_NAMING: 'interactive:resolveNaming',
|
||||
INTERACTIVE_REFINE_SCENE: 'interactive:refineScene',
|
||||
INTERACTIVE_ACCEPT_SCENE: 'interactive:acceptScene',
|
||||
INTERACTIVE_FINISH_CHAPTER: 'interactive:finishChapter',
|
||||
INTERACTIVE_ABORT: 'interactive:abort',
|
||||
INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk',
|
||||
INTERACTIVE_GET_SCENE_DRAFT: 'interactive:getSceneDraft'
|
||||
} as const
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ThemeId =
|
||||
|
||||
export type Language = 'zh-CN' | 'en'
|
||||
export type ChapterStatus = 'draft' | 'review' | 'done'
|
||||
export type ChapterOrigin = 'manual' | 'interactive'
|
||||
export type BookStatus = 'draft' | 'ongoing' | 'done'
|
||||
export type PublishStatus = 'draft' | 'ready' | 'published'
|
||||
export type OutlineStatus = 'pending' | 'writing' | 'done' | 'abandoned'
|
||||
@@ -99,6 +100,7 @@ export interface Chapter {
|
||||
wordCount: number
|
||||
sortOrder: number
|
||||
cursorOffset: number
|
||||
origin?: ChapterOrigin
|
||||
publishStatus?: PublishStatus
|
||||
povCharacterId?: string | null
|
||||
summary?: string
|
||||
@@ -273,3 +275,61 @@ export interface AiContextBuildResult {
|
||||
systemPrompt: string
|
||||
preview: ContextPreviewItem[]
|
||||
}
|
||||
|
||||
export type InteractiveStep =
|
||||
| 'context_confirm'
|
||||
| 'plot_suggest'
|
||||
| 'plot_select'
|
||||
| 'scene_generate'
|
||||
| 'naming_pause'
|
||||
| 'scene_review'
|
||||
| 'completed'
|
||||
|
||||
export type InteractiveFlowStatus = 'in_progress' | 'completed' | 'abandoned'
|
||||
|
||||
export interface PlotOption {
|
||||
id: 'A' | 'B' | 'C'
|
||||
label: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface NamingPause {
|
||||
elementType: string
|
||||
context: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
export interface PlotSelection {
|
||||
optionIds: Array<'A' | 'B' | 'C'>
|
||||
userNote?: string
|
||||
}
|
||||
|
||||
export interface InteractiveScene {
|
||||
id: string
|
||||
sortOrder: number
|
||||
contentHtml: string
|
||||
plotLabel: string
|
||||
refinedCount: number
|
||||
}
|
||||
|
||||
export interface InteractiveFlow {
|
||||
id: string
|
||||
sessionId: string
|
||||
step: InteractiveStep
|
||||
status: InteractiveFlowStatus
|
||||
scenes: InteractiveScene[]
|
||||
namingPending?: NamingPause | null
|
||||
contextSummary?: string
|
||||
}
|
||||
|
||||
export interface InteractiveGateResult {
|
||||
eligible: boolean
|
||||
reason?: 'no_chapter' | 'no_outline_or_setting'
|
||||
}
|
||||
|
||||
export interface InteractiveStreamChunkEvent {
|
||||
flowId: string
|
||||
phase: 'scene' | 'refine' | 'naming_resume'
|
||||
delta: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, it, expect } from 'vitest'
|
||||
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 }
|
||||
|
||||
describe('AiClientService', () => {
|
||||
const client = new AiClientService(() => DEFAULT_AI_CONFIG)
|
||||
const client = new AiClientService(() => AI_TEST_CONFIG)
|
||||
|
||||
it('IT-AI-01: chat returns non-empty content from LM Studio', async () => {
|
||||
const content = await client.chat([{ role: 'user', content: '回复一个字:好' }])
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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 { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('InteractiveWritingService.finishChapter', () => {
|
||||
let db: SqliteDb
|
||||
let service: InteractiveWritingService
|
||||
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('测试', 'test-model')
|
||||
const flow = new InteractiveRepository(db).create(session.id)
|
||||
flowId = flow.id
|
||||
const repo = new InteractiveRepository(db)
|
||||
repo.addScene(flowId, '<p>场景一</p>', 'A')
|
||||
repo.addScene(flowId, '<p>场景二</p>', 'B')
|
||||
service = new InteractiveWritingService(db, {
|
||||
chat: async () => ''
|
||||
} as never)
|
||||
})
|
||||
|
||||
afterEach(() => db.close())
|
||||
|
||||
it('IT-INT-04: merges scenes into new interactive chapter', () => {
|
||||
const { chapterId } = service.finishChapter(flowId, volId, '交互初稿')
|
||||
const ch = new ChapterRepository(db).get(chapterId)!
|
||||
expect(ch.origin).toBe('interactive')
|
||||
expect(ch.content).toContain('场景一')
|
||||
expect(ch.content).toContain('场景二')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { OutlineRepository } from '../../src/main/db/repositories/outline.repo'
|
||||
import { checkInteractiveGate } from '../../src/main/services/interactive-gate.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('checkInteractiveGate', () => {
|
||||
let db: SqliteDb
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
})
|
||||
|
||||
afterEach(() => db.close())
|
||||
|
||||
it('IT-INT-05: rejects empty book', () => {
|
||||
expect(checkInteractiveGate(db).eligible).toBe(false)
|
||||
})
|
||||
|
||||
it('IT-INT-05: accepts book with chapter content', () => {
|
||||
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||
new ChapterRepository(db).create(volId, '第一章', 0, '<p>有正文</p>')
|
||||
expect(checkInteractiveGate(db).eligible).toBe(true)
|
||||
})
|
||||
|
||||
it('IT-INT-05: accepts book with outline only', () => {
|
||||
new OutlineRepository(db).create(null, '节点', 0)
|
||||
expect(checkInteractiveGate(db).eligible).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parsePlotsJson, parseNamingRequiredJson } from '../../src/main/services/interactive-parse.service'
|
||||
|
||||
describe('interactive-parse', () => {
|
||||
it('parses three plot options', () => {
|
||||
const json = `{"plots":[{"id":"A","label":"锋芒","summary":"林远展露天赋引起轰动,同门侧目,长老暗中关注其来历与隐患。"},{"id":"B","label":"藏锋","summary":"林远按长老暗示收敛灵力,被安排在外门修行,暗中调查测灵石异变。"},{"id":"C","label":"转折","summary":"测灵石碎裂引发异象,闭关太上长老出关,宗门上下震动。"}]}`
|
||||
const plots = parsePlotsJson(json)
|
||||
expect(plots).toHaveLength(3)
|
||||
expect(plots[0].summary.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('parses naming_required payload', () => {
|
||||
const json = `{"type":"naming_required","elementType":"角色","context":"需要为新同伴命名","options":["名1","名2","名3","名4","名5"]}`
|
||||
const pause = parseNamingRequiredJson(json)
|
||||
expect(pause.options).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
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 { InteractiveWritingService } from '../../src/main/services/interactive-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 }
|
||||
|
||||
describe('interactive plots integration', () => {
|
||||
it('IT-INT-01: suggestPlots returns 3 options from LM Studio', async () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
|
||||
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||
const flow = service.start(session.id)
|
||||
service.confirmContext(
|
||||
flow.id,
|
||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||
'林远参加宗门入门考核,演武场测灵石前众人围观。'
|
||||
)
|
||||
const plots = await service.suggestPlots(flow.id)
|
||||
expect(plots).toHaveLength(3)
|
||||
db.close()
|
||||
}, 180_000)
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
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 { InteractiveWritingService } from '../../src/main/services/interactive-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 }
|
||||
|
||||
describe('interactive scene integration', () => {
|
||||
const service = (db: DatabaseSync) =>
|
||||
new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||
|
||||
it('IT-INT-02: generateScene returns non-empty draft', 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.confirmContext(
|
||||
flow.id,
|
||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
||||
'林远在演武场测试灵根,众人围观。'
|
||||
)
|
||||
const plots = await svc.suggestPlots(flow.id)
|
||||
svc.selectPlot(flow.id, { optionIds: [plots[0].id] })
|
||||
const afterGen = await svc.generateScene(flow.id, () => {})
|
||||
if (afterGen.step === 'naming_pause' && afterGen.namingPending) {
|
||||
await svc.resolveNaming(flow.id, afterGen.namingPending.options[0], () => {})
|
||||
}
|
||||
const draft = svc.getSceneDraft(flow.id)
|
||||
expect(draft.trim().length).toBeGreaterThan(50)
|
||||
db.close()
|
||||
}, 240_000)
|
||||
})
|
||||
@@ -30,7 +30,7 @@ describe('migrate v2', () => {
|
||||
expect(tableExists(db, 'search_fts')).toBe(true)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(3)
|
||||
expect(version.v).toBe(4)
|
||||
})
|
||||
|
||||
it('migrates existing v1 database to v2', () => {
|
||||
@@ -47,7 +47,7 @@ describe('migrate v2', () => {
|
||||
|
||||
expect(tableExists(db, 'outline_items')).toBe(true)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(3)
|
||||
expect(version.v).toBe(4)
|
||||
|
||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||
const colNames = cols.map((c) => c.name)
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
|
||||
expect(tableExists(db, 'ai_messages')).toBe(true)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(3)
|
||||
expect(version.v).toBe(4)
|
||||
})
|
||||
|
||||
it('migrates existing v2 database to v3', () => {
|
||||
@@ -49,6 +49,6 @@ describe('migrate v3', () => {
|
||||
expect(tables.map((t) => t.name)).toContain('ai_messages')
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(3)
|
||||
expect(version.v).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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 type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
function tableExists(db: SqliteDb, name: string): boolean {
|
||||
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name)
|
||||
return row != null
|
||||
}
|
||||
|
||||
describe('migrate v4', () => {
|
||||
let db: SqliteDb
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
})
|
||||
|
||||
it('IT-MIG-04: applies v4 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(4)
|
||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||
expect(tableExists(db, 'interactive_scenes')).toBe(true)
|
||||
|
||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||
expect(cols.map((c) => c.name)).toContain('origin')
|
||||
})
|
||||
|
||||
it('IT-MIG-04: migrates v3 database to v4', () => {
|
||||
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, description) VALUES (?, ?)').run(1, 'initial')
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2')
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(3, 'P3')
|
||||
|
||||
migrate(db)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(4)
|
||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
} from '../../src/main/services/naming-check.service'
|
||||
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
||||
|
||||
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000 }
|
||||
|
||||
describe('naming check integration', () => {
|
||||
const client = new AiClientService(() => DEFAULT_AI_CONFIG)
|
||||
const client = new AiClientService(() => AI_TEST_CONFIG)
|
||||
|
||||
it('IT-NAME-01: detects 林远 vs 林悦 from LM Studio', async () => {
|
||||
const prompt = buildNamingPrompt(
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@ import { resolve } from 'path'
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts']
|
||||
include: ['tests/**/*.test.ts'],
|
||||
testTimeout: 240_000
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user