From a8e0ba9ac9b219d38d35de1700b7fa7199f4511a Mon Sep 17 00:00:00 2001 From: kun1h Date: Mon, 6 Jul 2026 17:46:16 +0800 Subject: [PATCH] feat: ship v0.4.0 with interactive writing mode Co-authored-by: Cursor --- README.md | 7 +- .../plans/2026-07-08-bilin-p4-interactive.md | 112 ++++---- e2e/interactive-writing.spec.ts | 139 +++++++++ package.json | 2 +- public/locales/en/translation.json | 21 ++ public/locales/zh-CN/translation.json | 21 ++ src/main/db/migrate.ts | 10 +- src/main/db/repositories/chapter.repo.ts | 19 +- src/main/db/repositories/interactive.repo.ts | 169 +++++++++++ src/main/db/schema-v4.sql | 27 ++ src/main/ipc/handlers/interactive.handler.ts | 212 ++++++++++++++ src/main/ipc/register.ts | 2 + src/main/services/book-registry.ts | 5 + src/main/services/interactive-gate.service.ts | 24 ++ .../services/interactive-parse.service.ts | 47 +++ .../interactive-prompt-builder.service.ts | 74 +++++ .../services/interactive-writing.service.ts | 241 ++++++++++++++++ src/preload/index.ts | 61 ++++ src/renderer/components/ai/AiPanel.tsx | 68 ++++- .../components/ai/InteractivePanel.tsx | 267 ++++++++++++++++++ src/renderer/components/ai/PlotOptionCard.tsx | 21 ++ src/renderer/components/layout/RightPanel.tsx | 10 +- .../components/settings/SettingsPage.tsx | 2 +- src/renderer/stores/useInteractiveStore.ts | 198 +++++++++++++ src/renderer/styles/layout.css | 97 +++++++ src/shared/electron-api.d.ts | 42 +++ src/shared/ipc-channels.ts | 16 +- src/shared/types.ts | 60 ++++ tests/main/ai-client.test.ts | 4 +- tests/main/interactive-finish.test.ts | 41 +++ tests/main/interactive-gate.test.ts | 34 +++ tests/main/interactive-parse.test.ts | 17 ++ tests/main/interactive-plots.test.ts | 27 ++ tests/main/interactive-scene.test.ts | 36 +++ tests/main/migrate-v2.test.ts | 4 +- tests/main/migrate-v3.test.ts | 4 +- tests/main/migrate-v4.test.ts | 54 ++++ tests/main/naming-check.integration.test.ts | 4 +- vitest.config.ts | 3 +- 39 files changed, 2113 insertions(+), 89 deletions(-) create mode 100644 e2e/interactive-writing.spec.ts create mode 100644 src/main/db/repositories/interactive.repo.ts create mode 100644 src/main/db/schema-v4.sql create mode 100644 src/main/ipc/handlers/interactive.handler.ts create mode 100644 src/main/services/interactive-gate.service.ts create mode 100644 src/main/services/interactive-parse.service.ts create mode 100644 src/main/services/interactive-prompt-builder.service.ts create mode 100644 src/main/services/interactive-writing.service.ts create mode 100644 src/renderer/components/ai/InteractivePanel.tsx create mode 100644 src/renderer/components/ai/PlotOptionCard.tsx create mode 100644 src/renderer/stores/useInteractiveStore.ts create mode 100644 tests/main/interactive-finish.test.ts create mode 100644 tests/main/interactive-gate.test.ts create mode 100644 tests/main/interactive-parse.test.ts create mode 100644 tests/main/interactive-plots.test.ts create mode 100644 tests/main/interactive-scene.test.ts create mode 100644 tests/main/migrate-v4.test.ts diff --git a/README.md b/README.md index a6b0570..04ee40e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # 笔临 (Bilin) -长篇创作智能协作平台 — Electron 桌面客户端 v0.3.0(P2.1 + P3) +长篇创作智能协作平台 — Electron 桌面客户端 v0.4.0(P4 交互式写作) -## 功能概览(v0.3.0) +## 功能概览(v0.4.0) +- 交互式写作:剧情走向建议、场景生成、命名暂停、微调确认、成章落盘(P4) - 章节拖拽、全局搜索替换、灵感捕获、`@` 跳转、引用钉住(P2.1) - AI 对话助手:会话持久化、流式输出、上下文编辑、快捷命令(P3) - AI 设置页、命名一致性检查、离线降级、AI 快照(P3) @@ -29,6 +30,8 @@ npm run test:e2e # E2E 测试(需先 build) ## 文档 +- P4 交互式写作设计规格:`docs/superpowers/specs/2026-07-08-bilin-p4-interactive-design.md` +- P4 实现计划:`docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md` - P2.1 + P3 设计规格:`docs/superpowers/specs/2026-07-07-bilin-p21-p3-design.md` - P2.1 + P3 实现计划:`docs/superpowers/plans/2026-07-07-bilin-p21-p3.md` - P2 设计规格:`docs/superpowers/specs/2026-07-06-bilin-p2-design.md` diff --git a/docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md b/docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md index 0cd2925..832a1c8 100644 --- a/docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md +++ b/docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md @@ -1,6 +1,6 @@ # 笔临 P4 交互式写作 Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. **Goal:** 交付 v0.4.0:实装交互式写作全流程(剧情走向 → 场景生成 → 命名暂停 → 微调 → 成章落盘),schema v4 持久化,工程项含 `test-results/` gitignore 与 LM Studio 测试超时调整。 @@ -59,13 +59,13 @@ - Modify: `tests/main/ai-client.test.ts` - Modify: `e2e/ai-chat.spec.ts` -- [ ] **Step 1: `.gitignore` 追加** +- [x] **Step 1: `.gitignore` 追加** ``` test-results/ ``` -- [ ] **Step 2: Playwright 全局超时** +- [x] **Step 2: Playwright 全局超时** `playwright.config.ts`: @@ -78,13 +78,13 @@ export default defineConfig({ }) ``` -- [ ] **Step 3: 现有 AI 用例超时上调(可选但推荐)** +- [x] **Step 3: 现有 AI 用例超时上调(可选但推荐)** `tests/main/ai-client.test.ts`:`}, 180_000)` `e2e/ai-chat.spec.ts`:AI 用例 `test.setTimeout(180_000)`;助手 `expect` 保持或升至 `120_000`。 -- [ ] **Step 4: 验证** +- [x] **Step 4: 验证** ```bash npm run test @@ -92,7 +92,7 @@ npm run test Expected: 全部 PASS(需 LM Studio 运行) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add .gitignore playwright.config.ts tests/main/ai-client.test.ts e2e/ai-chat.spec.ts @@ -110,7 +110,7 @@ git commit -m "chore: ignore test-results and raise AI test timeouts" - Modify: `src/shared/ipc-channels.ts` - Create: `tests/main/migrate-v4.test.ts` -- [ ] **Step 1: 写迁移失败测试** +- [x] **Step 1: 写迁移失败测试** `tests/main/migrate-v4.test.ts`: @@ -153,7 +153,7 @@ describe('migrate v4', () => { }) ``` -- [ ] **Step 2: 运行确认 FAIL** +- [x] **Step 2: 运行确认 FAIL** ```bash npm run test -- tests/main/migrate-v4.test.ts @@ -161,11 +161,11 @@ npm run test -- tests/main/migrate-v4.test.ts Expected: FAIL(version 仍为 3 或表不存在) -- [ ] **Step 3: 创建 `schema-v4.sql`** +- [x] **Step 3: 创建 `schema-v4.sql`** 内容同 spec §2.3(`chapters.origin`、`interactive_flows`、`interactive_scenes` 及索引)。 -- [ ] **Step 4: 更新 `migrate.ts`** +- [x] **Step 4: 更新 `migrate.ts`** ```typescript import schemaV4 from './schema-v4.sql?raw' @@ -184,13 +184,13 @@ if (current < 4) { } ``` -- [ ] **Step 5: `types.ts` 追加类型** +- [x] **Step 5: `types.ts` 追加类型** 按 spec §3 追加:`InteractiveStep`、`InteractiveFlowStatus`、`ChapterOrigin`、`PlotOption`、`NamingPause`、`PlotSelection`、`InteractiveScene`、`InteractiveFlow`、`InteractiveGateResult`、`InteractiveStreamChunkEvent`。 `Chapter` 接口增加 `origin?: ChapterOrigin`。 -- [ ] **Step 6: `ipc-channels.ts` 追加** +- [x] **Step 6: `ipc-channels.ts` 追加** ```typescript INTERACTIVE_CHECK_GATE: 'interactive:checkGate', @@ -208,13 +208,13 @@ INTERACTIVE_ABORT: 'interactive:abort', INTERACTIVE_STREAM_CHUNK: 'interactive:streamChunk', ``` -- [ ] **Step 7: 运行测试 PASS** +- [x] **Step 7: 运行测试 PASS** ```bash npm run test -- tests/main/migrate-v4.test.ts ``` -- [ ] **Step 8: Commit** +- [x] **Step 8: Commit** ```bash git add src/main/db/schema-v4.sql src/main/db/migrate.ts src/shared/types.ts src/shared/ipc-channels.ts tests/main/migrate-v4.test.ts @@ -229,7 +229,7 @@ git commit -m "feat: add schema v4 and interactive shared types" - Create: `src/main/db/repositories/interactive.repo.ts` - Modify: `src/main/services/book-registry.ts` -- [ ] **Step 1: 实现 `InteractiveRepository`** +- [x] **Step 1: 实现 `InteractiveRepository`** ```typescript import { randomUUID } from 'crypto' @@ -358,7 +358,7 @@ export class InteractiveRepository { } ``` -- [ ] **Step 2: `book-registry.ts` 注册** +- [x] **Step 2: `book-registry.ts` 注册** ```typescript import { InteractiveRepository } from '../db/repositories/interactive.repo' @@ -368,7 +368,7 @@ getInteractiveRepo(bookId: string): InteractiveRepository { } ``` -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add src/main/db/repositories/interactive.repo.ts src/main/services/book-registry.ts @@ -385,7 +385,7 @@ git commit -m "feat: add InteractiveRepository for flow and scene persistence" - Modify: `src/main/db/repositories/chapter.repo.ts` - Modify: `src/main/services/word-count.ts`(若需导出 `stripHtml`,或从 naming-check 复用) -- [ ] **Step 1: 写门槛失败测试** +- [x] **Step 1: 写门槛失败测试** `tests/main/interactive-gate.test.ts`: @@ -427,7 +427,7 @@ describe('checkInteractiveGate', () => { }) ``` -- [ ] **Step 2: 实现 `interactive-gate.service.ts`** +- [x] **Step 2: 实现 `interactive-gate.service.ts`** ```typescript import type { InteractiveGateResult } from '../../shared/types' @@ -454,7 +454,7 @@ export function checkInteractiveGate(db: SqliteDb): InteractiveGateResult { (实现时可用 `stripHtml` 遍历章节列表,与 spec 语义一致即可。) -- [ ] **Step 3: `chapter.repo.ts` 支持 `origin`** +- [x] **Step 3: `chapter.repo.ts` 支持 `origin`** `mapRow` 增加 `origin: (row.origin as ChapterOrigin) ?? 'manual'`。 @@ -462,7 +462,7 @@ export function checkInteractiveGate(db: SqliteDb): InteractiveGateResult { INSERT 增加 `origin` 列。 -- [ ] **Step 4: 运行 PASS + Commit** +- [x] **Step 4: 运行 PASS + Commit** ```bash npm run test -- tests/main/interactive-gate.test.ts @@ -478,7 +478,7 @@ git commit -m "feat: add interactive gate check and chapter origin column" - Create: `src/main/services/interactive-prompt-builder.service.ts` - Create: `tests/main/interactive-parse.test.ts` -- [ ] **Step 1: 解析单元测试(无 AI)** +- [x] **Step 1: 解析单元测试(无 AI)** `tests/main/interactive-parse.test.ts`: @@ -502,7 +502,7 @@ describe('interactive-parse', () => { }) ``` -- [ ] **Step 2: 实现 `interactive-parse.service.ts`** +- [x] **Step 2: 实现 `interactive-parse.service.ts`** ```typescript import type { NamingPause, PlotOption } from '../../shared/types' @@ -553,7 +553,7 @@ export function tryParseNamingFromStream(buffer: string): NamingPause | null { } ``` -- [ ] **Step 3: 实现 `interactive-prompt-builder.service.ts`** +- [x] **Step 3: 实现 `interactive-prompt-builder.service.ts`** 导出函数: @@ -564,7 +564,7 @@ export function tryParseNamingFromStream(buffer: string): NamingPause | null { System prompt 明确要求 JSON 格式或 HTML 段落;走向步要求恰好 3 条。 -- [ ] **Step 4: 运行 PASS + Commit** +- [x] **Step 4: 运行 PASS + Commit** ```bash npm run test -- tests/main/interactive-parse.test.ts @@ -579,7 +579,7 @@ git commit -m "feat: add interactive prompt builder and JSON parsers" - Create: `src/main/services/interactive-writing.service.ts` - Create: `tests/main/interactive-finish.test.ts` -- [ ] **Step 1: 写无章 AI 的 finish 测试** +- [x] **Step 1: 写无章 AI 的 finish 测试** `tests/main/interactive-finish.test.ts`(IT-INT-04,无 LM Studio): @@ -633,7 +633,7 @@ describe('InteractiveWritingService.finishChapter', () => { }) ``` -- [ ] **Step 2: 实现 `InteractiveWritingService`** +- [x] **Step 2: 实现 `InteractiveWritingService`** 构造函数注入 `db` + `AiClientService`(或窄接口 `{ chat, chatStream }`)。 @@ -656,13 +656,13 @@ finishChapter(flowId: string, volumeId: string, title?: string): { chapterId: st 其余方法按 spec 步骤机实现;`suggestPlots` 调 `aiClient.chat` + `parsePlotsJson`(失败重试 1 次);`generateScene` 流式累积 buffer,开头检测 `tryParseNamingFromStream` → 写 `naming_pending_json` 并切 `naming_pause`。 -- [ ] **Step 3: 运行 PASS** +- [x] **Step 3: 运行 PASS** ```bash npm run test -- tests/main/interactive-finish.test.ts ``` -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git commit -m "feat: add InteractiveWritingService with finishChapter" @@ -678,7 +678,7 @@ git commit -m "feat: add InteractiveWritingService with finishChapter" - Modify: `src/preload/index.ts` - Modify: `src/shared/electron-api.d.ts` -- [ ] **Step 1: 实现 `registerInteractiveHandlers`** +- [x] **Step 1: 实现 `registerInteractiveHandlers`** 模式参照 `ai.handler.ts`: @@ -687,7 +687,7 @@ git commit -m "feat: add InteractiveWritingService with finishChapter" - `generateScene` / `refineScene` / `resolveNaming`:注册 `activeInteractive` AbortController Map;流式时 `win.webContents.send(IPC.INTERACTIVE_STREAM_CHUNK, payload)` - `finishChapter`:需 `{ bookId, flowId, volumeId, title? }` -- [ ] **Step 2: `register.ts` 注册** +- [x] **Step 2: `register.ts` 注册** ```typescript import { registerInteractiveHandlers } from './handlers/interactive.handler' @@ -695,7 +695,7 @@ import { registerInteractiveHandlers } from './handlers/interactive.handler' registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig)) ``` -- [ ] **Step 3: preload 暴露 `interactive` 命名空间** +- [x] **Step 3: preload 暴露 `interactive` 命名空间** ```typescript interactive: { @@ -707,7 +707,7 @@ interactive: { } ``` -- [ ] **Step 4: 手动 smoke** +- [x] **Step 4: 手动 smoke** ```bash npm run build @@ -715,7 +715,7 @@ npm run build Expected: 编译无 TS 错误 -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git commit -m "feat: wire interactive IPC handlers and preload API" @@ -729,7 +729,7 @@ git commit -m "feat: wire interactive IPC handlers and preload API" - Create: `tests/main/interactive-plots.test.ts` - Create: `tests/main/interactive-scene.test.ts` -- [ ] **Step 1: IT-INT-01** +- [x] **Step 1: IT-INT-01** ```typescript import { describe, it, expect } from 'vitest' @@ -755,14 +755,14 @@ describe('interactive plots integration', () => { }) ``` -- [ ] **Step 2: IT-INT-02 / IT-INT-03**(`interactive-scene.test.ts`) +- [x] **Step 2: IT-INT-02 / IT-INT-03**(`interactive-scene.test.ts`) - 先 `selectPlot`,再 `generateScene` 流式收集,断言正文长度 > 100 - 若模型返回 naming JSON,走 `resolveNaming` 后续写 超时:`240_000` -- [ ] **Step 3: 全量单元测试** +- [x] **Step 3: 全量单元测试** ```bash npm run test @@ -770,7 +770,7 @@ npm run test Expected: 50+ tests PASS(需 LM Studio) -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git commit -m "test: add interactive LM Studio integration tests" @@ -787,7 +787,7 @@ git commit -m "test: add interactive LM Studio integration tests" - Modify: `src/renderer/components/ai/AiPanel.tsx` - Modify: `src/renderer/styles/layout.css` -- [ ] **Step 1: `useInteractiveStore`** +- [x] **Step 1: `useInteractiveStore`** ```typescript import { create } from 'zustand' @@ -832,11 +832,11 @@ export const useInteractiveStore = create((set, get) => ({ })) ``` -- [ ] **Step 2: `PlotOptionCard.tsx`** +- [x] **Step 2: `PlotOptionCard.tsx`** 可点击卡片,`data-testid={`plot-option-${id}`}`,展示 label + summary。 -- [ ] **Step 3: `InteractivePanel.tsx`** +- [x] **Step 3: `InteractivePanel.tsx`** 按 `flow.step` 条件渲染;关键 testid: @@ -850,7 +850,7 @@ export const useInteractiveStore = create((set, get) => ({ - `interactive-accept-scene` - `interactive-finish-chapter` -- [ ] **Step 4: 修改 `AiPanel.tsx`** +- [x] **Step 4: 修改 `AiPanel.tsx`** ```typescript import { InteractivePanel } from '@renderer/components/ai/InteractivePanel' @@ -882,11 +882,11 @@ const handleModeClick = (mode: AiWritingMode): void => { 模式 Tab:`interactive` 在 `!gateEligible` 时 `disabled` + `title={t('interactive.gateTooltip')}`。 -- [ ] **Step 5: CSS** +- [x] **Step 5: CSS** 在 `layout.css` 追加 `.interactive-panel`、`.plot-option-card`、`.naming-options`、`.interactive-step-bar`(参考 `ui_pc.html` `.plot-opt`)。 -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git commit -m "feat: add InteractivePanel UI and wire AiPanel mode switch" @@ -901,11 +901,11 @@ git commit -m "feat: add InteractivePanel UI and wire AiPanel mode switch" - Modify: `public/locales/en/translation.json` - Modify: `src/renderer/stores/useInteractiveStore.ts`(finish 后导航) -- [ ] **Step 1: 追加文案键** +- [x] **Step 1: 追加文案键** `interactive.gateBlocked`、`interactive.gateTooltip`、`interactive.step.context`、`interactive.step.plot`、`interactive.step.scene`、`interactive.step.review`、`interactive.start`、`interactive.acceptScene`、`interactive.finishChapter`、`interactive.chapterCreated`、`interactive.namingTitle`、`interactive.refinePlaceholder` -- [ ] **Step 2: `finishChapter` 成功后** +- [x] **Step 2: `finishChapter` 成功后** ```typescript import { useBookStore } from '@renderer/stores/useBookStore' @@ -922,7 +922,7 @@ useAppStore.getState().setView('editor') showToast(t('interactive.chapterCreated')) ``` -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git commit -m "feat: add interactive i18n and chapter navigation after finish" @@ -935,7 +935,7 @@ git commit -m "feat: add interactive i18n and chapter navigation after finish" **Files:** - Create: `e2e/interactive-writing.spec.ts` -- [ ] **Step 1: E2E-INT-02(门槛,无 AI)** +- [x] **Step 1: E2E-INT-02(门槛,无 AI)** ```typescript test('E2E-INT-02: interactive tab disabled on empty book', async () => { @@ -945,7 +945,7 @@ test('E2E-INT-02: interactive tab disabled on empty book', async () => { }) ``` -- [ ] **Step 2: E2E-INT-01(完整流程,300s)** +- [x] **Step 2: E2E-INT-01(完整流程,300s)** ```typescript test('E2E-INT-01: interactive flow creates chapter', async () => { @@ -963,17 +963,17 @@ test('E2E-INT-01: interactive flow creates chapter', async () => { }) ``` -- [ ] **Step 3: E2E-INT-03(重启恢复,300s)** +- [x] **Step 3: E2E-INT-03(重启恢复,300s)** 进入交互 mid-flow → 关闭 app → 重开 → `getFlow` 恢复步骤 UI。 -- [ ] **Step 4: 运行 E2E** +- [x] **Step 4: 运行 E2E** ```bash npm run test:e2e -- e2e/interactive-writing.spec.ts ``` -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git commit -m "test: add interactive writing E2E specs" @@ -989,11 +989,11 @@ git commit -m "test: add interactive writing E2E specs" - Modify: `src/renderer/components/settings/SettingsPage.tsx`(about 版本号) - Modify: `docs/superpowers/plans/2026-07-08-bilin-p4-interactive.md`(勾选完成项) -- [ ] **Step 1: 版本号 → `0.4.0`** +- [x] **Step 1: 版本号 → `0.4.0`** -- [ ] **Step 2: README 功能概览追加 P4 交互写作** +- [x] **Step 2: README 功能概览追加 P4 交互写作** -- [ ] **Step 3: 全量测试** +- [x] **Step 3: 全量测试** ```bash npm run test @@ -1003,7 +1003,7 @@ npm run test:e2e Expected: 单元全部 PASS;E2E 全部 PASS(LM Studio 运行中) -- [ ] **Step 4: Commit + tag(可选)** +- [x] **Step 4: Commit + tag(可选)** ```bash git commit -m "chore: release v0.4.0 with interactive writing mode" diff --git a/e2e/interactive-writing.spec.ts b/e2e/interactive-writing.spec.ts new file mode 100644 index 0000000..b7995a1 --- /dev/null +++ b/e2e/interactive-writing.spec.ts @@ -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 { + await page.waitForLoadState('domcontentloaded') + if (await page.getByText('欢迎使用笔临').isVisible()) { + await page.getByRole('button', { name: '跳过' }).click() + } +} + +async function ensureChapterEditor(page: Page): Promise { + await page.getByRole('button', { name: '+ 新章' }).click() + await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) +} + +async function openBookWithContent(page: Page): Promise { + 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() + }) +}) diff --git a/package.json b/package.json index 77ed58a..d08a4f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "0.3.0", + "version": "0.4.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 9e024d5..1c33484 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -91,6 +91,27 @@ "ai.networkRestored": "Network restored. Cloud AI is available again.", "ai.insertToEditor": "Insert into editor", "ai.inserted": "Inserted into editor", + "ai.sending": "AI is thinking…", + "interactive.gateBlocked": "Add chapter content, outline, or settings before interactive writing", + "interactive.gateTooltip": "Requires chapter text or outline/settings", + "interactive.step.context": "Context", + "interactive.step.plot": "Plot", + "interactive.step.scene": "Scene", + "interactive.step.review": "Review", + "interactive.start": "Start interactive", + "interactive.loadingPlots": "Generating plot options…", + "interactive.plotNotePlaceholder": "Additional instructions (optional)", + "interactive.generateScene": "Generate scene", + "interactive.namingTitle": "Name this {{type}}", + "interactive.customName": "Custom name", + "interactive.useCustomName": "Use this name", + "interactive.refinePlaceholder": "Refinement instruction, e.g. make dialogue more hostile", + "interactive.refine": "Refine scene", + "interactive.acceptScene": "Next scene", + "interactive.finishChapter": "Finish chapter", + "interactive.chapterTitlePlaceholder": "Chapter title (optional)", + "interactive.chapterCreated": "Interactive draft chapter created", + "interactive.sceneCount": "{{count}} scene(s) confirmed", "snapshot.ai": "Before AI insert", "naming.open": "🔍 Naming consistency check", "naming.title": "Naming consistency check", diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index 9f5752a..eeae199 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -91,6 +91,27 @@ "ai.networkRestored": "网络已恢复,可继续使用云端 AI", "ai.insertToEditor": "插入到编辑器", "ai.inserted": "已插入到编辑器", + "ai.sending": "AI 思考中…", + "interactive.gateBlocked": "请先创建章节正文、大纲或设定后再使用交互写作", + "interactive.gateTooltip": "需要至少一章正文,或存在大纲/设定", + "interactive.step.context": "上下文", + "interactive.step.plot": "走向", + "interactive.step.scene": "场景", + "interactive.step.review": "确认", + "interactive.start": "开始交互", + "interactive.loadingPlots": "正在生成剧情走向…", + "interactive.plotNotePlaceholder": "补充指令(可选)", + "interactive.generateScene": "生成场景", + "interactive.namingTitle": "请为{{type}}命名", + "interactive.customName": "自定义名称", + "interactive.useCustomName": "使用此名称", + "interactive.refinePlaceholder": "输入微调指令,如:让对话更有敌意", + "interactive.refine": "微调场景", + "interactive.acceptScene": "满意,下一场景", + "interactive.finishChapter": "结束本章", + "interactive.chapterTitlePlaceholder": "章节标题(可选)", + "interactive.chapterCreated": "已生成交互初稿章节", + "interactive.sceneCount": "已确认 {{count}} 个场景", "snapshot.ai": "AI 生成前", "naming.open": "🔍 命名一致性检查", "naming.title": "命名一致性检查", diff --git a/src/main/db/migrate.ts b/src/main/db/migrate.ts index f92ec69..f54b98c 100644 --- a/src/main/db/migrate.ts +++ b/src/main/db/migrate.ts @@ -2,8 +2,9 @@ import type { SqliteDb } from './types' import schemaV1 from './schema-v1.sql?raw' import schemaV2 from './schema-v2.sql?raw' import schemaV3 from './schema-v3.sql?raw' +import schemaV4 from './schema-v4.sql?raw' -const CURRENT_VERSION = 3 +const CURRENT_VERSION = 4 function tryAlter(db: SqliteDb, sql: string): void { try { @@ -48,5 +49,12 @@ export function migrate(db: SqliteDb): void { if (current < 3) { db.exec(schemaV3) db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(3, 'P3 AI schema') + current = 3 + } + + if (current < 4) { + tryAlter(db, "ALTER TABLE chapters ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'") + db.exec(schemaV4) + db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(4, 'P4 interactive schema') } } diff --git a/src/main/db/repositories/chapter.repo.ts b/src/main/db/repositories/chapter.repo.ts index 662ea12..b4a4c65 100644 --- a/src/main/db/repositories/chapter.repo.ts +++ b/src/main/db/repositories/chapter.repo.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto' import { countWords } from '../../services/word-count' -import type { Chapter, ChapterStatus } from '../../../shared/types' +import type { Chapter, ChapterOrigin, ChapterStatus } from '../../../shared/types' import type { SqliteDb } from '../types' function mapRow(row: Record): Chapter { return { @@ -11,21 +11,28 @@ function mapRow(row: Record): Chapter { status: row.status as ChapterStatus, wordCount: row.word_count as number, sortOrder: row.sort_order as number, - cursorOffset: (row.cursor_offset as number) ?? 0 + cursorOffset: (row.cursor_offset as number) ?? 0, + origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin } } export class ChapterRepository { constructor(private db: SqliteDb) {} - create(volumeId: string, title: string, sortOrder: number, content = ''): Chapter { + create( + volumeId: string, + title: string, + sortOrder: number, + content = '', + origin: ChapterOrigin = 'manual' + ): Chapter { const id = randomUUID() const wordCount = countWords(content) this.db .prepare( - `INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order) - VALUES (?, ?, ?, ?, ?, ?)` + `INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order, origin) + VALUES (?, ?, ?, ?, ?, ?, ?)` ) - .run(id, volumeId, title, content, wordCount, sortOrder) + .run(id, volumeId, title, content, wordCount, sortOrder, origin) return this.get(id)! } diff --git a/src/main/db/repositories/interactive.repo.ts b/src/main/db/repositories/interactive.repo.ts new file mode 100644 index 0000000..7dc584d --- /dev/null +++ b/src/main/db/repositories/interactive.repo.ts @@ -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 | 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 + | 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[] + 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): 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 + } + } +} diff --git a/src/main/db/schema-v4.sql b/src/main/db/schema-v4.sql new file mode 100644 index 0000000..33dd632 --- /dev/null +++ b/src/main/db/schema-v4.sql @@ -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); diff --git a/src/main/ipc/handlers/interactive.handler.ts b/src/main/ipc/handlers/interactive.handler.ts new file mode 100644 index 0000000..08943af --- /dev/null +++ b/src/main/ipc/handlers/interactive.handler.ts @@ -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() + +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)) + ) +} diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 0e9a529..6aec633 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -15,6 +15,7 @@ import { registerSnapshotHandlers } from './handlers/snapshot.handler' import { registerBookmarkHandlers } from './handlers/bookmark.handler' import { registerSearchHandlers } from './handlers/search.handler' import { registerAiHandlers } from './handlers/ai.handler' +import { registerInteractiveHandlers } from './handlers/interactive.handler' import { AiClientService } from '../services/ai-client.service' import { NetworkMonitorService } from '../services/network-monitor.service' @@ -42,6 +43,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg registerBookmarkHandlers(books) registerSearchHandlers(books, settings) registerAiHandlers(books, settings, new AiClientService(() => settings.get().aiConfig)) + registerInteractiveHandlers(books, settings, new AiClientService(() => settings.get().aiConfig)) return { settings, books, shortcuts: shortcutManager } } diff --git a/src/main/services/book-registry.ts b/src/main/services/book-registry.ts index ed4e2fd..9857530 100644 --- a/src/main/services/book-registry.ts +++ b/src/main/services/book-registry.ts @@ -8,6 +8,7 @@ import { OutlineRepository } from '../db/repositories/outline.repo' import { SettingRepository } from '../db/repositories/setting.repo' import { InspirationRepository } from '../db/repositories/inspiration.repo' import { AiSessionRepository } from '../db/repositories/ai-session.repo' +import { InteractiveRepository } from '../db/repositories/interactive.repo' import { SnapshotRepository } from '../db/repositories/snapshot.repo' import { BookmarkRepository } from '../db/repositories/bookmark.repo' import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types' @@ -150,6 +151,10 @@ export class BookRegistryService { return new AiSessionRepository(this.getDb(bookId)) } + getInteractiveRepo(bookId: string): InteractiveRepository { + return new InteractiveRepository(this.getDb(bookId)) + } + getSnapshotRepo(bookId: string): SnapshotRepository { return new SnapshotRepository(this.getDb(bookId)) } diff --git a/src/main/services/interactive-gate.service.ts b/src/main/services/interactive-gate.service.ts new file mode 100644 index 0000000..b9667d8 --- /dev/null +++ b/src/main/services/interactive-gate.service.ts @@ -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' + } +} diff --git a/src/main/services/interactive-parse.service.ts b/src/main/services/interactive-parse.service.ts new file mode 100644 index 0000000..a167d5b --- /dev/null +++ b/src/main/services/interactive-parse.service.ts @@ -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 + 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 + 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 + } +} diff --git a/src/main/services/interactive-prompt-builder.service.ts b/src/main/services/interactive-prompt-builder.service.ts new file mode 100644 index 0000000..5f6a9ea --- /dev/null +++ b/src/main/services/interactive-prompt-builder.service.ts @@ -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

