From aa2c7dfed385884079acc9039d2458c52fa5aa2b Mon Sep 17 00:00:00 2001 From: kun1h Date: Tue, 7 Jul 2026 15:33:53 +0800 Subject: [PATCH] feat: ship v0.7.0 with AI knowledge context integration Add semi-automatic knowledge suggestions and user-confirmed injection across chat, interactive, auto, and wizard modes; fix writing flows to persist full systemPrompt in contextJson. Co-authored-by: Cursor --- README.md | 5 +- .../plans/2026-07-07-bilin-p6-ai-context.md | 104 ++++++------- e2e/ai-context.spec.ts | 135 +++++++++++++++++ package.json | 2 +- public/locales/en/translation.json | 13 +- public/locales/zh-CN/translation.json | 13 +- src/main/db/repositories/ai-session.repo.ts | 6 +- src/main/ipc/handlers/auto.handler.ts | 28 +++- src/main/ipc/handlers/interactive.handler.ts | 12 +- src/main/ipc/handlers/knowledge.handler.ts | 21 ++- src/main/ipc/handlers/wizard.handler.ts | 19 ++- src/main/ipc/register.ts | 2 +- .../services/ai-context-builder.service.ts | 67 ++++++++- src/main/services/auto-writing.service.ts | 20 ++- src/main/services/flow-context.util.ts | 53 +++++++ src/main/services/global-settings.ts | 6 +- .../services/interactive-writing.service.ts | 18 +-- .../services/knowledge-retrieval.service.ts | 90 ++++++++++++ src/main/services/wizard-writing.service.ts | 24 ++- src/preload/index.ts | 15 +- src/renderer/components/ai/AutoPanel.tsx | 78 +++++++++- .../components/ai/ContextEditorModal.tsx | 137 +++++++++++++++--- .../components/ai/InteractivePanel.tsx | 68 ++++++++- .../components/ai/KnowledgeSuggestList.tsx | 51 +++++++ src/renderer/components/ai/WizardPanel.tsx | 92 +++++++++++- .../components/settings/SettingsPage.tsx | 2 +- src/renderer/lib/ai-context-summary.ts | 5 +- src/renderer/stores/useAutoStore.ts | 14 ++ src/renderer/stores/useInteractiveStore.ts | 3 +- src/renderer/stores/useSettingsStore.ts | 2 + src/shared/electron-api.d.ts | 11 ++ src/shared/ipc-channels.ts | 2 + src/shared/types.ts | 24 ++- tests/main/ai-context-builder.test.ts | 63 +++++++- tests/main/ai-context-integration.test.ts | 88 +++++++++++ tests/main/flow-context.test.ts | 46 ++++++ tests/main/interactive-plots.test.ts | 16 +- tests/main/interactive-scene.test.ts | 16 +- tests/main/knowledge-retrieval.test.ts | 80 ++++++++++ tests/main/wizard-generate.test.ts | 16 +- tests/main/wizard-preview.test.ts | 16 +- 41 files changed, 1315 insertions(+), 168 deletions(-) create mode 100644 e2e/ai-context.spec.ts create mode 100644 src/main/services/flow-context.util.ts create mode 100644 src/main/services/knowledge-retrieval.service.ts create mode 100644 src/renderer/components/ai/KnowledgeSuggestList.tsx create mode 100644 tests/main/ai-context-integration.test.ts create mode 100644 tests/main/flow-context.test.ts create mode 100644 tests/main/knowledge-retrieval.test.ts diff --git a/README.md b/README.md index 1be70df..b576cf5 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # 笔临 (Bilin) -长篇创作智能协作平台 — Electron 桌面客户端 v0.6.0(P5 作家工作流 + 知识库) +长篇创作智能协作平台 — Electron 桌面客户端 v0.7.0(P6 AI 知识上下文) -## 功能概览(v0.6.0) +## 功能概览(v0.7.0) +- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6) - 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5) - 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5) - 章间衔接:上下章片段 + 可选 AI 建议(P5) diff --git a/docs/superpowers/plans/2026-07-07-bilin-p6-ai-context.md b/docs/superpowers/plans/2026-07-07-bilin-p6-ai-context.md index 51bbb5f..4210588 100644 --- a/docs/superpowers/plans/2026-07-07-bilin-p6-ai-context.md +++ b/docs/superpowers/plans/2026-07-07-bilin-p6-ai-context.md @@ -1,6 +1,6 @@ # 笔临 P6 AI 知识上下文整合 Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) 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.7.0:已审核知识库条目半自动推荐 + 用户勾选确认 + 注入对话/交互/自动/向导四种 AI 模式;修复写作流 `contextJson` 仅存标题摘要的问题。 @@ -57,7 +57,7 @@ - Modify: `src/shared/ipc-channels.ts` - Modify: `src/main/db/repositories/ai-session.repo.ts` -- [ ] **Step 1: 扩展 types** +- [x] **Step 1: 扩展 types** `src/shared/types.ts` 追加: @@ -104,7 +104,7 @@ knowledgeAutoSuggest: boolean knowledgeSuggestTopN: number ``` -- [ ] **Step 2: IPC 通道** +- [x] **Step 2: IPC 通道** `src/shared/ipc-channels.ts`: @@ -112,7 +112,7 @@ knowledgeSuggestTopN: number KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext', ``` -- [ ] **Step 3: ai-session.repo 默认 knowledgeIds** +- [x] **Step 3: ai-session.repo 默认 knowledgeIds** ```typescript const EMPTY_CONTEXT: AiContextBinding = { @@ -139,17 +139,17 @@ function parseContext(json: string): AiContextBinding { } ``` -- [ ] **Step 4: 全局修补 EMPTY_BINDING** +- [x] **Step 4: 全局修补 EMPTY_BINDING** 搜索 `inspirationIds: []` 且无 `knowledgeIds` 的 `AiContextBinding` 字面量(`ContextEditorModal.tsx`、`useInteractiveStore.ts` 等),全部补上 `knowledgeIds: []`。 -- [ ] **Step 5: build 验证** +- [x] **Step 5: build 验证** ```bash npm run build ``` -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add src/shared/types.ts src/shared/ipc-channels.ts src/main/db/repositories/ai-session.repo.ts @@ -164,7 +164,7 @@ git commit -m "feat: extend AiContextBinding with knowledgeIds for P6" - Create: `src/main/services/knowledge-retrieval.service.ts` - Create: `tests/main/knowledge-retrieval.test.ts` -- [ ] **Step 1: UT-CTX-01 / UT-CTX-04 失败测试** +- [x] **Step 1: UT-CTX-01 / UT-CTX-04 失败测试** `tests/main/knowledge-retrieval.test.ts`: @@ -254,13 +254,13 @@ describe('KnowledgeRetrievalService', () => { }) ``` -- [ ] **Step 2: 运行确认 FAIL** +- [x] **Step 2: 运行确认 FAIL** ```bash npm run test -- tests/main/knowledge-retrieval.test.ts ``` -- [ ] **Step 3: 实现 KnowledgeRetrievalService** +- [x] **Step 3: 实现 KnowledgeRetrievalService** `src/main/services/knowledge-retrieval.service.ts` 核心: @@ -357,13 +357,13 @@ export class KnowledgeRetrievalService { } ``` -- [ ] **Step 4: 运行 PASS** +- [x] **Step 4: 运行 PASS** ```bash npm run test -- tests/main/knowledge-retrieval.test.ts ``` -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/main/services/knowledge-retrieval.service.ts tests/main/knowledge-retrieval.test.ts @@ -380,7 +380,7 @@ git commit -m "feat: add KnowledgeRetrievalService with scoring for context sugg - Modify: `tests/main/ai-context-builder.test.ts` - Create: `tests/main/flow-context.test.ts` -- [ ] **Step 1: UT-CTX-02 / UT-CTX-03 测试** +- [x] **Step 1: UT-CTX-02 / UT-CTX-03 测试** 在 `tests/main/ai-context-builder.test.ts` 追加: @@ -483,13 +483,13 @@ describe('flow-context.util', () => { }) ``` -- [ ] **Step 2: 运行确认 FAIL** +- [x] **Step 2: 运行确认 FAIL** ```bash npm run test -- tests/main/ai-context-builder.test.ts tests/main/flow-context.test.ts ``` -- [ ] **Step 3: 扩展 ai-context-builder** +- [x] **Step 3: 扩展 ai-context-builder** 要点: @@ -522,13 +522,13 @@ export function buildFlowContextPayload( } ``` -- [ ] **Step 4: 运行 PASS** +- [x] **Step 4: 运行 PASS** ```bash npm run test -- tests/main/ai-context-builder.test.ts tests/main/flow-context.test.ts ``` -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/main/services/ai-context-builder.service.ts src/main/services/flow-context.util.ts tests/main/ @@ -546,7 +546,7 @@ git commit -m "feat: extend AiContextBuilder with knowledge blocks and flow payl - Modify: `src/main/services/global-settings.ts` - Modify: `src/renderer/stores/useSettingsStore.ts` -- [ ] **Step 1: knowledge.handler 追加** +- [x] **Step 1: knowledge.handler 追加** ```typescript ipcMain.handle( @@ -563,7 +563,7 @@ ipcMain.handle( `registerKnowledgeHandlers` 签名改为接收 `GlobalSettingsService`,`topN` 默认 `settings.get().knowledgeSuggestTopN ?? 8`。 -- [ ] **Step 2: global-settings 默认值** +- [x] **Step 2: global-settings 默认值** ```typescript knowledgeAutoSuggest: true, @@ -572,7 +572,7 @@ knowledgeSuggestTopN: 8, `get()` merge 补默认。 -- [ ] **Step 3: preload + electron-api.d.ts** +- [x] **Step 3: preload + electron-api.d.ts** ```typescript suggestContext: ( @@ -581,13 +581,13 @@ suggestContext: ( ): Promise> ``` -- [ ] **Step 4: build** +- [x] **Step 4: build** ```bash npm run build ``` -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git commit -m "feat: add knowledge suggestContext IPC and global settings defaults" @@ -604,7 +604,7 @@ git commit -m "feat: add knowledge suggestContext IPC and global settings defaul - Modify: `src/main/ipc/handlers/wizard.handler.ts` - Modify: `src/main/services/wizard-writing.service.ts`(`buildPreview` 追加知识列表) -- [ ] **Step 1: interactive confirmContext 存 FlowContextPayload** +- [x] **Step 1: interactive confirmContext 存 FlowContextPayload** `interactive.handler.ts` `INTERACTIVE_CONFIRM_CONTEXT`: @@ -642,7 +642,7 @@ enrichFlow(flow: InteractiveFlow): InteractiveFlow { } ``` -- [ ] **Step 2: auto.handler / wizard.handler readContextText** +- [x] **Step 2: auto.handler / wizard.handler readContextText** 替换本地 `readContextText` 为读 `systemPrompt`: @@ -660,21 +660,21 @@ function readContextText(registry, bookId, flowId): string { } ``` -- [ ] **Step 3: wizard confirmContext 同 interactive** +- [x] **Step 3: wizard confirmContext 同 interactive** `wizard.handler.ts` `WIZARD_CONFIRM_CONTEXT` 使用 `buildFlowContextPayload`。 -- [ ] **Step 4: buildPreview 追加知识标题** +- [x] **Step 4: buildPreview 追加知识标题** `WizardWritingService.buildPreview` 返回前,从 `payload.binding.knowledgeIds` 查标题,append `\n将注入知识:A、B、C`。 -- [ ] **Step 5: auto planScenes 前自动 confirm** +- [x] **Step 5: auto planScenes 前自动 confirm** `AutoPanel` 在 `planScenes` 调用前,若 flow 无 `systemPrompt`,用 session binding + 默认推荐 knowledgeIds 调 `interactive.confirmContext` 等价逻辑(可新增 `auto:confirmContext` IPC 或复用 `buildFlowContextPayload` + `setContextSummary` 扩展为存完整 payload)。 推荐:新增 `auto.handler` `AUTO_CONFIRM_CONTEXT` 与 interactive 对称。 -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git commit -m "fix: store full systemPrompt in writing flow contextJson for P6" @@ -689,7 +689,7 @@ git commit -m "fix: store full systemPrompt in writing flow contextJson for P6" - Modify: `src/renderer/components/ai/ContextEditorModal.tsx` - Modify: `src/renderer/lib/ai-context-summary.ts` -- [ ] **Step 1: KnowledgeSuggestList** +- [x] **Step 1: KnowledgeSuggestList** Props: @@ -703,7 +703,7 @@ interface KnowledgeSuggestListProps { 渲染 checkbox + title + `t(\`knowledge.type.${entry.type}\`)` + reason badges `t(reason)`。 -- [ ] **Step 2: ContextEditorModal 扩展** +- [x] **Step 2: ContextEditorModal 扩展** 新增 props:`goalText?: string` @@ -719,7 +719,7 @@ const suggested = await ipcCall(() => 新增 Tab `knowledge`;顶部推荐区 + `context-refresh-suggest`;下方 approved 全列表(`knowledge.list(bookId, { status: 'approved' })`)可勾选。 -- [ ] **Step 3: formatContextSummary 含 knowledge** +- [x] **Step 3: formatContextSummary 含 knowledge** ```typescript const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 } @@ -729,7 +729,7 @@ return t('ai.contextSummary', { ...counts }) 更新 `ai.contextSummary` i18n 为 `{{chapters}}章·{{outlines}}纲·{{settings}}设·{{inspirations}}感·{{knowledge}}知`。 -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git commit -m "feat: add KnowledgeSuggestList and knowledge tab in context editor" @@ -747,25 +747,25 @@ git commit -m "feat: add KnowledgeSuggestList and knowledge tab in context edito - Modify: `src/renderer/stores/useAutoStore.ts`(confirmContext) - Modify: `src/renderer/components/ai/AiPanel.tsx`(传 goalText 空) -- [ ] **Step 1: InteractivePanel context_confirm** +- [x] **Step 1: InteractivePanel context_confirm** - mount 时 `suggestContext` → state `suggested` - 内联 ``,`data-testid="interactive-knowledge-suggest"` - 勾选同步到 session `context.knowledgeIds`(`ai.sessionUpdate`) - `confirmContextAndStart` 传含 `knowledgeIds` 的 binding -- [ ] **Step 2: AutoPanel** +- [x] **Step 2: AutoPanel** - `goal_setup` 区域下方折叠「知识上下文」`data-testid="auto-knowledge-suggest"` - `goalText` = `chapterGoal` + 选中大纲 description - `planScenes` 前调用 `auto.confirmContext`(Task 5 新增 IPC) -- [ ] **Step 3: WizardPanel wizard_context** +- [x] **Step 3: WizardPanel wizard_context** - 同 Interactive 内联推荐列表 `data-testid="wizard-knowledge-suggest"` - `handleContextClose` 传 `goalText` = goal + styleNote -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git commit -m "feat: integrate knowledge suggest into interactive auto wizard panels" @@ -779,9 +779,9 @@ git commit -m "feat: integrate knowledge suggest into interactive auto wizard pa - Modify: `public/locales/zh-CN/translation.json` - Modify: `public/locales/en/translation.json` -- [ ] **Step 1: 追加 spec §9 全部键 + `ai.contextSummary` 更新** +- [x] **Step 1: 追加 spec §9 全部键 + `ai.contextSummary` 更新** -- [ ] **Step 2: Commit** +- [x] **Step 2: Commit** ```bash git commit -m "feat: add P6 AI context i18n strings" @@ -794,7 +794,7 @@ git commit -m "feat: add P6 AI context i18n strings" **Files:** - Create: `tests/main/ai-context-integration.test.ts` -- [ ] **Step 1: IT-CTX-01** +- [x] **Step 1: IT-CTX-01** ```typescript it('IT-CTX-01: suggestContext returns entries when approved exist', () => { @@ -802,11 +802,11 @@ it('IT-CTX-01: suggestContext returns entries when approved exist', () => { }) ``` -- [ ] **Step 2: IT-CTX-02** +- [x] **Step 2: IT-CTX-02** 通过 `aiContextBuilder.build` 含 knowledgeIds 断言 `systemPrompt` 含 `【知识·`(无需 Electron IPC)。 -- [ ] **Step 3: IT-CTX-03(240s)** +- [x] **Step 3: IT-CTX-03(240s)** ```typescript it('IT-CTX-03: interactive confirm then suggestPlots uses knowledge context', async () => { @@ -816,13 +816,13 @@ it('IT-CTX-03: interactive confirm then suggestPlots uses knowledge context', as }, 240_000) ``` -- [ ] **Step 4: 运行** +- [x] **Step 4: 运行** ```bash npm run test -- tests/main/ai-context-integration.test.ts ``` -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git commit -m "test: add P6 AI context integration tests" @@ -835,7 +835,7 @@ git commit -m "test: add P6 AI context integration tests" **Files:** - Create: `e2e/ai-context.spec.ts` -- [ ] **Step 1: E2E-CTX-01** +- [x] **Step 1: E2E-CTX-01** ```typescript test('E2E-CTX-01: context editor knowledge tab shows suggestions', async () => { @@ -843,7 +843,7 @@ test('E2E-CTX-01: context editor knowledge tab shows suggestions', async () => { }) ``` -- [ ] **Step 2: E2E-CTX-02** +- [x] **Step 2: E2E-CTX-02** ```typescript test('E2E-CTX-02: interactive context_confirm shows knowledge suggest', async () => { @@ -851,7 +851,7 @@ test('E2E-CTX-02: interactive context_confirm shows knowledge suggest', async () }) ``` -- [ ] **Step 3: E2E-CTX-03(300s)** +- [x] **Step 3: E2E-CTX-03(300s)** ```typescript test('E2E-CTX-03: chat with knowledge context gets AI reply', async () => { @@ -860,13 +860,13 @@ test('E2E-CTX-03: chat with knowledge context gets AI reply', async () => { }) ``` -- [ ] **Step 4: 运行** +- [x] **Step 4: 运行** ```bash npm run test:e2e -- e2e/ai-context.spec.ts ``` -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git commit -m "test: add P6 AI context E2E specs" @@ -882,15 +882,15 @@ git commit -m "test: add P6 AI context E2E specs" - Modify: `src/renderer/components/settings/SettingsPage.tsx`(about 版本号) - Modify: `docs/superpowers/plans/2026-07-07-bilin-p6-ai-context.md`(勾选完成项) -- [ ] **Step 1: 版本号 `0.7.0`** +- [x] **Step 1: 版本号 `0.7.0`** -- [ ] **Step 2: README 追加** +- [x] **Step 2: README 追加** ```markdown - 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6) ``` -- [ ] **Step 3: 全量测试** +- [x] **Step 3: 全量测试** ```bash npm run build @@ -898,7 +898,7 @@ npm run test npm run test:e2e ``` -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git commit -m "feat: ship v0.7.0 with AI knowledge context integration" diff --git a/e2e/ai-context.spec.ts b/e2e/ai-context.spec.ts new file mode 100644 index 0000000..c12f1fa --- /dev/null +++ b/e2e/ai-context.spec.ts @@ -0,0 +1,135 @@ +import path from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect, _electron as electron, type Page } from '@playwright/test' + +const ROOT = path.join(import.meta.dirname, '..') + +async function launchApp(userDataDir: string) { + return electron.launch({ + args: [ROOT], + env: { + ...process.env, + BILIN_E2E: '1', + BILIN_E2E_USER_DATA: userDataDir + } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 }) + if (await page.getByText('欢迎使用笔临').isVisible()) { + await page.getByRole('button', { name: '跳过' }).click() + await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 }) + } +} + +async function dismissCockpitIfOpen(page: Page): Promise { + const cockpit = page.locator('[data-testid="cockpit-modal"]') + if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.locator('[data-testid="cockpit-modal"] .modal-close').click() + await expect(cockpit).toBeHidden({ timeout: 5000 }) + } +} + +async function createAndOpenBook(page: Page, name: string): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill(name) + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) + await dismissCockpitIfOpen(page) +} + +async function ensureChapterEditor(page: Page): Promise { + const items = page.locator('.chapter-item') + if ((await items.count()) === 0) { + await page.getByRole('button', { name: '+ 新章' }).click() + } else { + await items.first().click() + } + await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) +} + +async function addApprovedKnowledge(page: Page, title: string): Promise { + await page.locator('[data-testid="panel-tab-knowledge"]').click() + await page.locator('[data-testid="knowledge-new"]').click() + await page.locator('[data-testid="knowledge-title-input"]').fill(title) + await page.locator('[data-testid="knowledge-save"]').click() + await page.locator('[data-testid="knowledge-tab-pending"]').click() + await page.locator('[data-testid^="knowledge-approve-"]').first().click() +} + +test.describe('AI knowledge context P6', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-ctx-')) + }) + + test.afterEach(async () => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-CTX-01: context editor knowledge tab shows suggestions', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '上下文书') + await ensureChapterEditor(page) + await addApprovedKnowledge(page, '测灵石异常') + await page.getByTestId('panel-tab-ai').click() + await page.getByTestId('ai-session-new').click() + await page.getByTestId('ai-context-preview').click() + await expect(page.getByTestId('context-editor-modal')).toBeVisible({ timeout: 10_000 }) + await page.getByTestId('context-tab-knowledge').click() + await expect(page.getByTestId('context-knowledge-suggest')).toBeVisible({ timeout: 10_000 }) + await app.close() + }) + + test('E2E-CTX-02: interactive context_confirm shows knowledge suggest', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '交互知识书') + await ensureChapterEditor(page) + const editor = page.locator('.ProseMirror') + await editor.click() + await editor.pressSequentially('林远站在演武场中央,众人目光汇聚。') + await page.keyboard.press('Control+s') + await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 }) + await addApprovedKnowledge(page, '测灵石异常') + await page.getByTestId('panel-tab-ai').click() + await page.getByTestId('ai-session-new').click() + await expect(page.getByTestId('ai-mode-tab-interactive')).toBeEnabled({ timeout: 10_000 }) + await page.getByTestId('ai-mode-tab-interactive').click() + await expect(page.getByTestId('interactive-knowledge-suggest')).toBeVisible({ timeout: 15_000 }) + await app.close() + }) + + test('E2E-CTX-03: chat with knowledge context gets AI reply', async () => { + test.setTimeout(300_000) + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page, '对话知识书') + await ensureChapterEditor(page) + await addApprovedKnowledge(page, '测灵石异常') + await page.getByTestId('panel-tab-ai').click() + await page.getByTestId('ai-session-new').click() + await page.getByTestId('ai-context-preview').click() + await page.getByTestId('context-tab-knowledge').click() + await page.getByTestId('context-save').click() + await page.getByTestId('ai-chat-input').fill('根据知识库,本章可能发生什么?') + await page.getByTestId('ai-send').click() + await expect(page.getByTestId('chat-message-assistant').last()).not.toBeEmpty({ timeout: 240_000 }) + await app.close() + }) +}) diff --git a/package.json b/package.json index bb677e2..1e752e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "0.6.0", + "version": "0.7.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index dbcfdeb..006ad0f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -72,12 +72,17 @@ "ai.untitledSession": "New chat", "ai.contextPlaceholder": "📎 Context: not configured yet", "ai.contextEmpty": "📎 Context: nothing selected", - "ai.contextSummary": "📎 Context: {{chapters}} ch. · {{outlines}} outline · {{settings}} settings · {{inspirations}} ideas", + "ai.contextSummary": "📎 Context: {{chapters}} ch. · {{outlines}} outline · {{settings}} settings · {{inspirations}} ideas · {{knowledge}} knowledge", "ai.contextEditorTitle": "Edit AI context", "ai.contextTab.chapter": "Chapters", "ai.contextTab.outline": "Outline", "ai.contextTab.setting": "Settings", "ai.contextTab.inspiration": "Ideas", + "ai.contextTab.knowledge": "Knowledge", + "ai.contextRefreshSuggest": "Refresh suggestions", + "ai.contextKnowledgeSuggestTitle": "Suggested knowledge", + "ai.contextKnowledgeAllTitle": "All approved knowledge", + "ai.contextKnowledgeEmpty": "No approved knowledge entries", "ai.contextEmptyList": "No items", "ai.contextRefresh": "Refresh context", "ai.contextSave": "Save", @@ -100,6 +105,7 @@ "interactive.step.scene": "Scene", "interactive.step.review": "Review", "interactive.start": "Start interactive", + "interactive.confirmKnowledge": "Confirm knowledge context", "interactive.loadingPlots": "Generating plot options…", "interactive.plotNotePlaceholder": "Additional instructions (optional)", "interactive.generateScene": "Generate scene", @@ -140,6 +146,7 @@ "wizard.rhythm.mixed": "Mixed", "wizard.styleNotePlaceholder": "Optional style note", "wizard.contextHint": "Confirm context including outline/settings clues", + "wizard.knowledgeList": "Knowledge to inject", "wizard.previewTitle": "Strategy preview", "wizard.refreshPreview": "Refresh preview", "wizard.startGenerate": "Start generating", @@ -168,6 +175,10 @@ "knowledge.type.location": "Location", "knowledge.type.relationship": "Relationship", "knowledge.type.fact": "Fact", + "knowledge.reason.highImportance": "High importance", + "knowledge.reason.chapterRelevant": "Chapter relevant", + "knowledge.reason.foreshadowBuried": "Foreshadow pending", + "knowledge.reason.goalMatch": "Goal match", "knowledge.titleLabel": "Title", "knowledge.contentLabel": "Content", "knowledge.importance": "Importance", diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index 67c9802..99eabfb 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -72,12 +72,17 @@ "ai.untitledSession": "新对话", "ai.contextPlaceholder": "📎 上下文:尚未配置", "ai.contextEmpty": "📎 上下文:尚未勾选", - "ai.contextSummary": "📎 上下文:{{chapters}}章 · 大纲{{outlines}}条 · 设定{{settings}}个 · 灵感{{inspirations}}条", + "ai.contextSummary": "📎 上下文:{{chapters}}章 · 大纲{{outlines}}条 · 设定{{settings}}个 · 灵感{{inspirations}}条 · 知识{{knowledge}}条", "ai.contextEditorTitle": "编辑 AI 上下文", "ai.contextTab.chapter": "章节", "ai.contextTab.outline": "大纲", "ai.contextTab.setting": "设定", "ai.contextTab.inspiration": "灵感", + "ai.contextTab.knowledge": "知识库", + "ai.contextRefreshSuggest": "刷新推荐", + "ai.contextKnowledgeSuggestTitle": "推荐知识", + "ai.contextKnowledgeAllTitle": "全部已审核知识", + "ai.contextKnowledgeEmpty": "暂无已审核知识条目", "ai.contextEmptyList": "暂无可选项", "ai.contextRefresh": "更新上下文", "ai.contextSave": "保存", @@ -100,6 +105,7 @@ "interactive.step.scene": "场景", "interactive.step.review": "确认", "interactive.start": "开始交互", + "interactive.confirmKnowledge": "确认知识上下文", "interactive.loadingPlots": "正在生成剧情走向…", "interactive.plotNotePlaceholder": "补充指令(可选)", "interactive.generateScene": "生成场景", @@ -140,6 +146,7 @@ "wizard.rhythm.mixed": "混合", "wizard.styleNotePlaceholder": "可选:本章风格说明", "wizard.contextHint": "确认与本章相关的上下文(含大纲/设定中的伏笔线索)", + "wizard.knowledgeList": "将注入知识", "wizard.previewTitle": "生成策略预览", "wizard.refreshPreview": "刷新预览", "wizard.startGenerate": "开始生成", @@ -168,6 +175,10 @@ "knowledge.type.location": "地点", "knowledge.type.relationship": "关系", "knowledge.type.fact": "事实", + "knowledge.reason.highImportance": "高重要度", + "knowledge.reason.chapterRelevant": "与当前章相关", + "knowledge.reason.foreshadowBuried": "伏笔待回收", + "knowledge.reason.goalMatch": "匹配章节目标", "knowledge.titleLabel": "标题", "knowledge.contentLabel": "内容", "knowledge.importance": "重要度", diff --git a/src/main/db/repositories/ai-session.repo.ts b/src/main/db/repositories/ai-session.repo.ts index a4ed508..31734e3 100644 --- a/src/main/db/repositories/ai-session.repo.ts +++ b/src/main/db/repositories/ai-session.repo.ts @@ -6,7 +6,8 @@ const EMPTY_CONTEXT: AiContextBinding = { chapterIds: [], outlineIds: [], settingIds: [], - inspirationIds: [] + inspirationIds: [], + knowledgeIds: [] } function parseContext(json: string): AiContextBinding { @@ -16,7 +17,8 @@ function parseContext(json: string): AiContextBinding { chapterIds: parsed.chapterIds ?? [], outlineIds: parsed.outlineIds ?? [], settingIds: parsed.settingIds ?? [], - inspirationIds: parsed.inspirationIds ?? [] + inspirationIds: parsed.inspirationIds ?? [], + knowledgeIds: parsed.knowledgeIds ?? [] } } catch { return { ...EMPTY_CONTEXT } diff --git a/src/main/ipc/handlers/auto.handler.ts b/src/main/ipc/handlers/auto.handler.ts index eac2c9f..5e87aaf 100644 --- a/src/main/ipc/handlers/auto.handler.ts +++ b/src/main/ipc/handlers/auto.handler.ts @@ -1,9 +1,10 @@ import { BrowserWindow, ipcMain } from 'electron' import { IPC } from '../../../shared/ipc-channels' -import type { AutoWritingConfig } from '../../../shared/types' +import type { AiContextBinding, AutoWritingConfig } from '../../../shared/types' import { BookRegistryService } from '../../services/book-registry' import { GlobalSettingsService } from '../../services/global-settings' import { AiClientService } from '../../services/ai-client.service' +import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util' import { checkInteractiveGate } from '../../services/interactive-gate.service' import { AutoWritingService } from '../../services/auto-writing.service' import { InteractiveRepository } from '../../db/repositories/interactive.repo' @@ -26,12 +27,7 @@ function readContextText(registry: BookRegistryService, bookId: string, flowId: .getDb(bookId) .prepare('SELECT context_json FROM interactive_flows WHERE id = ?') .get(flowId) as { context_json: string } | undefined - try { - const parsed = JSON.parse(row?.context_json ?? '{}') as { contextSummary?: string } - return parsed.contextSummary ?? '(无上下文摘要)' - } catch { - return '(无上下文摘要)' - } + return readFlowSystemPrompt(row?.context_json ?? '{}') } export function registerAutoHandlers( @@ -76,6 +72,24 @@ export function registerAutoHandlers( }) ) + ipcMain.handle( + IPC.AUTO_CONFIRM_CONTEXT, + ( + _e, + { bookId, flowId, binding }: { bookId: string; flowId: string; binding: AiContextBinding } + ) => + wrap(() => { + const svc = serviceFor(registry, bookId, aiClient) + const payload = buildFlowContextPayload( + binding, + registry, + bookId, + settings.get().penName + ) + return svc.enrichFlow(svc.confirmContext(flowId, payload)) + }) + ) + ipcMain.handle( IPC.AUTO_PLAN_SCENES, (_e, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) => diff --git a/src/main/ipc/handlers/interactive.handler.ts b/src/main/ipc/handlers/interactive.handler.ts index 08943af..e4a00c3 100644 --- a/src/main/ipc/handlers/interactive.handler.ts +++ b/src/main/ipc/handlers/interactive.handler.ts @@ -4,7 +4,7 @@ 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 { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util' import { checkInteractiveGate } from '../../services/interactive-gate.service' import { InteractiveWritingService } from '../../services/interactive-writing.service' import { wrap } from '../result' @@ -56,13 +56,13 @@ export function registerInteractiveHandlers( }: { 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, + const payload = buildFlowContextPayload( binding, - summary + registry, + bookId, + settings.get().penName ) + const flow = serviceFor(registry, bookId, aiClient).confirmContext(flowId, payload) return serviceFor(registry, bookId, aiClient).enrichFlow(flow) }) ) diff --git a/src/main/ipc/handlers/knowledge.handler.ts b/src/main/ipc/handlers/knowledge.handler.ts index 5ad3a7e..1a0a60b 100644 --- a/src/main/ipc/handlers/knowledge.handler.ts +++ b/src/main/ipc/handlers/knowledge.handler.ts @@ -1,11 +1,16 @@ import { ipcMain } from 'electron' import { IPC } from '../../../shared/ipc-channels' -import type { CreateKnowledgeInput, KnowledgeStatus, KnowledgeType } from '../../../shared/types' +import type { CreateKnowledgeInput, KnowledgeStatus, KnowledgeType, KnowledgeSuggestOptions } from '../../../shared/types' import { BookRegistryService } from '../../services/book-registry' +import { GlobalSettingsService } from '../../services/global-settings' +import { KnowledgeRetrievalService } from '../../services/knowledge-retrieval.service' import { KnowledgeService } from '../../services/knowledge.service' import { wrap } from '../result' -export function registerKnowledgeHandlers(registry: BookRegistryService): void { +export function registerKnowledgeHandlers( + registry: BookRegistryService, + settings: GlobalSettingsService +): void { ipcMain.handle( IPC.KNOWLEDGE_LIST, ( @@ -65,4 +70,16 @@ export function registerKnowledgeHandlers(registry: BookRegistryService): void { ipcMain.handle(IPC.KNOWLEDGE_STATS, (_e, { bookId }: { bookId: string }) => wrap(() => new KnowledgeService(registry.getDb(bookId)).getForeshadowStats()) ) + + ipcMain.handle( + IPC.KNOWLEDGE_SUGGEST_CONTEXT, + (_e, { bookId, opts }: { bookId: string; opts?: KnowledgeSuggestOptions }) => + wrap(() => { + const topN = opts?.topN ?? settings.get().knowledgeSuggestTopN ?? 8 + return new KnowledgeRetrievalService(registry.getDb(bookId)).suggest({ + ...opts, + topN + }) + }) + ) } diff --git a/src/main/ipc/handlers/wizard.handler.ts b/src/main/ipc/handlers/wizard.handler.ts index 4af3563..ae10535 100644 --- a/src/main/ipc/handlers/wizard.handler.ts +++ b/src/main/ipc/handlers/wizard.handler.ts @@ -4,7 +4,7 @@ import type { AiContextBinding, AutoRhythm, AutoWritingConfig, WizardWritingConf 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 { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util' import { WizardWritingService } from '../../services/wizard-writing.service' import { InteractiveRepository } from '../../db/repositories/interactive.repo' import { wrap } from '../result' @@ -20,12 +20,7 @@ function readContextText(registry: BookRegistryService, bookId: string, flowId: .getDb(bookId) .prepare('SELECT context_json FROM interactive_flows WHERE id = ?') .get(flowId) as { context_json: string } | undefined - try { - const parsed = JSON.parse(row?.context_json ?? '{}') as { contextSummary?: string } - return parsed.contextSummary ?? '(无上下文摘要)' - } catch { - return '(无上下文摘要)' - } + return readFlowSystemPrompt(row?.context_json ?? '{}') } export function registerWizardHandlers( @@ -98,10 +93,14 @@ export function registerWizardHandlers( { 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 payload = buildFlowContextPayload( + binding, + registry, + bookId, + settings.get().penName + ) const svc = serviceFor(registry, bookId, aiClient) - return svc.enrichFlow(svc.confirmContext(flowId, binding, summary)) + return svc.enrichFlow(svc.confirmContext(flowId, payload)) }) ) diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 0ba55e5..f28001c 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -52,7 +52,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg registerInteractiveHandlers(books, settings, aiClient) registerAutoHandlers(books, settings, aiClient) registerWizardHandlers(books, settings, aiClient) - registerKnowledgeHandlers(books) + registerKnowledgeHandlers(books, settings) registerCockpitHandlers(books, settings) registerBridgeHandlers(books, aiClient) diff --git a/src/main/services/ai-context-builder.service.ts b/src/main/services/ai-context-builder.service.ts index a258acd..8fef316 100644 --- a/src/main/services/ai-context-builder.service.ts +++ b/src/main/services/ai-context-builder.service.ts @@ -1,15 +1,24 @@ import { stripHtml } from './word-count' -import type { AiContextBinding, AiContextBuildResult, ContextPreviewItem } from '../../shared/types' +import type { + AiContextBinding, + AiContextBuildResult, + ContextPreviewItem, + KnowledgeType +} from '../../shared/types' import type { BookRegistryService } from './book-registry' +import { KnowledgeRepository } from '../db/repositories/knowledge.repo' const CHAPTER_CHAR_LIMIT = 2000 const OTHER_CHAR_LIMIT = 500 +const KNOWLEDGE_CHAR_LIMIT = 500 +const KNOWLEDGE_TOTAL_LIMIT = 4000 const TOTAL_CHAR_LIMIT = 16 * 1024 interface BlockCandidate { sortKey: number text: string preview: ContextPreviewItem + isKnowledge?: boolean } function truncate(text: string, max: number): string { @@ -17,12 +26,33 @@ function truncate(text: string, max: number): string { return `${text.slice(0, max)}…` } +function knowledgeTypeLabel(type: KnowledgeType): string { + const map: Record = { + character: '角色', + foreshadow: '伏笔', + location: '地点', + relationship: '关系', + fact: '事实' + } + return map[type] +} + function formatBlock(kind: ContextPreviewItem['kind'], title: string, body: string): string { const label = - kind === 'chapter' ? '章节' : kind === 'outline' ? '大纲' : kind === 'setting' ? '设定' : '灵感' + kind === 'chapter' + ? '章节' + : kind === 'outline' + ? '大纲' + : kind === 'setting' + ? '设定' + : '灵感' return `【${label}】${title}\n${body}` } +function formatKnowledgeBlock(type: KnowledgeType, title: string, body: string): string { + return `【知识·${knowledgeTypeLabel(type)}】${title}\n${body}` +} + export class AiContextBuilderService { build( binding: AiContextBinding, @@ -34,6 +64,7 @@ export class AiContextBuilderService { if (!meta) throw new Error('Book not found') const candidates: BlockCandidate[] = [] + const knowledgeIds = binding.knowledgeIds ?? [] for (const id of binding.chapterIds) { const chapter = registry.getChapterRepo(bookId).get(id) @@ -104,6 +135,38 @@ export class AiContextBuilderService { }) } + const knowledgeRepo = new KnowledgeRepository(registry.getDb(bookId)) + const knowledgeCandidates: BlockCandidate[] = [] + for (const id of knowledgeIds) { + const entry = knowledgeRepo.get(id) + if (!entry || entry.status !== 'approved') continue + const body = truncate(stripHtml(entry.content || entry.title), KNOWLEDGE_CHAR_LIMIT) + knowledgeCandidates.push({ + sortKey: entry.importance * 1000, + isKnowledge: true, + text: formatKnowledgeBlock(entry.type, entry.title, body), + preview: { + kind: 'knowledge', + id, + title: entry.title, + excerpt: body, + charCount: body.length, + knowledgeType: entry.type + } + }) + } + knowledgeCandidates.sort((a, b) => b.sortKey - a.sortKey) + + let knowledgeChars = 0 + const trimmedKnowledge: BlockCandidate[] = [] + for (const k of knowledgeCandidates) { + const next = knowledgeChars + k.text.length + (trimmedKnowledge.length > 0 ? 2 : 0) + if (next > KNOWLEDGE_TOTAL_LIMIT) break + trimmedKnowledge.push(k) + knowledgeChars = next + } + + candidates.push(...trimmedKnowledge) candidates.sort((a, b) => b.sortKey - a.sortKey) const selected: BlockCandidate[] = [] diff --git a/src/main/services/auto-writing.service.ts b/src/main/services/auto-writing.service.ts index 7d2f1e1..a5cddee 100644 --- a/src/main/services/auto-writing.service.ts +++ b/src/main/services/auto-writing.service.ts @@ -1,5 +1,6 @@ import type { AutoWritingConfig, + FlowContextPayload, InteractiveFlow, ScenePlanItem } from '../../shared/types' @@ -190,9 +191,26 @@ export class AutoWritingService { } } + confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow { + this.repo.updateStep(flowId, this.repo.get(flowId)!.step, { + contextJson: JSON.stringify(payload) + }) + return this.repo.get(flowId)! + } + setContextSummary(flowId: string, contextSummary: string): void { this.repo.updateStep(flowId, this.repo.get(flowId)!.step, { - contextJson: JSON.stringify({ contextSummary }) + contextJson: JSON.stringify({ + binding: { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [], + knowledgeIds: [] + }, + systemPrompt: contextSummary, + contextSummary + }) }) } diff --git a/src/main/services/flow-context.util.ts b/src/main/services/flow-context.util.ts new file mode 100644 index 0000000..69886fd --- /dev/null +++ b/src/main/services/flow-context.util.ts @@ -0,0 +1,53 @@ +import type { AiContextBinding, FlowContextPayload } from '../../shared/types' +import type { BookRegistryService } from './book-registry' +import { aiContextBuilder } from './ai-context-builder.service' + +const EMPTY_BINDING: AiContextBinding = { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [], + knowledgeIds: [] +} + +export function buildFlowContextPayload( + binding: AiContextBinding, + registry: BookRegistryService, + bookId: string, + penName: string +): FlowContextPayload { + const built = aiContextBuilder.build(binding, registry, bookId, penName) + const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 } + for (const p of built.preview) { + if (p.kind in counts) counts[p.kind as keyof typeof counts]++ + } + const contextSummary = `${counts.chapter}章·${counts.outline}纲·${counts.setting}设·${counts.inspiration}感·${counts.knowledge}知` + return { binding, systemPrompt: built.systemPrompt, contextSummary } +} + +export function parseFlowContextPayload(contextJson: string): FlowContextPayload | null { + try { + const parsed = JSON.parse(contextJson) as Partial + if (parsed.systemPrompt) { + return { + binding: { ...EMPTY_BINDING, ...parsed.binding }, + systemPrompt: parsed.systemPrompt, + contextSummary: parsed.contextSummary ?? '' + } + } + if (parsed.contextSummary) { + return { + binding: { ...EMPTY_BINDING, ...parsed.binding }, + systemPrompt: parsed.contextSummary, + contextSummary: parsed.contextSummary + } + } + return null + } catch { + return null + } +} + +export function readFlowSystemPrompt(contextJson: string): string { + return parseFlowContextPayload(contextJson)?.systemPrompt ?? '(无上下文)' +} diff --git a/src/main/services/global-settings.ts b/src/main/services/global-settings.ts index c5ccef4..d9e3a90 100644 --- a/src/main/services/global-settings.ts +++ b/src/main/services/global-settings.ts @@ -28,7 +28,9 @@ function defaults(): GlobalSettings { snapshotMaxPersist: 3, searchHistory: [], aiConfig: defaultAiConfig(), - namingIgnoreWords: [] + namingIgnoreWords: [], + knowledgeAutoSuggest: true, + knowledgeSuggestTopN: 8 } } @@ -49,6 +51,8 @@ export class GlobalSettingsService { ...raw, updateSchedule: raw.updateSchedule ?? 'none', stockBufferThreshold: raw.stockBufferThreshold ?? 3, + knowledgeAutoSuggest: raw.knowledgeAutoSuggest ?? true, + knowledgeSuggestTopN: raw.knowledgeSuggestTopN ?? 8, shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts }, aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig } } diff --git a/src/main/services/interactive-writing.service.ts b/src/main/services/interactive-writing.service.ts index 70d0331..1f7f454 100644 --- a/src/main/services/interactive-writing.service.ts +++ b/src/main/services/interactive-writing.service.ts @@ -1,5 +1,6 @@ import type { AiContextBinding, + FlowContextPayload, InteractiveFlow, PlotOption, PlotSelection @@ -16,6 +17,7 @@ import { buildRefineMessages, buildSceneGenerateMessages } from './interactive-prompt-builder.service' +import { readFlowSystemPrompt } from './flow-context.util' export class InteractiveWritingService { private repo: InteractiveRepository @@ -37,9 +39,9 @@ export class InteractiveWritingService { return this.repo.getBySession(sessionId) } - confirmContext(flowId: string, binding: AiContextBinding, contextSummary: string): InteractiveFlow { + confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow { this.repo.updateStep(flowId, 'plot_suggest', { - contextJson: JSON.stringify({ binding, contextSummary }) + contextJson: JSON.stringify(payload) }) return this.repo.get(flowId)! } @@ -165,8 +167,8 @@ export class InteractiveWritingService { } enrichFlow(flow: InteractiveFlow): InteractiveFlow { - const raw = this.repo.getContextJson(flow.id) try { + const raw = this.repo.getContextJson(flow.id) const parsed = JSON.parse(raw) as { contextSummary?: string } return { ...flow, contextSummary: parsed.contextSummary } } catch { @@ -175,14 +177,6 @@ export class InteractiveWritingService { } private readContextText(flowId: string): string { - try { - const parsed = JSON.parse(this.repo.getContextJson(flowId)) as { - contextSummary?: string - binding?: AiContextBinding - } - return parsed.contextSummary ?? '(无上下文摘要)' - } catch { - return '(无上下文摘要)' - } + return readFlowSystemPrompt(this.repo.getContextJson(flowId)) } } diff --git a/src/main/services/knowledge-retrieval.service.ts b/src/main/services/knowledge-retrieval.service.ts new file mode 100644 index 0000000..08811cd --- /dev/null +++ b/src/main/services/knowledge-retrieval.service.ts @@ -0,0 +1,90 @@ +import type { KnowledgeEntry, KnowledgeSuggestOptions, ScoredKnowledge } from '../../shared/types' +import type { SqliteDb } from '../db/types' +import { ChapterRepository } from '../db/repositories/chapter.repo' +import { KnowledgeRepository } from '../db/repositories/knowledge.repo' + +const STOP_WORDS = new Set(['的', '了', '在', '是', '与', '和', 'the', 'a', 'an']) + +function tokenize(text: string): string[] { + return text + .toLowerCase() + .split(/[\s,,。!?、;;::]+/) + .map((t) => t.trim()) + .filter((t) => t.length >= 2 && !STOP_WORDS.has(t)) +} + +function overlapRatio(a: string, b: string): number { + const ta = new Set(tokenize(a)) + const tb = tokenize(b) + if (ta.size === 0 || tb.length === 0) return 0 + let hit = 0 + for (const t of tb) if (ta.has(t)) hit++ + return hit / tb.length +} + +function chapterProximityBonus( + entry: KnowledgeEntry, + currentSort: number | null, + chapterSort: Map +): number { + if (currentSort == null) return 0 + const ids = [entry.sourceChapterId, entry.lastMentionChapterId].filter(Boolean) as string[] + if (ids.length === 0) return 0 + let best = Infinity + for (const id of ids) { + const order = chapterSort.get(id) + if (order != null) best = Math.min(best, Math.abs(currentSort - order)) + } + if (best === Infinity) return 0 + if (best === 0) return 30 + if (best <= 3) return 20 + if (best <= 10) return 10 + return 0 +} + +export class KnowledgeRetrievalService { + constructor(private db: SqliteDb) {} + + suggest(opts: KnowledgeSuggestOptions = {}): ScoredKnowledge[] { + const topN = opts.topN ?? 8 + const entries = new KnowledgeRepository(this.db).list({ status: 'approved' }) + if (entries.length === 0) return [] + + const chapters = new ChapterRepository(this.db).list() + const sortMap = new Map(chapters.map((c) => [c.id, c.sortOrder])) + const currentSort = opts.currentChapterId + ? (sortMap.get(opts.currentChapterId) ?? null) + : null + const goalText = opts.goalText ?? '' + + const scored: ScoredKnowledge[] = entries.map((entry) => { + let score = entry.importance * 20 + const reasons: string[] = [] + + const prox = chapterProximityBonus(entry, currentSort, sortMap) + score += prox + if (prox > 0) reasons.push('knowledge.reason.chapterRelevant') + + if (entry.type === 'foreshadow') { + const fs = entry.foreshadowStatus ?? 'buried' + if (fs === 'buried') { + score += 25 + reasons.push('knowledge.reason.foreshadowBuried') + } else if (fs === 'partial') score += 15 + } + + if (goalText) { + const text = `${entry.title} ${entry.content}` + const ratio = overlapRatio(goalText, text) + score += Math.round(ratio * 30) + if (ratio > 0.2) reasons.push('knowledge.reason.goalMatch') + } + + if (entry.importance >= 4) reasons.push('knowledge.reason.highImportance') + + return { entry, score, reasons: [...new Set(reasons)] } + }) + + return scored.sort((a, b) => b.score - a.score).slice(0, topN) + } +} diff --git a/src/main/services/wizard-writing.service.ts b/src/main/services/wizard-writing.service.ts index 0417023..7cd4e87 100644 --- a/src/main/services/wizard-writing.service.ts +++ b/src/main/services/wizard-writing.service.ts @@ -2,12 +2,14 @@ import type { AiContextBinding, AutoRhythm, AutoWritingConfig, + FlowContextPayload, InteractiveFlow, WizardWritingConfig } from '../../shared/types' import type { SqliteDb } from '../db/types' import { InteractiveRepository } from '../db/repositories/interactive.repo' import { SettingRepository } from '../db/repositories/setting.repo' +import { KnowledgeRepository } from '../db/repositories/knowledge.repo' import type { AiClientService } from './ai-client.service' import { buildWizardPreviewMessages } from './auto-prompt-builder.service' import { AutoWritingService } from './auto-writing.service' @@ -58,15 +60,11 @@ export class WizardWritingService { return this.repo.updateStep(flowId, 'wizard_context') } - confirmContext( - flowId: string, - binding: AiContextBinding, - contextSummary: string - ): InteractiveFlow { + confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow { const config = this.repo.getModeConfig(flowId) - this.repo.setModeConfig(flowId, { ...config, contextBinding: binding }) + this.repo.setModeConfig(flowId, { ...config, contextBinding: payload.binding }) this.repo.updateStep(flowId, 'wizard_preview', { - contextJson: JSON.stringify({ binding, contextSummary }) + contextJson: JSON.stringify(payload) }) return this.repo.get(flowId)! } @@ -75,7 +73,17 @@ export class WizardWritingService { const config = this.repo.getModeConfig(flowId) const messages = buildWizardPreviewMessages(config, contextText, povName) const html = await this.aiClient.chat(messages) - const preview = html.trim().includes('

