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)
|
# 笔临 (Bilin)
|
||||||
|
|
||||||
长篇创作智能协作平台 — Electron 桌面客户端 v0.3.0(P2.1 + P3)
|
长篇创作智能协作平台 — Electron 桌面客户端 v0.4.0(P4 交互式写作)
|
||||||
|
|
||||||
## 功能概览(v0.3.0)
|
## 功能概览(v0.4.0)
|
||||||
|
|
||||||
|
- 交互式写作:剧情走向建议、场景生成、命名暂停、微调确认、成章落盘(P4)
|
||||||
- 章节拖拽、全局搜索替换、灵感捕获、`@` 跳转、引用钉住(P2.1)
|
- 章节拖拽、全局搜索替换、灵感捕获、`@` 跳转、引用钉住(P2.1)
|
||||||
- AI 对话助手:会话持久化、流式输出、上下文编辑、快捷命令(P3)
|
- AI 对话助手:会话持久化、流式输出、上下文编辑、快捷命令(P3)
|
||||||
- AI 设置页、命名一致性检查、离线降级、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/specs/2026-07-07-bilin-p21-p3-design.md`
|
||||||
- P2.1 + P3 实现计划:`docs/superpowers/plans/2026-07-07-bilin-p21-p3.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`
|
- P2 设计规格:`docs/superpowers/specs/2026-07-06-bilin-p2-design.md`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 笔临 P4 交互式写作 Implementation Plan
|
# 笔临 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 测试超时调整。
|
**Goal:** 交付 v0.4.0:实装交互式写作全流程(剧情走向 → 场景生成 → 命名暂停 → 微调 → 成章落盘),schema v4 持久化,工程项含 `test-results/` gitignore 与 LM Studio 测试超时调整。
|
||||||
|
|
||||||
@@ -59,13 +59,13 @@
|
|||||||
- Modify: `tests/main/ai-client.test.ts`
|
- Modify: `tests/main/ai-client.test.ts`
|
||||||
- Modify: `e2e/ai-chat.spec.ts`
|
- Modify: `e2e/ai-chat.spec.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: `.gitignore` 追加**
|
- [x] **Step 1: `.gitignore` 追加**
|
||||||
|
|
||||||
```
|
```
|
||||||
test-results/
|
test-results/
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Playwright 全局超时**
|
- [x] **Step 2: Playwright 全局超时**
|
||||||
|
|
||||||
`playwright.config.ts`:
|
`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)`
|
`tests/main/ai-client.test.ts`:`}, 180_000)`
|
||||||
|
|
||||||
`e2e/ai-chat.spec.ts`:AI 用例 `test.setTimeout(180_000)`;助手 `expect` 保持或升至 `120_000`。
|
`e2e/ai-chat.spec.ts`:AI 用例 `test.setTimeout(180_000)`;助手 `expect` 保持或升至 `120_000`。
|
||||||
|
|
||||||
- [ ] **Step 4: 验证**
|
- [x] **Step 4: 验证**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test
|
npm run test
|
||||||
@@ -92,7 +92,7 @@ npm run test
|
|||||||
|
|
||||||
Expected: 全部 PASS(需 LM Studio 运行)
|
Expected: 全部 PASS(需 LM Studio 运行)
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .gitignore playwright.config.ts tests/main/ai-client.test.ts e2e/ai-chat.spec.ts
|
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`
|
- Modify: `src/shared/ipc-channels.ts`
|
||||||
- Create: `tests/main/migrate-v4.test.ts`
|
- Create: `tests/main/migrate-v4.test.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: 写迁移失败测试**
|
- [x] **Step 1: 写迁移失败测试**
|
||||||
|
|
||||||
`tests/main/migrate-v4.test.ts`:
|
`tests/main/migrate-v4.test.ts`:
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ describe('migrate v4', () => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 运行确认 FAIL**
|
- [x] **Step 2: 运行确认 FAIL**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -- tests/main/migrate-v4.test.ts
|
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 或表不存在)
|
Expected: FAIL(version 仍为 3 或表不存在)
|
||||||
|
|
||||||
- [ ] **Step 3: 创建 `schema-v4.sql`**
|
- [x] **Step 3: 创建 `schema-v4.sql`**
|
||||||
|
|
||||||
内容同 spec §2.3(`chapters.origin`、`interactive_flows`、`interactive_scenes` 及索引)。
|
内容同 spec §2.3(`chapters.origin`、`interactive_flows`、`interactive_scenes` 及索引)。
|
||||||
|
|
||||||
- [ ] **Step 4: 更新 `migrate.ts`**
|
- [x] **Step 4: 更新 `migrate.ts`**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import schemaV4 from './schema-v4.sql?raw'
|
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`。
|
按 spec §3 追加:`InteractiveStep`、`InteractiveFlowStatus`、`ChapterOrigin`、`PlotOption`、`NamingPause`、`PlotSelection`、`InteractiveScene`、`InteractiveFlow`、`InteractiveGateResult`、`InteractiveStreamChunkEvent`。
|
||||||
|
|
||||||
`Chapter` 接口增加 `origin?: ChapterOrigin`。
|
`Chapter` 接口增加 `origin?: ChapterOrigin`。
|
||||||
|
|
||||||
- [ ] **Step 6: `ipc-channels.ts` 追加**
|
- [x] **Step 6: `ipc-channels.ts` 追加**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
INTERACTIVE_CHECK_GATE: 'interactive:checkGate',
|
INTERACTIVE_CHECK_GATE: 'interactive:checkGate',
|
||||||
@@ -208,13 +208,13 @@ INTERACTIVE_ABORT: 'interactive:abort',
|
|||||||
INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk',
|
INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk',
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 7: 运行测试 PASS**
|
- [x] **Step 7: 运行测试 PASS**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -- tests/main/migrate-v4.test.ts
|
npm run test -- tests/main/migrate-v4.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
- [x] **Step 8: Commit**
|
||||||
|
|
||||||
```bash
|
```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
|
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`
|
- Create: `src/main/db/repositories/interactive.repo.ts`
|
||||||
- Modify: `src/main/services/book-registry.ts`
|
- Modify: `src/main/services/book-registry.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: 实现 `InteractiveRepository`**
|
- [x] **Step 1: 实现 `InteractiveRepository`**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
@@ -358,7 +358,7 @@ export class InteractiveRepository {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: `book-registry.ts` 注册**
|
- [x] **Step 2: `book-registry.ts` 注册**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||||
@@ -368,7 +368,7 @@ getInteractiveRepo(bookId: string): InteractiveRepository {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/main/db/repositories/interactive.repo.ts src/main/services/book-registry.ts
|
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/db/repositories/chapter.repo.ts`
|
||||||
- Modify: `src/main/services/word-count.ts`(若需导出 `stripHtml`,或从 naming-check 复用)
|
- Modify: `src/main/services/word-count.ts`(若需导出 `stripHtml`,或从 naming-check 复用)
|
||||||
|
|
||||||
- [ ] **Step 1: 写门槛失败测试**
|
- [x] **Step 1: 写门槛失败测试**
|
||||||
|
|
||||||
`tests/main/interactive-gate.test.ts`:
|
`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
|
```typescript
|
||||||
import type { InteractiveGateResult } from '../../shared/types'
|
import type { InteractiveGateResult } from '../../shared/types'
|
||||||
@@ -454,7 +454,7 @@ export function checkInteractiveGate(db: SqliteDb): InteractiveGateResult {
|
|||||||
|
|
||||||
(实现时可用 `stripHtml` 遍历章节列表,与 spec 语义一致即可。)
|
(实现时可用 `stripHtml` 遍历章节列表,与 spec 语义一致即可。)
|
||||||
|
|
||||||
- [ ] **Step 3: `chapter.repo.ts` 支持 `origin`**
|
- [x] **Step 3: `chapter.repo.ts` 支持 `origin`**
|
||||||
|
|
||||||
`mapRow` 增加 `origin: (row.origin as ChapterOrigin) ?? 'manual'`。
|
`mapRow` 增加 `origin: (row.origin as ChapterOrigin) ?? 'manual'`。
|
||||||
|
|
||||||
@@ -462,7 +462,7 @@ export function checkInteractiveGate(db: SqliteDb): InteractiveGateResult {
|
|||||||
|
|
||||||
INSERT 增加 `origin` 列。
|
INSERT 增加 `origin` 列。
|
||||||
|
|
||||||
- [ ] **Step 4: 运行 PASS + Commit**
|
- [x] **Step 4: 运行 PASS + Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -- tests/main/interactive-gate.test.ts
|
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: `src/main/services/interactive-prompt-builder.service.ts`
|
||||||
- Create: `tests/main/interactive-parse.test.ts`
|
- Create: `tests/main/interactive-parse.test.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: 解析单元测试(无 AI)**
|
- [x] **Step 1: 解析单元测试(无 AI)**
|
||||||
|
|
||||||
`tests/main/interactive-parse.test.ts`:
|
`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
|
```typescript
|
||||||
import type { NamingPause, PlotOption } from '../../shared/types'
|
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 条。
|
System prompt 明确要求 JSON 格式或 HTML 段落;走向步要求恰好 3 条。
|
||||||
|
|
||||||
- [ ] **Step 4: 运行 PASS + Commit**
|
- [x] **Step 4: 运行 PASS + Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -- tests/main/interactive-parse.test.ts
|
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: `src/main/services/interactive-writing.service.ts`
|
||||||
- Create: `tests/main/interactive-finish.test.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):
|
`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 }`)。
|
构造函数注入 `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`。
|
其余方法按 spec 步骤机实现;`suggestPlots` 调 `aiClient.chat` + `parsePlotsJson`(失败重试 1 次);`generateScene` 流式累积 buffer,开头检测 `tryParseNamingFromStream` → 写 `naming_pending_json` 并切 `naming_pause`。
|
||||||
|
|
||||||
- [ ] **Step 3: 运行 PASS**
|
- [x] **Step 3: 运行 PASS**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -- tests/main/interactive-finish.test.ts
|
npm run test -- tests/main/interactive-finish.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "feat: add InteractiveWritingService with finishChapter"
|
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/preload/index.ts`
|
||||||
- Modify: `src/shared/electron-api.d.ts`
|
- Modify: `src/shared/electron-api.d.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: 实现 `registerInteractiveHandlers`**
|
- [x] **Step 1: 实现 `registerInteractiveHandlers`**
|
||||||
|
|
||||||
模式参照 `ai.handler.ts`:
|
模式参照 `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)`
|
- `generateScene` / `refineScene` / `resolveNaming`:注册 `activeInteractive` AbortController Map;流式时 `win.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, payload)`
|
||||||
- `finishChapter`:需 `{ bookId, flowId, volumeId, title? }`
|
- `finishChapter`:需 `{ bookId, flowId, volumeId, title? }`
|
||||||
|
|
||||||
- [ ] **Step 2: `register.ts` 注册**
|
- [x] **Step 2: `register.ts` 注册**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { registerInteractiveHandlers } from './handlers/interactive.handler'
|
import { registerInteractiveHandlers } from './handlers/interactive.handler'
|
||||||
@@ -695,7 +695,7 @@ import { registerInteractiveHandlers } from './handlers/interactive.handler'
|
|||||||
registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: preload 暴露 `interactive` 命名空间**
|
- [x] **Step 3: preload 暴露 `interactive` 命名空间**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interactive: {
|
interactive: {
|
||||||
@@ -707,7 +707,7 @@ interactive: {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: 手动 smoke**
|
- [x] **Step 4: 手动 smoke**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
@@ -715,7 +715,7 @@ npm run build
|
|||||||
|
|
||||||
Expected: 编译无 TS 错误
|
Expected: 编译无 TS 错误
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "feat: wire interactive IPC handlers and preload API"
|
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-plots.test.ts`
|
||||||
- Create: `tests/main/interactive-scene.test.ts`
|
- Create: `tests/main/interactive-scene.test.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: IT-INT-01**
|
- [x] **Step 1: IT-INT-01**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect } from 'vitest'
|
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
|
- 先 `selectPlot`,再 `generateScene` 流式收集,断言正文长度 > 100
|
||||||
- 若模型返回 naming JSON,走 `resolveNaming` 后续写
|
- 若模型返回 naming JSON,走 `resolveNaming` 后续写
|
||||||
|
|
||||||
超时:`240_000`
|
超时:`240_000`
|
||||||
|
|
||||||
- [ ] **Step 3: 全量单元测试**
|
- [x] **Step 3: 全量单元测试**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test
|
npm run test
|
||||||
@@ -770,7 +770,7 @@ npm run test
|
|||||||
|
|
||||||
Expected: 50+ tests PASS(需 LM Studio)
|
Expected: 50+ tests PASS(需 LM Studio)
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "test: add interactive LM Studio integration tests"
|
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/components/ai/AiPanel.tsx`
|
||||||
- Modify: `src/renderer/styles/layout.css`
|
- Modify: `src/renderer/styles/layout.css`
|
||||||
|
|
||||||
- [ ] **Step 1: `useInteractiveStore`**
|
- [x] **Step 1: `useInteractiveStore`**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { create } from 'zustand'
|
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。
|
可点击卡片,`data-testid={`plot-option-${id}`}`,展示 label + summary。
|
||||||
|
|
||||||
- [ ] **Step 3: `InteractivePanel.tsx`**
|
- [x] **Step 3: `InteractivePanel.tsx`**
|
||||||
|
|
||||||
按 `flow.step` 条件渲染;关键 testid:
|
按 `flow.step` 条件渲染;关键 testid:
|
||||||
|
|
||||||
@@ -850,7 +850,7 @@ export const useInteractiveStore = create<InteractiveState>((set, get) => ({
|
|||||||
- `interactive-accept-scene`
|
- `interactive-accept-scene`
|
||||||
- `interactive-finish-chapter`
|
- `interactive-finish-chapter`
|
||||||
|
|
||||||
- [ ] **Step 4: 修改 `AiPanel.tsx`**
|
- [x] **Step 4: 修改 `AiPanel.tsx`**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
|
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
|
||||||
@@ -882,11 +882,11 @@ const handleModeClick = (mode: AiWritingMode): void => {
|
|||||||
|
|
||||||
模式 Tab:`interactive` 在 `!gateEligible` 时 `disabled` + `title={t('interactive.gateTooltip')}`。
|
模式 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`)。
|
在 `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
|
```bash
|
||||||
git commit -m "feat: add InteractivePanel UI and wire AiPanel mode switch"
|
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: `public/locales/en/translation.json`
|
||||||
- Modify: `src/renderer/stores/useInteractiveStore.ts`(finish 后导航)
|
- 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`
|
`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
|
```typescript
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
@@ -922,7 +922,7 @@ useAppStore.getState().setView('editor')
|
|||||||
showToast(t('interactive.chapterCreated'))
|
showToast(t('interactive.chapterCreated'))
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "feat: add interactive i18n and chapter navigation after finish"
|
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:**
|
**Files:**
|
||||||
- Create: `e2e/interactive-writing.spec.ts`
|
- Create: `e2e/interactive-writing.spec.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: E2E-INT-02(门槛,无 AI)**
|
- [x] **Step 1: E2E-INT-02(门槛,无 AI)**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
test('E2E-INT-02: interactive tab disabled on empty book', async () => {
|
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
|
```typescript
|
||||||
test('E2E-INT-01: interactive flow creates chapter', async () => {
|
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。
|
进入交互 mid-flow → 关闭 app → 重开 → `getFlow` 恢复步骤 UI。
|
||||||
|
|
||||||
- [ ] **Step 4: 运行 E2E**
|
- [x] **Step 4: 运行 E2E**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:e2e -- e2e/interactive-writing.spec.ts
|
npm run test:e2e -- e2e/interactive-writing.spec.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "test: add interactive writing E2E specs"
|
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: `src/renderer/components/settings/SettingsPage.tsx`(about 版本号)
|
||||||
- Modify: `docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md`(勾选完成项)
|
- 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
|
```bash
|
||||||
npm run test
|
npm run test
|
||||||
@@ -1003,7 +1003,7 @@ npm run test:e2e
|
|||||||
|
|
||||||
Expected: 单元全部 PASS;E2E 全部 PASS(LM Studio 运行中)
|
Expected: 单元全部 PASS;E2E 全部 PASS(LM Studio 运行中)
|
||||||
|
|
||||||
- [ ] **Step 4: Commit + tag(可选)**
|
- [x] **Step 4: Commit + tag(可选)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "chore: release v0.4.0 with interactive writing mode"
|
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",
|
"name": "bilin",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "笔临 - 长篇创作智能协作平台",
|
"description": "笔临 - 长篇创作智能协作平台",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -91,6 +91,27 @@
|
|||||||
"ai.networkRestored": "Network restored. Cloud AI is available again.",
|
"ai.networkRestored": "Network restored. Cloud AI is available again.",
|
||||||
"ai.insertToEditor": "Insert into editor",
|
"ai.insertToEditor": "Insert into editor",
|
||||||
"ai.inserted": "Inserted 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",
|
"snapshot.ai": "Before AI insert",
|
||||||
"naming.open": "🔍 Naming consistency check",
|
"naming.open": "🔍 Naming consistency check",
|
||||||
"naming.title": "Naming consistency check",
|
"naming.title": "Naming consistency check",
|
||||||
|
|||||||
@@ -91,6 +91,27 @@
|
|||||||
"ai.networkRestored": "网络已恢复,可继续使用云端 AI",
|
"ai.networkRestored": "网络已恢复,可继续使用云端 AI",
|
||||||
"ai.insertToEditor": "插入到编辑器",
|
"ai.insertToEditor": "插入到编辑器",
|
||||||
"ai.inserted": "已插入到编辑器",
|
"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 生成前",
|
"snapshot.ai": "AI 生成前",
|
||||||
"naming.open": "🔍 命名一致性检查",
|
"naming.open": "🔍 命名一致性检查",
|
||||||
"naming.title": "命名一致性检查",
|
"naming.title": "命名一致性检查",
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import type { SqliteDb } from './types'
|
|||||||
import schemaV1 from './schema-v1.sql?raw'
|
import schemaV1 from './schema-v1.sql?raw'
|
||||||
import schemaV2 from './schema-v2.sql?raw'
|
import schemaV2 from './schema-v2.sql?raw'
|
||||||
import schemaV3 from './schema-v3.sql?raw'
|
import schemaV3 from './schema-v3.sql?raw'
|
||||||
|
import schemaV4 from './schema-v4.sql?raw'
|
||||||
|
|
||||||
const CURRENT_VERSION = 3
|
const CURRENT_VERSION = 4
|
||||||
|
|
||||||
function tryAlter(db: SqliteDb, sql: string): void {
|
function tryAlter(db: SqliteDb, sql: string): void {
|
||||||
try {
|
try {
|
||||||
@@ -48,5 +49,12 @@ export function migrate(db: SqliteDb): void {
|
|||||||
if (current < 3) {
|
if (current < 3) {
|
||||||
db.exec(schemaV3)
|
db.exec(schemaV3)
|
||||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(3, 'P3 AI schema')
|
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 { randomUUID } from 'crypto'
|
||||||
import { countWords } from '../../services/word-count'
|
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'
|
import type { SqliteDb } from '../types'
|
||||||
function mapRow(row: Record<string, unknown>): Chapter {
|
function mapRow(row: Record<string, unknown>): Chapter {
|
||||||
return {
|
return {
|
||||||
@@ -11,21 +11,28 @@ function mapRow(row: Record<string, unknown>): Chapter {
|
|||||||
status: row.status as ChapterStatus,
|
status: row.status as ChapterStatus,
|
||||||
wordCount: row.word_count as number,
|
wordCount: row.word_count as number,
|
||||||
sortOrder: row.sort_order as number,
|
sortOrder: row.sort_order as number,
|
||||||
cursorOffset: (row.cursor_offset as number) ?? 0
|
cursorOffset: (row.cursor_offset as number) ?? 0,
|
||||||
|
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChapterRepository {
|
export class ChapterRepository {
|
||||||
constructor(private db: SqliteDb) {}
|
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 id = randomUUID()
|
||||||
const wordCount = countWords(content)
|
const wordCount = countWords(content)
|
||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order)
|
`INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order, origin)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.run(id, volumeId, title, content, wordCount, sortOrder)
|
.run(id, volumeId, title, content, wordCount, sortOrder, origin)
|
||||||
return this.get(id)!
|
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 { registerBookmarkHandlers } from './handlers/bookmark.handler'
|
||||||
import { registerSearchHandlers } from './handlers/search.handler'
|
import { registerSearchHandlers } from './handlers/search.handler'
|
||||||
import { registerAiHandlers } from './handlers/ai.handler'
|
import { registerAiHandlers } from './handlers/ai.handler'
|
||||||
|
import { registerInteractiveHandlers } from './handlers/interactive.handler'
|
||||||
import { AiClientService } from '../services/ai-client.service'
|
import { AiClientService } from '../services/ai-client.service'
|
||||||
import { NetworkMonitorService } from '../services/network-monitor.service'
|
import { NetworkMonitorService } from '../services/network-monitor.service'
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
|||||||
registerBookmarkHandlers(books)
|
registerBookmarkHandlers(books)
|
||||||
registerSearchHandlers(books, settings)
|
registerSearchHandlers(books, settings)
|
||||||
registerAiHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
registerAiHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
||||||
|
registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
||||||
|
|
||||||
return { settings, books, shortcuts: shortcutManager }
|
return { settings, books, shortcuts: shortcutManager }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { OutlineRepository } from '../db/repositories/outline.repo'
|
|||||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||||
import { InspirationRepository } from '../db/repositories/inspiration.repo'
|
import { InspirationRepository } from '../db/repositories/inspiration.repo'
|
||||||
import { AiSessionRepository } from '../db/repositories/ai-session.repo'
|
import { AiSessionRepository } from '../db/repositories/ai-session.repo'
|
||||||
|
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||||
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
|
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
|
||||||
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
|
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
|
||||||
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
|
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
|
||||||
@@ -150,6 +151,10 @@ export class BookRegistryService {
|
|||||||
return new AiSessionRepository(this.getDb(bookId))
|
return new AiSessionRepository(this.getDb(bookId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInteractiveRepo(bookId: string): InteractiveRepository {
|
||||||
|
return new InteractiveRepository(this.getDb(bookId))
|
||||||
|
}
|
||||||
|
|
||||||
getSnapshotRepo(bookId: string): SnapshotRepository {
|
getSnapshotRepo(bookId: string): SnapshotRepository {
|
||||||
return new SnapshotRepository(this.getDb(bookId))
|
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,
|
NamingConflict,
|
||||||
AiStreamChunkEvent,
|
AiStreamChunkEvent,
|
||||||
AiNetworkStatusEvent,
|
AiNetworkStatusEvent,
|
||||||
|
InteractiveFlow,
|
||||||
|
InteractiveGateResult,
|
||||||
|
InteractiveStreamChunkEvent,
|
||||||
|
PlotOption,
|
||||||
|
PlotSelection,
|
||||||
IpcResult,
|
IpcResult,
|
||||||
LandmarkType,
|
LandmarkType,
|
||||||
OutlineItem,
|
OutlineItem,
|
||||||
@@ -264,6 +269,55 @@ const electronAPI = {
|
|||||||
namingCheck: (bookId: string): Promise<IpcResult<NamingConflict[]>> =>
|
namingCheck: (bookId: string): Promise<IpcResult<NamingConflict[]>> =>
|
||||||
ipcRenderer.invoke(IPC.AI_NAMING_CHECK, { bookId })
|
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: {
|
wordfreq: {
|
||||||
analyze: (
|
analyze: (
|
||||||
bookId: string,
|
bookId: string,
|
||||||
@@ -297,6 +351,13 @@ const electronAPI = {
|
|||||||
}
|
}
|
||||||
ipcRenderer.on(IPC.AI_NETWORK_STATUS, handler)
|
ipcRenderer.on(IPC.AI_NETWORK_STATUS, handler)
|
||||||
return () => ipcRenderer.removeListener(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 { insertToEditor } from '@renderer/lib/editor-commands'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||||
|
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
|
||||||
|
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||||
|
|
||||||
const MODES: { id: AiWritingMode; labelKey: string }[] = [
|
const MODES: { id: AiWritingMode; labelKey: string }[] = [
|
||||||
{ id: 'chat', labelKey: 'ai.mode.chat' },
|
{ id: 'chat', labelKey: 'ai.mode.chat' },
|
||||||
@@ -39,6 +41,9 @@ export function AiPanel(): React.JSX.Element {
|
|||||||
const createSession = useAiStore((s) => s.createSession)
|
const createSession = useAiStore((s) => s.createSession)
|
||||||
const sendMessage = useAiStore((s) => s.sendMessage)
|
const sendMessage = useAiStore((s) => s.sendMessage)
|
||||||
const setWritingMode = useAiStore((s) => s.setWritingMode)
|
const setWritingMode = useAiStore((s) => s.setWritingMode)
|
||||||
|
const gateEligible = useInteractiveStore((s) => s.gateEligible)
|
||||||
|
const checkGate = useInteractiveStore((s) => s.checkGate)
|
||||||
|
const startInteractive = useInteractiveStore((s) => s.start)
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [contextOpen, setContextOpen] = useState(false)
|
const [contextOpen, setContextOpen] = useState(false)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -57,8 +62,11 @@ export function AiPanel(): React.JSX.Element {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bookId) void loadSessions(bookId)
|
if (bookId) {
|
||||||
}, [bookId, loadSessions])
|
void loadSessions(bookId)
|
||||||
|
void checkGate(bookId)
|
||||||
|
}
|
||||||
|
}, [bookId, loadSessions, checkGate])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
@@ -86,10 +94,32 @@ export function AiPanel(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleModeClick = (mode: AiWritingMode): void => {
|
const handleModeClick = (mode: AiWritingMode): void => {
|
||||||
if (mode !== 'chat') {
|
if (mode === 'auto' || mode === 'wizard') {
|
||||||
showToast(t('feature.comingSoon'))
|
showToast(t('feature.comingSoon'))
|
||||||
return
|
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)
|
setWritingMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,18 +158,28 @@ export function AiPanel(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ai-mode-tabs" role="tablist">
|
<div className="ai-mode-tabs" role="tablist">
|
||||||
{MODES.map((mode) => (
|
{MODES.map((mode) => {
|
||||||
|
const interactiveDisabled = mode.id === 'interactive' && !gateEligible
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={mode.id}
|
key={mode.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
|
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
|
||||||
|
data-testid={`ai-mode-tab-${mode.id}`}
|
||||||
|
disabled={interactiveDisabled}
|
||||||
|
title={interactiveDisabled ? t('interactive.gateTooltip') : undefined}
|
||||||
onClick={() => handleModeClick(mode.id)}
|
onClick={() => handleModeClick(mode.id)}
|
||||||
>
|
>
|
||||||
{t(mode.labelKey)}
|
{t(mode.labelKey)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{writingMode === 'interactive' ? (
|
||||||
|
<InteractivePanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="context-preview"
|
className="context-preview"
|
||||||
@@ -222,6 +262,8 @@ export function AiPanel(): React.JSX.Element {
|
|||||||
{t('ai.send')}
|
{t('ai.send')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ContextEditorModal open={contextOpen} onClose={() => setContextOpen(false)} />
|
<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 { useTranslation } from 'react-i18next'
|
||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
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 { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
|
||||||
import { AiPanel } from '@renderer/components/ai/AiPanel'
|
import { AiPanel } from '@renderer/components/ai/AiPanel'
|
||||||
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
|
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
|
||||||
@@ -24,7 +26,13 @@ export function RightPanel(): React.JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
|
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
|
||||||
data-testid={`panel-tab-${tab.id}`}
|
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}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
{section === 'shortcuts' && <ShortcutEditor />}
|
{section === 'shortcuts' && <ShortcutEditor />}
|
||||||
{section === 'about' && (
|
{section === 'about' && (
|
||||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
{t('app.name')} v0.3.0
|
{t('app.name')} v0.4.0
|
||||||
<br />
|
<br />
|
||||||
{t('app.tagline')}
|
{t('app.tagline')}
|
||||||
</p>
|
</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 {
|
.mention-item:hover {
|
||||||
background: var(--bg-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,
|
NamingConflict,
|
||||||
AiStreamChunkEvent,
|
AiStreamChunkEvent,
|
||||||
AiNetworkStatusEvent,
|
AiNetworkStatusEvent,
|
||||||
|
InteractiveFlow,
|
||||||
|
InteractiveGateResult,
|
||||||
|
InteractiveStreamChunkEvent,
|
||||||
|
PlotOption,
|
||||||
|
PlotSelection,
|
||||||
IpcResult,
|
IpcResult,
|
||||||
LandmarkType,
|
LandmarkType,
|
||||||
OutlineItem,
|
OutlineItem,
|
||||||
@@ -199,6 +204,42 @@ export interface ElectronAPI {
|
|||||||
testConnection: (config?: AiConfig) => Promise<IpcResult<{ ok: true }>>
|
testConnection: (config?: AiConfig) => Promise<IpcResult<{ ok: true }>>
|
||||||
namingCheck: (bookId: string) => Promise<IpcResult<NamingConflict[]>>
|
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: {
|
wordfreq: {
|
||||||
analyze: (
|
analyze: (
|
||||||
bookId: string,
|
bookId: string,
|
||||||
@@ -212,6 +253,7 @@ export interface ElectronAPI {
|
|||||||
onShortcutTriggered: (callback: (action: ActionId) => void) => () => void
|
onShortcutTriggered: (callback: (action: ActionId) => void) => () => void
|
||||||
onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void) => () => void
|
onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void) => () => void
|
||||||
onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void) => () => void
|
onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void) => () => void
|
||||||
|
onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -63,5 +63,19 @@ export const IPC = {
|
|||||||
AI_TEST_CONNECTION: 'ai:testConnection',
|
AI_TEST_CONNECTION: 'ai:testConnection',
|
||||||
AI_NAMING_CHECK: 'ai:namingCheck',
|
AI_NAMING_CHECK: 'ai:namingCheck',
|
||||||
AI_STREAM_CHUNK: 'ai:streamChunk',
|
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
|
} as const
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ThemeId =
|
|||||||
|
|
||||||
export type Language = 'zh-CN' | 'en'
|
export type Language = 'zh-CN' | 'en'
|
||||||
export type ChapterStatus = 'draft' | 'review' | 'done'
|
export type ChapterStatus = 'draft' | 'review' | 'done'
|
||||||
|
export type ChapterOrigin = 'manual' | 'interactive'
|
||||||
export type BookStatus = 'draft' | 'ongoing' | 'done'
|
export type BookStatus = 'draft' | 'ongoing' | 'done'
|
||||||
export type PublishStatus = 'draft' | 'ready' | 'published'
|
export type PublishStatus = 'draft' | 'ready' | 'published'
|
||||||
export type OutlineStatus = 'pending' | 'writing' | 'done' | 'abandoned'
|
export type OutlineStatus = 'pending' | 'writing' | 'done' | 'abandoned'
|
||||||
@@ -99,6 +100,7 @@ export interface Chapter {
|
|||||||
wordCount: number
|
wordCount: number
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
cursorOffset: number
|
cursorOffset: number
|
||||||
|
origin?: ChapterOrigin
|
||||||
publishStatus?: PublishStatus
|
publishStatus?: PublishStatus
|
||||||
povCharacterId?: string | null
|
povCharacterId?: string | null
|
||||||
summary?: string
|
summary?: string
|
||||||
@@ -273,3 +275,61 @@ export interface AiContextBuildResult {
|
|||||||
systemPrompt: string
|
systemPrompt: string
|
||||||
preview: ContextPreviewItem[]
|
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 { AiClientService } from '../../src/main/services/ai-client.service'
|
||||||
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
||||||
|
|
||||||
|
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
|
||||||
|
|
||||||
describe('AiClientService', () => {
|
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 () => {
|
it('IT-AI-01: chat returns non-empty content from LM Studio', async () => {
|
||||||
const content = await client.chat([{ role: 'user', content: '回复一个字:好' }])
|
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)
|
expect(tableExists(db, 'search_fts')).toBe(true)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(3)
|
expect(version.v).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates existing v1 database to v2', () => {
|
it('migrates existing v1 database to v2', () => {
|
||||||
@@ -47,7 +47,7 @@ describe('migrate v2', () => {
|
|||||||
|
|
||||||
expect(tableExists(db, 'outline_items')).toBe(true)
|
expect(tableExists(db, 'outline_items')).toBe(true)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(3)
|
expect(version.v).toBe(4)
|
||||||
|
|
||||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||||
const colNames = cols.map((c) => c.name)
|
const colNames = cols.map((c) => c.name)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
|
|||||||
expect(tableExists(db, 'ai_messages')).toBe(true)
|
expect(tableExists(db, 'ai_messages')).toBe(true)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(3)
|
expect(version.v).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates existing v2 database to v3', () => {
|
it('migrates existing v2 database to v3', () => {
|
||||||
@@ -49,6 +49,6 @@ describe('migrate v3', () => {
|
|||||||
expect(tables.map((t) => t.name)).toContain('ai_messages')
|
expect(tables.map((t) => t.name)).toContain('ai_messages')
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(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'
|
} from '../../src/main/services/naming-check.service'
|
||||||
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
import { DEFAULT_AI_CONFIG } from '../../src/shared/types'
|
||||||
|
|
||||||
|
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000 }
|
||||||
|
|
||||||
describe('naming check integration', () => {
|
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 () => {
|
it('IT-NAME-01: detects 林远 vs 林悦 from LM Studio', async () => {
|
||||||
const prompt = buildNamingPrompt(
|
const prompt = buildNamingPrompt(
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@ import { resolve } from 'path'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/**/*.test.ts']
|
include: ['tests/**/*.test.ts'],
|
||||||
|
testTimeout: 240_000
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user