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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
# 笔临 (Bilin)
|
# 笔临 (Bilin)
|
||||||
|
|
||||||
长篇创作智能协作平台 — Electron 桌面客户端 v0.6.0(P5 作家工作流 + 知识库)
|
长篇创作智能协作平台 — Electron 桌面客户端 v0.7.0(P6 AI 知识上下文)
|
||||||
|
|
||||||
## 功能概览(v0.6.0)
|
## 功能概览(v0.7.0)
|
||||||
|
|
||||||
|
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
|
||||||
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
|
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
|
||||||
- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5)
|
- 知识库 MVP:手动条目、审核流、伏笔追踪、地标同步(P5)
|
||||||
- 章间衔接:上下章片段 + 可选 AI 建议(P5)
|
- 章间衔接:上下章片段 + 可选 AI 建议(P5)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 笔临 P6 AI 知识上下文整合 Implementation Plan
|
# 笔临 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` 仅存标题摘要的问题。
|
**Goal:** 交付 v0.7.0:已审核知识库条目半自动推荐 + 用户勾选确认 + 注入对话/交互/自动/向导四种 AI 模式;修复写作流 `contextJson` 仅存标题摘要的问题。
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
- Modify: `src/shared/ipc-channels.ts`
|
- Modify: `src/shared/ipc-channels.ts`
|
||||||
- Modify: `src/main/db/repositories/ai-session.repo.ts`
|
- Modify: `src/main/db/repositories/ai-session.repo.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: 扩展 types**
|
- [x] **Step 1: 扩展 types**
|
||||||
|
|
||||||
`src/shared/types.ts` 追加:
|
`src/shared/types.ts` 追加:
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ knowledgeAutoSuggest: boolean
|
|||||||
knowledgeSuggestTopN: number
|
knowledgeSuggestTopN: number
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: IPC 通道**
|
- [x] **Step 2: IPC 通道**
|
||||||
|
|
||||||
`src/shared/ipc-channels.ts`:
|
`src/shared/ipc-channels.ts`:
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ knowledgeSuggestTopN: number
|
|||||||
KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext',
|
KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext',
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: ai-session.repo 默认 knowledgeIds**
|
- [x] **Step 3: ai-session.repo 默认 knowledgeIds**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const EMPTY_CONTEXT: AiContextBinding = {
|
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: []`。
|
搜索 `inspirationIds: []` 且无 `knowledgeIds` 的 `AiContextBinding` 字面量(`ContextEditorModal.tsx`、`useInteractiveStore.ts` 等),全部补上 `knowledgeIds: []`。
|
||||||
|
|
||||||
- [ ] **Step 5: build 验证**
|
- [x] **Step 5: build 验证**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/shared/types.ts src/shared/ipc-channels.ts src/main/db/repositories/ai-session.repo.ts
|
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: `src/main/services/knowledge-retrieval.service.ts`
|
||||||
- Create: `tests/main/knowledge-retrieval.test.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`:
|
`tests/main/knowledge-retrieval.test.ts`:
|
||||||
|
|
||||||
@@ -254,13 +254,13 @@ describe('KnowledgeRetrievalService', () => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 运行确认 FAIL**
|
- [x] **Step 2: 运行确认 FAIL**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -- tests/main/knowledge-retrieval.test.ts
|
npm run test -- tests/main/knowledge-retrieval.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: 实现 KnowledgeRetrievalService**
|
- [x] **Step 3: 实现 KnowledgeRetrievalService**
|
||||||
|
|
||||||
`src/main/services/knowledge-retrieval.service.ts` 核心:
|
`src/main/services/knowledge-retrieval.service.ts` 核心:
|
||||||
|
|
||||||
@@ -357,13 +357,13 @@ export class KnowledgeRetrievalService {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: 运行 PASS**
|
- [x] **Step 4: 运行 PASS**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -- tests/main/knowledge-retrieval.test.ts
|
npm run test -- tests/main/knowledge-retrieval.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/main/services/knowledge-retrieval.service.ts tests/main/knowledge-retrieval.test.ts
|
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`
|
- Modify: `tests/main/ai-context-builder.test.ts`
|
||||||
- Create: `tests/main/flow-context.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` 追加:
|
在 `tests/main/ai-context-builder.test.ts` 追加:
|
||||||
|
|
||||||
@@ -483,13 +483,13 @@ describe('flow-context.util', () => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 运行确认 FAIL**
|
- [x] **Step 2: 运行确认 FAIL**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -- tests/main/ai-context-builder.test.ts tests/main/flow-context.test.ts
|
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
|
```bash
|
||||||
npm run test -- tests/main/ai-context-builder.test.ts tests/main/flow-context.test.ts
|
npm run test -- tests/main/ai-context-builder.test.ts tests/main/flow-context.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/main/services/ai-context-builder.service.ts src/main/services/flow-context.util.ts tests/main/
|
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/main/services/global-settings.ts`
|
||||||
- Modify: `src/renderer/stores/useSettingsStore.ts`
|
- Modify: `src/renderer/stores/useSettingsStore.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: knowledge.handler 追加**
|
- [x] **Step 1: knowledge.handler 追加**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
@@ -563,7 +563,7 @@ ipcMain.handle(
|
|||||||
|
|
||||||
`registerKnowledgeHandlers` 签名改为接收 `GlobalSettingsService`,`topN` 默认 `settings.get().knowledgeSuggestTopN ?? 8`。
|
`registerKnowledgeHandlers` 签名改为接收 `GlobalSettingsService`,`topN` 默认 `settings.get().knowledgeSuggestTopN ?? 8`。
|
||||||
|
|
||||||
- [ ] **Step 2: global-settings 默认值**
|
- [x] **Step 2: global-settings 默认值**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
knowledgeAutoSuggest: true,
|
knowledgeAutoSuggest: true,
|
||||||
@@ -572,7 +572,7 @@ knowledgeSuggestTopN: 8,
|
|||||||
|
|
||||||
`get()` merge 补默认。
|
`get()` merge 补默认。
|
||||||
|
|
||||||
- [ ] **Step 3: preload + electron-api.d.ts**
|
- [x] **Step 3: preload + electron-api.d.ts**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
suggestContext: (
|
suggestContext: (
|
||||||
@@ -581,13 +581,13 @@ suggestContext: (
|
|||||||
): Promise<IpcResult<ScoredKnowledge[]>>
|
): Promise<IpcResult<ScoredKnowledge[]>>
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: build**
|
- [x] **Step 4: build**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "feat: add knowledge suggestContext IPC and global settings defaults"
|
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/ipc/handlers/wizard.handler.ts`
|
||||||
- Modify: `src/main/services/wizard-writing.service.ts`(`buildPreview` 追加知识列表)
|
- 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`:
|
`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`:
|
替换本地 `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`。
|
`wizard.handler.ts` `WIZARD_CONFIRM_CONTEXT` 使用 `buildFlowContextPayload`。
|
||||||
|
|
||||||
- [ ] **Step 4: buildPreview 追加知识标题**
|
- [x] **Step 4: buildPreview 追加知识标题**
|
||||||
|
|
||||||
`WizardWritingService.buildPreview` 返回前,从 `payload.binding.knowledgeIds` 查标题,append `\n将注入知识:A、B、C`。
|
`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)。
|
`AutoPanel` 在 `planScenes` 调用前,若 flow 无 `systemPrompt`,用 session binding + 默认推荐 knowledgeIds 调 `interactive.confirmContext` 等价逻辑(可新增 `auto:confirmContext` IPC 或复用 `buildFlowContextPayload` + `setContextSummary` 扩展为存完整 payload)。
|
||||||
|
|
||||||
推荐:新增 `auto.handler` `AUTO_CONFIRM_CONTEXT` 与 interactive 对称。
|
推荐:新增 `auto.handler` `AUTO_CONFIRM_CONTEXT` 与 interactive 对称。
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "fix: store full systemPrompt in writing flow contextJson for P6"
|
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/components/ai/ContextEditorModal.tsx`
|
||||||
- Modify: `src/renderer/lib/ai-context-summary.ts`
|
- Modify: `src/renderer/lib/ai-context-summary.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: KnowledgeSuggestList**
|
- [x] **Step 1: KnowledgeSuggestList**
|
||||||
|
|
||||||
Props:
|
Props:
|
||||||
|
|
||||||
@@ -703,7 +703,7 @@ interface KnowledgeSuggestListProps {
|
|||||||
|
|
||||||
渲染 checkbox + title + `t(\`knowledge.type.${entry.type}\`)` + reason badges `t(reason)`。
|
渲染 checkbox + title + `t(\`knowledge.type.${entry.type}\`)` + reason badges `t(reason)`。
|
||||||
|
|
||||||
- [ ] **Step 2: ContextEditorModal 扩展**
|
- [x] **Step 2: ContextEditorModal 扩展**
|
||||||
|
|
||||||
新增 props:`goalText?: string`
|
新增 props:`goalText?: string`
|
||||||
|
|
||||||
@@ -719,7 +719,7 @@ const suggested = await ipcCall(() =>
|
|||||||
|
|
||||||
新增 Tab `knowledge`;顶部推荐区 + `context-refresh-suggest`;下方 approved 全列表(`knowledge.list(bookId, { status: 'approved' })`)可勾选。
|
新增 Tab `knowledge`;顶部推荐区 + `context-refresh-suggest`;下方 approved 全列表(`knowledge.list(bookId, { status: 'approved' })`)可勾选。
|
||||||
|
|
||||||
- [ ] **Step 3: formatContextSummary 含 knowledge**
|
- [x] **Step 3: formatContextSummary 含 knowledge**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 }
|
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}}知`。
|
更新 `ai.contextSummary` i18n 为 `{{chapters}}章·{{outlines}}纲·{{settings}}设·{{inspirations}}感·{{knowledge}}知`。
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "feat: add KnowledgeSuggestList and knowledge tab in context editor"
|
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/stores/useAutoStore.ts`(confirmContext)
|
||||||
- Modify: `src/renderer/components/ai/AiPanel.tsx`(传 goalText 空)
|
- Modify: `src/renderer/components/ai/AiPanel.tsx`(传 goalText 空)
|
||||||
|
|
||||||
- [ ] **Step 1: InteractivePanel context_confirm**
|
- [x] **Step 1: InteractivePanel context_confirm**
|
||||||
|
|
||||||
- mount 时 `suggestContext` → state `suggested`
|
- mount 时 `suggestContext` → state `suggested`
|
||||||
- 内联 `<KnowledgeSuggestList>`,`data-testid="interactive-knowledge-suggest"`
|
- 内联 `<KnowledgeSuggestList>`,`data-testid="interactive-knowledge-suggest"`
|
||||||
- 勾选同步到 session `context.knowledgeIds`(`ai.sessionUpdate`)
|
- 勾选同步到 session `context.knowledgeIds`(`ai.sessionUpdate`)
|
||||||
- `confirmContextAndStart` 传含 `knowledgeIds` 的 binding
|
- `confirmContextAndStart` 传含 `knowledgeIds` 的 binding
|
||||||
|
|
||||||
- [ ] **Step 2: AutoPanel**
|
- [x] **Step 2: AutoPanel**
|
||||||
|
|
||||||
- `goal_setup` 区域下方折叠「知识上下文」`data-testid="auto-knowledge-suggest"`
|
- `goal_setup` 区域下方折叠「知识上下文」`data-testid="auto-knowledge-suggest"`
|
||||||
- `goalText` = `chapterGoal` + 选中大纲 description
|
- `goalText` = `chapterGoal` + 选中大纲 description
|
||||||
- `planScenes` 前调用 `auto.confirmContext`(Task 5 新增 IPC)
|
- `planScenes` 前调用 `auto.confirmContext`(Task 5 新增 IPC)
|
||||||
|
|
||||||
- [ ] **Step 3: WizardPanel wizard_context**
|
- [x] **Step 3: WizardPanel wizard_context**
|
||||||
|
|
||||||
- 同 Interactive 内联推荐列表 `data-testid="wizard-knowledge-suggest"`
|
- 同 Interactive 内联推荐列表 `data-testid="wizard-knowledge-suggest"`
|
||||||
- `handleContextClose` 传 `goalText` = goal + styleNote
|
- `handleContextClose` 传 `goalText` = goal + styleNote
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "feat: integrate knowledge suggest into interactive auto wizard panels"
|
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/zh-CN/translation.json`
|
||||||
- Modify: `public/locales/en/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
|
```bash
|
||||||
git commit -m "feat: add P6 AI context i18n strings"
|
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:**
|
**Files:**
|
||||||
- Create: `tests/main/ai-context-integration.test.ts`
|
- Create: `tests/main/ai-context-integration.test.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: IT-CTX-01**
|
- [x] **Step 1: IT-CTX-01**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
it('IT-CTX-01: suggestContext returns entries when approved exist', () => {
|
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)。
|
通过 `aiContextBuilder.build` 含 knowledgeIds 断言 `systemPrompt` 含 `【知识·`(无需 Electron IPC)。
|
||||||
|
|
||||||
- [ ] **Step 3: IT-CTX-03(240s)**
|
- [x] **Step 3: IT-CTX-03(240s)**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
it('IT-CTX-03: interactive confirm then suggestPlots uses knowledge context', async () => {
|
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)
|
}, 240_000)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: 运行**
|
- [x] **Step 4: 运行**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test -- tests/main/ai-context-integration.test.ts
|
npm run test -- tests/main/ai-context-integration.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "test: add P6 AI context integration tests"
|
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:**
|
**Files:**
|
||||||
- Create: `e2e/ai-context.spec.ts`
|
- Create: `e2e/ai-context.spec.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: E2E-CTX-01**
|
- [x] **Step 1: E2E-CTX-01**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
test('E2E-CTX-01: context editor knowledge tab shows suggestions', async () => {
|
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
|
```typescript
|
||||||
test('E2E-CTX-02: interactive context_confirm shows knowledge suggest', async () => {
|
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
|
```typescript
|
||||||
test('E2E-CTX-03: chat with knowledge context gets AI reply', async () => {
|
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
|
```bash
|
||||||
npm run test:e2e -- e2e/ai-context.spec.ts
|
npm run test:e2e -- e2e/ai-context.spec.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "test: add P6 AI context E2E specs"
|
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: `src/renderer/components/settings/SettingsPage.tsx`(about 版本号)
|
||||||
- Modify: `docs/superpowers/plans/2026-07-07-bilin-p6-ai-context.md`(勾选完成项)
|
- 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
|
```markdown
|
||||||
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
|
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: 全量测试**
|
- [x] **Step 3: 全量测试**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
@@ -898,7 +898,7 @@ npm run test
|
|||||||
npm run test:e2e
|
npm run test:e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "feat: ship v0.7.0 with AI knowledge context integration"
|
git commit -m "feat: ship v0.7.0 with AI knowledge context integration"
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect, _electron as electron, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const ROOT = path.join(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
async function launchApp(userDataDir: string) {
|
||||||
|
return electron.launch({
|
||||||
|
args: [ROOT],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BILIN_E2E: '1',
|
||||||
|
BILIN_E2E_USER_DATA: userDataDir
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipOnboarding(page: Page): Promise<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||||
|
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||||
|
await page.getByRole('button', { name: '跳过' }).click()
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissCockpitIfOpen(page: Page): Promise<void> {
|
||||||
|
const cockpit = page.locator('[data-testid="cockpit-modal"]')
|
||||||
|
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||||
|
await page.locator('[data-testid="cockpit-modal"] .modal-close').click()
|
||||||
|
await expect(cockpit).toBeHidden({ timeout: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndOpenBook(page: Page, name: string): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||||
|
await page.locator('.dialog-content input').first().fill(name)
|
||||||
|
await page.getByRole('button', { name: '创建' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await dismissCockpitIfOpen(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureChapterEditor(page: Page): Promise<void> {
|
||||||
|
const items = page.locator('.chapter-item')
|
||||||
|
if ((await items.count()) === 0) {
|
||||||
|
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||||
|
} else {
|
||||||
|
await items.first().click()
|
||||||
|
}
|
||||||
|
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addApprovedKnowledge(page: Page, title: string): Promise<void> {
|
||||||
|
await page.locator('[data-testid="panel-tab-knowledge"]').click()
|
||||||
|
await page.locator('[data-testid="knowledge-new"]').click()
|
||||||
|
await page.locator('[data-testid="knowledge-title-input"]').fill(title)
|
||||||
|
await page.locator('[data-testid="knowledge-save"]').click()
|
||||||
|
await page.locator('[data-testid="knowledge-tab-pending"]').click()
|
||||||
|
await page.locator('[data-testid^="knowledge-approve-"]').first().click()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('AI knowledge context P6', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-ctx-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-CTX-01: context editor knowledge tab shows suggestions', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createAndOpenBook(page, '上下文书')
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
await addApprovedKnowledge(page, '测灵石异常')
|
||||||
|
await page.getByTestId('panel-tab-ai').click()
|
||||||
|
await page.getByTestId('ai-session-new').click()
|
||||||
|
await page.getByTestId('ai-context-preview').click()
|
||||||
|
await expect(page.getByTestId('context-editor-modal')).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('context-tab-knowledge').click()
|
||||||
|
await expect(page.getByTestId('context-knowledge-suggest')).toBeVisible({ timeout: 10_000 })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-CTX-02: interactive context_confirm shows knowledge suggest', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createAndOpenBook(page, '交互知识书')
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
const editor = page.locator('.ProseMirror')
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially('林远站在演武场中央,众人目光汇聚。')
|
||||||
|
await page.keyboard.press('Control+s')
|
||||||
|
await expect(page.getByText('已保存')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await addApprovedKnowledge(page, '测灵石异常')
|
||||||
|
await page.getByTestId('panel-tab-ai').click()
|
||||||
|
await page.getByTestId('ai-session-new').click()
|
||||||
|
await expect(page.getByTestId('ai-mode-tab-interactive')).toBeEnabled({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('ai-mode-tab-interactive').click()
|
||||||
|
await expect(page.getByTestId('interactive-knowledge-suggest')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-CTX-03: chat with knowledge context gets AI reply', async () => {
|
||||||
|
test.setTimeout(300_000)
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createAndOpenBook(page, '对话知识书')
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
await addApprovedKnowledge(page, '测灵石异常')
|
||||||
|
await page.getByTestId('panel-tab-ai').click()
|
||||||
|
await page.getByTestId('ai-session-new').click()
|
||||||
|
await page.getByTestId('ai-context-preview').click()
|
||||||
|
await page.getByTestId('context-tab-knowledge').click()
|
||||||
|
await page.getByTestId('context-save').click()
|
||||||
|
await page.getByTestId('ai-chat-input').fill('根据知识库,本章可能发生什么?')
|
||||||
|
await page.getByTestId('ai-send').click()
|
||||||
|
await expect(page.getByTestId('chat-message-assistant').last()).not.toBeEmpty({ timeout: 240_000 })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"description": "笔临 - 长篇创作智能协作平台",
|
"description": "笔临 - 长篇创作智能协作平台",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -72,12 +72,17 @@
|
|||||||
"ai.untitledSession": "New chat",
|
"ai.untitledSession": "New chat",
|
||||||
"ai.contextPlaceholder": "📎 Context: not configured yet",
|
"ai.contextPlaceholder": "📎 Context: not configured yet",
|
||||||
"ai.contextEmpty": "📎 Context: nothing selected",
|
"ai.contextEmpty": "📎 Context: nothing selected",
|
||||||
"ai.contextSummary": "📎 Context: {{chapters}} ch. · {{outlines}} outline · {{settings}} settings · {{inspirations}} ideas",
|
"ai.contextSummary": "📎 Context: {{chapters}} ch. · {{outlines}} outline · {{settings}} settings · {{inspirations}} ideas · {{knowledge}} knowledge",
|
||||||
"ai.contextEditorTitle": "Edit AI context",
|
"ai.contextEditorTitle": "Edit AI context",
|
||||||
"ai.contextTab.chapter": "Chapters",
|
"ai.contextTab.chapter": "Chapters",
|
||||||
"ai.contextTab.outline": "Outline",
|
"ai.contextTab.outline": "Outline",
|
||||||
"ai.contextTab.setting": "Settings",
|
"ai.contextTab.setting": "Settings",
|
||||||
"ai.contextTab.inspiration": "Ideas",
|
"ai.contextTab.inspiration": "Ideas",
|
||||||
|
"ai.contextTab.knowledge": "Knowledge",
|
||||||
|
"ai.contextRefreshSuggest": "Refresh suggestions",
|
||||||
|
"ai.contextKnowledgeSuggestTitle": "Suggested knowledge",
|
||||||
|
"ai.contextKnowledgeAllTitle": "All approved knowledge",
|
||||||
|
"ai.contextKnowledgeEmpty": "No approved knowledge entries",
|
||||||
"ai.contextEmptyList": "No items",
|
"ai.contextEmptyList": "No items",
|
||||||
"ai.contextRefresh": "Refresh context",
|
"ai.contextRefresh": "Refresh context",
|
||||||
"ai.contextSave": "Save",
|
"ai.contextSave": "Save",
|
||||||
@@ -100,6 +105,7 @@
|
|||||||
"interactive.step.scene": "Scene",
|
"interactive.step.scene": "Scene",
|
||||||
"interactive.step.review": "Review",
|
"interactive.step.review": "Review",
|
||||||
"interactive.start": "Start interactive",
|
"interactive.start": "Start interactive",
|
||||||
|
"interactive.confirmKnowledge": "Confirm knowledge context",
|
||||||
"interactive.loadingPlots": "Generating plot options…",
|
"interactive.loadingPlots": "Generating plot options…",
|
||||||
"interactive.plotNotePlaceholder": "Additional instructions (optional)",
|
"interactive.plotNotePlaceholder": "Additional instructions (optional)",
|
||||||
"interactive.generateScene": "Generate scene",
|
"interactive.generateScene": "Generate scene",
|
||||||
@@ -140,6 +146,7 @@
|
|||||||
"wizard.rhythm.mixed": "Mixed",
|
"wizard.rhythm.mixed": "Mixed",
|
||||||
"wizard.styleNotePlaceholder": "Optional style note",
|
"wizard.styleNotePlaceholder": "Optional style note",
|
||||||
"wizard.contextHint": "Confirm context including outline/settings clues",
|
"wizard.contextHint": "Confirm context including outline/settings clues",
|
||||||
|
"wizard.knowledgeList": "Knowledge to inject",
|
||||||
"wizard.previewTitle": "Strategy preview",
|
"wizard.previewTitle": "Strategy preview",
|
||||||
"wizard.refreshPreview": "Refresh preview",
|
"wizard.refreshPreview": "Refresh preview",
|
||||||
"wizard.startGenerate": "Start generating",
|
"wizard.startGenerate": "Start generating",
|
||||||
@@ -168,6 +175,10 @@
|
|||||||
"knowledge.type.location": "Location",
|
"knowledge.type.location": "Location",
|
||||||
"knowledge.type.relationship": "Relationship",
|
"knowledge.type.relationship": "Relationship",
|
||||||
"knowledge.type.fact": "Fact",
|
"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.titleLabel": "Title",
|
||||||
"knowledge.contentLabel": "Content",
|
"knowledge.contentLabel": "Content",
|
||||||
"knowledge.importance": "Importance",
|
"knowledge.importance": "Importance",
|
||||||
|
|||||||
@@ -72,12 +72,17 @@
|
|||||||
"ai.untitledSession": "新对话",
|
"ai.untitledSession": "新对话",
|
||||||
"ai.contextPlaceholder": "📎 上下文:尚未配置",
|
"ai.contextPlaceholder": "📎 上下文:尚未配置",
|
||||||
"ai.contextEmpty": "📎 上下文:尚未勾选",
|
"ai.contextEmpty": "📎 上下文:尚未勾选",
|
||||||
"ai.contextSummary": "📎 上下文:{{chapters}}章 · 大纲{{outlines}}条 · 设定{{settings}}个 · 灵感{{inspirations}}条",
|
"ai.contextSummary": "📎 上下文:{{chapters}}章 · 大纲{{outlines}}条 · 设定{{settings}}个 · 灵感{{inspirations}}条 · 知识{{knowledge}}条",
|
||||||
"ai.contextEditorTitle": "编辑 AI 上下文",
|
"ai.contextEditorTitle": "编辑 AI 上下文",
|
||||||
"ai.contextTab.chapter": "章节",
|
"ai.contextTab.chapter": "章节",
|
||||||
"ai.contextTab.outline": "大纲",
|
"ai.contextTab.outline": "大纲",
|
||||||
"ai.contextTab.setting": "设定",
|
"ai.contextTab.setting": "设定",
|
||||||
"ai.contextTab.inspiration": "灵感",
|
"ai.contextTab.inspiration": "灵感",
|
||||||
|
"ai.contextTab.knowledge": "知识库",
|
||||||
|
"ai.contextRefreshSuggest": "刷新推荐",
|
||||||
|
"ai.contextKnowledgeSuggestTitle": "推荐知识",
|
||||||
|
"ai.contextKnowledgeAllTitle": "全部已审核知识",
|
||||||
|
"ai.contextKnowledgeEmpty": "暂无已审核知识条目",
|
||||||
"ai.contextEmptyList": "暂无可选项",
|
"ai.contextEmptyList": "暂无可选项",
|
||||||
"ai.contextRefresh": "更新上下文",
|
"ai.contextRefresh": "更新上下文",
|
||||||
"ai.contextSave": "保存",
|
"ai.contextSave": "保存",
|
||||||
@@ -100,6 +105,7 @@
|
|||||||
"interactive.step.scene": "场景",
|
"interactive.step.scene": "场景",
|
||||||
"interactive.step.review": "确认",
|
"interactive.step.review": "确认",
|
||||||
"interactive.start": "开始交互",
|
"interactive.start": "开始交互",
|
||||||
|
"interactive.confirmKnowledge": "确认知识上下文",
|
||||||
"interactive.loadingPlots": "正在生成剧情走向…",
|
"interactive.loadingPlots": "正在生成剧情走向…",
|
||||||
"interactive.plotNotePlaceholder": "补充指令(可选)",
|
"interactive.plotNotePlaceholder": "补充指令(可选)",
|
||||||
"interactive.generateScene": "生成场景",
|
"interactive.generateScene": "生成场景",
|
||||||
@@ -140,6 +146,7 @@
|
|||||||
"wizard.rhythm.mixed": "混合",
|
"wizard.rhythm.mixed": "混合",
|
||||||
"wizard.styleNotePlaceholder": "可选:本章风格说明",
|
"wizard.styleNotePlaceholder": "可选:本章风格说明",
|
||||||
"wizard.contextHint": "确认与本章相关的上下文(含大纲/设定中的伏笔线索)",
|
"wizard.contextHint": "确认与本章相关的上下文(含大纲/设定中的伏笔线索)",
|
||||||
|
"wizard.knowledgeList": "将注入知识",
|
||||||
"wizard.previewTitle": "生成策略预览",
|
"wizard.previewTitle": "生成策略预览",
|
||||||
"wizard.refreshPreview": "刷新预览",
|
"wizard.refreshPreview": "刷新预览",
|
||||||
"wizard.startGenerate": "开始生成",
|
"wizard.startGenerate": "开始生成",
|
||||||
@@ -168,6 +175,10 @@
|
|||||||
"knowledge.type.location": "地点",
|
"knowledge.type.location": "地点",
|
||||||
"knowledge.type.relationship": "关系",
|
"knowledge.type.relationship": "关系",
|
||||||
"knowledge.type.fact": "事实",
|
"knowledge.type.fact": "事实",
|
||||||
|
"knowledge.reason.highImportance": "高重要度",
|
||||||
|
"knowledge.reason.chapterRelevant": "与当前章相关",
|
||||||
|
"knowledge.reason.foreshadowBuried": "伏笔待回收",
|
||||||
|
"knowledge.reason.goalMatch": "匹配章节目标",
|
||||||
"knowledge.titleLabel": "标题",
|
"knowledge.titleLabel": "标题",
|
||||||
"knowledge.contentLabel": "内容",
|
"knowledge.contentLabel": "内容",
|
||||||
"knowledge.importance": "重要度",
|
"knowledge.importance": "重要度",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ const EMPTY_CONTEXT: AiContextBinding = {
|
|||||||
chapterIds: [],
|
chapterIds: [],
|
||||||
outlineIds: [],
|
outlineIds: [],
|
||||||
settingIds: [],
|
settingIds: [],
|
||||||
inspirationIds: []
|
inspirationIds: [],
|
||||||
|
knowledgeIds: []
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseContext(json: string): AiContextBinding {
|
function parseContext(json: string): AiContextBinding {
|
||||||
@@ -16,7 +17,8 @@ function parseContext(json: string): AiContextBinding {
|
|||||||
chapterIds: parsed.chapterIds ?? [],
|
chapterIds: parsed.chapterIds ?? [],
|
||||||
outlineIds: parsed.outlineIds ?? [],
|
outlineIds: parsed.outlineIds ?? [],
|
||||||
settingIds: parsed.settingIds ?? [],
|
settingIds: parsed.settingIds ?? [],
|
||||||
inspirationIds: parsed.inspirationIds ?? []
|
inspirationIds: parsed.inspirationIds ?? [],
|
||||||
|
knowledgeIds: parsed.knowledgeIds ?? []
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { ...EMPTY_CONTEXT }
|
return { ...EMPTY_CONTEXT }
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { BrowserWindow, ipcMain } from 'electron'
|
import { BrowserWindow, ipcMain } from 'electron'
|
||||||
import { IPC } from '../../../shared/ipc-channels'
|
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 { BookRegistryService } from '../../services/book-registry'
|
||||||
import { GlobalSettingsService } from '../../services/global-settings'
|
import { GlobalSettingsService } from '../../services/global-settings'
|
||||||
import { AiClientService } from '../../services/ai-client.service'
|
import { AiClientService } from '../../services/ai-client.service'
|
||||||
|
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
||||||
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
||||||
import { AutoWritingService } from '../../services/auto-writing.service'
|
import { AutoWritingService } from '../../services/auto-writing.service'
|
||||||
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
||||||
@@ -26,12 +27,7 @@ function readContextText(registry: BookRegistryService, bookId: string, flowId:
|
|||||||
.getDb(bookId)
|
.getDb(bookId)
|
||||||
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
|
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
|
||||||
.get(flowId) as { context_json: string } | undefined
|
.get(flowId) as { context_json: string } | undefined
|
||||||
try {
|
return readFlowSystemPrompt(row?.context_json ?? '{}')
|
||||||
const parsed = JSON.parse(row?.context_json ?? '{}') as { contextSummary?: string }
|
|
||||||
return parsed.contextSummary ?? '(无上下文摘要)'
|
|
||||||
} catch {
|
|
||||||
return '(无上下文摘要)'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerAutoHandlers(
|
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(
|
ipcMain.handle(
|
||||||
IPC.AUTO_PLAN_SCENES,
|
IPC.AUTO_PLAN_SCENES,
|
||||||
(_e, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
|
(_e, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AiContextBinding, PlotSelection } from '../../../shared/types'
|
|||||||
import { BookRegistryService } from '../../services/book-registry'
|
import { BookRegistryService } from '../../services/book-registry'
|
||||||
import { GlobalSettingsService } from '../../services/global-settings'
|
import { GlobalSettingsService } from '../../services/global-settings'
|
||||||
import { AiClientService } from '../../services/ai-client.service'
|
import { AiClientService } from '../../services/ai-client.service'
|
||||||
import { aiContextBuilder } from '../../services/ai-context-builder.service'
|
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
||||||
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
||||||
import { InteractiveWritingService } from '../../services/interactive-writing.service'
|
import { InteractiveWritingService } from '../../services/interactive-writing.service'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
@@ -56,13 +56,13 @@ export function registerInteractiveHandlers(
|
|||||||
}: { bookId: string; flowId: string; binding: AiContextBinding }
|
}: { bookId: string; flowId: string; binding: AiContextBinding }
|
||||||
) =>
|
) =>
|
||||||
wrap(() => {
|
wrap(() => {
|
||||||
const built = aiContextBuilder.build(binding, registry, bookId, settings.get().penName)
|
const payload = buildFlowContextPayload(
|
||||||
const summary = built.preview.map((p) => p.title).join('、') || '(空)'
|
|
||||||
const flow = serviceFor(registry, bookId, aiClient).confirmContext(
|
|
||||||
flowId,
|
|
||||||
binding,
|
binding,
|
||||||
summary
|
registry,
|
||||||
|
bookId,
|
||||||
|
settings.get().penName
|
||||||
)
|
)
|
||||||
|
const flow = serviceFor(registry, bookId, aiClient).confirmContext(flowId, payload)
|
||||||
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import { IPC } from '../../../shared/ipc-channels'
|
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 { 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 { KnowledgeService } from '../../services/knowledge.service'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
|
|
||||||
export function registerKnowledgeHandlers(registry: BookRegistryService): void {
|
export function registerKnowledgeHandlers(
|
||||||
|
registry: BookRegistryService,
|
||||||
|
settings: GlobalSettingsService
|
||||||
|
): void {
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.KNOWLEDGE_LIST,
|
IPC.KNOWLEDGE_LIST,
|
||||||
(
|
(
|
||||||
@@ -65,4 +70,16 @@ export function registerKnowledgeHandlers(registry: BookRegistryService): void {
|
|||||||
ipcMain.handle(IPC.KNOWLEDGE_STATS, (_e, { bookId }: { bookId: string }) =>
|
ipcMain.handle(IPC.KNOWLEDGE_STATS, (_e, { bookId }: { bookId: string }) =>
|
||||||
wrap(() => new KnowledgeService(registry.getDb(bookId)).getForeshadowStats())
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AiContextBinding, AutoRhythm, AutoWritingConfig, WizardWritingConf
|
|||||||
import { BookRegistryService } from '../../services/book-registry'
|
import { BookRegistryService } from '../../services/book-registry'
|
||||||
import { GlobalSettingsService } from '../../services/global-settings'
|
import { GlobalSettingsService } from '../../services/global-settings'
|
||||||
import { AiClientService } from '../../services/ai-client.service'
|
import { AiClientService } from '../../services/ai-client.service'
|
||||||
import { aiContextBuilder } from '../../services/ai-context-builder.service'
|
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
||||||
import { WizardWritingService } from '../../services/wizard-writing.service'
|
import { WizardWritingService } from '../../services/wizard-writing.service'
|
||||||
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
@@ -20,12 +20,7 @@ function readContextText(registry: BookRegistryService, bookId: string, flowId:
|
|||||||
.getDb(bookId)
|
.getDb(bookId)
|
||||||
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
|
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
|
||||||
.get(flowId) as { context_json: string } | undefined
|
.get(flowId) as { context_json: string } | undefined
|
||||||
try {
|
return readFlowSystemPrompt(row?.context_json ?? '{}')
|
||||||
const parsed = JSON.parse(row?.context_json ?? '{}') as { contextSummary?: string }
|
|
||||||
return parsed.contextSummary ?? '(无上下文摘要)'
|
|
||||||
} catch {
|
|
||||||
return '(无上下文摘要)'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerWizardHandlers(
|
export function registerWizardHandlers(
|
||||||
@@ -98,10 +93,14 @@ export function registerWizardHandlers(
|
|||||||
{ bookId, flowId, binding }: { bookId: string; flowId: string; binding: AiContextBinding }
|
{ bookId, flowId, binding }: { bookId: string; flowId: string; binding: AiContextBinding }
|
||||||
) =>
|
) =>
|
||||||
wrap(() => {
|
wrap(() => {
|
||||||
const built = aiContextBuilder.build(binding, registry, bookId, settings.get().penName)
|
const payload = buildFlowContextPayload(
|
||||||
const summary = built.preview.map((p) => p.title).join('、') || '(空)'
|
binding,
|
||||||
|
registry,
|
||||||
|
bookId,
|
||||||
|
settings.get().penName
|
||||||
|
)
|
||||||
const svc = serviceFor(registry, bookId, aiClient)
|
const svc = serviceFor(registry, bookId, aiClient)
|
||||||
return svc.enrichFlow(svc.confirmContext(flowId, binding, summary))
|
return svc.enrichFlow(svc.confirmContext(flowId, payload))
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
|||||||
registerInteractiveHandlers(books, settings, aiClient)
|
registerInteractiveHandlers(books, settings, aiClient)
|
||||||
registerAutoHandlers(books, settings, aiClient)
|
registerAutoHandlers(books, settings, aiClient)
|
||||||
registerWizardHandlers(books, settings, aiClient)
|
registerWizardHandlers(books, settings, aiClient)
|
||||||
registerKnowledgeHandlers(books)
|
registerKnowledgeHandlers(books, settings)
|
||||||
registerCockpitHandlers(books, settings)
|
registerCockpitHandlers(books, settings)
|
||||||
registerBridgeHandlers(books, aiClient)
|
registerBridgeHandlers(books, aiClient)
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { stripHtml } from './word-count'
|
import { stripHtml } from './word-count'
|
||||||
import type { AiContextBinding, AiContextBuildResult, ContextPreviewItem } from '../../shared/types'
|
import type {
|
||||||
|
AiContextBinding,
|
||||||
|
AiContextBuildResult,
|
||||||
|
ContextPreviewItem,
|
||||||
|
KnowledgeType
|
||||||
|
} from '../../shared/types'
|
||||||
import type { BookRegistryService } from './book-registry'
|
import type { BookRegistryService } from './book-registry'
|
||||||
|
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
|
||||||
|
|
||||||
const CHAPTER_CHAR_LIMIT = 2000
|
const CHAPTER_CHAR_LIMIT = 2000
|
||||||
const OTHER_CHAR_LIMIT = 500
|
const OTHER_CHAR_LIMIT = 500
|
||||||
|
const KNOWLEDGE_CHAR_LIMIT = 500
|
||||||
|
const KNOWLEDGE_TOTAL_LIMIT = 4000
|
||||||
const TOTAL_CHAR_LIMIT = 16 * 1024
|
const TOTAL_CHAR_LIMIT = 16 * 1024
|
||||||
|
|
||||||
interface BlockCandidate {
|
interface BlockCandidate {
|
||||||
sortKey: number
|
sortKey: number
|
||||||
text: string
|
text: string
|
||||||
preview: ContextPreviewItem
|
preview: ContextPreviewItem
|
||||||
|
isKnowledge?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncate(text: string, max: number): string {
|
function truncate(text: string, max: number): string {
|
||||||
@@ -17,12 +26,33 @@ function truncate(text: string, max: number): string {
|
|||||||
return `${text.slice(0, max)}…`
|
return `${text.slice(0, max)}…`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function knowledgeTypeLabel(type: KnowledgeType): string {
|
||||||
|
const map: Record<KnowledgeType, string> = {
|
||||||
|
character: '角色',
|
||||||
|
foreshadow: '伏笔',
|
||||||
|
location: '地点',
|
||||||
|
relationship: '关系',
|
||||||
|
fact: '事实'
|
||||||
|
}
|
||||||
|
return map[type]
|
||||||
|
}
|
||||||
|
|
||||||
function formatBlock(kind: ContextPreviewItem['kind'], title: string, body: string): string {
|
function formatBlock(kind: ContextPreviewItem['kind'], title: string, body: string): string {
|
||||||
const label =
|
const label =
|
||||||
kind === 'chapter' ? '章节' : kind === 'outline' ? '大纲' : kind === 'setting' ? '设定' : '灵感'
|
kind === 'chapter'
|
||||||
|
? '章节'
|
||||||
|
: kind === 'outline'
|
||||||
|
? '大纲'
|
||||||
|
: kind === 'setting'
|
||||||
|
? '设定'
|
||||||
|
: '灵感'
|
||||||
return `【${label}】${title}\n${body}`
|
return `【${label}】${title}\n${body}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatKnowledgeBlock(type: KnowledgeType, title: string, body: string): string {
|
||||||
|
return `【知识·${knowledgeTypeLabel(type)}】${title}\n${body}`
|
||||||
|
}
|
||||||
|
|
||||||
export class AiContextBuilderService {
|
export class AiContextBuilderService {
|
||||||
build(
|
build(
|
||||||
binding: AiContextBinding,
|
binding: AiContextBinding,
|
||||||
@@ -34,6 +64,7 @@ export class AiContextBuilderService {
|
|||||||
if (!meta) throw new Error('Book not found')
|
if (!meta) throw new Error('Book not found')
|
||||||
|
|
||||||
const candidates: BlockCandidate[] = []
|
const candidates: BlockCandidate[] = []
|
||||||
|
const knowledgeIds = binding.knowledgeIds ?? []
|
||||||
|
|
||||||
for (const id of binding.chapterIds) {
|
for (const id of binding.chapterIds) {
|
||||||
const chapter = registry.getChapterRepo(bookId).get(id)
|
const chapter = registry.getChapterRepo(bookId).get(id)
|
||||||
@@ -104,6 +135,38 @@ export class AiContextBuilderService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const knowledgeRepo = new KnowledgeRepository(registry.getDb(bookId))
|
||||||
|
const knowledgeCandidates: BlockCandidate[] = []
|
||||||
|
for (const id of knowledgeIds) {
|
||||||
|
const entry = knowledgeRepo.get(id)
|
||||||
|
if (!entry || entry.status !== 'approved') continue
|
||||||
|
const body = truncate(stripHtml(entry.content || entry.title), KNOWLEDGE_CHAR_LIMIT)
|
||||||
|
knowledgeCandidates.push({
|
||||||
|
sortKey: entry.importance * 1000,
|
||||||
|
isKnowledge: true,
|
||||||
|
text: formatKnowledgeBlock(entry.type, entry.title, body),
|
||||||
|
preview: {
|
||||||
|
kind: 'knowledge',
|
||||||
|
id,
|
||||||
|
title: entry.title,
|
||||||
|
excerpt: body,
|
||||||
|
charCount: body.length,
|
||||||
|
knowledgeType: entry.type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
knowledgeCandidates.sort((a, b) => b.sortKey - a.sortKey)
|
||||||
|
|
||||||
|
let knowledgeChars = 0
|
||||||
|
const trimmedKnowledge: BlockCandidate[] = []
|
||||||
|
for (const k of knowledgeCandidates) {
|
||||||
|
const next = knowledgeChars + k.text.length + (trimmedKnowledge.length > 0 ? 2 : 0)
|
||||||
|
if (next > KNOWLEDGE_TOTAL_LIMIT) break
|
||||||
|
trimmedKnowledge.push(k)
|
||||||
|
knowledgeChars = next
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(...trimmedKnowledge)
|
||||||
candidates.sort((a, b) => b.sortKey - a.sortKey)
|
candidates.sort((a, b) => b.sortKey - a.sortKey)
|
||||||
|
|
||||||
const selected: BlockCandidate[] = []
|
const selected: BlockCandidate[] = []
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AutoWritingConfig,
|
AutoWritingConfig,
|
||||||
|
FlowContextPayload,
|
||||||
InteractiveFlow,
|
InteractiveFlow,
|
||||||
ScenePlanItem
|
ScenePlanItem
|
||||||
} from '../../shared/types'
|
} 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 {
|
setContextSummary(flowId: string, contextSummary: string): void {
|
||||||
this.repo.updateStep(flowId, this.repo.get(flowId)!.step, {
|
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
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { AiContextBinding, FlowContextPayload } from '../../shared/types'
|
||||||
|
import type { BookRegistryService } from './book-registry'
|
||||||
|
import { aiContextBuilder } from './ai-context-builder.service'
|
||||||
|
|
||||||
|
const EMPTY_BINDING: AiContextBinding = {
|
||||||
|
chapterIds: [],
|
||||||
|
outlineIds: [],
|
||||||
|
settingIds: [],
|
||||||
|
inspirationIds: [],
|
||||||
|
knowledgeIds: []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFlowContextPayload(
|
||||||
|
binding: AiContextBinding,
|
||||||
|
registry: BookRegistryService,
|
||||||
|
bookId: string,
|
||||||
|
penName: string
|
||||||
|
): FlowContextPayload {
|
||||||
|
const built = aiContextBuilder.build(binding, registry, bookId, penName)
|
||||||
|
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 }
|
||||||
|
for (const p of built.preview) {
|
||||||
|
if (p.kind in counts) counts[p.kind as keyof typeof counts]++
|
||||||
|
}
|
||||||
|
const contextSummary = `${counts.chapter}章·${counts.outline}纲·${counts.setting}设·${counts.inspiration}感·${counts.knowledge}知`
|
||||||
|
return { binding, systemPrompt: built.systemPrompt, contextSummary }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFlowContextPayload(contextJson: string): FlowContextPayload | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(contextJson) as Partial<FlowContextPayload>
|
||||||
|
if (parsed.systemPrompt) {
|
||||||
|
return {
|
||||||
|
binding: { ...EMPTY_BINDING, ...parsed.binding },
|
||||||
|
systemPrompt: parsed.systemPrompt,
|
||||||
|
contextSummary: parsed.contextSummary ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.contextSummary) {
|
||||||
|
return {
|
||||||
|
binding: { ...EMPTY_BINDING, ...parsed.binding },
|
||||||
|
systemPrompt: parsed.contextSummary,
|
||||||
|
contextSummary: parsed.contextSummary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFlowSystemPrompt(contextJson: string): string {
|
||||||
|
return parseFlowContextPayload(contextJson)?.systemPrompt ?? '(无上下文)'
|
||||||
|
}
|
||||||
@@ -28,7 +28,9 @@ function defaults(): GlobalSettings {
|
|||||||
snapshotMaxPersist: 3,
|
snapshotMaxPersist: 3,
|
||||||
searchHistory: [],
|
searchHistory: [],
|
||||||
aiConfig: defaultAiConfig(),
|
aiConfig: defaultAiConfig(),
|
||||||
namingIgnoreWords: []
|
namingIgnoreWords: [],
|
||||||
|
knowledgeAutoSuggest: true,
|
||||||
|
knowledgeSuggestTopN: 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@ export class GlobalSettingsService {
|
|||||||
...raw,
|
...raw,
|
||||||
updateSchedule: raw.updateSchedule ?? 'none',
|
updateSchedule: raw.updateSchedule ?? 'none',
|
||||||
stockBufferThreshold: raw.stockBufferThreshold ?? 3,
|
stockBufferThreshold: raw.stockBufferThreshold ?? 3,
|
||||||
|
knowledgeAutoSuggest: raw.knowledgeAutoSuggest ?? true,
|
||||||
|
knowledgeSuggestTopN: raw.knowledgeSuggestTopN ?? 8,
|
||||||
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
||||||
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AiContextBinding,
|
AiContextBinding,
|
||||||
|
FlowContextPayload,
|
||||||
InteractiveFlow,
|
InteractiveFlow,
|
||||||
PlotOption,
|
PlotOption,
|
||||||
PlotSelection
|
PlotSelection
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
buildRefineMessages,
|
buildRefineMessages,
|
||||||
buildSceneGenerateMessages
|
buildSceneGenerateMessages
|
||||||
} from './interactive-prompt-builder.service'
|
} from './interactive-prompt-builder.service'
|
||||||
|
import { readFlowSystemPrompt } from './flow-context.util'
|
||||||
|
|
||||||
export class InteractiveWritingService {
|
export class InteractiveWritingService {
|
||||||
private repo: InteractiveRepository
|
private repo: InteractiveRepository
|
||||||
@@ -37,9 +39,9 @@ export class InteractiveWritingService {
|
|||||||
return this.repo.getBySession(sessionId)
|
return this.repo.getBySession(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmContext(flowId: string, binding: AiContextBinding, contextSummary: string): InteractiveFlow {
|
confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow {
|
||||||
this.repo.updateStep(flowId, 'plot_suggest', {
|
this.repo.updateStep(flowId, 'plot_suggest', {
|
||||||
contextJson: JSON.stringify({ binding, contextSummary })
|
contextJson: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
return this.repo.get(flowId)!
|
return this.repo.get(flowId)!
|
||||||
}
|
}
|
||||||
@@ -165,8 +167,8 @@ export class InteractiveWritingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
|
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
|
||||||
const raw = this.repo.getContextJson(flow.id)
|
|
||||||
try {
|
try {
|
||||||
|
const raw = this.repo.getContextJson(flow.id)
|
||||||
const parsed = JSON.parse(raw) as { contextSummary?: string }
|
const parsed = JSON.parse(raw) as { contextSummary?: string }
|
||||||
return { ...flow, contextSummary: parsed.contextSummary }
|
return { ...flow, contextSummary: parsed.contextSummary }
|
||||||
} catch {
|
} catch {
|
||||||
@@ -175,14 +177,6 @@ export class InteractiveWritingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readContextText(flowId: string): string {
|
private readContextText(flowId: string): string {
|
||||||
try {
|
return readFlowSystemPrompt(this.repo.getContextJson(flowId))
|
||||||
const parsed = JSON.parse(this.repo.getContextJson(flowId)) as {
|
|
||||||
contextSummary?: string
|
|
||||||
binding?: AiContextBinding
|
|
||||||
}
|
|
||||||
return parsed.contextSummary ?? '(无上下文摘要)'
|
|
||||||
} catch {
|
|
||||||
return '(无上下文摘要)'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { KnowledgeEntry, KnowledgeSuggestOptions, ScoredKnowledge } from '../../shared/types'
|
||||||
|
import type { SqliteDb } from '../db/types'
|
||||||
|
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||||
|
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
|
||||||
|
|
||||||
|
const STOP_WORDS = new Set(['的', '了', '在', '是', '与', '和', 'the', 'a', 'an'])
|
||||||
|
|
||||||
|
function tokenize(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[\s,,。!?、;;::]+/)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t.length >= 2 && !STOP_WORDS.has(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapRatio(a: string, b: string): number {
|
||||||
|
const ta = new Set(tokenize(a))
|
||||||
|
const tb = tokenize(b)
|
||||||
|
if (ta.size === 0 || tb.length === 0) return 0
|
||||||
|
let hit = 0
|
||||||
|
for (const t of tb) if (ta.has(t)) hit++
|
||||||
|
return hit / tb.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function chapterProximityBonus(
|
||||||
|
entry: KnowledgeEntry,
|
||||||
|
currentSort: number | null,
|
||||||
|
chapterSort: Map<string, number>
|
||||||
|
): number {
|
||||||
|
if (currentSort == null) return 0
|
||||||
|
const ids = [entry.sourceChapterId, entry.lastMentionChapterId].filter(Boolean) as string[]
|
||||||
|
if (ids.length === 0) return 0
|
||||||
|
let best = Infinity
|
||||||
|
for (const id of ids) {
|
||||||
|
const order = chapterSort.get(id)
|
||||||
|
if (order != null) best = Math.min(best, Math.abs(currentSort - order))
|
||||||
|
}
|
||||||
|
if (best === Infinity) return 0
|
||||||
|
if (best === 0) return 30
|
||||||
|
if (best <= 3) return 20
|
||||||
|
if (best <= 10) return 10
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KnowledgeRetrievalService {
|
||||||
|
constructor(private db: SqliteDb) {}
|
||||||
|
|
||||||
|
suggest(opts: KnowledgeSuggestOptions = {}): ScoredKnowledge[] {
|
||||||
|
const topN = opts.topN ?? 8
|
||||||
|
const entries = new KnowledgeRepository(this.db).list({ status: 'approved' })
|
||||||
|
if (entries.length === 0) return []
|
||||||
|
|
||||||
|
const chapters = new ChapterRepository(this.db).list()
|
||||||
|
const sortMap = new Map(chapters.map((c) => [c.id, c.sortOrder]))
|
||||||
|
const currentSort = opts.currentChapterId
|
||||||
|
? (sortMap.get(opts.currentChapterId) ?? null)
|
||||||
|
: null
|
||||||
|
const goalText = opts.goalText ?? ''
|
||||||
|
|
||||||
|
const scored: ScoredKnowledge[] = entries.map((entry) => {
|
||||||
|
let score = entry.importance * 20
|
||||||
|
const reasons: string[] = []
|
||||||
|
|
||||||
|
const prox = chapterProximityBonus(entry, currentSort, sortMap)
|
||||||
|
score += prox
|
||||||
|
if (prox > 0) reasons.push('knowledge.reason.chapterRelevant')
|
||||||
|
|
||||||
|
if (entry.type === 'foreshadow') {
|
||||||
|
const fs = entry.foreshadowStatus ?? 'buried'
|
||||||
|
if (fs === 'buried') {
|
||||||
|
score += 25
|
||||||
|
reasons.push('knowledge.reason.foreshadowBuried')
|
||||||
|
} else if (fs === 'partial') score += 15
|
||||||
|
}
|
||||||
|
|
||||||
|
if (goalText) {
|
||||||
|
const text = `${entry.title} ${entry.content}`
|
||||||
|
const ratio = overlapRatio(goalText, text)
|
||||||
|
score += Math.round(ratio * 30)
|
||||||
|
if (ratio > 0.2) reasons.push('knowledge.reason.goalMatch')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.importance >= 4) reasons.push('knowledge.reason.highImportance')
|
||||||
|
|
||||||
|
return { entry, score, reasons: [...new Set(reasons)] }
|
||||||
|
})
|
||||||
|
|
||||||
|
return scored.sort((a, b) => b.score - a.score).slice(0, topN)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import type {
|
|||||||
AiContextBinding,
|
AiContextBinding,
|
||||||
AutoRhythm,
|
AutoRhythm,
|
||||||
AutoWritingConfig,
|
AutoWritingConfig,
|
||||||
|
FlowContextPayload,
|
||||||
InteractiveFlow,
|
InteractiveFlow,
|
||||||
WizardWritingConfig
|
WizardWritingConfig
|
||||||
} from '../../shared/types'
|
} from '../../shared/types'
|
||||||
import type { SqliteDb } from '../db/types'
|
import type { SqliteDb } from '../db/types'
|
||||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||||
|
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
|
||||||
import type { AiClientService } from './ai-client.service'
|
import type { AiClientService } from './ai-client.service'
|
||||||
import { buildWizardPreviewMessages } from './auto-prompt-builder.service'
|
import { buildWizardPreviewMessages } from './auto-prompt-builder.service'
|
||||||
import { AutoWritingService } from './auto-writing.service'
|
import { AutoWritingService } from './auto-writing.service'
|
||||||
@@ -58,15 +60,11 @@ export class WizardWritingService {
|
|||||||
return this.repo.updateStep(flowId, 'wizard_context')
|
return this.repo.updateStep(flowId, 'wizard_context')
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmContext(
|
confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow {
|
||||||
flowId: string,
|
|
||||||
binding: AiContextBinding,
|
|
||||||
contextSummary: string
|
|
||||||
): InteractiveFlow {
|
|
||||||
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
||||||
this.repo.setModeConfig(flowId, { ...config, contextBinding: binding })
|
this.repo.setModeConfig(flowId, { ...config, contextBinding: payload.binding })
|
||||||
this.repo.updateStep(flowId, 'wizard_preview', {
|
this.repo.updateStep(flowId, 'wizard_preview', {
|
||||||
contextJson: JSON.stringify({ binding, contextSummary })
|
contextJson: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
return this.repo.get(flowId)!
|
return this.repo.get(flowId)!
|
||||||
}
|
}
|
||||||
@@ -75,7 +73,17 @@ export class WizardWritingService {
|
|||||||
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
||||||
const messages = buildWizardPreviewMessages(config, contextText, povName)
|
const messages = buildWizardPreviewMessages(config, contextText, povName)
|
||||||
const html = await this.aiClient.chat(messages)
|
const html = await this.aiClient.chat(messages)
|
||||||
const preview = html.trim().includes('<p>') ? html.trim() : `<p>${html.trim()}</p>`
|
let preview = html.trim().includes('<p>') ? html.trim() : `<p>${html.trim()}</p>`
|
||||||
|
const binding = config.contextBinding
|
||||||
|
if (binding?.knowledgeIds?.length) {
|
||||||
|
const repo = new KnowledgeRepository(this.db)
|
||||||
|
const titles = binding.knowledgeIds
|
||||||
|
.map((id) => repo.get(id)?.title)
|
||||||
|
.filter(Boolean)
|
||||||
|
if (titles.length) {
|
||||||
|
preview += `<p><strong>将注入知识:</strong>${titles.join('、')}</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
this.repo.setModeConfig(flowId, { ...config, strategyPreview: preview })
|
this.repo.setModeConfig(flowId, { ...config, strategyPreview: preview })
|
||||||
return preview
|
return preview
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,8 @@
|
|||||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
|
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
|
||||||
import { IPC } from '../shared/ipc-channels'
|
import { IPC } from '../shared/ipc-channels'
|
||||||
import type {
|
import type {
|
||||||
|
KnowledgeSuggestOptions,
|
||||||
|
ScoredKnowledge,
|
||||||
ActionId,
|
ActionId,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
BookMeta,
|
BookMeta,
|
||||||
@@ -346,6 +348,12 @@ const electronAPI = {
|
|||||||
config: Partial<AutoWritingConfig>
|
config: Partial<AutoWritingConfig>
|
||||||
): Promise<IpcResult<InteractiveFlow>> =>
|
): Promise<IpcResult<InteractiveFlow>> =>
|
||||||
ipcRenderer.invoke(IPC.AUTO_SET_GOAL, { bookId, flowId, config }),
|
ipcRenderer.invoke(IPC.AUTO_SET_GOAL, { bookId, flowId, config }),
|
||||||
|
confirmContext: (
|
||||||
|
bookId: string,
|
||||||
|
flowId: string,
|
||||||
|
binding: AiContextBinding
|
||||||
|
): Promise<IpcResult<InteractiveFlow>> =>
|
||||||
|
ipcRenderer.invoke(IPC.AUTO_CONFIRM_CONTEXT, { bookId, flowId, binding }),
|
||||||
planScenes: (
|
planScenes: (
|
||||||
bookId: string,
|
bookId: string,
|
||||||
flowId: string,
|
flowId: string,
|
||||||
@@ -448,7 +456,12 @@ const electronAPI = {
|
|||||||
stats: (
|
stats: (
|
||||||
bookId: string
|
bookId: string
|
||||||
): Promise<IpcResult<{ buried: number; resolved: number; forgotten: number }>> =>
|
): Promise<IpcResult<{ buried: number; resolved: number; forgotten: number }>> =>
|
||||||
ipcRenderer.invoke(IPC.KNOWLEDGE_STATS, { bookId })
|
ipcRenderer.invoke(IPC.KNOWLEDGE_STATS, { bookId }),
|
||||||
|
suggestContext: (
|
||||||
|
bookId: string,
|
||||||
|
opts?: KnowledgeSuggestOptions
|
||||||
|
): Promise<IpcResult<ScoredKnowledge[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.KNOWLEDGE_SUGGEST_CONTEXT, { bookId, opts })
|
||||||
},
|
},
|
||||||
cockpit: {
|
cockpit: {
|
||||||
getSummary: (bookId: string, volumeId?: string): Promise<IpcResult<CockpitSummary>> =>
|
getSummary: (bookId: string, volumeId?: string): Promise<IpcResult<CockpitSummary>> =>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
import { useAutoStore } from '@renderer/stores/useAutoStore'
|
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 {
|
interface AutoPanelProps {
|
||||||
bookId: string
|
bookId: string
|
||||||
@@ -37,6 +41,20 @@ export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): Reac
|
|||||||
const [outlineIds, setOutlineIds] = useState<string[]>([])
|
const [outlineIds, setOutlineIds] = useState<string[]>([])
|
||||||
const [outlines, setOutlines] = useState<OutlineItem[]>([])
|
const [outlines, setOutlines] = useState<OutlineItem[]>([])
|
||||||
const [chapterTitle, setChapterTitle] = useState('')
|
const [chapterTitle, setChapterTitle] = useState('')
|
||||||
|
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
|
||||||
|
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([])
|
||||||
|
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
|
||||||
|
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||||
|
const sessions = useAiStore((s) => s.sessions)
|
||||||
|
const activeSessionId = useAiStore((s) => s.activeSessionId)
|
||||||
|
|
||||||
|
const goalText = useMemo(() => {
|
||||||
|
const outlineText = outlines
|
||||||
|
.filter((o) => outlineIds.includes(o.id))
|
||||||
|
.map((o) => o.description ?? '')
|
||||||
|
.join(' ')
|
||||||
|
return [goal.trim(), outlineText].filter(Boolean).join(' ')
|
||||||
|
}, [goal, outlineIds, outlines])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mount()
|
mount()
|
||||||
@@ -52,6 +70,52 @@ export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): Reac
|
|||||||
void ipcCall(() => window.electronAPI.outline.list(bookId)).then(setOutlines)
|
void ipcCall(() => window.electronAPI.outline.list(bookId)).then(setOutlines)
|
||||||
}, [bookId])
|
}, [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) {
|
if (!sessionId || !flow) {
|
||||||
return (
|
return (
|
||||||
<div className="auto-panel" data-testid="auto-panel">
|
<div className="auto-panel" data-testid="auto-panel">
|
||||||
@@ -121,6 +185,16 @@ export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): Reac
|
|||||||
onChange={(e) => setWordMax(Number(e.target.value))}
|
onChange={(e) => setWordMax(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{knowledgeAutoSuggest && (
|
||||||
|
<details className="auto-knowledge-section" data-testid="auto-knowledge-suggest">
|
||||||
|
<summary>{t('interactive.confirmKnowledge')}</summary>
|
||||||
|
<KnowledgeSuggestList
|
||||||
|
items={suggested}
|
||||||
|
selectedIds={knowledgeIds}
|
||||||
|
onToggle={toggleKnowledge}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
|
|||||||
@@ -1,45 +1,80 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { AiContextBinding } from '@shared/types'
|
import type { AiContextBinding, KnowledgeEntry, ScoredKnowledge } from '@shared/types'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||||
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
import { formatContextSummary } from '@renderer/lib/ai-context-summary'
|
import { formatContextSummary } from '@renderer/lib/ai-context-summary'
|
||||||
|
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
|
||||||
import i18n from '@renderer/i18n'
|
import i18n from '@renderer/i18n'
|
||||||
|
|
||||||
type ContextTab = 'chapter' | 'outline' | 'setting' | 'inspiration'
|
type ContextTab = 'chapter' | 'outline' | 'setting' | 'inspiration' | 'knowledge'
|
||||||
|
|
||||||
interface ContextEditorModalProps {
|
interface ContextEditorModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
goalText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_BINDING: AiContextBinding = {
|
const EMPTY_BINDING: AiContextBinding = {
|
||||||
chapterIds: [],
|
chapterIds: [],
|
||||||
outlineIds: [],
|
outlineIds: [],
|
||||||
settingIds: [],
|
settingIds: [],
|
||||||
inspirationIds: []
|
inspirationIds: [],
|
||||||
|
knowledgeIds: []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContextEditorModal({ open, onClose }: ContextEditorModalProps): React.JSX.Element | null {
|
export function ContextEditorModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
goalText = ''
|
||||||
|
}: ContextEditorModalProps): React.JSX.Element | null {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const bookId = useBookStore((s) => s.currentBookId)
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||||
const chapters = useBookStore((s) => s.chapters)
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
const outlines = useBookStore((s) => s.outlines)
|
const outlines = useBookStore((s) => s.outlines)
|
||||||
const settings = useBookStore((s) => s.settings)
|
const settings = useBookStore((s) => s.settings)
|
||||||
const inspirations = useBookStore((s) => s.inspirations)
|
const inspirations = useBookStore((s) => s.inspirations)
|
||||||
|
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
|
||||||
const activeSessionId = useAiStore((s) => s.activeSessionId)
|
const activeSessionId = useAiStore((s) => s.activeSessionId)
|
||||||
const sessions = useAiStore((s) => s.sessions)
|
const sessions = useAiStore((s) => s.sessions)
|
||||||
const [tab, setTab] = useState<ContextTab>('setting')
|
const [tab, setTab] = useState<ContextTab>('setting')
|
||||||
const [binding, setBinding] = useState<AiContextBinding>(EMPTY_BINDING)
|
const [binding, setBinding] = useState<AiContextBinding>(EMPTY_BINDING)
|
||||||
|
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
|
||||||
|
const [approvedKnowledge, setApprovedKnowledge] = useState<KnowledgeEntry[]>([])
|
||||||
|
|
||||||
|
const loadSuggestions = useCallback(async (): Promise<void> => {
|
||||||
|
if (!bookId || !knowledgeAutoSuggest) {
|
||||||
|
setSuggested([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const items = await ipcCall(() =>
|
||||||
|
window.electronAPI.knowledge.suggestContext(bookId, {
|
||||||
|
currentChapterId: selectedChapterId ?? undefined,
|
||||||
|
goalText: goalText || undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setSuggested(items)
|
||||||
|
setBinding((prev) => {
|
||||||
|
const merged = new Set([...prev.knowledgeIds, ...items.map((x) => x.entry.id)])
|
||||||
|
return { ...prev, knowledgeIds: [...merged] }
|
||||||
|
})
|
||||||
|
}, [bookId, goalText, knowledgeAutoSuggest, selectedChapterId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const session = sessions.find((s) => s.id === activeSessionId)
|
const session = sessions.find((s) => s.id === activeSessionId)
|
||||||
setBinding(session?.context ?? EMPTY_BINDING)
|
setBinding(session?.context ?? EMPTY_BINDING)
|
||||||
}, [open, activeSessionId, sessions])
|
if (!bookId) return
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.knowledge.list(bookId, { status: 'approved' })
|
||||||
|
).then(setApprovedKnowledge)
|
||||||
|
void loadSuggestions()
|
||||||
|
}, [open, activeSessionId, sessions, bookId, loadSuggestions])
|
||||||
|
|
||||||
const toggle = (kind: ContextTab, id: string): void => {
|
const toggle = (kind: Exclude<ContextTab, 'knowledge'>, id: string): void => {
|
||||||
const key =
|
const key =
|
||||||
kind === 'chapter'
|
kind === 'chapter'
|
||||||
? 'chapterIds'
|
? 'chapterIds'
|
||||||
@@ -57,6 +92,15 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleKnowledge = (id: string): void => {
|
||||||
|
setBinding((prev) => ({
|
||||||
|
...prev,
|
||||||
|
knowledgeIds: prev.knowledgeIds.includes(id)
|
||||||
|
? prev.knowledgeIds.filter((x) => x !== id)
|
||||||
|
: [...prev.knowledgeIds, id]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
if (!bookId || !activeSessionId) return
|
if (!bookId || !activeSessionId) return
|
||||||
const updated = await ipcCall(() =>
|
const updated = await ipcCall(() =>
|
||||||
@@ -87,7 +131,9 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
|
|||||||
? outlines.map((o) => ({ id: o.id, title: o.title }))
|
? outlines.map((o) => ({ id: o.id, title: o.title }))
|
||||||
: tab === 'setting'
|
: tab === 'setting'
|
||||||
? settings.map((s) => ({ id: s.id, title: s.name }))
|
? settings.map((s) => ({ id: s.id, title: s.name }))
|
||||||
: inspirations.map((i) => ({ id: i.id, title: i.title || t('inspiration.untitled') }))
|
: tab === 'inspiration'
|
||||||
|
? inspirations.map((i) => ({ id: i.id, title: i.title || t('inspiration.untitled') }))
|
||||||
|
: approvedKnowledge.map((k) => ({ id: k.id, title: k.title }))
|
||||||
|
|
||||||
const selectedIds =
|
const selectedIds =
|
||||||
tab === 'chapter'
|
tab === 'chapter'
|
||||||
@@ -96,7 +142,9 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
|
|||||||
? binding.outlineIds
|
? binding.outlineIds
|
||||||
: tab === 'setting'
|
: tab === 'setting'
|
||||||
? binding.settingIds
|
? binding.settingIds
|
||||||
: binding.inspirationIds
|
: tab === 'inspiration'
|
||||||
|
? binding.inspirationIds
|
||||||
|
: binding.knowledgeIds
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" data-testid="context-editor-modal">
|
<div className="modal-overlay" data-testid="context-editor-modal">
|
||||||
@@ -108,7 +156,7 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="context-editor-tabs">
|
<div className="context-editor-tabs">
|
||||||
{(['chapter', 'outline', 'setting', 'inspiration'] as const).map((id) => (
|
{(['chapter', 'outline', 'setting', 'inspiration', 'knowledge'] as const).map((id) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -120,18 +168,65 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{tab === 'knowledge' && (
|
||||||
|
<div className="context-knowledge-suggest">
|
||||||
|
<div className="context-knowledge-suggest-header">
|
||||||
|
<span>{t('ai.contextKnowledgeSuggestTitle')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
data-testid="context-refresh-suggest"
|
||||||
|
onClick={() => void loadSuggestions()}
|
||||||
|
>
|
||||||
|
{t('ai.contextRefreshSuggest')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<KnowledgeSuggestList
|
||||||
|
items={suggested}
|
||||||
|
selectedIds={binding.knowledgeIds}
|
||||||
|
onToggle={toggleKnowledge}
|
||||||
|
testId="context-knowledge-suggest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="context-editor-list">
|
<div className="context-editor-list">
|
||||||
|
{tab === 'knowledge' ? (
|
||||||
|
<>
|
||||||
|
<div className="context-knowledge-all-title">{t('ai.contextKnowledgeAllTitle')}</div>
|
||||||
|
{approvedKnowledge.length === 0 && (
|
||||||
|
<div className="sidebar-empty">{t('ai.contextKnowledgeEmpty')}</div>
|
||||||
|
)}
|
||||||
|
{approvedKnowledge.map((entry) => (
|
||||||
|
<label
|
||||||
|
key={entry.id}
|
||||||
|
className="context-editor-item"
|
||||||
|
data-testid={`context-knowledge-item-${entry.id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={binding.knowledgeIds.includes(entry.id)}
|
||||||
|
onChange={() => toggleKnowledge(entry.id)}
|
||||||
|
/>
|
||||||
|
<span>{entry.title}</span>
|
||||||
|
<span className="knowledge-suggest-type">{t(`knowledge.type.${entry.type}`)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{items.length === 0 && <div className="sidebar-empty">{t('ai.contextEmptyList')}</div>}
|
{items.length === 0 && <div className="sidebar-empty">{t('ai.contextEmptyList')}</div>}
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<label key={item.id} className="context-editor-item" data-testid={`context-item-${item.id}`}>
|
<label key={item.id} className="context-editor-item" data-testid={`context-item-${item.id}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedIds.includes(item.id)}
|
checked={selectedIds.includes(item.id)}
|
||||||
onChange={() => toggle(tab, item.id)}
|
onChange={() => toggle(tab as Exclude<ContextTab, 'knowledge'>, item.id)}
|
||||||
/>
|
/>
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" className="btn" data-testid="context-refresh" onClick={() => void handleRefresh()}>
|
<button type="button" className="btn" data-testid="context-refresh" onClick={() => void handleRefresh()}>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { PlotSelection } from '@shared/types'
|
import type { PlotSelection, ScoredKnowledge } from '@shared/types'
|
||||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||||
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||||
|
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
|
||||||
import { PlotOptionCard } from '@renderer/components/ai/PlotOptionCard'
|
import { PlotOptionCard } from '@renderer/components/ai/PlotOptionCard'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
interface InteractivePanelProps {
|
interface InteractivePanelProps {
|
||||||
bookId: string
|
bookId: string
|
||||||
@@ -19,6 +23,9 @@ export function InteractivePanel({
|
|||||||
}: InteractivePanelProps): React.JSX.Element {
|
}: InteractivePanelProps): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const backend = useSettingsStore((s) => s.aiConfig.backend)
|
const backend = useSettingsStore((s) => s.aiConfig.backend)
|
||||||
|
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
|
||||||
|
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||||
|
const sessions = useAiStore((s) => s.sessions)
|
||||||
const {
|
const {
|
||||||
flow,
|
flow,
|
||||||
plots,
|
plots,
|
||||||
@@ -37,6 +44,8 @@ export function InteractivePanel({
|
|||||||
} = useInteractiveStore()
|
} = useInteractiveStore()
|
||||||
|
|
||||||
const [contextOpen, setContextOpen] = useState(false)
|
const [contextOpen, setContextOpen] = useState(false)
|
||||||
|
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
|
||||||
|
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([])
|
||||||
const [selectedPlots, setSelectedPlots] = useState<Array<'A' | 'B' | 'C'>>([])
|
const [selectedPlots, setSelectedPlots] = useState<Array<'A' | 'B' | 'C'>>([])
|
||||||
const [userNote, setUserNote] = useState('')
|
const [userNote, setUserNote] = useState('')
|
||||||
const [refineInput, setRefineInput] = useState('')
|
const [refineInput, setRefineInput] = useState('')
|
||||||
@@ -52,6 +61,33 @@ export function InteractivePanel({
|
|||||||
if (bookId && sessionId) void loadFlow(bookId, sessionId)
|
if (bookId && sessionId) void loadFlow(bookId, sessionId)
|
||||||
}, [bookId, sessionId, loadFlow])
|
}, [bookId, sessionId, loadFlow])
|
||||||
|
|
||||||
|
const step = flow?.step
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bookId || !sessionId || step !== 'context_confirm' || !knowledgeAutoSuggest) return
|
||||||
|
const session = sessions.find((s) => s.id === sessionId)
|
||||||
|
const existing = session?.context.knowledgeIds ?? []
|
||||||
|
setKnowledgeIds(existing)
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.knowledge.suggestContext(bookId, {
|
||||||
|
currentChapterId: selectedChapterId ?? undefined
|
||||||
|
})
|
||||||
|
).then((items) => {
|
||||||
|
setSuggested(items)
|
||||||
|
const merged = [...new Set([...existing, ...items.map((x) => x.entry.id)])]
|
||||||
|
setKnowledgeIds(merged)
|
||||||
|
if (!session) return
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
|
||||||
|
context: { ...session.context, knowledgeIds: merged }
|
||||||
|
})
|
||||||
|
).then((updated) => {
|
||||||
|
useAiStore.setState((s) => ({
|
||||||
|
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [bookId, sessionId, step, knowledgeAutoSuggest, selectedChapterId, sessions])
|
||||||
|
|
||||||
if (!sessionId || !flow) {
|
if (!sessionId || !flow) {
|
||||||
return (
|
return (
|
||||||
<div className="interactive-panel" data-testid="interactive-panel">
|
<div className="interactive-panel" data-testid="interactive-panel">
|
||||||
@@ -60,13 +96,31 @@ export function InteractivePanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const step = flow.step
|
|
||||||
const previewHtml = streaming ? streamBuffer : sceneDraft
|
const previewHtml = streaming ? streamBuffer : sceneDraft
|
||||||
const showReview =
|
const showReview =
|
||||||
step === 'scene_review' ||
|
step === 'scene_review' ||
|
||||||
(!streaming && sceneDraft.trim().length > 0 && step === 'scene_generate')
|
(!streaming && sceneDraft.trim().length > 0 && step === 'scene_generate')
|
||||||
const showFinishChapter = flow.scenes.length > 0 && flow.status !== 'completed'
|
const showFinishChapter = flow.scenes.length > 0 && flow.status !== 'completed'
|
||||||
|
|
||||||
|
const toggleKnowledge = (id: string): void => {
|
||||||
|
setKnowledgeIds((prev) => {
|
||||||
|
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
const session = sessions.find((s) => s.id === sessionId)
|
||||||
|
if (bookId && sessionId && session) {
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
|
||||||
|
context: { ...session.context, knowledgeIds: next }
|
||||||
|
})
|
||||||
|
).then((updated) => {
|
||||||
|
useAiStore.setState((s) => ({
|
||||||
|
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleStart = (): void => {
|
const handleStart = (): void => {
|
||||||
void confirmContextAndStart(bookId, flow.id)
|
void confirmContextAndStart(bookId, flow.id)
|
||||||
}
|
}
|
||||||
@@ -94,6 +148,16 @@ export function InteractivePanel({
|
|||||||
{step === 'context_confirm' && (
|
{step === 'context_confirm' && (
|
||||||
<div className="interactive-section">
|
<div className="interactive-section">
|
||||||
<p>{flow.contextSummary ?? t('ai.contextEmpty')}</p>
|
<p>{flow.contextSummary ?? t('ai.contextEmpty')}</p>
|
||||||
|
{knowledgeAutoSuggest && (
|
||||||
|
<div className="interactive-knowledge-section" data-testid="interactive-knowledge-suggest">
|
||||||
|
<h4>{t('interactive.confirmKnowledge')}</h4>
|
||||||
|
<KnowledgeSuggestList
|
||||||
|
items={suggested}
|
||||||
|
selectedIds={knowledgeIds}
|
||||||
|
onToggle={toggleKnowledge}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button type="button" className="btn" onClick={() => setContextOpen(true)}>
|
<button type="button" className="btn" onClick={() => setContextOpen(true)}>
|
||||||
{t('ai.contextEditorTitle')}
|
{t('ai.contextEditorTitle')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { ScoredKnowledge } from '@shared/types'
|
||||||
|
|
||||||
|
interface KnowledgeSuggestListProps {
|
||||||
|
items: ScoredKnowledge[]
|
||||||
|
selectedIds: string[]
|
||||||
|
onToggle: (id: string) => void
|
||||||
|
testId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeSuggestList({
|
||||||
|
items,
|
||||||
|
selectedIds,
|
||||||
|
onToggle,
|
||||||
|
testId = 'knowledge-suggest-list'
|
||||||
|
}: KnowledgeSuggestListProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <div className="sidebar-empty">{t('ai.contextKnowledgeEmpty')}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="knowledge-suggest-list" data-testid={testId}>
|
||||||
|
{items.map(({ entry, reasons }) => (
|
||||||
|
<label
|
||||||
|
key={entry.id}
|
||||||
|
className="context-editor-item knowledge-suggest-item"
|
||||||
|
data-testid={`knowledge-suggest-item-${entry.id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.includes(entry.id)}
|
||||||
|
onChange={() => onToggle(entry.id)}
|
||||||
|
/>
|
||||||
|
<span className="knowledge-suggest-title">{entry.title}</span>
|
||||||
|
<span className="knowledge-suggest-type">{t(`knowledge.type.${entry.type}`)}</span>
|
||||||
|
{reasons.length > 0 && (
|
||||||
|
<span className="knowledge-suggest-reasons">
|
||||||
|
{reasons.map((reason) => (
|
||||||
|
<span key={reason} className="knowledge-reason-badge">
|
||||||
|
{t(reason)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
||||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||||
|
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
|
||||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
|
|
||||||
interface WizardPanelProps {
|
interface WizardPanelProps {
|
||||||
bookId: string
|
bookId: string
|
||||||
@@ -41,6 +44,44 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps):
|
|||||||
const [characters, setCharacters] = useState<SettingEntry[]>([])
|
const [characters, setCharacters] = useState<SettingEntry[]>([])
|
||||||
const [povId, setPovId] = useState('')
|
const [povId, setPovId] = useState('')
|
||||||
const [contextOpen, setContextOpen] = useState(false)
|
const [contextOpen, setContextOpen] = useState(false)
|
||||||
|
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
|
||||||
|
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([])
|
||||||
|
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
|
||||||
|
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||||
|
const sessions = useAiStore((s) => s.sessions)
|
||||||
|
|
||||||
|
const goalText = useMemo(
|
||||||
|
() => [goal.trim(), styleNote.trim()].filter(Boolean).join(' '),
|
||||||
|
[goal, styleNote]
|
||||||
|
)
|
||||||
|
|
||||||
|
const step = flow?.step
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bookId || !sessionId || step !== 'wizard_context' || !knowledgeAutoSuggest) return
|
||||||
|
const session = sessions.find((s) => s.id === sessionId)
|
||||||
|
const existing = session?.context.knowledgeIds ?? []
|
||||||
|
setKnowledgeIds(existing)
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.knowledge.suggestContext(bookId, {
|
||||||
|
currentChapterId: selectedChapterId ?? undefined,
|
||||||
|
goalText: goalText || undefined
|
||||||
|
})
|
||||||
|
).then((items) => {
|
||||||
|
setSuggested(items)
|
||||||
|
const merged = [...new Set([...existing, ...items.map((x) => x.entry.id)])]
|
||||||
|
setKnowledgeIds(merged)
|
||||||
|
if (!session) return
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
|
||||||
|
context: { ...session.context, knowledgeIds: merged }
|
||||||
|
})
|
||||||
|
).then((updated) => {
|
||||||
|
useAiStore.setState((s) => ({
|
||||||
|
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [bookId, sessionId, step, knowledgeAutoSuggest, selectedChapterId, goalText, sessions])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mount()
|
mount()
|
||||||
@@ -66,9 +107,32 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const step = flow.step
|
|
||||||
const config = flow.modeConfig as WizardWritingConfig
|
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 => {
|
const handleContextClose = (): void => {
|
||||||
setContextOpen(false)
|
setContextOpen(false)
|
||||||
const session = useAiStore.getState().sessions.find(
|
const session = useAiStore.getState().sessions.find(
|
||||||
@@ -178,15 +242,33 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps):
|
|||||||
{step === 'wizard_context' && (
|
{step === 'wizard_context' && (
|
||||||
<div className="interactive-section">
|
<div className="interactive-section">
|
||||||
<p>{t('wizard.contextHint')}</p>
|
<p>{t('wizard.contextHint')}</p>
|
||||||
|
{knowledgeAutoSuggest && (
|
||||||
|
<div className="wizard-knowledge-section" data-testid="wizard-knowledge-suggest">
|
||||||
|
<h4>{t('interactive.confirmKnowledge')}</h4>
|
||||||
|
<KnowledgeSuggestList
|
||||||
|
items={suggested}
|
||||||
|
selectedIds={knowledgeIds}
|
||||||
|
onToggle={toggleKnowledge}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary"
|
className="btn"
|
||||||
data-testid="wizard-context-edit"
|
data-testid="wizard-context-edit"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => setContextOpen(true)}
|
onClick={() => setContextOpen(true)}
|
||||||
>
|
>
|
||||||
{t('ai.contextEditorTitle')}
|
{t('ai.contextEditorTitle')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={handleConfirmContext}
|
||||||
|
>
|
||||||
|
{t('wizard.next')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -230,7 +312,7 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps):
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ContextEditorModal open={contextOpen} onClose={handleContextClose} />
|
<ContextEditorModal open={contextOpen} onClose={handleContextClose} goalText={goalText} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
{section === 'shortcuts' && <ShortcutEditor />}
|
{section === 'shortcuts' && <ShortcutEditor />}
|
||||||
{section === 'about' && (
|
{section === 'about' && (
|
||||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
{t('app.name')} v0.6.0
|
{t('app.name')} v0.7.0
|
||||||
<br />
|
<br />
|
||||||
{t('app.tagline')}
|
{t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import type { ContextPreviewItem } from '@shared/types'
|
|||||||
export function formatContextSummary(preview: ContextPreviewItem[], t: TFunction): string {
|
export function formatContextSummary(preview: ContextPreviewItem[], t: TFunction): string {
|
||||||
if (preview.length === 0) return t('ai.contextEmpty')
|
if (preview.length === 0) return t('ai.contextEmpty')
|
||||||
|
|
||||||
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0 }
|
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 }
|
||||||
for (const item of preview) counts[item.kind]++
|
for (const item of preview) counts[item.kind]++
|
||||||
|
|
||||||
return t('ai.contextSummary', {
|
return t('ai.contextSummary', {
|
||||||
chapters: counts.chapter,
|
chapters: counts.chapter,
|
||||||
outlines: counts.outline,
|
outlines: counts.outline,
|
||||||
settings: counts.setting,
|
settings: counts.setting,
|
||||||
inspirations: counts.inspiration
|
inspirations: counts.inspiration,
|
||||||
|
knowledge: counts.knowledge
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface AutoState {
|
|||||||
loadFlow: (bookId: string, sessionId: string) => Promise<void>
|
loadFlow: (bookId: string, sessionId: string) => Promise<void>
|
||||||
start: (bookId: string, sessionId: string) => Promise<void>
|
start: (bookId: string, sessionId: string) => Promise<void>
|
||||||
setGoal: (bookId: string, config: Partial<AutoWritingConfig>) => Promise<void>
|
setGoal: (bookId: string, config: Partial<AutoWritingConfig>) => Promise<void>
|
||||||
|
confirmContext: (bookId: string) => Promise<void>
|
||||||
planScenes: (bookId: string) => Promise<void>
|
planScenes: (bookId: string) => Promise<void>
|
||||||
generateNext: (bookId: string) => Promise<void>
|
generateNext: (bookId: string) => Promise<void>
|
||||||
resolveNaming: (bookId: string, chosenName: string) => Promise<void>
|
resolveNaming: (bookId: string, chosenName: string) => Promise<void>
|
||||||
@@ -64,11 +65,24 @@ export const useAutoStore = create<AutoState>((set, get) => ({
|
|||||||
set({ flow })
|
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) => {
|
planScenes: async (bookId) => {
|
||||||
const flowId = get().flow?.id
|
const flowId = get().flow?.id
|
||||||
if (!flowId) return
|
if (!flowId) return
|
||||||
set({ streaming: true })
|
set({ streaming: true })
|
||||||
try {
|
try {
|
||||||
|
await get().confirmContext(bookId)
|
||||||
const summary = useAiStore.getState().contextSummary
|
const summary = useAiStore.getState().contextSummary
|
||||||
const flow = await ipcCall(() =>
|
const flow = await ipcCall(() =>
|
||||||
window.electronAPI.auto.planScenes(bookId, flowId, summary)
|
window.electronAPI.auto.planScenes(bookId, flowId, summary)
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ export const useInteractiveStore = create<InteractiveState>((set, get) => ({
|
|||||||
chapterIds: [],
|
chapterIds: [],
|
||||||
outlineIds: [],
|
outlineIds: [],
|
||||||
settingIds: [],
|
settingIds: [],
|
||||||
inspirationIds: []
|
inspirationIds: [],
|
||||||
|
knowledgeIds: []
|
||||||
}
|
}
|
||||||
const flow = await ipcCall(() =>
|
const flow = await ipcCall(() =>
|
||||||
window.electronAPI.interactive.confirmContext(bookId, flowId, binding)
|
window.electronAPI.interactive.confirmContext(bookId, flowId, binding)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
shortcuts: {} as Record<ActionId, string>,
|
shortcuts: {} as Record<ActionId, string>,
|
||||||
aiConfig: { ...DEFAULT_AI_CONFIG },
|
aiConfig: { ...DEFAULT_AI_CONFIG },
|
||||||
namingIgnoreWords: [] as string[],
|
namingIgnoreWords: [] as string[],
|
||||||
|
knowledgeAutoSuggest: true,
|
||||||
|
knowledgeSuggestTopN: 8,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||||
|
|||||||
Vendored
+11
@@ -35,6 +35,8 @@ import type {
|
|||||||
Volume,
|
Volume,
|
||||||
WordFreqResult,
|
WordFreqResult,
|
||||||
KnowledgeEntry,
|
KnowledgeEntry,
|
||||||
|
KnowledgeSuggestOptions,
|
||||||
|
ScoredKnowledge,
|
||||||
CreateKnowledgeInput,
|
CreateKnowledgeInput,
|
||||||
KnowledgeStatus,
|
KnowledgeStatus,
|
||||||
KnowledgeType,
|
KnowledgeType,
|
||||||
@@ -263,6 +265,11 @@ export interface ElectronAPI {
|
|||||||
flowId: string,
|
flowId: string,
|
||||||
config: Partial<AutoWritingConfig>
|
config: Partial<AutoWritingConfig>
|
||||||
) => Promise<IpcResult<InteractiveFlow>>
|
) => Promise<IpcResult<InteractiveFlow>>
|
||||||
|
confirmContext: (
|
||||||
|
bookId: string,
|
||||||
|
flowId: string,
|
||||||
|
binding: AiContextBinding
|
||||||
|
) => Promise<IpcResult<InteractiveFlow>>
|
||||||
planScenes: (
|
planScenes: (
|
||||||
bookId: string,
|
bookId: string,
|
||||||
flowId: string,
|
flowId: string,
|
||||||
@@ -338,6 +345,10 @@ export interface ElectronAPI {
|
|||||||
stats: (
|
stats: (
|
||||||
bookId: string
|
bookId: string
|
||||||
) => Promise<IpcResult<{ buried: number; resolved: number; forgotten: number }>>
|
) => Promise<IpcResult<{ buried: number; resolved: number; forgotten: number }>>
|
||||||
|
suggestContext: (
|
||||||
|
bookId: string,
|
||||||
|
opts?: KnowledgeSuggestOptions
|
||||||
|
) => Promise<IpcResult<ScoredKnowledge[]>>
|
||||||
}
|
}
|
||||||
cockpit: {
|
cockpit: {
|
||||||
getSummary: (bookId: string, volumeId?: string) => Promise<IpcResult<CockpitSummary>>
|
getSummary: (bookId: string, volumeId?: string) => Promise<IpcResult<CockpitSummary>>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const IPC = {
|
|||||||
AUTO_GET_FLOW: 'auto:getFlow',
|
AUTO_GET_FLOW: 'auto:getFlow',
|
||||||
AUTO_START: 'auto:start',
|
AUTO_START: 'auto:start',
|
||||||
AUTO_SET_GOAL: 'auto:setGoal',
|
AUTO_SET_GOAL: 'auto:setGoal',
|
||||||
|
AUTO_CONFIRM_CONTEXT: 'auto:confirmContext',
|
||||||
AUTO_PLAN_SCENES: 'auto:planScenes',
|
AUTO_PLAN_SCENES: 'auto:planScenes',
|
||||||
AUTO_GENERATE: 'auto:generate',
|
AUTO_GENERATE: 'auto:generate',
|
||||||
AUTO_RESOLVE_NAMING: 'auto:resolveNaming',
|
AUTO_RESOLVE_NAMING: 'auto:resolveNaming',
|
||||||
@@ -110,6 +111,7 @@ export const IPC = {
|
|||||||
KNOWLEDGE_BATCH_APPROVE: 'knowledge:batchApprove',
|
KNOWLEDGE_BATCH_APPROVE: 'knowledge:batchApprove',
|
||||||
KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark',
|
KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark',
|
||||||
KNOWLEDGE_STATS: 'knowledge:stats',
|
KNOWLEDGE_STATS: 'knowledge:stats',
|
||||||
|
KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext',
|
||||||
COCKPIT_SUMMARY: 'cockpit:summary',
|
COCKPIT_SUMMARY: 'cockpit:summary',
|
||||||
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
|
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
|
||||||
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
|
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
|
||||||
|
|||||||
+23
-1
@@ -99,6 +99,24 @@ export interface ChapterBridgeResult {
|
|||||||
aiSuggestion?: string
|
aiSuggestion?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeSuggestOptions {
|
||||||
|
currentChapterId?: string
|
||||||
|
goalText?: string
|
||||||
|
topN?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoredKnowledge {
|
||||||
|
entry: KnowledgeEntry
|
||||||
|
score: number
|
||||||
|
reasons: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowContextPayload {
|
||||||
|
binding: AiContextBinding
|
||||||
|
systemPrompt: string
|
||||||
|
contextSummary: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GlobalSettings {
|
export interface GlobalSettings {
|
||||||
penName: string
|
penName: string
|
||||||
onboardingCompleted: boolean
|
onboardingCompleted: boolean
|
||||||
@@ -114,6 +132,8 @@ export interface GlobalSettings {
|
|||||||
searchHistory: string[]
|
searchHistory: string[]
|
||||||
aiConfig: AiConfig
|
aiConfig: AiConfig
|
||||||
namingIgnoreWords: string[]
|
namingIgnoreWords: string[]
|
||||||
|
knowledgeAutoSuggest?: boolean
|
||||||
|
knowledgeSuggestTopN?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookMeta {
|
export interface BookMeta {
|
||||||
@@ -274,6 +294,7 @@ export interface AiContextBinding {
|
|||||||
outlineIds: string[]
|
outlineIds: string[]
|
||||||
settingIds: string[]
|
settingIds: string[]
|
||||||
inspirationIds: string[]
|
inspirationIds: string[]
|
||||||
|
knowledgeIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AiSession {
|
export interface AiSession {
|
||||||
@@ -312,7 +333,7 @@ export interface AiNetworkStatusEvent {
|
|||||||
online: boolean
|
online: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContextPreviewKind = 'chapter' | 'outline' | 'setting' | 'inspiration'
|
export type ContextPreviewKind = 'chapter' | 'outline' | 'setting' | 'inspiration' | 'knowledge'
|
||||||
|
|
||||||
export interface ContextPreviewItem {
|
export interface ContextPreviewItem {
|
||||||
kind: ContextPreviewKind
|
kind: ContextPreviewKind
|
||||||
@@ -320,6 +341,7 @@ export interface ContextPreviewItem {
|
|||||||
title: string
|
title: string
|
||||||
excerpt: string
|
excerpt: string
|
||||||
charCount: number
|
charCount: number
|
||||||
|
knowledgeType?: KnowledgeType
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AiContextBuildResult {
|
export interface AiContextBuildResult {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { migrate } from '../../src/main/db/migrate'
|
|||||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||||
|
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||||
import { BookRegistryService } from '../../src/main/services/book-registry'
|
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||||
import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service'
|
import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service'
|
||||||
import type { SqliteDb } from '../../src/main/db/types'
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
@@ -38,7 +39,7 @@ describe('AiContextBuilderService', () => {
|
|||||||
const chapter = chapters.create(volId, '长章节', 0, longHtml)
|
const chapter = chapters.create(volId, '长章节', 0, longHtml)
|
||||||
|
|
||||||
const result = aiContextBuilder.build(
|
const result = aiContextBuilder.build(
|
||||||
{ chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [] },
|
{ chapterIds: [chapter.id], outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [] },
|
||||||
registry,
|
registry,
|
||||||
bookId,
|
bookId,
|
||||||
'作者'
|
'作者'
|
||||||
@@ -58,7 +59,7 @@ describe('AiContextBuilderService', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = aiContextBuilder.build(
|
const result = aiContextBuilder.build(
|
||||||
{ chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [] },
|
{ chapterIds: ids, outlineIds: [], settingIds: [], inspirationIds: [], knowledgeIds: [] },
|
||||||
registry,
|
registry,
|
||||||
bookId,
|
bookId,
|
||||||
'作者'
|
'作者'
|
||||||
@@ -74,7 +75,7 @@ describe('AiContextBuilderService', () => {
|
|||||||
const setting = settings.create('character', '主角林远', '少年修士')
|
const setting = settings.create('character', '主角林远', '少年修士')
|
||||||
|
|
||||||
const result = aiContextBuilder.build(
|
const result = aiContextBuilder.build(
|
||||||
{ chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [] },
|
{ chapterIds: [], outlineIds: [], settingIds: [setting.id], inspirationIds: [], knowledgeIds: [] },
|
||||||
registry,
|
registry,
|
||||||
bookId,
|
bookId,
|
||||||
'作者'
|
'作者'
|
||||||
@@ -83,4 +84,60 @@ describe('AiContextBuilderService', () => {
|
|||||||
expect(result.preview[0].title).toBe('主角林远')
|
expect(result.preview[0].title).toBe('主角林远')
|
||||||
expect(result.systemPrompt).toContain('主角林远')
|
expect(result.systemPrompt).toContain('主角林远')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('UT-CTX-02: build includes knowledge preview items', () => {
|
||||||
|
const know = new KnowledgeRepository(db)
|
||||||
|
const entry = know.create({
|
||||||
|
type: 'foreshadow',
|
||||||
|
title: '测灵石',
|
||||||
|
content: '石头发热',
|
||||||
|
status: 'approved'
|
||||||
|
})
|
||||||
|
const result = aiContextBuilder.build(
|
||||||
|
{
|
||||||
|
chapterIds: [],
|
||||||
|
outlineIds: [],
|
||||||
|
settingIds: [],
|
||||||
|
inspirationIds: [],
|
||||||
|
knowledgeIds: [entry.id]
|
||||||
|
},
|
||||||
|
registry,
|
||||||
|
bookId,
|
||||||
|
'作者'
|
||||||
|
)
|
||||||
|
expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true)
|
||||||
|
expect(result.systemPrompt).toContain('【知识·伏笔】')
|
||||||
|
expect(result.systemPrompt).toContain('测灵石')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-CTX-03: caps knowledge section at 4000 chars', () => {
|
||||||
|
const know = new KnowledgeRepository(db)
|
||||||
|
const ids: string[] = []
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const e = know.create({
|
||||||
|
type: 'fact',
|
||||||
|
title: `条目${i}`,
|
||||||
|
content: '长'.repeat(400),
|
||||||
|
importance: 5,
|
||||||
|
status: 'approved'
|
||||||
|
})
|
||||||
|
ids.push(e.id)
|
||||||
|
}
|
||||||
|
const result = aiContextBuilder.build(
|
||||||
|
{
|
||||||
|
chapterIds: [],
|
||||||
|
outlineIds: [],
|
||||||
|
settingIds: [],
|
||||||
|
inspirationIds: [],
|
||||||
|
knowledgeIds: ids
|
||||||
|
},
|
||||||
|
registry,
|
||||||
|
bookId,
|
||||||
|
'作者'
|
||||||
|
)
|
||||||
|
const knowledgeChars = result.preview
|
||||||
|
.filter((p) => p.kind === 'knowledge')
|
||||||
|
.reduce((s, p) => s + p.charCount, 0)
|
||||||
|
expect(knowledgeChars).toBeLessThanOrEqual(4000)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { AiSessionRepository } from '../../src/main/db/repositories/ai-session.repo'
|
||||||
|
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||||
|
import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service'
|
||||||
|
import { InteractiveWritingService } from '../../src/main/services/interactive-writing.service'
|
||||||
|
import { AiClientService } from '../../src/main/services/ai-client.service'
|
||||||
|
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||||
|
import { aiContextBuilder } from '../../src/main/services/ai-context-builder.service'
|
||||||
|
import { DEFAULT_AI_CONFIG, type AiContextBinding } from '../../src/shared/types'
|
||||||
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
|
||||||
|
const AI_TEST_CONFIG = { ...DEFAULT_AI_CONFIG, timeoutMs: 240_000, maxTokens: 4096 }
|
||||||
|
|
||||||
|
const EMPTY_BINDING: AiContextBinding = {
|
||||||
|
chapterIds: [],
|
||||||
|
outlineIds: [],
|
||||||
|
settingIds: [],
|
||||||
|
inspirationIds: [],
|
||||||
|
knowledgeIds: []
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AI context integration', () => {
|
||||||
|
it('IT-CTX-01: suggestContext returns entries when approved exist', () => {
|
||||||
|
const db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
new KnowledgeRepository(db).create({
|
||||||
|
type: 'fact',
|
||||||
|
title: '测灵石异常',
|
||||||
|
content: '石头发热',
|
||||||
|
status: 'approved',
|
||||||
|
importance: 4
|
||||||
|
})
|
||||||
|
const result = new KnowledgeRetrievalService(db).suggest({ topN: 5 })
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(result[0].entry.title).toBe('测灵石异常')
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IT-CTX-02: build with knowledgeIds includes knowledge block in systemPrompt', () => {
|
||||||
|
const userDir = mkdtempSync(join(tmpdir(), 'bilin-ctx-it-'))
|
||||||
|
const registry = new BookRegistryService(userDir)
|
||||||
|
const meta = registry.create({ name: '测试书', category: '玄幻' })
|
||||||
|
const bookDb = registry.getDb(meta.id)
|
||||||
|
const entry = new KnowledgeRepository(bookDb).create({
|
||||||
|
type: 'foreshadow',
|
||||||
|
title: '测灵石',
|
||||||
|
content: '石头发热',
|
||||||
|
status: 'approved'
|
||||||
|
})
|
||||||
|
const result = aiContextBuilder.build(
|
||||||
|
{ ...EMPTY_BINDING, knowledgeIds: [entry.id] },
|
||||||
|
registry,
|
||||||
|
meta.id,
|
||||||
|
'作者'
|
||||||
|
)
|
||||||
|
expect(result.preview.some((p) => p.kind === 'knowledge')).toBe(true)
|
||||||
|
expect(result.systemPrompt).toContain('【知识·')
|
||||||
|
bookDb.close()
|
||||||
|
rmSync(userDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IT-CTX-03: interactive confirm then suggestPlots uses knowledge context', async () => {
|
||||||
|
const db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
|
||||||
|
const know = new KnowledgeRepository(db).create({
|
||||||
|
type: 'foreshadow',
|
||||||
|
title: '测灵石异常',
|
||||||
|
content: '入门考核时测灵石发热,暗示主角特殊灵根',
|
||||||
|
status: 'approved',
|
||||||
|
importance: 5
|
||||||
|
})
|
||||||
|
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||||
|
const flow = service.start(session.id)
|
||||||
|
service.confirmContext(flow.id, {
|
||||||
|
binding: { ...EMPTY_BINDING, knowledgeIds: [know.id] },
|
||||||
|
systemPrompt: `【知识·伏笔·测灵石异常】入门考核时测灵石发热,暗示主角特殊灵根\n\n林远参加宗门入门考核,演武场测灵石前众人围观。`,
|
||||||
|
contextSummary: '0章·0纲·0设·0感·1知'
|
||||||
|
})
|
||||||
|
const plots = await service.suggestPlots(flow.id)
|
||||||
|
expect(plots).toHaveLength(3)
|
||||||
|
db.close()
|
||||||
|
}, 240_000)
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||||
|
import { buildFlowContextPayload } from '../../src/main/services/flow-context.util'
|
||||||
|
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||||
|
|
||||||
|
describe('flow-context.util', () => {
|
||||||
|
let userDir: string
|
||||||
|
let registry: BookRegistryService
|
||||||
|
let bookId: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
userDir = mkdtempSync(join(tmpdir(), 'bilin-flow-ctx-'))
|
||||||
|
registry = new BookRegistryService(userDir)
|
||||||
|
bookId = registry.create({ name: '书', category: '玄幻' }).id
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => rmSync(userDir, { recursive: true, force: true }))
|
||||||
|
|
||||||
|
it('buildFlowContextPayload stores systemPrompt and summary', () => {
|
||||||
|
const db = registry.getDb(bookId)
|
||||||
|
const entry = new KnowledgeRepository(db).create({
|
||||||
|
type: 'fact',
|
||||||
|
title: '测试知识',
|
||||||
|
content: '内容',
|
||||||
|
status: 'approved'
|
||||||
|
})
|
||||||
|
const payload = buildFlowContextPayload(
|
||||||
|
{
|
||||||
|
chapterIds: [],
|
||||||
|
outlineIds: [],
|
||||||
|
settingIds: [],
|
||||||
|
inspirationIds: [],
|
||||||
|
knowledgeIds: [entry.id]
|
||||||
|
},
|
||||||
|
registry,
|
||||||
|
bookId,
|
||||||
|
'作者'
|
||||||
|
)
|
||||||
|
expect(payload.systemPrompt).toContain('测试知识')
|
||||||
|
expect(payload.contextSummary).toContain('1')
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,11 +15,17 @@ describe('interactive plots integration', () => {
|
|||||||
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
|
const session = new AiSessionRepository(db).create('t', AI_TEST_CONFIG.model)
|
||||||
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
const service = new InteractiveWritingService(db, new AiClientService(() => AI_TEST_CONFIG))
|
||||||
const flow = service.start(session.id)
|
const flow = service.start(session.id)
|
||||||
service.confirmContext(
|
service.confirmContext(flow.id, {
|
||||||
flow.id,
|
binding: {
|
||||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
chapterIds: [],
|
||||||
'林远参加宗门入门考核,演武场测灵石前众人围观。'
|
outlineIds: [],
|
||||||
)
|
settingIds: [],
|
||||||
|
inspirationIds: [],
|
||||||
|
knowledgeIds: []
|
||||||
|
},
|
||||||
|
systemPrompt: '林远参加宗门入门考核,演武场测灵石前众人围观。',
|
||||||
|
contextSummary: '0章·0纲·0设·0感·0知'
|
||||||
|
})
|
||||||
const plots = await service.suggestPlots(flow.id)
|
const plots = await service.suggestPlots(flow.id)
|
||||||
expect(plots).toHaveLength(3)
|
expect(plots).toHaveLength(3)
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -18,11 +18,17 @@ describe('interactive scene integration', () => {
|
|||||||
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
|
const session = new AiSessionRepository(db).create('t', DEFAULT_AI_CONFIG.model)
|
||||||
const svc = service(db)
|
const svc = service(db)
|
||||||
const flow = svc.start(session.id)
|
const flow = svc.start(session.id)
|
||||||
svc.confirmContext(
|
svc.confirmContext(flow.id, {
|
||||||
flow.id,
|
binding: {
|
||||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
chapterIds: [],
|
||||||
'林远在演武场测试灵根,众人围观。'
|
outlineIds: [],
|
||||||
)
|
settingIds: [],
|
||||||
|
inspirationIds: [],
|
||||||
|
knowledgeIds: []
|
||||||
|
},
|
||||||
|
systemPrompt: '林远在演武场测试灵根,众人围观。',
|
||||||
|
contextSummary: '0章·0纲·0设·0感·0知'
|
||||||
|
})
|
||||||
const plots = await svc.suggestPlots(flow.id)
|
const plots = await svc.suggestPlots(flow.id)
|
||||||
svc.selectPlot(flow.id, { optionIds: [plots[0].id] })
|
svc.selectPlot(flow.id, { optionIds: [plots[0].id] })
|
||||||
const afterGen = await svc.generateScene(flow.id, () => {})
|
const afterGen = await svc.generateScene(flow.id, () => {})
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||||
|
import { KnowledgeRetrievalService } from '../../src/main/services/knowledge-retrieval.service'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('KnowledgeRetrievalService', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-CTX-01: ranks by importance + chapter proximity + goal match', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||||
|
const chapterRepo = new ChapterRepository(db)
|
||||||
|
const cur = chapterRepo.create(volId, '当前章', 5, '<p>x</p>')
|
||||||
|
const near = chapterRepo.create(volId, '近章', 4, '<p>x</p>')
|
||||||
|
chapterRepo.create(volId, '远章', 0, '<p>x</p>')
|
||||||
|
const repo = new KnowledgeRepository(db)
|
||||||
|
const low = repo.create({
|
||||||
|
type: 'fact',
|
||||||
|
title: '无关事实',
|
||||||
|
content: '普通内容',
|
||||||
|
importance: 2,
|
||||||
|
status: 'approved'
|
||||||
|
})
|
||||||
|
const high = repo.create({
|
||||||
|
type: 'foreshadow',
|
||||||
|
title: '测灵石异常',
|
||||||
|
content: '测灵石在入门考核中发热',
|
||||||
|
importance: 5,
|
||||||
|
status: 'approved',
|
||||||
|
foreshadowStatus: 'buried',
|
||||||
|
sourceChapterId: near.id
|
||||||
|
})
|
||||||
|
const service = new KnowledgeRetrievalService(db)
|
||||||
|
const result = service.suggest({
|
||||||
|
currentChapterId: cur.id,
|
||||||
|
goalText: '测灵石入门考核',
|
||||||
|
topN: 5
|
||||||
|
})
|
||||||
|
expect(result[0].entry.id).toBe(high.id)
|
||||||
|
expect(result[0].score).toBeGreaterThan(
|
||||||
|
result.find((r) => r.entry.id === low.id)!.score
|
||||||
|
)
|
||||||
|
expect(result[0].reasons.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-CTX-04: resolved foreshadow scores lower than buried', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const volId = new VolumeRepository(db).create('卷一', 0).id
|
||||||
|
const chId = new ChapterRepository(db).create(volId, '章', 0).id
|
||||||
|
const repo = new KnowledgeRepository(db)
|
||||||
|
const buried = repo.create({
|
||||||
|
type: 'foreshadow',
|
||||||
|
title: '埋设伏笔',
|
||||||
|
importance: 3,
|
||||||
|
status: 'approved',
|
||||||
|
foreshadowStatus: 'buried',
|
||||||
|
sourceChapterId: chId
|
||||||
|
})
|
||||||
|
const resolved = repo.create({
|
||||||
|
type: 'foreshadow',
|
||||||
|
title: '已回收伏笔',
|
||||||
|
importance: 3,
|
||||||
|
status: 'approved',
|
||||||
|
foreshadowStatus: 'resolved',
|
||||||
|
sourceChapterId: chId
|
||||||
|
})
|
||||||
|
const service = new KnowledgeRetrievalService(db)
|
||||||
|
const scores = service.suggest({ currentChapterId: chId, topN: 10 })
|
||||||
|
const buriedScore = scores.find((s) => s.entry.id === buried.id)!.score
|
||||||
|
const resolvedScore = scores.find((s) => s.entry.id === resolved.id)!.score
|
||||||
|
expect(buriedScore).toBeGreaterThan(resolvedScore)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -26,11 +26,17 @@ describe('WizardWritingService.startGenerate', () => {
|
|||||||
})
|
})
|
||||||
flow = svc.setRhythm(flow.id, 'tense')
|
flow = svc.setRhythm(flow.id, 'tense')
|
||||||
flow = svc.setPov(flow.id)
|
flow = svc.setPov(flow.id)
|
||||||
flow = svc.confirmContext(
|
flow = svc.confirmContext(flow.id, {
|
||||||
flow.id,
|
binding: {
|
||||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
chapterIds: [],
|
||||||
CONTEXT
|
outlineIds: [],
|
||||||
)
|
settingIds: [],
|
||||||
|
inspirationIds: [],
|
||||||
|
knowledgeIds: []
|
||||||
|
},
|
||||||
|
systemPrompt: CONTEXT,
|
||||||
|
contextSummary: '0章·0纲·0设·0感·0知'
|
||||||
|
})
|
||||||
return flow.id
|
return flow.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,17 @@ describe('WizardWritingService.buildPreview', () => {
|
|||||||
})
|
})
|
||||||
flow = svc.setRhythm(flow.id, 'mixed')
|
flow = svc.setRhythm(flow.id, 'mixed')
|
||||||
flow = svc.setPov(flow.id)
|
flow = svc.setPov(flow.id)
|
||||||
flow = svc.confirmContext(
|
flow = svc.confirmContext(flow.id, {
|
||||||
flow.id,
|
binding: {
|
||||||
{ chapterIds: [], outlineIds: [], settingIds: [], inspirationIds: [] },
|
chapterIds: [],
|
||||||
CONTEXT
|
outlineIds: [],
|
||||||
)
|
settingIds: [],
|
||||||
|
inspirationIds: [],
|
||||||
|
knowledgeIds: []
|
||||||
|
},
|
||||||
|
systemPrompt: CONTEXT,
|
||||||
|
contextSummary: '0章·0纲·0设·0感·0知'
|
||||||
|
})
|
||||||
|
|
||||||
const preview = await svc.buildPreview(flow.id, CONTEXT, '林远')
|
const preview = await svc.buildPreview(flow.id, CONTEXT, '林远')
|
||||||
expect(preview).toMatch(/<p>/)
|
expect(preview).toMatch(/<p>/)
|
||||||
|
|||||||
Reference in New Issue
Block a user