') ? html.trim() : `

${html.trim()}

` + let preview = html.trim().includes('

') ? html.trim() : `

${html.trim()}

` + const binding = config.contextBinding + if (binding?.knowledgeIds?.length) { + const repo = new KnowledgeRepository(this.db) + const titles = binding.knowledgeIds + .map((id) => repo.get(id)?.title) + .filter(Boolean) + if (titles.length) { + preview += `

将注入知识:${titles.join('、')}

` + } + } this.repo.setModeConfig(flowId, { ...config, strategyPreview: preview }) return preview } diff --git a/src/preload/index.ts b/src/preload/index.ts index 04d8156..dfe93fa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,8 @@ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron' import { IPC } from '../shared/ipc-channels' import type { + KnowledgeSuggestOptions, + ScoredKnowledge, ActionId, Bookmark, BookMeta, @@ -346,6 +348,12 @@ const electronAPI = { config: Partial ): Promise> => ipcRenderer.invoke(IPC.AUTO_SET_GOAL, { bookId, flowId, config }), + confirmContext: ( + bookId: string, + flowId: string, + binding: AiContextBinding + ): Promise> => + ipcRenderer.invoke(IPC.AUTO_CONFIRM_CONTEXT, { bookId, flowId, binding }), planScenes: ( bookId: string, flowId: string, @@ -448,7 +456,12 @@ const electronAPI = { stats: ( bookId: string ): Promise> => - ipcRenderer.invoke(IPC.KNOWLEDGE_STATS, { bookId }) + ipcRenderer.invoke(IPC.KNOWLEDGE_STATS, { bookId }), + suggestContext: ( + bookId: string, + opts?: KnowledgeSuggestOptions + ): Promise> => + ipcRenderer.invoke(IPC.KNOWLEDGE_SUGGEST_CONTEXT, { bookId, opts }) }, cockpit: { getSummary: (bookId: string, volumeId?: string): Promise> => diff --git a/src/renderer/components/ai/AutoPanel.tsx b/src/renderer/components/ai/AutoPanel.tsx index 09f1bfe..ab8e8fd 100644 --- a/src/renderer/components/ai/AutoPanel.tsx +++ b/src/renderer/components/ai/AutoPanel.tsx @@ -1,8 +1,12 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import type { AutoWritingConfig, OutlineItem } from '@shared/types' +import type { AutoWritingConfig, OutlineItem, ScoredKnowledge } from '@shared/types' import { ipcCall } from '@renderer/lib/ipc-client' import { useAutoStore } from '@renderer/stores/useAutoStore' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useAiStore } from '@renderer/stores/useAiStore' +import { useSettingsStore } from '@renderer/stores/useSettingsStore' +import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList' interface AutoPanelProps { bookId: string @@ -37,6 +41,20 @@ export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): Reac const [outlineIds, setOutlineIds] = useState([]) const [outlines, setOutlines] = useState([]) const [chapterTitle, setChapterTitle] = useState('') + const [suggested, setSuggested] = useState([]) + const [knowledgeIds, setKnowledgeIds] = useState([]) + const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true) + const selectedChapterId = useBookStore((s) => s.selectedChapterId) + const sessions = useAiStore((s) => s.sessions) + const activeSessionId = useAiStore((s) => s.activeSessionId) + + const goalText = useMemo(() => { + const outlineText = outlines + .filter((o) => outlineIds.includes(o.id)) + .map((o) => o.description ?? '') + .join(' ') + return [goal.trim(), outlineText].filter(Boolean).join(' ') + }, [goal, outlineIds, outlines]) useEffect(() => { mount() @@ -52,6 +70,52 @@ export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): Reac void ipcCall(() => window.electronAPI.outline.list(bookId)).then(setOutlines) }, [bookId]) + useEffect(() => { + if (!bookId || !sessionId || !knowledgeAutoSuggest) return + const session = sessions.find((s) => s.id === sessionId) + const existing = session?.context.knowledgeIds ?? [] + setKnowledgeIds(existing) + void ipcCall(() => + window.electronAPI.knowledge.suggestContext(bookId, { + currentChapterId: selectedChapterId ?? undefined, + goalText: goalText || undefined + }) + ).then((items) => { + setSuggested(items) + const merged = [...new Set([...existing, ...items.map((x) => x.entry.id)])] + setKnowledgeIds(merged) + if (!session) return + void ipcCall(() => + window.electronAPI.ai.sessionUpdate(bookId, sessionId, { + context: { ...session.context, knowledgeIds: merged } + }) + ).then((updated) => { + useAiStore.setState((s) => ({ + sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x)) + })) + }) + }) + }, [bookId, sessionId, knowledgeAutoSuggest, selectedChapterId, goalText, sessions]) + + const toggleKnowledge = (id: string): void => { + if (!bookId || !activeSessionId) return + const session = sessions.find((s) => s.id === activeSessionId) + if (!session) return + const next = knowledgeIds.includes(id) + ? knowledgeIds.filter((x) => x !== id) + : [...knowledgeIds, id] + setKnowledgeIds(next) + void ipcCall(() => + window.electronAPI.ai.sessionUpdate(bookId, activeSessionId, { + context: { ...session.context, knowledgeIds: next } + }) + ).then((updated) => { + useAiStore.setState((s) => ({ + sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x)) + })) + }) + } + if (!sessionId || !flow) { return (
@@ -121,6 +185,16 @@ export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): Reac onChange={(e) => setWordMax(Number(e.target.value))} />
+ {knowledgeAutoSuggest && ( +
+ {t('interactive.confirmKnowledge')} + +
+ )}
- {(['chapter', 'outline', 'setting', 'inspiration'] as const).map((id) => ( + {(['chapter', 'outline', 'setting', 'inspiration', 'knowledge'] as const).map((id) => (
+ {tab === 'knowledge' && ( +
+
+ {t('ai.contextKnowledgeSuggestTitle')} + +
+ +
+ )}
- {items.length === 0 &&
{t('ai.contextEmptyList')}
} - {items.map((item) => ( - - ))} + {tab === 'knowledge' ? ( + <> +
{t('ai.contextKnowledgeAllTitle')}
+ {approvedKnowledge.length === 0 && ( +
{t('ai.contextKnowledgeEmpty')}
+ )} + {approvedKnowledge.map((entry) => ( + + ))} + + ) : ( + <> + {items.length === 0 &&
{t('ai.contextEmptyList')}
} + {items.map((item) => ( + + ))} + + )}
diff --git a/src/renderer/components/ai/KnowledgeSuggestList.tsx b/src/renderer/components/ai/KnowledgeSuggestList.tsx new file mode 100644 index 0000000..6631a52 --- /dev/null +++ b/src/renderer/components/ai/KnowledgeSuggestList.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from 'react-i18next' +import type { ScoredKnowledge } from '@shared/types' + +interface KnowledgeSuggestListProps { + items: ScoredKnowledge[] + selectedIds: string[] + onToggle: (id: string) => void + testId?: string +} + +export function KnowledgeSuggestList({ + items, + selectedIds, + onToggle, + testId = 'knowledge-suggest-list' +}: KnowledgeSuggestListProps): React.JSX.Element { + const { t } = useTranslation() + + if (items.length === 0) { + return
{t('ai.contextKnowledgeEmpty')}
+ } + + return ( +
+ {items.map(({ entry, reasons }) => ( + + ))} +
+ ) +} diff --git a/src/renderer/components/ai/WizardPanel.tsx b/src/renderer/components/ai/WizardPanel.tsx index 154eb80..82b3943 100644 --- a/src/renderer/components/ai/WizardPanel.tsx +++ b/src/renderer/components/ai/WizardPanel.tsx @@ -1,10 +1,13 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import type { AutoRhythm, AutoWritingConfig, SettingEntry, WizardWritingConfig } from '@shared/types' +import type { AutoRhythm, AutoWritingConfig, ScoredKnowledge, SettingEntry, WizardWritingConfig } from '@shared/types' import { ipcCall } from '@renderer/lib/ipc-client' import { useWizardStore } from '@renderer/stores/useWizardStore' import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal' +import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList' import { useAiStore } from '@renderer/stores/useAiStore' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useSettingsStore } from '@renderer/stores/useSettingsStore' interface WizardPanelProps { bookId: string @@ -41,6 +44,44 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps): const [characters, setCharacters] = useState([]) const [povId, setPovId] = useState('') const [contextOpen, setContextOpen] = useState(false) + const [suggested, setSuggested] = useState([]) + const [knowledgeIds, setKnowledgeIds] = useState([]) + const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true) + const selectedChapterId = useBookStore((s) => s.selectedChapterId) + const sessions = useAiStore((s) => s.sessions) + + const goalText = useMemo( + () => [goal.trim(), styleNote.trim()].filter(Boolean).join(' '), + [goal, styleNote] + ) + + const step = flow?.step + useEffect(() => { + if (!bookId || !sessionId || step !== 'wizard_context' || !knowledgeAutoSuggest) return + const session = sessions.find((s) => s.id === sessionId) + const existing = session?.context.knowledgeIds ?? [] + setKnowledgeIds(existing) + void ipcCall(() => + window.electronAPI.knowledge.suggestContext(bookId, { + currentChapterId: selectedChapterId ?? undefined, + goalText: goalText || undefined + }) + ).then((items) => { + setSuggested(items) + const merged = [...new Set([...existing, ...items.map((x) => x.entry.id)])] + setKnowledgeIds(merged) + if (!session) return + void ipcCall(() => + window.electronAPI.ai.sessionUpdate(bookId, sessionId, { + context: { ...session.context, knowledgeIds: merged } + }) + ).then((updated) => { + useAiStore.setState((s) => ({ + sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x)) + })) + }) + }) + }, [bookId, sessionId, step, knowledgeAutoSuggest, selectedChapterId, goalText, sessions]) useEffect(() => { mount() @@ -66,9 +107,32 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps): ) } - const step = flow.step const config = flow.modeConfig as WizardWritingConfig + const toggleKnowledge = (id: string): void => { + if (!bookId || !sessionId) return + const session = sessions.find((s) => s.id === sessionId) + if (!session) return + const next = knowledgeIds.includes(id) + ? knowledgeIds.filter((x) => x !== id) + : [...knowledgeIds, id] + setKnowledgeIds(next) + void ipcCall(() => + window.electronAPI.ai.sessionUpdate(bookId, sessionId, { + context: { ...session.context, knowledgeIds: next } + }) + ).then((updated) => { + useAiStore.setState((s) => ({ + sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x)) + })) + }) + } + + const handleConfirmContext = (): void => { + const session = sessions.find((s) => s.id === sessionId) + if (session) void confirmContext(bookId, session.context) + } + const handleContextClose = (): void => { setContextOpen(false) const session = useAiStore.getState().sessions.find( @@ -178,15 +242,33 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps): {step === 'wizard_context' && (

{t('wizard.contextHint')}

+ {knowledgeAutoSuggest && ( +
+

{t('interactive.confirmKnowledge')}

+ +
+ )} +
)} @@ -230,7 +312,7 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps):
)} - + ) } diff --git a/src/renderer/components/settings/SettingsPage.tsx b/src/renderer/components/settings/SettingsPage.tsx index a9334d5..5350a2c 100644 --- a/src/renderer/components/settings/SettingsPage.tsx +++ b/src/renderer/components/settings/SettingsPage.tsx @@ -137,7 +137,7 @@ export function SettingsPage(): React.JSX.Element { {section === 'shortcuts' && } {section === 'about' && (

- {t('app.name')} v0.6.0 + {t('app.name')} v0.7.0
{t('app.tagline')}

diff --git a/src/renderer/lib/ai-context-summary.ts b/src/renderer/lib/ai-context-summary.ts index 3fea86c..39f5bf3 100644 --- a/src/renderer/lib/ai-context-summary.ts +++ b/src/renderer/lib/ai-context-summary.ts @@ -4,13 +4,14 @@ import type { ContextPreviewItem } from '@shared/types' export function formatContextSummary(preview: ContextPreviewItem[], t: TFunction): string { if (preview.length === 0) return t('ai.contextEmpty') - const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0 } + const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 } for (const item of preview) counts[item.kind]++ return t('ai.contextSummary', { chapters: counts.chapter, outlines: counts.outline, settings: counts.setting, - inspirations: counts.inspiration + inspirations: counts.inspiration, + knowledge: counts.knowledge }) } diff --git a/src/renderer/stores/useAutoStore.ts b/src/renderer/stores/useAutoStore.ts index 17e4cdc..f5e4ca1 100644 --- a/src/renderer/stores/useAutoStore.ts +++ b/src/renderer/stores/useAutoStore.ts @@ -19,6 +19,7 @@ interface AutoState { loadFlow: (bookId: string, sessionId: string) => Promise start: (bookId: string, sessionId: string) => Promise setGoal: (bookId: string, config: Partial) => Promise + confirmContext: (bookId: string) => Promise planScenes: (bookId: string) => Promise generateNext: (bookId: string) => Promise resolveNaming: (bookId: string, chosenName: string) => Promise @@ -64,11 +65,24 @@ export const useAutoStore = create((set, get) => ({ set({ flow }) }, + confirmContext: async (bookId) => { + const flowId = get().flow?.id + const session = useAiStore.getState().sessions.find( + (s) => s.id === useAiStore.getState().activeSessionId + ) + if (!flowId || !session) return + const flow = await ipcCall(() => + window.electronAPI.auto.confirmContext(bookId, flowId, session.context) + ) + set({ flow }) + }, + planScenes: async (bookId) => { const flowId = get().flow?.id if (!flowId) return set({ streaming: true }) try { + await get().confirmContext(bookId) const summary = useAiStore.getState().contextSummary const flow = await ipcCall(() => window.electronAPI.auto.planScenes(bookId, flowId, summary) diff --git a/src/renderer/stores/useInteractiveStore.ts b/src/renderer/stores/useInteractiveStore.ts index ac8edb5..4800fcf 100644 --- a/src/renderer/stores/useInteractiveStore.ts +++ b/src/renderer/stores/useInteractiveStore.ts @@ -67,7 +67,8 @@ export const useInteractiveStore = create((set, get) => ({ chapterIds: [], outlineIds: [], settingIds: [], - inspirationIds: [] + inspirationIds: [], + knowledgeIds: [] } const flow = await ipcCall(() => window.electronAPI.interactive.confirmContext(bookId, flowId, binding) diff --git a/src/renderer/stores/useSettingsStore.ts b/src/renderer/stores/useSettingsStore.ts index e8b4f85..ff8e9be 100644 --- a/src/renderer/stores/useSettingsStore.ts +++ b/src/renderer/stores/useSettingsStore.ts @@ -22,6 +22,8 @@ export const useSettingsStore = create((set, get) => ({ shortcuts: {} as Record, aiConfig: { ...DEFAULT_AI_CONFIG }, namingIgnoreWords: [] as string[], + knowledgeAutoSuggest: true, + knowledgeSuggestTopN: 8, loaded: false, load: async () => { const data = await ipcCall(() => window.electronAPI.settings.get()) diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index cc36ba1..9238707 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -35,6 +35,8 @@ import type { Volume, WordFreqResult, KnowledgeEntry, + KnowledgeSuggestOptions, + ScoredKnowledge, CreateKnowledgeInput, KnowledgeStatus, KnowledgeType, @@ -263,6 +265,11 @@ export interface ElectronAPI { flowId: string, config: Partial ) => Promise> + confirmContext: ( + bookId: string, + flowId: string, + binding: AiContextBinding + ) => Promise> planScenes: ( bookId: string, flowId: string, @@ -338,6 +345,10 @@ export interface ElectronAPI { stats: ( bookId: string ) => Promise> + suggestContext: ( + bookId: string, + opts?: KnowledgeSuggestOptions + ) => Promise> } cockpit: { getSummary: (bookId: string, volumeId?: string) => Promise> diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 90ddd2e..3be105c 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -82,6 +82,7 @@ export const IPC = { AUTO_GET_FLOW: 'auto:getFlow', AUTO_START: 'auto:start', AUTO_SET_GOAL: 'auto:setGoal', + AUTO_CONFIRM_CONTEXT: 'auto:confirmContext', AUTO_PLAN_SCENES: 'auto:planScenes', AUTO_GENERATE: 'auto:generate', AUTO_RESOLVE_NAMING: 'auto:resolveNaming', @@ -110,6 +111,7 @@ export const IPC = { KNOWLEDGE_BATCH_APPROVE: 'knowledge:batchApprove', KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark', KNOWLEDGE_STATS: 'knowledge:stats', + KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext', COCKPIT_SUMMARY: 'cockpit:summary', COCKPIT_MARK_SEEN: 'cockpit:markSeen', COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen', diff --git a/src/shared/types.ts b/src/shared/types.ts index ab03cfc..2c375b0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -99,6 +99,24 @@ export interface ChapterBridgeResult { aiSuggestion?: string } +export interface KnowledgeSuggestOptions { + currentChapterId?: string + goalText?: string + topN?: number +} + +export interface ScoredKnowledge { + entry: KnowledgeEntry + score: number + reasons: string[] +} + +export interface FlowContextPayload { + binding: AiContextBinding + systemPrompt: string + contextSummary: string +} + export interface GlobalSettings { penName: string onboardingCompleted: boolean @@ -114,6 +132,8 @@ export interface GlobalSettings { searchHistory: string[] aiConfig: AiConfig namingIgnoreWords: string[] + knowledgeAutoSuggest?: boolean + knowledgeSuggestTopN?: number } export interface BookMeta { @@ -274,6 +294,7 @@ export interface AiContextBinding { outlineIds: string[] settingIds: string[] inspirationIds: string[] + knowledgeIds: string[] } export interface AiSession { @@ -312,7 +333,7 @@ export interface AiNetworkStatusEvent { online: boolean } -export type ContextPreviewKind = 'chapter' | 'outline' | 'setting' | 'inspiration' +export type ContextPreviewKind = 'chapter' | 'outline' | 'setting' | 'inspiration' | 'knowledge' export interface ContextPreviewItem { kind: ContextPreviewKind @@ -320,6 +341,7 @@ export interface ContextPreviewItem { title: string excerpt: string charCount: number + knowledgeType?: KnowledgeType } export interface AiContextBuildResult { diff --git a/tests/main/ai-context-builder.test.ts b/tests/main/ai-context-builder.test.ts index 2bb60d0..ec607fc 100644 --- a/tests/main/ai-context-builder.test.ts +++ b/tests/main/ai-context-builder.test.ts @@ -7,6 +7,7 @@ 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 { SettingRepository } from '../../src/main/db/repositories/setting.repo' +import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo' import { BookRegistryService } from '../../src/main/services/book-registry' import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service' import type { SqliteDb } from '../../src/main/db/types' @@ -38,7 +39,7 @@ describe('AiContextBuilderService', () => { const chapter = chapters.create(volId, '长章节', 0, longHtml) const result = aiContextBuilder.build( - { chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [] }, + { chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [] }, registry, bookId, '作者' @@ -58,7 +59,7 @@ describe('AiContextBuilderService', () => { } const result = aiContextBuilder.build( - { chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [] }, + { chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [] }, registry, bookId, '作者' @@ -74,7 +75,7 @@ describe('AiContextBuilderService', () => { const setting = settings.create('character', '主角林远', '少年修士') const result = aiContextBuilder.build( - { chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [] }, + { chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [], knowledgeIds: [] }, registry, bookId, '作者' @@ -83,4 +84,60 @@ describe('AiContextBuilderService', () => { expect(result.preview[0].title).toBe('主角林远') expect(result.systemPrompt).toContain('主角林远') }) + + it('UT-CTX-02: build includes knowledge preview items', () => { + const know = new KnowledgeRepository(db) + const entry = know.create({ + type: 'foreshadow', + title: '测灵石', + content: '石头发热', + status: 'approved' + }) + const result = aiContextBuilder.build( + { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [], + knowledgeIds: [entry.id] + }, + registry, + bookId, + '作者' + ) + expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true) + expect(result.systemPrompt).toContain('【知识·伏笔】') + expect(result.systemPrompt).toContain('测灵石') + }) + + it('UT-CTX-03: caps knowledge section at 4000 chars', () => { + const know = new KnowledgeRepository(db) + const ids: string[] = [] + for (let i = 0; i < 20; i++) { + const e = know.create({ + type: 'fact', + title: `条目${i}`, + content: '长'.repeat(400), + importance: 5, + status: 'approved' + }) + ids.push(e.id) + } + const result = aiContextBuilder.build( + { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [], + knowledgeIds: ids + }, + registry, + bookId, + '作者' + ) + const knowledgeChars = result.preview + .filter((p) => p.kind === 'knowledge') + .reduce((s, p) => s + p.charCount, 0) + expect(knowledgeChars).toBeLessThanOrEqual(4000) + }) }) diff --git a/tests/main/ai-context-integration.test.ts b/tests/main/ai-context-integration.test.ts new file mode 100644 index 0000000..5bce816 --- /dev/null +++ b/tests/main/ai-context-integration.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import { DatabaseSync } from 'node:sqlite' +import { migrate } from '../../src/main/db/migrate' +import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo' +import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo' +import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service' +import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service' +import { AiClientService } from '../../src/main/services/ai-client.service' +import { BookRegistryService } from '../../src/main/services/book-registry' +import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service' +import { DEFAULT_AI_CONFIG, type AiContextBinding } from '../../src/shared/types' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 } + +const EMPTY_BINDING: AiContextBinding = { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [], + knowledgeIds: [] +} + +describe('AI context integration', () => { + it('IT-CTX-01: suggestContext returns entries when approved exist', () => { + const db = new DatabaseSync(':memory:') + migrate(db) + new KnowledgeRepository(db).create({ + type: 'fact', + title: '测灵石异常', + content: '石头发热', + status: 'approved', + importance: 4 + }) + const result = new KnowledgeRetrievalService(db).suggest({ topN: 5 }) + expect(result.length).toBeGreaterThanOrEqual(1) + expect(result[0].entry.title).toBe('测灵石异常') + db.close() + }) + + it('IT-CTX-02: build with knowledgeIds includes knowledge block in systemPrompt', () => { + const userDir = mkdtempSync(join(tmpdir(), 'bilin-ctx-it-')) + const registry = new BookRegistryService(userDir) + const meta = registry.create({ name: '测试书', category: '玄幻' }) + const bookDb = registry.getDb(meta.id) + const entry = new KnowledgeRepository(bookDb).create({ + type: 'foreshadow', + title: '测灵石', + content: '石头发热', + status: 'approved' + }) + const result = aiContextBuilder.build( + { ...EMPTY_BINDING, knowledgeIds: [entry.id] }, + registry, + meta.id, + '作者' + ) + expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true) + expect(result.systemPrompt).toContain('【知识·') + bookDb.close() + rmSync(userDir, { recursive: true, force: true }) + }) + + it('IT-CTX-03: interactive confirm then suggestPlots uses knowledge context', async () => { + const db = new DatabaseSync(':memory:') + migrate(db) + const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model) + const know = new KnowledgeRepository(db).create({ + type: 'foreshadow', + title: '测灵石异常', + content: '入门考核时测灵石发热,暗示主角特殊灵根', + status: 'approved', + importance: 5 + }) + const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG)) + const flow = service.start(session.id) + service.confirmContext(flow.id, { + binding: { ...EMPTY_BINDING, knowledgeIds: [know.id] }, + systemPrompt: `【知识·伏笔·测灵石异常】入门考核时测灵石发热,暗示主角特殊灵根\n\n林远参加宗门入门考核,演武场测灵石前众人围观。`, + contextSummary: '0章·0纲·0设·0感·1知' + }) + const plots = await service.suggestPlots(flow.id) + expect(plots).toHaveLength(3) + db.close() + }, 240_000) +}) diff --git a/tests/main/flow-context.test.ts b/tests/main/flow-context.test.ts new file mode 100644 index 0000000..9dc1957 --- /dev/null +++ b/tests/main/flow-context.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { BookRegistryService } from '../../src/main/services/book-registry' +import { buildFlowContextPayload } from '../../src/main/services/flow-context.util' +import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo' + +describe('flow-context.util', () => { + let userDir: string + let registry: BookRegistryService + let bookId: string + + beforeEach(() => { + userDir = mkdtempSync(join(tmpdir(), 'bilin-flow-ctx-')) + registry = new BookRegistryService(userDir) + bookId = registry.create({ name: '书', category: '玄幻' }).id + }) + + afterEach(() => rmSync(userDir, { recursive: true, force: true })) + + it('buildFlowContextPayload stores systemPrompt and summary', () => { + const db = registry.getDb(bookId) + const entry = new KnowledgeRepository(db).create({ + type: 'fact', + title: '测试知识', + content: '内容', + status: 'approved' + }) + const payload = buildFlowContextPayload( + { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [], + knowledgeIds: [entry.id] + }, + registry, + bookId, + '作者' + ) + expect(payload.systemPrompt).toContain('测试知识') + expect(payload.contextSummary).toContain('1') + db.close() + }) +}) diff --git a/tests/main/interactive-plots.test.ts b/tests/main/interactive-plots.test.ts index 1cf6771..c80655b 100644 --- a/tests/main/interactive-plots.test.ts +++ b/tests/main/interactive-plots.test.ts @@ -15,11 +15,17 @@ describe('interactive plots integration', () => { 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: [] }, - '林远参加宗门入门考核,演武场测灵石前众人围观。' - ) + service.confirmContext(flow.id, { + binding: { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [], + knowledgeIds: [] + }, + systemPrompt: '林远参加宗门入门考核,演武场测灵石前众人围观。', + contextSummary: '0章·0纲·0设·0感·0知' + }) const plots = await service.suggestPlots(flow.id) expect(plots).toHaveLength(3) db.close() diff --git a/tests/main/interactive-scene.test.ts b/tests/main/interactive-scene.test.ts index 14be61e..4dc5d50 100644 --- a/tests/main/interactive-scene.test.ts +++ b/tests/main/interactive-scene.test.ts @@ -18,11 +18,17 @@ describe('interactive scene integration', () => { 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: [] }, - '林远在演武场测试灵根,众人围观。' - ) + svc.confirmContext(flow.id, { + binding: { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [], + knowledgeIds: [] + }, + systemPrompt: '林远在演武场测试灵根,众人围观。', + contextSummary: '0章·0纲·0设·0感·0知' + }) const plots = await svc.suggestPlots(flow.id) svc.selectPlot(flow.id, { optionIds: [plots[0].id] }) const afterGen = await svc.generateScene(flow.id, () => {}) diff --git a/tests/main/knowledge-retrieval.test.ts b/tests/main/knowledge-retrieval.test.ts new file mode 100644 index 0000000..d2a5759 --- /dev/null +++ b/tests/main/knowledge-retrieval.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { DatabaseSync } from 'node:sqlite' +import { migrate } from '../../src/main/db/migrate' +import { VolumeRepository } from '../../src/main/db/repositories/volume.repo' +import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo' +import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo' +import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service' +import type { SqliteDb } from '../../src/main/db/types' + +describe('KnowledgeRetrievalService', () => { + let db: SqliteDb + afterEach(() => db?.close()) + + it('UT-CTX-01: ranks by importance + chapter proximity + goal match', () => { + db = new DatabaseSync(':memory:') + migrate(db) + const volId = new VolumeRepository(db).create('卷一', 0).id + const chapterRepo = new ChapterRepository(db) + const cur = chapterRepo.create(volId, '当前章', 5, '

x

') + const near = chapterRepo.create(volId, '近章', 4, '

x

') + chapterRepo.create(volId, '远章', 0, '

x

') + const repo = new KnowledgeRepository(db) + const low = repo.create({ + type: 'fact', + title: '无关事实', + content: '普通内容', + importance: 2, + status: 'approved' + }) + const high = repo.create({ + type: 'foreshadow', + title: '测灵石异常', + content: '测灵石在入门考核中发热', + importance: 5, + status: 'approved', + foreshadowStatus: 'buried', + sourceChapterId: near.id + }) + const service = new KnowledgeRetrievalService(db) + const result = service.suggest({ + currentChapterId: cur.id, + goalText: '测灵石入门考核', + topN: 5 + }) + expect(result[0].entry.id).toBe(high.id) + expect(result[0].score).toBeGreaterThan( + result.find((r) => r.entry.id === low.id)!.score + ) + expect(result[0].reasons.length).toBeGreaterThan(0) + }) + + it('UT-CTX-04: resolved foreshadow scores lower than buried', () => { + db = new DatabaseSync(':memory:') + migrate(db) + const volId = new VolumeRepository(db).create('卷一', 0).id + const chId = new ChapterRepository(db).create(volId, '章', 0).id + const repo = new KnowledgeRepository(db) + const buried = repo.create({ + type: 'foreshadow', + title: '埋设伏笔', + importance: 3, + status: 'approved', + foreshadowStatus: 'buried', + sourceChapterId: chId + }) + const resolved = repo.create({ + type: 'foreshadow', + title: '已回收伏笔', + importance: 3, + status: 'approved', + foreshadowStatus: 'resolved', + sourceChapterId: chId + }) + const service = new KnowledgeRetrievalService(db) + const scores = service.suggest({ currentChapterId: chId, topN: 10 }) + const buriedScore = scores.find((s) => s.entry.id === buried.id)!.score + const resolvedScore = scores.find((s) => s.entry.id === resolved.id)!.score + expect(buriedScore).toBeGreaterThan(resolvedScore) + }) +}) diff --git a/tests/main/wizard-generate.test.ts b/tests/main/wizard-generate.test.ts index 609ac1f..d7bf4c8 100644 --- a/tests/main/wizard-generate.test.ts +++ b/tests/main/wizard-generate.test.ts @@ -26,11 +26,17 @@ describe('WizardWritingService.startGenerate', () => { }) flow = svc.setRhythm(flow.id, 'tense') flow = svc.setPov(flow.id) - flow = svc.confirmContext( - flow.id, - { chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] }, - CONTEXT - ) + flow = svc.confirmContext(flow.id, { + binding: { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [], + knowledgeIds: [] + }, + systemPrompt: CONTEXT, + contextSummary: '0章·0纲·0设·0感·0知' + }) return flow.id } diff --git a/tests/main/wizard-preview.test.ts b/tests/main/wizard-preview.test.ts index 143d3b9..aee9a25 100644 --- a/tests/main/wizard-preview.test.ts +++ b/tests/main/wizard-preview.test.ts @@ -26,11 +26,17 @@ describe('WizardWritingService.buildPreview', () => { }) flow = svc.setRhythm(flow.id, 'mixed') flow = svc.setPov(flow.id) - flow = svc.confirmContext( - flow.id, - { chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] }, - CONTEXT - ) + flow = svc.confirmContext(flow.id, { + binding: { + chapterIds: [], + outlineIds: [], + settingIds: [], + inspirationIds: [], + knowledgeIds: [] + }, + systemPrompt: CONTEXT, + contextSummary: '0章·0纲·0设·0感·0知' + }) const preview = await svc.buildPreview(flow.id, CONTEXT, '林远') expect(preview).toMatch(/

/)