段落,不要其它说明。' + }, + { + 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

段落,将选定名称写入正文。' + }, + { + role: 'user', + content: `命名说明:${context}\n选定名称:${chosenName}\n已有片段:${partialScene}\n\n请续写并完成本场景。` + } + ] +} + +export function buildRefineMessages(currentSceneHtml: string, instruction: string): ChatMessage[] { + return [ + { + role: 'system', + content: '根据用户指令修订当前场景,输出完整修订后的 HTML

段落,不要解释。' + }, + { + role: 'user', + content: `当前场景:\n${currentSceneHtml}\n\n修改指令:${instruction}` + } + ] +} diff --git a/src/main/services/interactive-writing.service.ts b/src/main/services/interactive-writing.service.ts new file mode 100644 index 0000000..3fb5028 --- /dev/null +++ b/src/main/services/interactive-writing.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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('

')) return t + return t + .split(/\n\n+/) + .map((p) => `

${p.trim()}

`) + .join('') + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index dc6a3dd..4b497b8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,11 @@ import type { NamingConflict, AiStreamChunkEvent, AiNetworkStatusEvent, + InteractiveFlow, + InteractiveGateResult, + InteractiveStreamChunkEvent, + PlotOption, + PlotSelection, IpcResult, LandmarkType, OutlineItem, @@ -264,6 +269,55 @@ const electronAPI = { namingCheck: (bookId: string): Promise> => ipcRenderer.invoke(IPC.AI_NAMING_CHECK, { bookId }) }, + interactive: { + checkGate: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_CHECK_GATE, { bookId }), + getFlow: (bookId: string, sessionId: string): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_GET_FLOW, { bookId, sessionId }), + start: (bookId: string, sessionId: string): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_START, { bookId, sessionId }), + confirmContext: ( + bookId: string, + flowId: string, + binding: AiContextBinding + ): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_CONFIRM_CONTEXT, { bookId, flowId, binding }), + suggestPlots: (bookId: string, flowId: string): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_SUGGEST_PLOTS, { bookId, flowId }), + selectPlot: ( + bookId: string, + flowId: string, + selection: PlotSelection + ): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_SELECT_PLOT, { bookId, flowId, selection }), + generateScene: (bookId: string, flowId: string): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_GENERATE_SCENE, { bookId, flowId }), + resolveNaming: ( + bookId: string, + flowId: string, + chosenName: string + ): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_RESOLVE_NAMING, { bookId, flowId, chosenName }), + refineScene: ( + bookId: string, + flowId: string, + instruction: string + ): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_REFINE_SCENE, { bookId, flowId, instruction }), + acceptScene: (bookId: string, flowId: string): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_ACCEPT_SCENE, { bookId, flowId }), + finishChapter: ( + bookId: string, + flowId: string, + volumeId: string, + title?: string + ): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_FINISH_CHAPTER, { bookId, flowId, volumeId, title }), + abort: (flowId: string): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_ABORT, { flowId }), + getSceneDraft: (bookId: string, flowId: string): Promise> => + ipcRenderer.invoke(IPC.INTERACTIVE_GET_SCENE_DRAFT, { bookId, flowId }) + }, wordfreq: { analyze: ( bookId: string, @@ -297,6 +351,13 @@ const electronAPI = { } ipcRenderer.on(IPC.AI_NETWORK_STATUS, handler) return () => ipcRenderer.removeListener(IPC.AI_NETWORK_STATUS, handler) + }, + onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void): (() => void) => { + const handler = (_event: IpcRendererEvent, payload: InteractiveStreamChunkEvent) => { + callback(payload) + } + ipcRenderer.on(IPC.INTERACTIVE_STREAM_CHUNK, handler) + return () => ipcRenderer.removeListener(IPC.INTERACTIVE_STREAM_CHUNK, handler) } } diff --git a/src/renderer/components/ai/AiPanel.tsx b/src/renderer/components/ai/AiPanel.tsx index 4a9574d..a6449ff 100644 --- a/src/renderer/components/ai/AiPanel.tsx +++ b/src/renderer/components/ai/AiPanel.tsx @@ -9,6 +9,8 @@ import { applySlashCommand } from '@renderer/lib/ai-slash-commands' import { insertToEditor } from '@renderer/lib/editor-commands' import { ipcCall } from '@renderer/lib/ipc-client' import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal' +import { InteractivePanel } from '@renderer/components/ai/InteractivePanel' +import { useInteractiveStore } from '@renderer/stores/useInteractiveStore' const MODES: { id: AiWritingMode; labelKey: string }[] = [ { id: 'chat', labelKey: 'ai.mode.chat' }, @@ -39,6 +41,9 @@ export function AiPanel(): React.JSX.Element { const createSession = useAiStore((s) => s.createSession) const sendMessage = useAiStore((s) => s.sendMessage) const setWritingMode = useAiStore((s) => s.setWritingMode) + const gateEligible = useInteractiveStore((s) => s.gateEligible) + const checkGate = useInteractiveStore((s) => s.checkGate) + const startInteractive = useInteractiveStore((s) => s.start) const [input, setInput] = useState('') const [contextOpen, setContextOpen] = useState(false) const messagesEndRef = useRef(null) @@ -57,8 +62,11 @@ export function AiPanel(): React.JSX.Element { }, []) useEffect(() => { - if (bookId) void loadSessions(bookId) - }, [bookId, loadSessions]) + if (bookId) { + void loadSessions(bookId) + void checkGate(bookId) + } + }, [bookId, loadSessions, checkGate]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -86,10 +94,32 @@ export function AiPanel(): React.JSX.Element { } const handleModeClick = (mode: AiWritingMode): void => { - if (mode !== 'chat') { + if (mode === 'auto' || mode === 'wizard') { showToast(t('feature.comingSoon')) return } + if (mode === 'interactive') { + void (async () => { + if (bookId) await checkGate(bookId) + if (!useInteractiveStore.getState().gateEligible) { + showToast(t('interactive.gateBlocked')) + return + } + setWritingMode(mode) + const sid = activeSessionId ?? useAiStore.getState().activeSessionId + if (!bookId) return + if (!sid) { + await createSession(bookId) + } + const sessionId = useAiStore.getState().activeSessionId + if (!sessionId) return + await useInteractiveStore.getState().loadFlow(bookId, sessionId) + if (!useInteractiveStore.getState().flow) { + await startInteractive(bookId, sessionId) + } + })() + return + } setWritingMode(mode) } @@ -128,18 +158,28 @@ export function AiPanel(): React.JSX.Element {
- {MODES.map((mode) => ( - - ))} + {MODES.map((mode) => { + const interactiveDisabled = mode.id === 'interactive' && !gateEligible + return ( + + ) + })}
+ {writingMode === 'interactive' ? ( + + ) : ( + <> + + + )} + + {(step === 'plot_suggest' || step === 'plot_select') && ( +
+ {plots.length === 0 ? ( +

{streaming ? t('ai.sending') : t('interactive.loadingPlots')}

+ ) : ( + <> +
+ {plots.map((p) => ( + togglePlot(p.id)} + /> + ))} +
+