feat: add managed Granite embedding models

GoodBuddy previously required an external OpenAI-compatible endpoint for vector retrieval. It can now install, verify, import, remove, and run the built-in Granite multilingual embedding model in an isolated local inference process, while retaining custom embedding connections and migrating existing settings.

The model connection UI now manages built-in and custom vector connections consistently, reports the actual retrieval path, preserves credentials across failed saves, and renders the vector tab synchronously. Related settings copy, switch styling, metadata typography, and renderer bundle guardrails are aligned with the resulting workflow.

Release note: 新增可管理的内置 Granite 多语言向量模型,支持本地下载、离线导入、连接切换与诊断;同时优化向量设置及相关界面的文案、开关和状态显示。
This commit is contained in:
mesalogo
2026-08-23 11:26:44 +08:00
parent 58d774ae0c
commit 076b3aaa7b
90 changed files with 7820 additions and 1446 deletions
+5
View File
@@ -89,6 +89,11 @@ Keep Electron security boundaries intact:
blocking page state, or an error with an immediate local recovery action.
- Do not show the same event both inline and as an application notification.
Preserve user input and actionable error context when an operation fails.
- Treat renderer bundle budgets as regression guardrails, not UX targets. Do
not lazy-load one lightweight peer tab solely to satisfy a byte ceiling.
When a genuinely heavy view needs deferred loading, preserve peer
interaction semantics, render a non-empty layout-stable local fallback,
and test its first interaction rather than only its eventual appearance.
## Commit Messages and Release Notes
+12
View File
@@ -316,3 +316,15 @@ Windows 与 POSIX 构建输出。构建专用 module manifest 只记录项目相
绝对路径;检查成功后会删除该诊断文件,检查失败时保留以便排查。标准 Vite
manifest 会保留在输出中。预算以干净生产构建为基线并保留有限余量;若业务确需
提高预算,必须先检查 manifest 闭包和产物差异,不能只为通过 CI 调大数值。
Bundle 大小是发现意外依赖和持续膨胀的粗粒度工程护栏,不是用户体验指标,也不能替代
Electron 中的实际交互验证。Settings 的硬上限以 2026-08-23 验证构建
`648.78 kB raw / 97.79 kB gzip` 为基线,增加约 15% 评审余量后取整为
`750 kB raw / 115 kB gzip`。该上限用于拦截显著增长,不要求实现长期贴线运行。
调整 Renderer 拆包时遵守以下优先级:
1. 同级页签的交互一致性、首个绘制帧内反馈和输入保留优先于包体数字。
2. 不得仅为通过预算,把一个原本轻量的同级页签单独改成首次点击才加载。
3. 确需动态加载重型功能时,必须提供非空、无布局跳变的局部状态,并增加首次交互测试。
4. 修改预算必须记录干净生产构建基线、增量闭包差异和 Electron 实测结果。
+17 -2
View File
@@ -2,13 +2,17 @@ const { existsSync, readFileSync, rmSync } = require('node:fs')
const { gzipSync } = require('node:zlib')
const { resolve } = require('node:path')
// These are hard regression ceilings, not user-experience targets. In
// particular, do not lazy-load one peer tab solely to stay below a ceiling.
// The Settings ceiling is the validated 2026-08-23 production baseline
// (648.78 kB raw / 97.79 kB gzip), rounded up with about 15% review headroom.
const rendererBundleBudgets = Object.freeze({
initial: Object.freeze({ raw: 3_500_000, gzip: 720_000 }),
knowledge: Object.freeze({ raw: 330_000, gzip: 50_000 }),
graph: Object.freeze({ raw: 3_500_000, gzip: 720_000 }),
activity: Object.freeze({ raw: 55_000, gzip: 9_000 }),
magicNotes: Object.freeze({ raw: 630_000, gzip: 130_000 }),
settings: Object.freeze({ raw: 650_000, gzip: 105_000 })
settings: Object.freeze({ raw: 750_000, gzip: 115_000 })
})
function normalizePath(value) {
@@ -29,10 +33,21 @@ function findEntryKey(manifest) {
function findSourceKey(manifest, sourceSuffix) {
const normalizedSuffix = normalizePath(sourceSuffix)
const matches = Object.entries(manifest).filter(([key, item]) => {
let matches = Object.entries(manifest).filter(([key, item]) => {
const source = normalizePath(item.src || key)
return source.endsWith(normalizedSuffix)
})
if (matches.length === 0) {
const sourceName = normalizedSuffix
.split('/')
.at(-1)
?.replace(/\.[^.]+$/u, '')
matches = Object.entries(manifest).filter(
([, item]) =>
item?.isDynamicEntry &&
item.name === sourceName
)
}
if (matches.length !== 1) {
throw new Error(
`Expected one manifest entry for ${sourceSuffix}, found ${matches.length}`
File diff suppressed because it is too large Load Diff
@@ -232,8 +232,7 @@
[ 通用设置 ] [ 魔法笔记 ]
┌ 本地模型 ──────────────────────────────────────────────┐
│ 模型下载源 │
┌ 模型下载源 ────────────────────────────────────────────┐
│ 选择 GoodBuddy 托管本地模型后续下载使用的平台。 │
│ 已安装模型和 ZIP 导入不受影响。 │
│ │
@@ -243,7 +242,6 @@
│ ○ Hugging Face │
│ 适合可以稳定访问 Hugging Face 的网络环境 │
│ │
│ 当前选择:ModelScope │
└────────────────────────────────────────────────────────┘
```
@@ -299,14 +297,10 @@
- 当前模型切换。
- 活动下载取消。
### 7.5 活动下载说明
### 7.5 活动下载
来源选项下方持续说明:
> 正在进行的模型下载会继续使用启动时的来源;新的下载使用当前选择。
首期不为这段说明新增跨语音、OCR 和向量管理器的活动任务聚合 IPC。具体活动操作继续在
对应模型卡显示冻结来源、进度和取消入口。
不常驻展示活动下载说明。具体下载操作在对应模型卡显示进度和取消入口;需要显示来源时
必须使用任务启动时冻结的来源。切换全局来源不改变已经开始的操作。
---
@@ -651,12 +645,12 @@ resolveModelDownloadPackage(files, source)
### 12.1 来源状态
语音、OCR、向量和后续模型卡统一显示:
语音和 OCR 的当前模型详情各保留一个全局下载源标签。向量内置连接不重复显示平台功能中
已经明确的全局来源,也不显示固定本地安装路径;下载仍使用已保存的全局来源。
- 当前全局下载源。
- 当前模型在该来源是否可下载。
- 活动操作的冻结来源。
- 已安装模型的安装和校验状态。
所有模型管理界面都显示当前模型是否可下载、活动操作的进度与取消入口,以及已安装模型
的安装和校验状态。活动操作需要显示来源时,使用任务启动时冻结的来源,不能读取后来
切换的全局值。
未安装且来源可用:
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 实施中 |
| 版本 | 0.1 |
| 日期 | 2026-08-11 |
| 版本 | 0.2 |
| 日期 | 2026-08-22 |
| 适用产品 | GoodBuddy 桌面端 |
| 实施范围 | 第一阶段:可用、可见、可诊断;第二阶段:可调、可优化、可维护 |
| 相关设计 | [本地文本向量模型与连接设计](../../architecture/local-text-embedding-model-design.md) |
@@ -40,7 +40,7 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
预检索,不能只依赖提示词要求模型调用工具。
4. 知识库新建后不默认启用全部已有知识库;对话中的范围继续由用户显式选择。
5. 向量服务不可用时保留全文与中文检索,但必须返回明确降级状态。这是可见的检索通道
降级,不得自动切换应用托管模型、Ollama、云端 Provider 或其他向量模型。
降级,不得自动切换内置连接、用户添加的 OpenAI 兼容连接或其他向量模型。
6. 中文召回使用应用内可控的 CJK n-gram 索引,不新增远程服务依赖。
7. 混合检索保留 RRF 候选融合,并增加本地确定性重排、可选的
Cohere/Jina 兼容学习型重排、最低相关度和上下文预算。学习型重排失败时
@@ -60,6 +60,12 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
用户配置决定。
14. Ask 的运行时边界保持只读。知识库内容始终被标记为不可信证据,
不能成为系统指令。
15. 向量连接是 GoodBuddy Main 管理的知识服务依赖,不是 Agent Runtime 模型。它不进入
直连模型、OpenCode、Continue、DeepSeek Harness、远程 Runtime 或项目 Runtime 的
模型选择、配置和凭据边界。
16. Runtime 只能获得 Main 检索后选出的有界文本证据、来源引用和脱敏诊断,不能获得
Embedding Provider、连接 Profile、Endpoint、API Key、模型目录、Tokenizer、原始向量
或索引 Fingerprint。
## 3. 目标
@@ -102,6 +108,9 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
- 自动问题生成、FAQ 生成和训练数据标注平台。
- 完整 RAG 离线评测平台。第二阶段只提供手动检索测试与可导出的诊断信息。
- 在应用内高保真渲染所有原始 Office 和 PDF 文档。
- 把向量连接用作 LLM、Agent Runtime 模型或 Runtime 模型网关。
- 让 Runtime 直接调用 Embeddings Endpoint、内置推理进程或向量数据库。
- 未经独立设计就把知识库向量连接扩展到记忆、自动化、项目文件搜索或其他应用服务。
## 5. 竞品基线与 GoodBuddy 定位
@@ -160,10 +169,25 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
“检索测试”是当前知识库的高频诊断操作,通过知识库标题区次操作打开独立
工作台,不新增第五个一级页签。
全局向量模型仍在“设置 → 模型连接 → 向量模型”中配置。应用托管本地模型、
用户自行安装的 Ollama/自托管服务和云端兼容服务的界面、数据路径及切换语义以
全局向量模型仍在“设置 → 模型连接 → 向量模型”中配置。GoodBuddy 内置连接和用户添加的
OpenAI 兼容连接使用同一连接列表;其界面、数据路径及切换语义以
[本地文本向量模型与连接设计](../../architecture/local-text-embedding-model-design.md)
为准,知识库页面只显示当前模型、索引兼容性、覆盖率和重建操作。
为准,知识库页面只显示当前模型、索引兼容性、覆盖率和重建操作。“模型连接”是设置入口
名称,不表示向量连接可以分配给 Agent Runtime;项目 Runtime 和 Agent Runtime 的模型
选择中不显示向量连接。
知识检索与 Runtime 的关系固定为:
```text
用户问题
→ Main / KnowledgeService 使用当前向量连接检索
→ Main 生成有界文本证据、引用和检索诊断
→ 当前 Runtime 仅消费这些不可信检索结果
```
`always` 模式由 Main 在启动 Runtime 前完成检索;`auto` 模式中的 `knowledge_search`
也只允许 Runtime 调用 Main 的有界知识工具。两种模式都不把向量连接、凭据、模型文件、
Tokenizer、原始向量或索引访问能力传入 Runtime。切换 Runtime 不改变当前向量连接。
对话输入区的知识范围弹层包含:
@@ -567,7 +591,12 @@ GoodBuddy 不上传私人检索查询或文档内容。本地诊断至少记录
- “每次先检索”在 Runtime 启动前产生检索诊断和引用,即使模型未调用工具。
- 未配置向量模型时,中文改写问题仍能通过 CJK 索引召回相关分块。
- 向量查询失败时回答可继续,界面明确显示已降级。
- 应用托管模型、Ollama 和云端向量连接之间不会自动切换;实际数据路径持续可见。
- 内置连接和用户添加的 OpenAI 兼容连接之间不会自动切换;用户连接的实际数据去向持续
可见,内置连接不重复展示固定本地路径。
- 项目 Runtime 和 Agent Runtime 的模型选择不显示向量连接。
- Runtime 只获得有界文本证据、引用和脱敏诊断,不获得向量 Provider、凭据或原始向量。
- 切换直连模型、OpenCode、Continue、DeepSeek Harness 或远程 Runtime 不改变当前向量
连接。
- 10,000 个分块的向量测试能够返回正确 Top K,不出现固定上限空结果。
- 同名模型切换端点后,不会读取 Fingerprint 不匹配的旧向量。
- 重建失败时,上一版已就绪向量仍能继续召回。
+15 -3
View File
@@ -133,6 +133,17 @@ function rendererBundleModuleManifestPlugin(): Plugin {
}
}
export function stableMainEntryFileName(chunk: {
readonly name: string
}): string {
return [
'deepseek-harness-host-bootstrap',
'embedding-inference-bootstrap'
].includes(chunk.name)
? `${chunk.name}.js`
: '[name].js'
}
export default defineConfig({
main: {
plugins: [
@@ -177,6 +188,9 @@ export default defineConfig({
),
'deepseek-harness-host-bootstrap': resolve(
'src/main/deepseek-harness-host-bootstrap.ts'
),
'embedding-inference-bootstrap': resolve(
'src/main/embedding-inference-bootstrap.ts'
)
},
external: [
@@ -186,9 +200,7 @@ export default defineConfig({
],
output: {
entryFileNames(chunk) {
return chunk.name === 'deepseek-harness-host-bootstrap'
? 'deepseek-harness-host-bootstrap.js'
: '[name].js'
return stableMainEntryFileName(chunk)
},
chunkFileNames(chunk) {
const moduleIds = chunk.moduleIds.join('\n')
+7
View File
@@ -34,6 +34,7 @@
"@deepseek-ai/dsh-tool-skill": "0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
"@deepseek-ai/dsh-user-approval": "0.1.0-rc.6",
"@huggingface/tokenizers": "^0.1.3",
"@modelcontextprotocol/sdk": "^1.30.0",
"@napi-rs/canvas": "1.0.3",
"@opencode-ai/sdk": "^1.18.9",
@@ -3087,6 +3088,12 @@
"hono": "^4"
}
},
"node_modules/@huggingface/tokenizers": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz",
"integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==",
"license": "Apache-2.0"
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+8 -1
View File
@@ -54,6 +54,7 @@
"asarUnpack": [
"out/main/package.json",
"out/main/deepseek-harness-*",
"out/main/embedding-inference-bootstrap.js",
"out/main/chunks/**/*",
"node_modules/node-pty/lib/**/*",
"node_modules/node-pty/package.json",
@@ -62,7 +63,8 @@
"node_modules/koffi/**/*",
"node_modules/@koromix/koffi-*/**/*",
"node_modules/@napi-rs/canvas{,/**/*}",
"node_modules/@napi-rs/canvas-*/**/*"
"node_modules/@napi-rs/canvas-*/**/*",
"node_modules/onnxruntime-web/dist/**/*.wasm"
],
"npmRebuild": false,
"compression": "maximum",
@@ -172,6 +174,10 @@
"from": "node_modules/onnxruntime-web/LICENSE",
"to": "licenses/onnxruntime-web-MIT.txt"
},
{
"from": "node_modules/@huggingface/tokenizers/LICENSE",
"to": "licenses/huggingface-tokenizers-Apache-2.0.txt"
},
{
"from": "node_modules/katex/LICENSE",
"to": "licenses/katex-MIT.txt"
@@ -254,6 +260,7 @@
"@deepseek-ai/dsh-tool-skill": "0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
"@deepseek-ai/dsh-user-approval": "0.1.0-rc.6",
"@huggingface/tokenizers": "^0.1.3",
"@modelcontextprotocol/sdk": "^1.30.0",
"@napi-rs/canvas": "1.0.3",
"@opencode-ai/sdk": "^1.18.9",
+33
View File
@@ -0,0 +1,33 @@
import path from 'node:path'
import { GraniteEmbeddingEngine } from './knowledge/granite-embedding-engine'
import {
createEmbeddingInferenceWorkerPort,
EmbeddingInferenceWorker
} from './knowledge/embedding-inference-worker'
const modelDirectory = process.argv[2]
if (
typeof modelDirectory !== 'string' ||
modelDirectory.length < 1 ||
modelDirectory.length > 4_096 ||
!path.isAbsolute(modelDirectory)
) {
throw new TypeError('Expected a bounded absolute model directory in argv')
}
const worker = new EmbeddingInferenceWorker({
modelDirectory,
engine: new GraniteEmbeddingEngine(),
port: createEmbeddingInferenceWorkerPort(process.parentPort)
})
async function shutdown(): Promise<void> {
await worker.shutdown()
}
process.once('disconnect', () => {
void shutdown()
})
process.once('SIGTERM', () => {
void shutdown()
})
+292 -15
View File
@@ -37,6 +37,13 @@ import { KnowledgeService } from './knowledge/knowledge-service'
import { AssistantDatabase } from './assistant/assistant-database'
import { createModelGraphExtractor } from './knowledge/model-extractor'
import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
import { embeddingProviderFingerprint } from './knowledge/embedding-provider-key'
import type { EmbeddingProvider } from './knowledge/types'
import { EmbeddingModelManager } from './knowledge/embedding-model-manager'
import {
EmbeddingInferenceBroker,
type EmbeddingInferenceTransport
} from './knowledge/embedding-inference-broker'
import { CohereRerankClient } from './knowledge/cohere-rerank-client'
import { RuntimeSettingsStore } from './runtime-settings-store'
import type { ResolvedRuntimeSettings } from './runtime-settings-store'
@@ -101,6 +108,7 @@ import { ShortcutSettingsService } from './shortcut-settings-service'
import { defaultGlobalShortcutSettings } from '../shared/shortcut'
import { requestProcessTreeTermination } from './agent/child-process-termination'
import { createContinueUtilityProcessChild } from './agent/continue-utility-process-adapter'
import type { RuntimeSettings, RuntimeSettingsInput } from '../shared/contracts'
const legacyDefaultShortcut =
defaultGlobalShortcutSettings.accelerator
@@ -145,19 +153,220 @@ let browserService: BrowserService | undefined
let globalTlsPolicy: GlobalTlsPolicy | undefined
let documentOcrBroker: DocumentOcrBroker | undefined
let documentOcrModelManager: DocumentOcrModelManager | undefined
let embeddingModelManager: EmbeddingModelManager | undefined
const embeddingBrokers = new Set<EmbeddingInferenceBroker>()
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
function createEmbeddingProvider(
settings: ResolvedRuntimeSettings
): OpenAIEmbeddingClient | undefined {
return settings.knowledgeEmbeddingEnabled
? new OpenAIEmbeddingClient({
endpoint: settings.knowledgeEmbeddingBaseUrl,
model: settings.knowledgeEmbeddingModel,
apiKey: settings.knowledgeEmbeddingApiKey
})
: undefined
type ManagedEmbeddingProvider = EmbeddingProvider & {
dispose?: () => void | Promise<void>
}
function createEmbeddingUtilityTransport(
modulePath: string,
modelDirectory: string
): EmbeddingInferenceTransport {
const child = utilityProcess.fork(
modulePath,
[modelDirectory],
{
serviceName: 'GoodBuddy Embedding Inference',
stdio: 'pipe',
allowLoadingUnsignedLibraries: false
}
)
return {
postMessage: (message) => child.postMessage(message),
onMessage: (listener) => {
child.on('message', listener)
return () => child.removeListener('message', listener)
},
onClose: (listener) => {
let closed = false
const notify = (): void => {
if (closed) {
return
}
closed = true
listener()
}
child.on('exit', notify)
child.on('error', notify)
return () => {
child.removeListener('exit', notify)
child.removeListener('error', notify)
}
},
close: () => {
child.kill()
}
}
}
async function createEmbeddingProvider(
settings: ResolvedRuntimeSettings,
manager: EmbeddingModelManager,
connectionId = settings.activeEmbeddingConnectionId
): Promise<ManagedEmbeddingProvider | undefined> {
if (!settings.knowledgeEmbeddingEnabled) {
return undefined
}
const connection = settings.embeddingConnections?.find(
(candidate) => candidate.id === connectionId
)
if (!connection) {
throw new Error('当前向量连接不存在')
}
if (connection.kind !== 'builtin') {
return new OpenAIEmbeddingClient({
endpoint: connection.baseUrl,
model: connection.modelName,
apiKey: connection.apiKey
})
}
const snapshot = await manager.getSnapshot()
const model =
snapshot.catalog.find((candidate) => candidate.recommended) ??
snapshot.catalog[0]
if (!model) {
throw new Error('内置向量模型目录为空')
}
const modelDirectory =
await manager.getVerifiedModelDirectory(model.id)
const installed = snapshot.installed.find(
(candidate) => candidate.id === model.id
)
if (!installed) {
throw new Error('内置向量模型尚未安装')
}
const tokenizerDigest = installed.files.find(
(file) => file.role === 'tokenizer'
)?.sha256
const modelDigest = installed.files.find(
(file) => file.role === 'model'
)?.sha256
if (!tokenizerDigest || !modelDigest) {
throw new Error('内置向量模型安装清单不完整')
}
const broker = new EmbeddingInferenceBroker({
createTransport: () =>
createEmbeddingUtilityTransport(
join(mainModuleDirectory, 'embedding-inference-bootstrap.js'),
modelDirectory
)
})
embeddingBrokers.add(broker)
return {
provider: 'builtin',
model: model.id,
fingerprint: embeddingProviderFingerprint({
provider: 'builtin',
dataPath: { kind: 'device' },
model: model.id,
dimensions: model.dimensions,
encodingRecipe: {
recipeId: 'granite-embedding-97m-r2',
artifactDigest: modelDigest,
tokenizerDigest,
pooling: 'cls',
normalization: 'l2',
queryTemplate: '{text}',
documentTemplate: '{text}',
maximumSequenceTokens: model.contextTokens
}
}),
embed: (texts, signal) =>
broker.embed(texts, 'document', signal),
embedQuery: (texts, signal) =>
broker.embed(texts, 'query', signal),
embedDocuments: (texts, signal) =>
broker.embed(texts, 'document', signal),
dispose: async () => {
try {
await broker.shutdown()
} finally {
embeddingBrokers.delete(broker)
}
}
}
}
function runtimeSettingsInputWithEmbeddingSelection(
settings: RuntimeSettings,
activeEmbeddingConnectionId: string
): RuntimeSettingsInput {
const configured = settings.configured ?? settings
const defaultProfile =
settings.modelProfiles.find(
(profile) => profile.id === settings.defaultModelProfileId
) ?? settings.modelProfiles[0]
if (!defaultProfile || !settings.embeddingConnections) {
throw new Error('模型连接设置不完整')
}
return {
provider: settings.provider,
modelBaseUrl: defaultProfile.baseUrl,
modelName: defaultProfile.modelName,
modelProtocol: defaultProfile.protocol,
modelAuthentication: defaultProfile.authentication,
imageGenerationQuality: defaultProfile.imageGenerationQuality,
opencodeBaseUrl: configured.opencodeBaseUrl,
opencodeEmbedded: settings.opencodeEmbedded,
opencodeBinaryPath: configured.opencodeBinaryPath,
opencodeConfigPath: configured.opencodeConfigPath,
continueBinaryPath: configured.continueBinaryPath,
continueConfigPath: configured.continueConfigPath,
continueMode: settings.continueMode,
subagentSmartRoutingEnabled: settings.subagentSmartRoutingEnabled,
knowledgeEmbeddingEnabled: settings.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl: settings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey: { action: 'keep' },
embeddingConnections: settings.embeddingConnections.map(
(connection) =>
connection.kind === 'builtin'
? {
id: connection.id,
name: connection.name,
kind: connection.kind
}
: {
id: connection.id,
name: connection.name,
kind: connection.kind,
baseUrl: connection.baseUrl,
modelName: connection.modelName,
authentication: connection.authentication,
apiKey: { action: 'keep' as const }
}
),
activeEmbeddingConnectionId,
knowledgeRerankEnabled: settings.knowledgeRerankEnabled ?? false,
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint ?? '',
knowledgeRerankModel: settings.knowledgeRerankModel ?? '',
knowledgeRerankApiKey: { action: 'keep' },
contextCompression: settings.contextCompression,
workspacePath: configured.workspacePath,
apiKey: { action: 'keep' },
modelProfiles: settings.modelProfiles.map((profile) => ({
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: { action: 'keep' as const }
})),
defaultModelProfileId: settings.defaultModelProfileId,
opencodeModelSource: configured.opencodeModelSource,
continueModelSource: configured.continueModelSource,
deepseekHarnessModelSource:
configured.deepseekHarnessModelSource ?? { kind: 'platform' },
toolApproval: settings.toolApproval
}
}
function createRerankProvider(
@@ -410,6 +619,13 @@ if (hasSingleInstanceLock) {
const applicationSettingsStore = new ApplicationSettingsStore(
join(app.getPath('userData'), 'application-settings.json')
)
const startupEmbeddingModelManager = new EmbeddingModelManager({
userDataDirectory: app.getPath('userData'),
fetch: globalThis.fetch,
getDownloadSource: async () =>
(await applicationSettingsStore.get()).modelDownloadSource
})
embeddingModelManager = startupEmbeddingModelManager
const releaseNotesService = new ReleaseNotesService({
currentVersion: app.getVersion(),
filePath: app.isPackaged
@@ -505,7 +721,7 @@ if (hasSingleInstanceLock) {
})
knowledgeService = startupKnowledgeService
let activeEmbeddingProvider:
| ReturnType<typeof createEmbeddingProvider>
| Awaited<ReturnType<typeof createEmbeddingProvider>>
| undefined
let activeRerankProvider:
| ReturnType<typeof createRerankProvider>
@@ -621,8 +837,9 @@ if (hasSingleInstanceLock) {
},
initializeKnowledgeAndGateway: async () => {
await startupKnowledgeService.initialize()
const embeddingProvider = createEmbeddingProvider(
initialResolvedSettings
const embeddingProvider = await createEmbeddingProvider(
initialResolvedSettings,
startupEmbeddingModelManager
)
const rerankProvider = createRerankProvider(
initialResolvedSettings
@@ -706,7 +923,10 @@ if (hasSingleInstanceLock) {
}
const settings = await settingsStore.getResolvedSettings()
const nextEmbeddingProvider =
createEmbeddingProvider(settings)
await createEmbeddingProvider(
settings,
startupEmbeddingModelManager
)
const nextRerankProvider = createRerankProvider(settings)
let nextRuntime: AgentRuntime | undefined
let nextSubagentRuntime: AgentRuntime | undefined
@@ -760,8 +980,10 @@ if (hasSingleInstanceLock) {
nextSubagentProfileRuntimes
)
await selectedRuntimeManager?.reset()
const previousEmbeddingProvider = activeEmbeddingProvider
activeEmbeddingProvider = nextEmbeddingProvider
activeRerankProvider = nextRerankProvider
await previousEmbeddingProvider?.dispose?.()
} catch (activationError) {
const rollbackResults = knowledgeService
? await Promise.allSettled([
@@ -774,6 +996,7 @@ if (hasSingleInstanceLock) {
])
: []
await Promise.allSettled([
nextEmbeddingProvider?.dispose?.(),
runtimeConsumed ? undefined : nextRuntime?.dispose(),
subagentRuntimesConsumed
? undefined
@@ -804,6 +1027,41 @@ if (hasSingleInstanceLock) {
runtimeReconfigurationClosing = true
await runtimeReconfigurationQueue
}
const setCurrentEmbeddingConnection = async (
connectionId: string
): Promise<void> => {
const previous = await settingsStore.getPublicSettings()
if (previous.activeEmbeddingConnectionId === connectionId) {
return
}
await settingsStore.update(
runtimeSettingsInputWithEmbeddingSelection(
previous,
connectionId
)
)
try {
await reconfigureRuntimes()
} catch (activationError) {
try {
await settingsStore.update(
runtimeSettingsInputWithEmbeddingSelection(
previous,
previous.activeEmbeddingConnectionId ??
connectionId
)
)
await reconfigureRuntimes()
} catch (rollbackError) {
throw new AggregateError(
[activationError, rollbackError],
'向量模型连接激活失败,且回滚未能完成',
{ cause: rollbackError }
)
}
throw activationError
}
}
removeIpcHandlers = registerIpcHandlers(
mainWindow,
@@ -837,7 +1095,21 @@ if (hasSingleInstanceLock) {
releaseNotesService,
goodbuddyConfigService,
runtimeExtensionStore,
shortcutSettingsService
shortcutSettingsService,
startupEmbeddingModelManager,
async (connectionId) => {
const settings = await settingsStore.getResolvedSettings()
const provider = await createEmbeddingProvider(
settings,
startupEmbeddingModelManager,
connectionId
)
if (!provider) {
throw new Error('请先启用并保存向量模型设置')
}
return provider
},
setCurrentEmbeddingConnection
)
loadMainWindow(mainWindow)
setImmediate(() => {
@@ -927,6 +1199,11 @@ app.on('before-quit', (event) => {
() => selectedRuntimeManager?.dispose(),
() => browserService?.dispose(),
() => globalTlsPolicy?.dispose(),
() =>
Promise.allSettled(
[...embeddingBrokers].map((broker) => broker.shutdown())
).then(() => undefined),
() => embeddingModelManager?.dispose(),
() => documentOcrModelManager?.dispose(),
() => documentOcrBroker?.dispose()
],
+31 -1
View File
@@ -1,5 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import {
mkdtemp,
readFile,
realpath,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ipcChannels } from '../shared/ipc-channels'
@@ -12,6 +18,30 @@ import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
import { AssistantDatabase } from './assistant/assistant-database'
import { registerIpcHandlers } from './ipc'
describe('embedding IPC boundary', () => {
it('validates explicit IDs and keeps ZIP paths in the native dialog', async () => {
const source = await readFile(
join(process.cwd(), 'src', 'main', 'ipc.ts'),
'utf8'
)
const region =
source.match(
/const requireEmbeddingProvider[\s\S]*?registerHandler\(ipcChannels\.speechModelsGet/u
)?.[0] ?? ''
expect(region).toContain('embeddingConnectionIdRequestSchema.parse(input)')
expect(region).toContain('embeddingModelInstallInputSchema.parse(input)')
expect(region).toContain('embeddingModelActionInputSchema.parse(input)')
expect(region).toContain("properties: ['openFile']")
expect(region).toContain('filters: modelArchiveDialogFilters')
expect(region).not.toMatch(
/embeddingModelsImportArchive[\s\S]{0,300}input\.(?:path|archivePath|directory)/u
)
expect(region).not.toMatch(
/(?:agent|selectedRuntimes|remoteModelGateway).{0,80}(?:embedding|vector)/iu
)
})
})
type InvokeHandler = (event: unknown, input?: unknown) => unknown
type KnowledgeGrantMock = (
requestId: string,
+233 -30
View File
@@ -119,6 +119,10 @@ import {
speechModelSelectionInputSchema
} from '../shared/speech-model-contracts'
import {
embeddingConnectionIdRequestSchema,
embeddingModelActionInputSchema,
embeddingModelInstallInputSchema,
embeddingModelSnapshotSchema,
embeddingSettingsSnapshotSchema,
knowledgeEmbeddingIndexCancelRequestSchema,
knowledgeEmbeddingIndexRequestSchema,
@@ -256,6 +260,8 @@ import type { SpeechModelManager } from './speech/speech-model-manager'
import type { SpeechTranscriptionService } from './speech/speech-transcription-service'
import { diagnoseEmbeddingProvider } from './knowledge/embedding-index-coordinator'
import type { EmbeddingIndexCoordinator } from './knowledge/embedding-index-coordinator'
import type { EmbeddingModelManager } from './knowledge/embedding-model-manager'
import type { EmbeddingProvider } from './knowledge/types'
import type { DocumentParsingService } from './document-parsing-service'
import type { DocumentOcrModelManager } from './document-ocr-model-manager'
import type { DocumentOcrBroker } from './document-ocr-broker'
@@ -264,7 +270,6 @@ import type {
GoodBuddyConfigApplyEvent,
GoodBuddyConfigService
} from './goodbuddy-config-service'
import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
import {
magicNotePlainText,
validateMagicNoteRichContent
@@ -915,7 +920,19 @@ export function registerIpcHandlers(
releaseNotesService?: ReleaseNotesService,
goodbuddyConfigService?: GoodBuddyConfigService,
runtimeExtensionStore?: RuntimeExtensionStore,
shortcutSettingsService?: ShortcutSettingsService
shortcutSettingsService?: ShortcutSettingsService,
embeddingModelManager?: EmbeddingModelManager & {
importArchive?(
modelId: string,
archivePath: string
): Promise<unknown>
},
resolveEmbeddingProvider?: (
connectionId: string
) => Promise<EmbeddingProvider>,
setCurrentEmbeddingConnection?: (
connectionId: string
) => Promise<void>
): () => Promise<void> {
type ActiveRequestLease = {
controller: AbortController
@@ -4339,38 +4356,209 @@ export function registerIpcHandlers(
}
)
const requireEmbeddingProvider = async (): Promise<OpenAIEmbeddingClient> => {
const requireEmbeddingProvider = async (
connectionId: string
): Promise<EmbeddingProvider> => {
const settings = await settingsStore.getResolvedSettings()
if (!settings.knowledgeEmbeddingEnabled) {
throw new Error('请先启用并保存向量模型设置')
}
return new OpenAIEmbeddingClient({
endpoint: settings.knowledgeEmbeddingBaseUrl,
model: settings.knowledgeEmbeddingModel,
apiKey: settings.knowledgeEmbeddingApiKey
if (
!settings.embeddingConnections?.some(
(connection) => connection.id === connectionId
)
) {
throw new Error('当前向量连接不存在')
}
if (!resolveEmbeddingProvider) {
throw new Error('向量模型服务不可用')
}
return resolveEmbeddingProvider(connectionId)
}
const getEmbeddingSettingsSnapshot = async () => {
if (!embeddingModelManager) {
throw new Error('内置向量模型服务不可用')
}
const settings = await settingsStore.getPublicSettings()
const models = await embeddingModelManager.getSnapshot()
const connection = settings.embeddingConnections?.find(
(candidate) =>
candidate.id === settings.activeEmbeddingConnectionId
)
if (!connection || !settings.activeEmbeddingConnectionId) {
throw new Error('当前向量连接不存在')
}
const builtinModel =
models.catalog.find((model) => model.recommended) ??
models.catalog[0]
if (!builtinModel) {
throw new Error('内置向量模型目录为空')
}
return embeddingSettingsSnapshotSchema.parse({
configuration:
connection?.kind === 'builtin'
? {
provider: 'builtin',
model: builtinModel.id,
credentialConfigured: false
}
: {
provider: 'openai-compatible',
model:
connection?.modelName ??
settings.knowledgeEmbeddingModel,
endpoint:
connection?.baseUrl ??
settings.knowledgeEmbeddingBaseUrl,
credentialConfigured:
connection?.apiKeyConfigured ??
settings.knowledgeEmbeddingApiKeyConfigured
},
connections: (settings.embeddingConnections ?? []).map(
(candidate) =>
candidate.kind === 'builtin'
? {
id: candidate.id,
name: candidate.name,
kind: candidate.kind,
model: builtinModel.id,
credentialConfigured: false
}
: {
id: candidate.id,
name: candidate.name,
kind: candidate.kind,
model: candidate.modelName,
endpoint: candidate.baseUrl,
authentication: candidate.authentication,
credentialConfigured:
candidate.apiKeyConfigured ?? false
}
),
currentConnectionId: settings.activeEmbeddingConnectionId,
models
})
}
registerHandler(ipcChannels.embeddingSettingsGet, async (event) => {
assertTrustedSender(event, window)
const settings = await settingsStore.getPublicSettings()
return embeddingSettingsSnapshotSchema.parse({
configuration: {
provider: 'openai-compatible',
model: settings.knowledgeEmbeddingModel,
endpoint: settings.knowledgeEmbeddingBaseUrl,
credentialConfigured:
settings.knowledgeEmbeddingApiKeyConfigured
}
})
return getEmbeddingSettingsSnapshot()
})
registerHandler(ipcChannels.embeddingDiagnose, async (event) => {
assertTrustedSender(event, window)
return diagnoseEmbeddingProvider(
await requireEmbeddingProvider()
)
})
registerHandler(
ipcChannels.embeddingDiagnose,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const { connectionId } =
embeddingConnectionIdRequestSchema.parse(input)
const provider = await requireEmbeddingProvider(connectionId)
try {
return await diagnoseEmbeddingProvider(provider)
} finally {
await (
provider as EmbeddingProvider & {
dispose?: () => void | Promise<void>
}
).dispose?.()
}
}
)
registerHandler(
ipcChannels.embeddingSetCurrent,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!setCurrentEmbeddingConnection) {
throw new Error('向量模型设置服务不可用')
}
const { connectionId } =
embeddingConnectionIdRequestSchema.parse(input)
await setCurrentEmbeddingConnection(connectionId)
return getEmbeddingSettingsSnapshot()
}
)
registerHandler(
ipcChannels.embeddingModelsInstall,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!embeddingModelManager || !applicationSettingsStore) {
throw new Error('内置向量模型服务不可用')
}
const { modelId, expectedDownloadSource } =
embeddingModelInstallInputSchema.parse(input)
const { modelDownloadSource } =
await applicationSettingsStore.get()
if (modelDownloadSource !== expectedDownloadSource) {
throw new Error('模型下载源已变化,请刷新后重试')
}
await embeddingModelManager.install(
modelId,
expectedDownloadSource
)
return embeddingModelSnapshotSchema.parse(
await embeddingModelManager.getSnapshot()
)
}
)
registerHandler(
ipcChannels.embeddingModelsCancel,
(event, input: unknown) => {
assertTrustedSender(event, window)
if (!embeddingModelManager) {
throw new Error('内置向量模型服务不可用')
}
const { modelId } =
embeddingModelActionInputSchema.parse(input)
return embeddingModelManager.cancel(modelId)
}
)
registerHandler(
ipcChannels.embeddingModelsImportArchive,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!embeddingModelManager?.importArchive) {
throw new Error('当前版本不支持导入向量模型 ZIP')
}
const { modelId } =
embeddingModelActionInputSchema.parse(input)
const result = await dialog.showOpenDialog(window, {
title: '导入向量模型 ZIP',
properties: ['openFile'],
filters: modelArchiveDialogFilters
})
const archivePath = result.filePaths[0]
if (result.canceled || !archivePath) {
return undefined
}
await embeddingModelManager.importArchive(
modelId,
archivePath
)
return embeddingModelSnapshotSchema.parse(
await embeddingModelManager.getSnapshot()
)
}
)
registerHandler(
ipcChannels.embeddingModelsRemove,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!embeddingModelManager) {
throw new Error('内置向量模型服务不可用')
}
const { modelId } =
embeddingModelActionInputSchema.parse(input)
await embeddingModelManager.remove(modelId)
return embeddingModelSnapshotSchema.parse(
await embeddingModelManager.getSnapshot()
)
}
)
registerHandler(ipcChannels.speechModelsGet, (event) => {
assertTrustedSender(event, window)
@@ -6091,13 +6279,28 @@ export function registerIpcHandlers(
const embeddingConfiguration = async () => {
const settings = await settingsStore.getPublicSettings()
return {
provider: 'openai-compatible',
model: settings.knowledgeEmbeddingModel,
endpoint: settings.knowledgeEmbeddingBaseUrl,
credentialConfigured:
settings.knowledgeEmbeddingApiKeyConfigured
}
const connection = settings.embeddingConnections?.find(
(candidate) =>
candidate.id === settings.activeEmbeddingConnectionId
)
return connection?.kind === 'builtin'
? {
provider: 'builtin',
model: 'granite-embedding-97m-multilingual-r2',
credentialConfigured: false
}
: {
provider: 'openai-compatible',
model:
connection?.modelName ??
settings.knowledgeEmbeddingModel,
endpoint:
connection?.baseUrl ??
settings.knowledgeEmbeddingBaseUrl,
credentialConfigured:
connection?.apiKeyConfigured ??
settings.knowledgeEmbeddingApiKeyConfigured
}
}
registerHandler(
@@ -156,6 +156,59 @@ describe('EmbeddingIndexCoordinator', () => {
)
})
it('uses explicit query and document provider entry points when available', async () => {
const repository = new MemoryRepository()
const embed = vi.fn(async () => [[9, 9]])
const embedQuery = vi.fn(async () => [[1, 2]])
const embedDocuments = vi.fn(async (input: readonly string[]) =>
input.map(() => [3, 4])
)
const roleAwareProvider = {
...provider(embed),
embedQuery,
embedDocuments
}
const coordinator = new EmbeddingIndexCoordinator(repository, {
createId: () => 'job-role-aware'
})
await coordinator.diagnose(roleAwareProvider)
coordinator.startRebuild(roleAwareProvider)
await coordinator.waitForCompletion()
expect(embedQuery).toHaveBeenCalledWith(
['GoodBuddy 向量模型连接测试'],
undefined
)
expect(embedDocuments).toHaveBeenCalledWith(
['GoodBuddy 知识文档向量连接测试'],
undefined
)
expect(embedDocuments).toHaveBeenCalledWith(['alpha'], expect.any(AbortSignal))
expect(embedDocuments).toHaveBeenCalledWith(
['beta', 'gamma'],
expect.any(AbortSignal)
)
expect(embed).not.toHaveBeenCalled()
})
it('rejects diagnostics whose query and document dimensions differ', async () => {
const repository = new MemoryRepository()
const coordinator = new EmbeddingIndexCoordinator(repository)
const roleAwareProvider = {
...provider(async () => [[9, 9]]),
embedQuery: vi.fn(async () => [[1, 2]]),
embedDocuments: vi.fn(async () => [[1, 2, 3]])
}
await expect(coordinator.diagnose(roleAwareProvider)).resolves.toMatchObject({
status: 'unavailable',
error: {
code: 'invalid_response'
}
})
})
it('reports a safe diagnostic failure instead of treating config as success', async () => {
const repository = new MemoryRepository()
const coordinator = new EmbeddingIndexCoordinator(repository, {
@@ -19,6 +19,14 @@ export interface EmbeddingIndexProvider {
readonly model: string
readonly fingerprint?: string
embed(input: readonly string[], signal?: AbortSignal): Promise<number[][]>
embedQuery?(
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]>
embedDocuments?(
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]>
}
export interface EmbeddingIndexItem {
@@ -161,11 +169,13 @@ export async function diagnoseEmbeddingProvider(
const now = options.now ?? Date.now
const startedAt = now()
try {
const vectors = await provider.embed(
[options.probeText ?? 'GoodBuddy 向量模型连接测试'],
options.signal
)
if (vectors.length !== 1 || !vectors[0]) {
const queryDiagnosticInput = [
options.probeText ?? 'GoodBuddy 向量模型连接测试'
]
const queryVectors = provider.embedQuery
? await provider.embedQuery(queryDiagnosticInput, options.signal)
: await provider.embed(queryDiagnosticInput, options.signal)
if (queryVectors.length !== 1 || !queryVectors[0]) {
throw new EmbeddingOperationError({
code: 'invalid_response',
message: '向量服务返回了无效结果。',
@@ -173,7 +183,27 @@ export async function diagnoseEmbeddingProvider(
remedy: '请确认服务为每个输入返回一个有效向量。'
})
}
const dimensions = validateVector(vectors[0])
const dimensions = validateVector(queryVectors[0])
if (provider.embedQuery || provider.embedDocuments) {
const documentVectors = provider.embedDocuments
? await provider.embedDocuments(
['GoodBuddy 知识文档向量连接测试'],
options.signal
)
: await provider.embed(
['GoodBuddy 知识文档向量连接测试'],
options.signal
)
if (documentVectors.length !== 1 || !documentVectors[0]) {
throw new EmbeddingOperationError({
code: 'invalid_response',
message: '向量服务返回了无效结果。',
retryable: false,
remedy: '请确认服务为每个输入返回一个有效向量。'
})
}
validateVector(documentVectors[0], dimensions)
}
const checkedAt = now()
return {
status: 'available',
@@ -394,10 +424,10 @@ export class EmbeddingIndexCoordinator {
offset,
offset + this.batchSize
)
const vectors = await provider.embed(
batch.map((item) => item.content),
signal
)
const embeddingInput = batch.map((item) => item.content)
const vectors = provider.embedDocuments
? await provider.embedDocuments(embeddingInput, signal)
: await provider.embed(embeddingInput, signal)
if (vectors.length !== batch.length) {
throw new EmbeddingOperationError({
code: 'invalid_response',
@@ -0,0 +1,243 @@
import { describe, expect, it, vi } from 'vitest'
import {
EMBEDDING_INFERENCE_PROTOCOL,
EMBEDDING_INFERENCE_PROTOCOL_VERSION,
type EmbeddingInferenceRequest
} from './embedding-inference-contracts'
import {
EmbeddingInferenceBroker,
type EmbeddingInferenceTransport
} from './embedding-inference-broker'
class TestTransport implements EmbeddingInferenceTransport {
readonly sent: EmbeddingInferenceRequest[] = []
readonly close = vi.fn()
private readonly messageListeners = new Set<(message: unknown) => void>()
private readonly closeListeners = new Set<() => void>()
postMessage(message: EmbeddingInferenceRequest): void {
this.sent.push(message)
}
onMessage(listener: (message: unknown) => void): () => void {
this.messageListeners.add(listener)
return () => this.messageListeners.delete(listener)
}
onClose(listener: () => void): () => void {
this.closeListeners.add(listener)
return () => this.closeListeners.delete(listener)
}
receive(message: unknown): void {
for (const listener of this.messageListeners) {
listener(message)
}
}
crash(): void {
for (const listener of this.closeListeners) {
listener()
}
}
}
function response(message: Record<string, unknown>): Record<string, unknown> {
return {
protocol: EMBEDDING_INFERENCE_PROTOCOL,
version: EMBEDDING_INFERENCE_PROTOCOL_VERSION,
...message
}
}
function embedRequest(transport: TestTransport): Extract<
EmbeddingInferenceRequest,
{ type: 'embed' }
> {
const request = transport.sent.find((item) => item.type === 'embed')
if (!request || request.type !== 'embed') {
throw new Error('No embed request was sent')
}
return request
}
describe('EmbeddingInferenceBroker', () => {
it('uses UUID requests with roles and validates bounded results', async () => {
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({
createTransport: () => transport
})
const pending = broker.embed(['first', 'second'], 'query')
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
const request = embedRequest(transport)
expect(request).toMatchObject({
type: 'embed',
role: 'query',
texts: ['first', 'second']
})
expect(request.requestId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
)
expect(request).not.toHaveProperty('modelDirectory')
expect(request).not.toHaveProperty('env')
transport.receive(
response({
type: 'result',
requestId: request.requestId,
vectors: [
[1, 2],
[3, 4]
]
})
)
await expect(pending).resolves.toEqual([
[1, 2],
[3, 4]
])
const shuttingDown = broker.shutdown()
transport.receive(response({ type: 'shutdown-complete' }))
await shuttingDown
})
it('rejects malformed input and untrusted vector responses', async () => {
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({
createTransport: () => transport
})
await expect(broker.embed([], 'document')).rejects.toThrow(
'texts must contain'
)
const pending = broker.embed(['safe input'], 'document')
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
const request = embedRequest(transport)
transport.receive(
response({
type: 'result',
requestId: request.requestId,
vectors: [[0, 0]]
})
)
await expect(pending).rejects.toMatchObject({
code: 'PROTOCOL_VIOLATION'
})
expect(transport.close).toHaveBeenCalledOnce()
})
it('propagates cancellation and timeout without accepting late results', async () => {
vi.useFakeTimers()
try {
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({
createTransport: () => transport,
timeoutMs: 100
})
const controller = new AbortController()
const cancelled = broker.embed(
['cancel this'],
'query',
controller.signal
)
await vi.advanceTimersByTimeAsync(0)
const cancelledRequest = embedRequest(transport)
controller.abort()
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
expect(transport.sent.at(-1)).toMatchObject({
type: 'cancel',
requestId: cancelledRequest.requestId
})
transport.receive(
response({
type: 'error',
requestId: cancelledRequest.requestId,
code: 'CANCELLED',
message: 'Embedding inference was cancelled'
})
)
expect(transport.close).not.toHaveBeenCalled()
const timedOut = broker.embed(['time this'], 'document')
await vi.advanceTimersByTimeAsync(0)
const timedRequest = transport.sent.find(
(item) =>
item.type === 'embed' &&
item.requestId !== cancelledRequest.requestId
)
if (!timedRequest || timedRequest.type !== 'embed') {
throw new Error('No second embed request was sent')
}
const timedOutExpectation = expect(timedOut).rejects.toMatchObject({
name: 'TimeoutError'
})
await vi.advanceTimersByTimeAsync(100)
await timedOutExpectation
expect(transport.sent.at(-1)).toMatchObject({
type: 'cancel',
requestId: timedRequest.requestId
})
const shuttingDown = broker.shutdown()
transport.receive(response({ type: 'shutdown-complete' }))
await shuttingDown
} finally {
vi.useRealTimers()
}
})
it('rejects on process close and enforces its lifetime restart bound', async () => {
const transports = [
new TestTransport(),
new TestTransport(),
new TestTransport()
]
const factory = vi.fn(() => {
const transport = transports[factory.mock.calls.length - 1]
if (!transport) {
throw new Error('unexpected restart')
}
return transport
})
const broker = new EmbeddingInferenceBroker({
createTransport: factory,
maxRestarts: 1
})
const first = broker.embed(['first'], 'query')
await vi.waitFor(() => expect(transports[0]?.sent).toHaveLength(1))
transports[0]?.crash()
await expect(first).rejects.toMatchObject({ code: 'PROCESS_CLOSED' })
const second = broker.embed(['second'], 'document')
await vi.waitFor(() => expect(transports[1]?.sent).toHaveLength(1))
transports[1]?.crash()
await expect(second).rejects.toMatchObject({ code: 'PROCESS_CLOSED' })
await expect(broker.embed(['third'], 'query')).rejects.toMatchObject({
code: 'RESTART_LIMIT'
})
expect(factory).toHaveBeenCalledTimes(2)
})
it('performs graceful shutdown and rejects future work', async () => {
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({
createTransport: () => transport
})
const pending = broker.embed(['active'], 'query')
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
const shuttingDown = broker.shutdown()
await expect(pending).rejects.toMatchObject({ code: 'CLOSED' })
expect(transport.sent.at(-1)).toMatchObject({ type: 'shutdown' })
transport.receive(response({ type: 'shutdown-complete' }))
await expect(shuttingDown).resolves.toBeUndefined()
expect(transport.close).toHaveBeenCalledOnce()
await expect(broker.embed(['late'], 'query')).rejects.toMatchObject({
code: 'CLOSED'
})
})
})
@@ -0,0 +1,455 @@
import { randomUUID } from 'node:crypto'
import {
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
embeddingInferenceProtocolBase,
parseEmbeddingInferenceResponse,
validateEmbeddingInferenceTexts,
validateEmbeddingInferenceVectors,
type EmbeddingInferenceRequest,
type EmbeddingInferenceRole
} from './embedding-inference-contracts'
const DEFAULT_TIMEOUT_MS = 120_000
const MIN_TIMEOUT_MS = 100
const MAX_TIMEOUT_MS = 300_000
const DEFAULT_MAX_RESTARTS = 2
const MAX_RESTARTS = 10
const SHUTDOWN_GRACE_MS = 1_000
const MAX_RETIRED_REQUESTS = 256
export interface EmbeddingInferenceTransport {
postMessage(message: EmbeddingInferenceRequest): void
onMessage(listener: (message: unknown) => void): () => void
onClose(listener: () => void): () => void
close(): void
}
export type EmbeddingInferenceTransportFactory = () =>
| EmbeddingInferenceTransport
| Promise<EmbeddingInferenceTransport>
export interface EmbeddingInferenceBrokerOptions {
readonly createTransport: EmbeddingInferenceTransportFactory
readonly timeoutMs?: number
/**
* Maximum replacement processes over this broker's lifetime. The initial
* process is not a restart and failed requests are never replayed.
*/
readonly maxRestarts?: number
}
type PendingRequest = {
readonly expectedCount: number
readonly resolve: (vectors: number[][]) => void
readonly reject: (error: Error) => void
readonly timeout: ReturnType<typeof setTimeout>
removeAbortListener?: () => void
}
type ActiveTransport = {
readonly transport: EmbeddingInferenceTransport
readonly removeMessageListener: () => void
readonly removeCloseListener: () => void
}
export class EmbeddingInferenceBrokerError extends Error {
constructor(
readonly code:
| 'CLOSED'
| 'PROCESS_CLOSED'
| 'PROTOCOL_VIOLATION'
| 'RESTART_LIMIT'
| 'START_FAILURE'
| 'ENGINE_FAILURE'
| 'INVALID_RESULT'
| 'OVERLOADED'
) {
super(`Embedding inference failed (${code})`)
this.name = 'EmbeddingInferenceBrokerError'
}
}
function boundedInteger(
value: number,
field: string,
minimum: number,
maximum: number
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RangeError(
`${field} must be an integer between ${minimum} and ${maximum}`
)
}
return value
}
function abortError(): Error {
const error = new Error('Embedding inference was cancelled')
error.name = 'AbortError'
return error
}
function timeoutError(): Error {
const error = new Error('Embedding inference timed out')
error.name = 'TimeoutError'
return error
}
export class EmbeddingInferenceBroker {
private readonly createTransport: EmbeddingInferenceTransportFactory
private readonly timeoutMs: number
private readonly maxRestarts: number
private readonly pending = new Map<string, PendingRequest>()
private readonly retiredRequestIds = new Set<string>()
private active?: ActiveTransport
private starting?: Promise<ActiveTransport>
private restarts = 0
private everStarted = false
private closed = false
private shutdownComplete?: () => void
constructor(options: EmbeddingInferenceBrokerOptions) {
if (typeof options.createTransport !== 'function') {
throw new TypeError('createTransport must be a function')
}
this.createTransport = options.createTransport
this.timeoutMs = boundedInteger(
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
'timeoutMs',
MIN_TIMEOUT_MS,
MAX_TIMEOUT_MS
)
this.maxRestarts = boundedInteger(
options.maxRestarts ?? DEFAULT_MAX_RESTARTS,
'maxRestarts',
0,
MAX_RESTARTS
)
}
async embed(
texts: readonly string[],
role: EmbeddingInferenceRole,
signal?: AbortSignal
): Promise<number[][]> {
const normalizedTexts = validateEmbeddingInferenceTexts(texts)
if (role !== 'query' && role !== 'document') {
throw new TypeError('role must be query or document')
}
if (this.closed) {
throw new EmbeddingInferenceBrokerError('CLOSED')
}
if (signal?.aborted) {
throw abortError()
}
if (this.pending.size >= EMBEDDING_INFERENCE_MAX_IN_FLIGHT) {
throw new EmbeddingInferenceBrokerError('OVERLOADED')
}
const active = await this.ensureTransport()
if (this.closed) {
throw new EmbeddingInferenceBrokerError('CLOSED')
}
if (signal?.aborted) {
throw abortError()
}
if (this.pending.size >= EMBEDDING_INFERENCE_MAX_IN_FLIGHT) {
throw new EmbeddingInferenceBrokerError('OVERLOADED')
}
const requestId = randomUUID()
return new Promise<number[][]>((resolve, reject) => {
const timeout = setTimeout(() => {
this.cancelPending(requestId, timeoutError())
}, this.timeoutMs)
const pending: PendingRequest = {
expectedCount: normalizedTexts.length,
resolve,
reject,
timeout
}
if (signal) {
const onAbort = (): void => {
this.cancelPending(requestId, abortError())
}
signal.addEventListener('abort', onAbort, { once: true })
pending.removeAbortListener = () =>
signal.removeEventListener('abort', onAbort)
}
this.pending.set(requestId, pending)
try {
active.transport.postMessage({
...embeddingInferenceProtocolBase(),
type: 'embed',
requestId,
role,
texts: normalizedTexts
})
} catch {
this.failTransport(
active.transport,
new EmbeddingInferenceBrokerError('PROCESS_CLOSED')
)
}
})
}
async shutdown(): Promise<void> {
if (this.closed) {
return
}
this.closed = true
for (const requestId of this.pending.keys()) {
this.retireRequestId(requestId)
}
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
let active = this.active
if (!active && this.starting) {
try {
active = await this.starting
} catch {
return
}
}
if (!active || this.active !== active) {
return
}
await new Promise<void>((resolve) => {
let settled = false
const finish = (): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
this.shutdownComplete = undefined
this.disposeTransport(active.transport, true)
resolve()
}
const timeout = setTimeout(finish, SHUTDOWN_GRACE_MS)
this.shutdownComplete = finish
try {
active.transport.postMessage({
...embeddingInferenceProtocolBase(),
type: 'shutdown'
})
} catch {
finish()
}
})
}
private async ensureTransport(): Promise<ActiveTransport> {
if (this.active) {
return this.active
}
if (this.starting) {
return this.starting
}
if (this.everStarted && this.restarts >= this.maxRestarts) {
throw new EmbeddingInferenceBrokerError('RESTART_LIMIT')
}
if (this.everStarted) {
this.restarts += 1
}
this.everStarted = true
const starting = Promise.resolve()
.then(() => this.createTransport())
.then((transport) => {
if (this.closed) {
transport.close()
throw new EmbeddingInferenceBrokerError('CLOSED')
}
const active: ActiveTransport = {
transport,
removeMessageListener: transport.onMessage((message) =>
this.handleMessage(transport, message)
),
removeCloseListener: transport.onClose(() =>
this.failTransport(
transport,
new EmbeddingInferenceBrokerError('PROCESS_CLOSED')
)
)
}
this.active = active
return active
})
.catch((error: unknown) => {
if (error instanceof EmbeddingInferenceBrokerError) {
throw error
}
throw new EmbeddingInferenceBrokerError('START_FAILURE')
})
.finally(() => {
if (this.starting === starting) {
this.starting = undefined
}
})
this.starting = starting
return starting
}
private handleMessage(
transport: EmbeddingInferenceTransport,
rawMessage: unknown
): void {
if (this.active?.transport !== transport) {
return
}
const message = parseEmbeddingInferenceResponse(rawMessage)
if (!message) {
this.failTransport(
transport,
new EmbeddingInferenceBrokerError('PROTOCOL_VIOLATION')
)
return
}
if (message.type === 'shutdown-complete') {
if (!this.closed || !this.shutdownComplete) {
this.failTransport(
transport,
new EmbeddingInferenceBrokerError('PROTOCOL_VIOLATION')
)
return
}
this.shutdownComplete()
return
}
if (message.type === 'fatal') {
this.failTransport(
transport,
new EmbeddingInferenceBrokerError('PROTOCOL_VIOLATION')
)
return
}
const pending = this.pending.get(message.requestId)
if (!pending) {
if (this.retiredRequestIds.delete(message.requestId)) {
return
}
this.failTransport(
transport,
new EmbeddingInferenceBrokerError('PROTOCOL_VIOLATION')
)
return
}
if (message.type === 'error') {
this.finishPending(
message.requestId,
message.code === 'CANCELLED'
? abortError()
: new EmbeddingInferenceBrokerError(message.code)
)
return
}
try {
const vectors = validateEmbeddingInferenceVectors(
message.vectors,
pending.expectedCount
)
this.finishPending(message.requestId, undefined, vectors)
} catch {
this.failTransport(
transport,
new EmbeddingInferenceBrokerError('PROTOCOL_VIOLATION')
)
}
}
private cancelPending(requestId: string, error: Error): void {
const pending = this.pending.get(requestId)
if (!pending) {
return
}
this.retireRequestId(requestId)
this.finishPending(requestId, error)
try {
this.active?.transport.postMessage({
...embeddingInferenceProtocolBase(),
type: 'cancel',
requestId
})
} catch {
if (this.active) {
this.failTransport(
this.active.transport,
new EmbeddingInferenceBrokerError('PROCESS_CLOSED')
)
}
}
}
private finishPending(
requestId: string,
error?: Error,
vectors?: number[][]
): void {
const pending = this.pending.get(requestId)
if (!pending) {
return
}
this.pending.delete(requestId)
clearTimeout(pending.timeout)
pending.removeAbortListener?.()
if (error) {
pending.reject(error)
} else {
pending.resolve(vectors as number[][])
}
}
private retireRequestId(requestId: string): void {
this.retiredRequestIds.add(requestId)
if (this.retiredRequestIds.size > MAX_RETIRED_REQUESTS) {
const oldest = this.retiredRequestIds.values().next().value
if (typeof oldest === 'string') {
this.retiredRequestIds.delete(oldest)
}
}
}
private failTransport(
transport: EmbeddingInferenceTransport,
error: Error
): void {
if (this.active?.transport !== transport) {
return
}
this.disposeTransport(transport, true)
this.rejectAll(error)
this.shutdownComplete?.()
}
private disposeTransport(
transport: EmbeddingInferenceTransport,
close: boolean
): void {
const active = this.active
if (!active || active.transport !== transport) {
return
}
this.active = undefined
active.removeMessageListener()
active.removeCloseListener()
if (close) {
try {
transport.close()
} catch {
// The process is already unavailable.
}
}
}
private rejectAll(error: Error): void {
for (const requestId of [...this.pending.keys()]) {
this.finishPending(requestId, error)
}
}
}
@@ -0,0 +1,262 @@
export const EMBEDDING_INFERENCE_PROTOCOL =
'goodbuddy.embedding-inference'
export const EMBEDDING_INFERENCE_PROTOCOL_VERSION = 1
export const EMBEDDING_INFERENCE_MAX_TEXTS = 64
export const EMBEDDING_INFERENCE_MAX_TEXT_LENGTH = 16_000
export const EMBEDDING_INFERENCE_MAX_TOTAL_TEXT_LENGTH = 128_000
export const EMBEDDING_INFERENCE_MAX_DIMENSIONS = 8_192
export const EMBEDDING_INFERENCE_MAX_IN_FLIGHT = 8
export const EMBEDDING_INFERENCE_MAX_ERROR_LENGTH = 256
export type EmbeddingInferenceRole = 'query' | 'document'
type ProtocolBase = {
readonly protocol: typeof EMBEDDING_INFERENCE_PROTOCOL
readonly version: typeof EMBEDDING_INFERENCE_PROTOCOL_VERSION
}
export type EmbeddingInferenceRequest =
| (ProtocolBase & {
readonly type: 'embed'
readonly requestId: string
readonly role: EmbeddingInferenceRole
readonly texts: readonly string[]
})
| (ProtocolBase & {
readonly type: 'cancel'
readonly requestId: string
})
| (ProtocolBase & {
readonly type: 'shutdown'
})
export type EmbeddingInferenceErrorCode =
| 'CANCELLED'
| 'ENGINE_FAILURE'
| 'INVALID_RESULT'
| 'OVERLOADED'
export type EmbeddingInferenceResponse =
| (ProtocolBase & {
readonly type: 'result'
readonly requestId: string
readonly vectors: readonly (readonly number[])[]
})
| (ProtocolBase & {
readonly type: 'error'
readonly requestId: string
readonly code: EmbeddingInferenceErrorCode
readonly message: string
})
| (ProtocolBase & {
readonly type: 'fatal'
readonly code: 'PROTOCOL_VIOLATION'
})
| (ProtocolBase & {
readonly type: 'shutdown-complete'
})
const BASE_KEYS = ['protocol', 'version', 'type'] as const
const REQUEST_ID_KEYS = [...BASE_KEYS, 'requestId'] as const
const EMBED_KEYS = [...REQUEST_ID_KEYS, 'role', 'texts'] as const
const RESULT_KEYS = [...REQUEST_ID_KEYS, 'vectors'] as const
const ERROR_KEYS = [...REQUEST_ID_KEYS, 'code', 'message'] as const
const FATAL_KEYS = [...BASE_KEYS, 'code'] as const
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
function isRecord(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false
}
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
function hasExactKeys(
value: Record<string, unknown>,
expected: readonly string[]
): boolean {
const keys = Object.keys(value)
return (
keys.length === expected.length &&
expected.every((key) => Object.prototype.hasOwnProperty.call(value, key))
)
}
function hasProtocolBase(value: Record<string, unknown>): boolean {
return (
value.protocol === EMBEDDING_INFERENCE_PROTOCOL &&
value.version === EMBEDDING_INFERENCE_PROTOCOL_VERSION &&
typeof value.type === 'string'
)
}
export function isEmbeddingInferenceRequestId(
value: unknown
): value is string {
return typeof value === 'string' && UUID_PATTERN.test(value)
}
export function validateEmbeddingInferenceTexts(
value: unknown
): readonly string[] {
if (
!Array.isArray(value) ||
value.length < 1 ||
value.length > EMBEDDING_INFERENCE_MAX_TEXTS
) {
throw new RangeError(
`texts must contain between 1 and ${EMBEDDING_INFERENCE_MAX_TEXTS} items`
)
}
let totalLength = 0
for (const [index, text] of value.entries()) {
if (typeof text !== 'string' || text.length < 1) {
throw new TypeError(`texts[${index}] must be a non-empty string`)
}
if (text.length > EMBEDDING_INFERENCE_MAX_TEXT_LENGTH) {
throw new RangeError(
`texts[${index}] must be at most ${EMBEDDING_INFERENCE_MAX_TEXT_LENGTH} characters`
)
}
totalLength += text.length
if (totalLength > EMBEDDING_INFERENCE_MAX_TOTAL_TEXT_LENGTH) {
throw new RangeError(
`texts must total at most ${EMBEDDING_INFERENCE_MAX_TOTAL_TEXT_LENGTH} characters`
)
}
}
return value as readonly string[]
}
export function validateEmbeddingInferenceVectors(
value: unknown,
expectedCount: number
): number[][] {
if (!Array.isArray(value) || value.length !== expectedCount) {
throw new RangeError('Embedding result count does not match the request')
}
let dimensions: number | undefined
return value.map((rawVector, vectorIndex) => {
if (
!Array.isArray(rawVector) ||
rawVector.length < 1 ||
rawVector.length > EMBEDDING_INFERENCE_MAX_DIMENSIONS
) {
throw new RangeError(
`Embedding ${vectorIndex} has invalid dimensions`
)
}
if (dimensions === undefined) {
dimensions = rawVector.length
} else if (rawVector.length !== dimensions) {
throw new RangeError('Embeddings have inconsistent dimensions')
}
let magnitudeSquared = 0
const vector = rawVector.map((component) => {
if (typeof component !== 'number' || !Number.isFinite(component)) {
throw new TypeError('Embeddings must contain finite numbers')
}
magnitudeSquared += component * component
return component
})
if (!Number.isFinite(magnitudeSquared) || magnitudeSquared <= 0) {
throw new RangeError('Embeddings must have a finite non-zero norm')
}
return vector
})
}
export function parseEmbeddingInferenceRequest(
value: unknown
): EmbeddingInferenceRequest | undefined {
if (!isRecord(value) || !hasProtocolBase(value)) {
return undefined
}
if (value.type === 'shutdown') {
return hasExactKeys(value, BASE_KEYS)
? (value as EmbeddingInferenceRequest)
: undefined
}
if (
value.type === 'cancel' &&
hasExactKeys(value, REQUEST_ID_KEYS) &&
isEmbeddingInferenceRequestId(value.requestId)
) {
return value as EmbeddingInferenceRequest
}
if (
value.type !== 'embed' ||
!hasExactKeys(value, EMBED_KEYS) ||
!isEmbeddingInferenceRequestId(value.requestId) ||
(value.role !== 'query' && value.role !== 'document')
) {
return undefined
}
try {
validateEmbeddingInferenceTexts(value.texts)
return value as EmbeddingInferenceRequest
} catch {
return undefined
}
}
export function parseEmbeddingInferenceResponse(
value: unknown
): EmbeddingInferenceResponse | undefined {
if (!isRecord(value) || !hasProtocolBase(value)) {
return undefined
}
if (value.type === 'shutdown-complete') {
return hasExactKeys(value, BASE_KEYS)
? (value as EmbeddingInferenceResponse)
: undefined
}
if (
value.type === 'fatal' &&
hasExactKeys(value, FATAL_KEYS) &&
value.code === 'PROTOCOL_VIOLATION'
) {
return value as EmbeddingInferenceResponse
}
if (
value.type === 'result' &&
hasExactKeys(value, RESULT_KEYS) &&
isEmbeddingInferenceRequestId(value.requestId) &&
Array.isArray(value.vectors)
) {
return value as EmbeddingInferenceResponse
}
const errorCodes: readonly EmbeddingInferenceErrorCode[] = [
'CANCELLED',
'ENGINE_FAILURE',
'INVALID_RESULT',
'OVERLOADED'
]
if (
value.type === 'error' &&
hasExactKeys(value, ERROR_KEYS) &&
isEmbeddingInferenceRequestId(value.requestId) &&
typeof value.code === 'string' &&
errorCodes.includes(value.code as EmbeddingInferenceErrorCode) &&
typeof value.message === 'string' &&
value.message.length > 0 &&
value.message.length <= EMBEDDING_INFERENCE_MAX_ERROR_LENGTH
) {
return value as EmbeddingInferenceResponse
}
return undefined
}
export function embeddingInferenceProtocolBase(): ProtocolBase {
return {
protocol: EMBEDDING_INFERENCE_PROTOCOL,
version: EMBEDDING_INFERENCE_PROTOCOL_VERSION
}
}
@@ -0,0 +1,251 @@
import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
EMBEDDING_INFERENCE_PROTOCOL,
EMBEDDING_INFERENCE_PROTOCOL_VERSION,
type EmbeddingInferenceRequest,
type EmbeddingInferenceResponse
} from './embedding-inference-contracts'
import {
EmbeddingInferenceWorker,
type EmbeddingInferenceEngine,
type EmbeddingInferenceWorkerPort
} from './embedding-inference-worker'
const FIRST_ID = '123e4567-e89b-42d3-a456-426614174000'
const SECOND_ID = '123e4567-e89b-42d3-a456-426614174001'
type RequestWithoutBase<T> = T extends unknown
? Omit<T, 'protocol' | 'version'>
: never
class TestPort implements EmbeddingInferenceWorkerPort {
readonly sent: EmbeddingInferenceResponse[] = []
private readonly listeners = new Set<(message: unknown) => void>()
postMessage(message: EmbeddingInferenceResponse): void {
this.sent.push(message)
}
onMessage(listener: (message: unknown) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
receive(message: unknown): void {
for (const listener of this.listeners) {
listener(message)
}
}
}
function request(
message: RequestWithoutBase<EmbeddingInferenceRequest>
): EmbeddingInferenceRequest {
return {
protocol: EMBEDDING_INFERENCE_PROTOCOL,
version: EMBEDDING_INFERENCE_PROTOCOL_VERSION,
...message
} as EmbeddingInferenceRequest
}
function engine(
embed: EmbeddingInferenceEngine['embed']
): EmbeddingInferenceEngine {
return {
tokenizer: { tokenize: async () => ({}) },
onnx: { run: async () => ({}) },
embed
}
}
describe('EmbeddingInferenceWorker', () => {
it('uses only its trusted model directory and forwards role to the engine', async () => {
const port = new TestPort()
const modelDirectory = path.resolve('trusted-model')
const embed = vi.fn<EmbeddingInferenceEngine['embed']>(
async () => [[1, 2, 3]]
)
const worker = new EmbeddingInferenceWorker({
modelDirectory,
engine: engine(embed),
port
})
port.receive(
request({
type: 'embed',
requestId: FIRST_ID,
role: 'document',
texts: ['bounded text']
})
)
await vi.waitFor(() => expect(port.sent).toHaveLength(1))
expect(embed).toHaveBeenCalledWith({
modelDirectory,
texts: ['bounded text'],
role: 'document',
signal: expect.any(AbortSignal)
})
expect(port.sent[0]).toMatchObject({
type: 'result',
requestId: FIRST_ID,
vectors: [[1, 2, 3]]
})
await worker.shutdown()
})
it('rejects invalid engine output instead of fabricating vectors', async () => {
const port = new TestPort()
const worker = new EmbeddingInferenceWorker({
modelDirectory: path.resolve('trusted-model'),
engine: engine(async () => [[Number.NaN]]),
port
})
port.receive(
request({
type: 'embed',
requestId: FIRST_ID,
role: 'query',
texts: ['input']
})
)
await vi.waitFor(() =>
expect(port.sent[0]).toMatchObject({
type: 'error',
requestId: FIRST_ID,
code: 'INVALID_RESULT'
})
)
await worker.shutdown()
})
it('aborts an active engine request when cancelled', async () => {
const port = new TestPort()
let observedSignal: AbortSignal | undefined
const worker = new EmbeddingInferenceWorker({
modelDirectory: path.resolve('trusted-model'),
engine: engine(
async ({ signal }) =>
new Promise<readonly (readonly number[])[]>((_resolve, reject) => {
observedSignal = signal
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
),
port
})
port.receive(
request({
type: 'embed',
requestId: FIRST_ID,
role: 'query',
texts: ['input']
})
)
port.receive(request({ type: 'cancel', requestId: FIRST_ID }))
await vi.waitFor(() => expect(observedSignal?.aborted).toBe(true))
await vi.waitFor(() =>
expect(port.sent[0]).toMatchObject({
type: 'error',
requestId: FIRST_ID,
code: 'CANCELLED'
})
)
await worker.shutdown()
})
it('treats malformed, credential-bearing, and duplicate requests as violations', async () => {
const malformedPort = new TestPort()
const malformedWorker = new EmbeddingInferenceWorker({
modelDirectory: path.resolve('trusted-model'),
engine: engine(async () => [[1]]),
port: malformedPort
})
malformedPort.receive({
...request({
type: 'embed',
requestId: FIRST_ID,
role: 'query',
texts: ['input']
}),
apiKey: 'must-not-cross-boundary'
})
await vi.waitFor(() =>
expect(malformedPort.sent[0]).toMatchObject({
type: 'fatal',
code: 'PROTOCOL_VIOLATION'
})
)
await malformedWorker.shutdown()
let finish:
| ((vectors: readonly (readonly number[])[]) => void)
| undefined
const duplicatePort = new TestPort()
const duplicateWorker = new EmbeddingInferenceWorker({
modelDirectory: path.resolve('trusted-model'),
engine: engine(
async () =>
new Promise<readonly (readonly number[])[]>((resolve) => {
finish = resolve
})
),
port: duplicatePort
})
const duplicate = request({
type: 'embed',
requestId: SECOND_ID,
role: 'query',
texts: ['input']
})
duplicatePort.receive(duplicate)
duplicatePort.receive(duplicate)
await vi.waitFor(() =>
expect(duplicatePort.sent[0]).toMatchObject({
type: 'fatal',
code: 'PROTOCOL_VIOLATION'
})
)
finish?.([[1]])
await duplicateWorker.shutdown()
})
it('acknowledges shutdown only after disposing its engine', async () => {
const port = new TestPort()
const dispose = vi.fn(async () => undefined)
new EmbeddingInferenceWorker({
modelDirectory: path.resolve('trusted-model'),
engine: {
...engine(async () => [[1]]),
dispose
},
port
})
port.receive(request({ type: 'shutdown' }))
await vi.waitFor(() =>
expect(port.sent[0]).toMatchObject({ type: 'shutdown-complete' })
)
expect(dispose).toHaveBeenCalledOnce()
})
it('requires explicit tokenizer and ONNX engine implementations', () => {
expect(
() =>
new EmbeddingInferenceWorker({
modelDirectory: path.resolve('trusted-model'),
engine: {
tokenizer: undefined,
onnx: undefined,
embed: async () => [[1]]
} as unknown as EmbeddingInferenceEngine,
port: new TestPort()
})
).toThrow('tokenizer and ONNX')
})
})
@@ -0,0 +1,309 @@
import path from 'node:path'
import {
EMBEDDING_INFERENCE_MAX_ERROR_LENGTH,
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
embeddingInferenceProtocolBase,
parseEmbeddingInferenceRequest,
validateEmbeddingInferenceVectors,
type EmbeddingInferenceRequest,
type EmbeddingInferenceResponse,
type EmbeddingInferenceRole
} from './embedding-inference-contracts'
export interface EmbeddingTokenizer {
tokenize(options: {
readonly texts: readonly string[]
readonly role: EmbeddingInferenceRole
readonly signal: AbortSignal
}): Promise<unknown>
}
export interface EmbeddingOnnxRuntime {
run(options: {
readonly modelDirectory: string
readonly inputs: unknown
readonly signal: AbortSignal
}): Promise<unknown>
}
/**
* Production engines must own both a tokenizer and an ONNX runtime. This
* boundary deliberately supplies no fallback or synthetic vector engine.
*/
export interface EmbeddingInferenceEngine {
readonly tokenizer: EmbeddingTokenizer
readonly onnx: EmbeddingOnnxRuntime
embed(options: {
readonly modelDirectory: string
readonly texts: readonly string[]
readonly role: EmbeddingInferenceRole
readonly signal: AbortSignal
}): Promise<readonly (readonly number[])[]>
dispose?(): void | Promise<void>
}
export interface EmbeddingInferenceWorkerPort {
postMessage(message: EmbeddingInferenceResponse): void
onMessage(listener: (message: unknown) => void): () => void
}
export interface EmbeddingInferenceWorkerOptions {
/**
* Trusted Main configuration, never read from a protocol message.
*/
readonly modelDirectory: string
readonly engine: EmbeddingInferenceEngine
readonly port: EmbeddingInferenceWorkerPort
}
type RunningRequest = {
readonly controller: AbortController
readonly done: Promise<void>
}
function trustedModelDirectory(value: string): string {
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > 4_096 ||
!path.isAbsolute(value)
) {
throw new TypeError('modelDirectory must be a bounded absolute path')
}
return path.normalize(value)
}
function boundedMessage(value: string): string {
return value.slice(0, EMBEDDING_INFERENCE_MAX_ERROR_LENGTH)
}
export class EmbeddingInferenceWorker {
private readonly modelDirectory: string
private readonly engine: EmbeddingInferenceEngine
private readonly port: EmbeddingInferenceWorkerPort
private readonly running = new Map<string, RunningRequest>()
private readonly removeMessageListener: () => void
private shuttingDown = false
private disposed = false
private shutdownTask?: Promise<void>
constructor(options: EmbeddingInferenceWorkerOptions) {
this.modelDirectory = trustedModelDirectory(options.modelDirectory)
this.engine = options.engine
this.port = options.port
if (
typeof options.engine.tokenizer?.tokenize !== 'function' ||
typeof options.engine.onnx?.run !== 'function'
) {
throw new TypeError('engine must provide tokenizer and ONNX implementations')
}
this.removeMessageListener = this.port.onMessage((message) => {
this.handleMessage(message)
})
}
async shutdown(): Promise<void> {
await this.stop(false)
}
private handleMessage(rawMessage: unknown): void {
if (this.disposed) {
return
}
const message = parseEmbeddingInferenceRequest(rawMessage)
if (!message) {
this.protocolViolation()
return
}
if (message.type === 'shutdown') {
if (this.shuttingDown) {
this.protocolViolation()
return
}
this.shuttingDown = true
void this.stop(true).then(
() => undefined,
() => {
this.post({
...embeddingInferenceProtocolBase(),
type: 'fatal',
code: 'PROTOCOL_VIOLATION'
})
}
)
return
}
if (message.type === 'cancel') {
this.running.get(message.requestId)?.controller.abort()
return
}
this.startEmbedding(message)
}
private startEmbedding(
message: Extract<EmbeddingInferenceRequest, { type: 'embed' }>
): void {
if (
this.shuttingDown ||
this.running.has(message.requestId)
) {
this.protocolViolation()
return
}
if (this.running.size >= EMBEDDING_INFERENCE_MAX_IN_FLIGHT) {
this.postError(
message.requestId,
'OVERLOADED',
'Embedding inference worker is busy'
)
return
}
const controller = new AbortController()
const done = this.runEmbedding(message, controller)
this.running.set(message.requestId, { controller, done })
void done.finally(() => {
this.running.delete(message.requestId)
})
}
private async runEmbedding(
message: Extract<EmbeddingInferenceRequest, { type: 'embed' }>,
controller: AbortController
): Promise<void> {
try {
const rawVectors = await this.engine.embed({
modelDirectory: this.modelDirectory,
texts: message.texts,
role: message.role,
signal: controller.signal
})
if (controller.signal.aborted) {
this.postError(
message.requestId,
'CANCELLED',
'Embedding inference was cancelled'
)
return
}
let vectors: number[][]
try {
vectors = validateEmbeddingInferenceVectors(
rawVectors,
message.texts.length
)
} catch {
this.postError(
message.requestId,
'INVALID_RESULT',
'Embedding engine returned an invalid result'
)
return
}
this.post({
...embeddingInferenceProtocolBase(),
type: 'result',
requestId: message.requestId,
vectors
})
} catch {
this.postError(
message.requestId,
controller.signal.aborted ? 'CANCELLED' : 'ENGINE_FAILURE',
controller.signal.aborted
? 'Embedding inference was cancelled'
: 'Embedding engine failed'
)
}
}
private postError(
requestId: string,
code: 'CANCELLED' | 'ENGINE_FAILURE' | 'INVALID_RESULT' | 'OVERLOADED',
message: string
): void {
this.post({
...embeddingInferenceProtocolBase(),
type: 'error',
requestId,
code,
message: boundedMessage(message)
})
}
private protocolViolation(): void {
if (this.disposed) {
return
}
this.shuttingDown = true
this.post({
...embeddingInferenceProtocolBase(),
type: 'fatal',
code: 'PROTOCOL_VIOLATION'
})
void this.shutdown()
}
private stop(sendCompletion: boolean): Promise<void> {
if (this.shutdownTask) {
return this.shutdownTask
}
this.shuttingDown = true
const task = (async (): Promise<void> => {
try {
for (const request of this.running.values()) {
request.controller.abort()
}
await Promise.allSettled(
[...this.running.values()].map((request) => request.done)
)
await this.engine.dispose?.()
if (sendCompletion) {
this.post({
...embeddingInferenceProtocolBase(),
type: 'shutdown-complete'
})
}
} finally {
this.disposed = true
this.removeMessageListener()
}
})()
this.shutdownTask = task
return task
}
private post(message: EmbeddingInferenceResponse): void {
try {
this.port.postMessage(message)
} catch {
void this.shutdown()
}
}
}
export interface ElectronParentPortLike {
postMessage(message: unknown): void
on(
event: 'message',
listener: (event: { readonly data: unknown }) => void
): unknown
removeListener(
event: 'message',
listener: (event: { readonly data: unknown }) => void
): unknown
}
export function createEmbeddingInferenceWorkerPort(
parentPort: ElectronParentPortLike
): EmbeddingInferenceWorkerPort {
return {
postMessage: (message) => parentPort.postMessage(message),
onMessage: (listener) => {
const onMessage = (event: { readonly data: unknown }): void =>
listener(event.data)
parentPort.on('message', onMessage)
return () => parentPort.removeListener('message', onMessage)
}
}
}
@@ -0,0 +1,136 @@
import {
embeddingModelCatalogEntrySchema,
type EmbeddingModelCatalogEntry
} from './embedding-model-contracts'
import {
huggingFaceTarget,
modelScopeTarget
} from '../model-download-targets'
const repository = 'onnx-community/granite-embedding-97m-multilingual-r2-ONNX'
const huggingFaceRevision = '536a9f241cb3f02a9c5995a1e708c784bd274859'
const modelScopeRevision = '2741cd30a7448219ec2699afdf373a44df5aaa33'
export const EMBEDDING_MODEL_CATALOG: readonly EmbeddingModelCatalogEntry[] =
embeddingModelCatalogEntrySchema.array().parse([
{
id: 'granite-embedding-97m-multilingual-r2-int8',
displayName: 'Granite Embedding 97M Multilingual R2',
description:
'面向中文、英文及跨语言检索的 384 维本地 INT8 向量模型。',
languages: ['200+ languages'],
runtime: 'onnxruntime-web/wasm',
dimensions: 384,
contextTokens: 32_768,
quantization: 'INT8',
recommended: true,
available: true,
repositoryUrls: {
modelscope: `https://modelscope.cn/models/${repository}`,
'hugging-face': `https://huggingface.co/${repository}`
},
license: {
name: 'Apache License 2.0',
notice:
'模型由 IBM Granite 发布,ONNX 工件由 ONNX Community 转换,依据 Apache License 2.0 使用。',
url: 'https://huggingface.co/ibm-granite/granite-embedding-97m-multilingual-r2/blob/main/LICENSE'
},
files: [
{
name: 'model_quantized.onnx',
role: 'model',
size: 97_858_099,
sha256:
'704c1ebca5fbb7cd83ced41827658ac4c9990c64f7f2874d22b78044e5022e22',
targets: {
modelscope: modelScopeTarget(
repository,
modelScopeRevision,
'onnx/model_quantized.onnx'
),
'hugging-face': huggingFaceTarget(
repository,
huggingFaceRevision,
'onnx/model_quantized.onnx'
)
}
},
{
name: 'tokenizer.json',
role: 'tokenizer',
size: 25_301_671,
sha256:
'51947676cae1f991fa51c6b9a24e14ee5460e5f0b9f692f13bb3159829d1592a',
targets: {
modelscope: modelScopeTarget(
repository,
modelScopeRevision,
'tokenizer.json'
),
'hugging-face': huggingFaceTarget(
repository,
huggingFaceRevision,
'tokenizer.json'
)
}
},
{
name: 'tokenizer_config.json',
role: 'tokenizer-configuration',
size: 12_860,
sha256:
'6ed69389e30a8ecabfce2f9ebcdf0c908b34056f24d994340f2f216521c057d5',
targets: {
modelscope: modelScopeTarget(
repository,
modelScopeRevision,
'tokenizer_config.json'
),
'hugging-face': huggingFaceTarget(
repository,
huggingFaceRevision,
'tokenizer_config.json'
)
}
},
{
name: 'config.json',
role: 'configuration',
size: 1_215,
sha256:
'ae74d55a56f779774cb9a8e63d3c2da9ae1af83c00229ffdff43d0b38407a0ee',
targets: {
modelscope: modelScopeTarget(
repository,
modelScopeRevision,
'config.json'
),
'hugging-face': huggingFaceTarget(
repository,
huggingFaceRevision,
'config.json'
)
}
},
{
name: 'special_tokens_map.json',
role: 'tokenizer-configuration',
size: 871,
sha256:
'013787ee251ff611722479197c00853b62113ad303cb0a36524231783c676c69',
targets: {
modelscope: modelScopeTarget(
repository,
modelScopeRevision,
'special_tokens_map.json'
),
'hugging-face': huggingFaceTarget(
repository,
huggingFaceRevision,
'special_tokens_map.json'
)
}
}
]
}
])
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { embeddingModelCatalogEntrySchema } from './embedding-model-contracts'
describe('embedding model contracts', () => {
it('requires a reason when a catalog package is unavailable', () => {
const result = embeddingModelCatalogEntrySchema.safeParse({
id: 'test-model',
displayName: 'Test',
description: 'Test model.',
languages: ['English'],
runtime: 'onnxruntime-web/wasm',
dimensions: 4,
contextTokens: 128,
quantization: 'int8',
recommended: false,
available: false,
repositoryUrls: {},
license: {
name: 'Test',
notice: 'Test only.',
url: 'https://example.com/license'
},
files: [
{
name: 'model.onnx',
role: 'model',
size: 1,
sha256: 'a'.repeat(64),
targets: {}
}
]
})
expect(result.success).toBe(false)
})
it('requires all runtime artifacts for an available package', () => {
const result = embeddingModelCatalogEntrySchema.safeParse({
id: 'test-model',
displayName: 'Test',
description: 'Test model.',
languages: ['English'],
runtime: 'onnxruntime-web/wasm',
dimensions: 4,
contextTokens: 128,
quantization: 'int8',
recommended: false,
available: true,
repositoryUrls: {},
license: {
name: 'Test',
notice: 'Test only.',
url: 'https://example.com/license'
},
files: [
{
name: 'model.onnx',
role: 'model',
size: 1,
sha256: 'a'.repeat(64),
targets: {}
}
]
})
expect(result.success).toBe(false)
})
})
@@ -0,0 +1,220 @@
import { z } from 'zod'
import {
MODEL_DOWNLOAD_SOURCES,
modelArtifactTargetsSchema,
modelDownloadAvailabilitySchema,
modelDownloadSourceSchema
} from '../../shared/model-download-contracts'
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u)
const modelFileNameSchema = z
.string()
.min(1)
.max(255)
.regex(/^[^/\\:\0]+$/u)
export const embeddingModelIdSchema = z
.string()
.min(1)
.max(96)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u)
export const embeddingModelFileRoleSchema = z.enum([
'model',
'tokenizer',
'tokenizer-configuration',
'configuration',
'license'
])
export const embeddingModelFileSchema = z
.object({
name: modelFileNameSchema,
role: embeddingModelFileRoleSchema,
size: z.number().int().positive().safe(),
sha256: sha256Schema,
targets: modelArtifactTargetsSchema
})
.strict()
const repositoryUrlsSchema = z
.object({
modelscope: z.url().optional(),
'hugging-face': z.url().optional()
})
.strict()
const embeddingModelLicenseSchema = z
.object({
name: z.string().trim().min(1).max(120),
notice: z.string().trim().min(1).max(1_000),
url: z.url()
})
.strict()
export const embeddingModelCatalogEntrySchema = z
.object({
id: embeddingModelIdSchema,
displayName: z.string().trim().min(1).max(120),
description: z.string().trim().min(1).max(1_000),
languages: z.array(z.string().trim().min(1).max(80)).min(1).max(32),
runtime: z.literal('onnxruntime-web/wasm'),
dimensions: z.number().int().positive().max(65_536),
contextTokens: z.number().int().positive().max(10_000_000),
quantization: z.string().trim().min(1).max(40),
recommended: z.boolean(),
available: z.boolean(),
unavailableReason: z.string().trim().min(1).max(1_000).optional(),
repositoryUrls: repositoryUrlsSchema,
license: embeddingModelLicenseSchema,
files: z.array(embeddingModelFileSchema).min(1).max(32)
})
.strict()
.superRefine((entry, context) => {
if (!entry.available && !entry.unavailableReason) {
context.addIssue({
code: 'custom',
path: ['unavailableReason'],
message: '不可用的内置向量模型必须说明原因'
})
}
if (entry.available && entry.unavailableReason) {
context.addIssue({
code: 'custom',
path: ['unavailableReason'],
message: '可用的内置向量模型不能包含不可用原因'
})
}
if (entry.available) {
for (const role of [
'model',
'tokenizer',
'tokenizer-configuration',
'configuration'
] as const) {
if (!entry.files.some((file) => file.role === role)) {
context.addIssue({
code: 'custom',
path: ['files'],
message: `可安装的向量模型缺少 ${role} 工件`
})
}
}
}
if (
new Set(entry.files.map((file) => file.name.toLowerCase())).size !==
entry.files.length
) {
context.addIssue({
code: 'custom',
path: ['files'],
message: '向量模型目录包含重复文件名'
})
}
})
const embeddingModelCatalogViewFileSchema = embeddingModelFileSchema.omit({
targets: true
})
export const embeddingModelCatalogViewEntrySchema = z
.object({
id: embeddingModelCatalogEntrySchema.shape.id,
displayName: embeddingModelCatalogEntrySchema.shape.displayName,
description: embeddingModelCatalogEntrySchema.shape.description,
languages: embeddingModelCatalogEntrySchema.shape.languages,
runtime: embeddingModelCatalogEntrySchema.shape.runtime,
dimensions: embeddingModelCatalogEntrySchema.shape.dimensions,
contextTokens: embeddingModelCatalogEntrySchema.shape.contextTokens,
quantization: embeddingModelCatalogEntrySchema.shape.quantization,
recommended: embeddingModelCatalogEntrySchema.shape.recommended,
available: embeddingModelCatalogEntrySchema.shape.available,
unavailableReason:
embeddingModelCatalogEntrySchema.shape.unavailableReason,
license: embeddingModelCatalogEntrySchema.shape.license,
files: z.array(embeddingModelCatalogViewFileSchema).min(1).max(32),
downloadAvailability: z
.array(modelDownloadAvailabilitySchema)
.length(MODEL_DOWNLOAD_SOURCES.length)
})
.strict()
export const installedEmbeddingModelFileSchema = z
.object({
name: modelFileNameSchema,
role: embeddingModelFileRoleSchema,
size: z.number().int().positive().safe(),
sha256: sha256Schema
})
.strict()
export const installedEmbeddingModelSchema = z
.object({
id: embeddingModelIdSchema,
displayName: z.string().trim().min(1).max(120),
source: z.enum(['download', 'local']),
installedAt: z.string().datetime(),
files: z.array(installedEmbeddingModelFileSchema).min(1).max(32)
})
.strict()
export const embeddingModelOperationSchema = z
.object({
modelId: embeddingModelIdSchema,
kind: z.enum(['download', 'import']),
phase: z.enum(['preparing', 'transferring', 'installing']),
currentFile: modelFileNameSchema.nullable(),
completedBytes: z.number().int().nonnegative().safe(),
totalBytes: z.number().int().positive().safe().nullable(),
downloadSource: modelDownloadSourceSchema.optional()
})
.strict()
export const embeddingModelSnapshotSchema = z
.object({
selectedDownloadSource: modelDownloadSourceSchema,
catalog: z.array(embeddingModelCatalogViewEntrySchema),
installed: z.array(installedEmbeddingModelSchema),
operations: z.array(embeddingModelOperationSchema)
})
.strict()
export const embeddingModelProgressSnapshotSchema = z
.object({
operations: z.array(embeddingModelOperationSchema)
})
.strict()
export const embeddingModelStatusSchema = z
.object({
id: embeddingModelIdSchema,
displayName: z.string().trim().min(1).max(120),
catalogAvailable: z.boolean(),
installed: z.boolean(),
verified: z.boolean(),
detail: z.string().trim().min(1).max(1_000)
})
.strict()
export type EmbeddingModelCatalogEntry = z.infer<
typeof embeddingModelCatalogEntrySchema
>
export type EmbeddingModelCatalogViewEntry = z.infer<
typeof embeddingModelCatalogViewEntrySchema
>
export type EmbeddingModelFile = z.infer<typeof embeddingModelFileSchema>
export type InstalledEmbeddingModel = z.infer<
typeof installedEmbeddingModelSchema
>
export type EmbeddingModelOperation = z.infer<
typeof embeddingModelOperationSchema
>
export type EmbeddingModelSnapshot = z.infer<
typeof embeddingModelSnapshotSchema
>
export type EmbeddingModelProgressSnapshot = z.infer<
typeof embeddingModelProgressSnapshotSchema
>
export type EmbeddingModelStatus = z.infer<
typeof embeddingModelStatusSchema
>
@@ -0,0 +1,492 @@
import { createHash } from 'node:crypto'
import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { zipSync } from 'fflate'
import { afterEach, describe, expect, it } from 'vitest'
import {
MODEL_DOWNLOAD_SOURCES,
resolveModelDownloadPackage
} from '../../shared/model-download-contracts'
import type { EmbeddingModelCatalogEntry } from './embedding-model-contracts'
import { EMBEDDING_MODEL_CATALOG } from './embedding-model-catalog'
import { EmbeddingModelManager } from './embedding-model-manager'
const roots: string[] = []
function sha256(contents: string): string {
return createHash('sha256').update(contents).digest('hex')
}
function testCatalog(
contents: Record<string, string>
): EmbeddingModelCatalogEntry[] {
const roles = [
'model',
'tokenizer',
'tokenizer-configuration',
'configuration',
'license'
] as const
return [
{
id: 'complete-test-model',
displayName: 'Complete Test Model',
description: 'A complete injected test model.',
languages: ['English'],
runtime: 'onnxruntime-web/wasm',
dimensions: 4,
contextTokens: 128,
quantization: 'test',
recommended: false,
available: true,
repositoryUrls: {},
license: {
name: 'Test license',
notice: 'Tests only.',
url: 'https://example.com/license'
},
files: roles.map((role, index) => {
const name = Object.keys(contents)[index]!
const value = contents[name]!
return {
name,
role,
size: Buffer.byteLength(value),
sha256: sha256(value),
targets: {
modelscope: {
url: `https://modelscope.cn/models/test/model/resolve/${'a'.repeat(40)}/${name}`,
repositoryUrl:
'https://modelscope.cn/models/test/model',
revision: 'a'.repeat(40),
redirectHosts: []
},
'hugging-face': {
url: `https://huggingface.co/test/model/resolve/${'b'.repeat(40)}/${name}`,
repositoryUrl: 'https://huggingface.co/test/model',
revision: 'b'.repeat(40),
redirectHosts: []
}
}
}
})
}
]
}
function embeddingArchive(
contents: Record<string, string>,
overrides: {
kind?: string
modelId?: string
extraEntries?: Record<string, Uint8Array>
hashOverrides?: Record<string, string>
} = {}
): Uint8Array {
const entry = testCatalog(contents)[0]!
const files = entry.files.map(({ name, role, size, sha256 }) => ({
name,
role,
size,
sha256: overrides.hashOverrides?.[name] ?? sha256
}))
return zipSync({
'goodbuddy-model.json': Buffer.from(
JSON.stringify({
format: 'goodbuddy-model-archive',
version: 1,
kind: overrides.kind ?? 'embedding',
modelId: overrides.modelId ?? entry.id,
displayName: entry.displayName,
exportedAt: '2026-08-20T00:00:00.000Z',
files
})
),
...Object.fromEntries(
Object.entries(contents).map(([name, value]) => [
name,
Buffer.from(value)
])
),
...overrides.extraEntries
})
}
async function fixture(): Promise<{
root: string
source: string
manager: EmbeddingModelManager
contents: Record<string, string>
}> {
const root = await mkdtemp(join(tmpdir(), 'embedding-model-'))
roots.push(root)
const source = join(root, 'source')
await mkdir(source)
const contents = {
'model.onnx': 'model',
'tokenizer.json': 'tokenizer',
'tokenizer_config.json': 'tokenizer config',
'config.json': 'model config',
LICENSE: 'license'
}
for (const [name, value] of Object.entries(contents)) {
await writeFile(join(source, name), value)
}
return {
root,
source,
contents,
manager: new EmbeddingModelManager({
userDataDirectory: root,
catalog: testCatalog(contents)
})
}
}
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) =>
rm(root, { recursive: true, force: true })
)
)
})
describe('EmbeddingModelManager', () => {
it('publishes a complete downloadable production Granite package', async () => {
expect(EMBEDDING_MODEL_CATALOG).toHaveLength(1)
expect(EMBEDDING_MODEL_CATALOG[0]).toMatchObject({
available: true,
runtime: 'onnxruntime-web/wasm'
})
expect(
EMBEDDING_MODEL_CATALOG[0]!.files.map(
({ name, size, sha256 }) => ({ name, size, sha256 })
)
).toEqual([
{
name: 'model_quantized.onnx',
size: 97_858_099,
sha256:
'704c1ebca5fbb7cd83ced41827658ac4c9990c64f7f2874d22b78044e5022e22'
},
{
name: 'tokenizer.json',
size: 25_301_671,
sha256:
'51947676cae1f991fa51c6b9a24e14ee5460e5f0b9f692f13bb3159829d1592a'
},
{
name: 'tokenizer_config.json',
size: 12_860,
sha256:
'6ed69389e30a8ecabfce2f9ebcdf0c908b34056f24d994340f2f216521c057d5'
},
{
name: 'config.json',
size: 1_215,
sha256:
'ae74d55a56f779774cb9a8e63d3c2da9ae1af83c00229ffdff43d0b38407a0ee'
},
{
name: 'special_tokens_map.json',
size: 871,
sha256:
'013787ee251ff611722479197c00853b62113ad303cb0a36524231783c676c69'
}
])
for (const source of MODEL_DOWNLOAD_SOURCES) {
const resolved = resolveModelDownloadPackage(
EMBEDDING_MODEL_CATALOG[0]!.files,
source
)
expect(resolved.files.map((file) => file.name)).toEqual([
'model_quantized.onnx',
'tokenizer.json',
'tokenizer_config.json',
'config.json',
'special_tokens_map.json'
])
expect(resolved.files.every((file) => file.target.revision.length === 40))
.toBe(true)
}
const root = await mkdtemp(join(tmpdir(), 'embedding-catalog-'))
roots.push(root)
const snapshot = await new EmbeddingModelManager({
userDataDirectory: root
}).getSnapshot()
expect(snapshot.catalog[0]!.downloadAvailability).toEqual([
expect.objectContaining({ available: true }),
expect.objectContaining({ available: true })
])
})
it('downloads, verifies, and atomically publishes a package', async () => {
const root = await mkdtemp(join(tmpdir(), 'embedding-download-'))
roots.push(root)
const contents: Record<string, string> = {
'model.onnx': 'model',
'tokenizer.json': 'tokenizer',
'tokenizer_config.json': 'tokenizer config',
'config.json': 'model config',
LICENSE: 'license'
}
const requests: string[] = []
const manager = new EmbeddingModelManager({
userDataDirectory: root,
catalog: testCatalog(contents),
fetch: async (input) => {
const url = input.toString()
requests.push(url)
const name = decodeURIComponent(new URL(url).pathname.split('/').at(-1)!)
const body = contents[name]
if (body === undefined) {
return new Response(null, { status: 404 })
}
return new Response(body, {
headers: { 'content-length': String(Buffer.byteLength(body)) }
})
}
})
const installed = await manager.install(
'complete-test-model',
'modelscope'
)
expect(installed).toMatchObject({
source: 'download',
files: expect.arrayContaining([
expect.objectContaining({ name: 'model.onnx' }),
expect.objectContaining({ name: 'tokenizer.json' })
])
})
expect(requests).toHaveLength(5)
expect(await manager.getVerifiedModelDirectory('complete-test-model'))
.toBe(join(manager.rootDirectory, 'complete-test-model'))
expect(
await readFile(
join(manager.rootDirectory, 'complete-test-model', 'config.json'),
'utf8'
)
).toBe(contents['config.json'])
expect(
(
await readdir(manager.rootDirectory)
).some((name) => name.startsWith('.install-'))
).toBe(false)
})
it('installs a complete local package and exposes only a verified Main path', async () => {
const { manager, source } = await fixture()
const installed = await manager.registerLocalDirectory(
'complete-test-model',
source
)
expect(installed.files).toHaveLength(5)
const snapshot = await manager.getSnapshot()
expect(snapshot).not.toHaveProperty('rootDirectory')
expect(JSON.stringify(snapshot)).not.toContain(manager.rootDirectory)
expect(snapshot.catalog[0]).not.toHaveProperty('repositoryUrls')
expect(snapshot.catalog[0]!.files[0]).not.toHaveProperty('targets')
expect(snapshot.installed).toHaveLength(1)
const trusted = await manager.getVerifiedModelDirectory(
'complete-test-model'
)
expect(trusted).toBe(
join(manager.rootDirectory, 'complete-test-model')
)
expect(await manager.getStatus('complete-test-model')).toMatchObject({
catalogAvailable: true,
installed: true,
verified: true
})
})
it('imports and atomically publishes a verified embedding ZIP', async () => {
const { manager, root, contents } = await fixture()
const archive = join(root, 'embedding.zip')
await writeFile(archive, embeddingArchive(contents))
await expect(
manager.importArchive('complete-test-model', archive)
).resolves.toMatchObject({
id: 'complete-test-model',
source: 'local',
files: expect.arrayContaining([
expect.objectContaining({ name: 'model.onnx' }),
expect.objectContaining({ name: 'tokenizer.json' })
])
})
const directory = await manager.getVerifiedModelDirectory(
'complete-test-model'
)
expect(await readdir(directory)).toHaveLength(6)
await expect(
readFile(join(directory, 'goodbuddy-model.json'))
).rejects.toThrow()
expect(
(await readdir(manager.rootDirectory)).some((name) =>
name.startsWith('.install-')
)
).toBe(false)
})
it('rejects mismatched ZIP descriptors and hashes without publishing', async () => {
const { manager, root, contents } = await fixture()
const wrongKind = join(root, 'wrong-kind.zip')
const wrongHash = join(root, 'wrong-hash.zip')
await Promise.all([
writeFile(
wrongKind,
embeddingArchive(contents, { kind: 'speech' })
),
writeFile(
wrongHash,
embeddingArchive(contents, {
hashOverrides: { 'model.onnx': 'a'.repeat(64) }
})
)
])
await expect(
manager.importArchive('complete-test-model', wrongKind)
).rejects.toThrow(/类型或模型 ID 不匹配/u)
await expect(
manager.importArchive('complete-test-model', wrongHash)
).rejects.toThrow(/校验失败/u)
expect((await manager.getSnapshot()).installed).toEqual([])
expect(
(await readdir(manager.rootDirectory)).some((name) =>
name.startsWith('.install-')
)
).toBe(false)
})
it('rejects undeclared ZIP entries and preserves an installed model', async () => {
const { manager, source, root, contents } = await fixture()
await manager.registerLocalDirectory('complete-test-model', source)
const directory = await manager.getVerifiedModelDirectory(
'complete-test-model'
)
const archive = join(root, 'unsafe.zip')
await writeFile(
archive,
embeddingArchive(contents, {
extraEntries: {
'../outside.exe': Buffer.from('unsafe')
}
})
)
await expect(
manager.importArchive('complete-test-model', archive)
).rejects.toThrow(/已安装/u)
await expect(
manager.getVerifiedModelDirectory('complete-test-model')
).resolves.toBe(directory)
await expect(
readFile(join(directory, 'model.onnx'), 'utf8')
).resolves.toBe(contents['model.onnx'])
})
it('honors an already-aborted ZIP import and cleans staging', async () => {
const { manager, root, contents } = await fixture()
const archive = join(root, 'embedding.zip')
await writeFile(archive, embeddingArchive(contents))
const controller = new AbortController()
controller.abort()
await expect(
manager.importArchive(
'complete-test-model',
archive,
controller.signal
)
).rejects.toMatchObject({ name: 'AbortError' })
expect(manager.getProgressSnapshot().operations).toEqual([])
expect(
(await readdir(manager.rootDirectory)).some((name) =>
name.startsWith('.install-')
)
).toBe(false)
})
it('rejects wrong hashes and leaves no installed model', async () => {
const { manager, source } = await fixture()
await writeFile(join(source, 'model.onnx'), 'tampered')
await expect(
manager.registerLocalDirectory('complete-test-model', source)
).rejects.toThrow(/校验失败/u)
expect((await manager.getSnapshot()).installed).toEqual([])
})
it('re-verifies installed bytes before returning the trusted directory', async () => {
const { manager, source } = await fixture()
await manager.registerLocalDirectory('complete-test-model', source)
const directory = join(
manager.rootDirectory,
'complete-test-model'
)
await writeFile(join(directory, 'tokenizer.json'), 'tampered')
await expect(
manager.getVerifiedModelDirectory('complete-test-model')
).rejects.toThrow(/校验失败/u)
expect(await manager.getStatus('complete-test-model')).toMatchObject({
installed: true,
verified: false
})
})
it('rejects source symlinks', async () => {
const { manager, source, root } = await fixture()
const linked = join(root, 'linked')
await symlink(source, linked, 'junction')
await expect(
manager.registerLocalDirectory('complete-test-model', linked)
).rejects.toThrow(/普通目录/u)
})
it('honors an already-aborted local import signal', async () => {
const { manager, source } = await fixture()
const controller = new AbortController()
controller.abort()
await expect(
manager.registerLocalDirectory(
'complete-test-model',
source,
controller.signal
)
).rejects.toMatchObject({ name: 'AbortError' })
expect(manager.getProgressSnapshot().operations).toEqual([])
})
it('does not trust a modified manifest', async () => {
const { manager, source } = await fixture()
await manager.registerLocalDirectory('complete-test-model', source)
const manifestPath = join(
manager.rootDirectory,
'complete-test-model',
'manifest.json'
)
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
id: string
}
manifest.id = 'other-model'
await writeFile(manifestPath, JSON.stringify(manifest))
await expect(
manager.getVerifiedModelDirectory('complete-test-model')
).rejects.toThrow()
})
})
@@ -0,0 +1,824 @@
import { createHash } from 'node:crypto'
import {
copyFile,
lstat,
mkdir,
open,
readFile,
readdir,
rename,
rm,
stat,
writeFile
} from 'node:fs/promises'
import { resolve } from 'node:path'
import {
MODEL_DOWNLOAD_SOURCES,
getMaximumModelPackageBytes,
getModelDownloadAvailability,
resolveModelDownloadPackage,
type ModelDownloadAvailability,
type ModelDownloadSource,
type ResolvedModelArtifactFile
} from '../../shared/model-download-contracts'
import { extractModelArchive } from '../model-archive'
import { fetchModelDownloadResponse } from '../model-download-transport'
import {
MODEL_PARTIAL_SUFFIX,
attachModelAbortSignal,
cleanupStaleModelInstallArtifacts,
createModelStagingDirectory,
ensureModelOperationNotAborted,
hashModelFile,
managedModelChild,
writeModelBuffer
} from '../model-package-utils'
import { isMissingFileError } from '../settings-file-utils'
import { EMBEDDING_MODEL_CATALOG } from './embedding-model-catalog'
import {
embeddingModelCatalogEntrySchema,
embeddingModelCatalogViewEntrySchema,
embeddingModelIdSchema,
embeddingModelProgressSnapshotSchema,
embeddingModelSnapshotSchema,
embeddingModelStatusSchema,
installedEmbeddingModelSchema,
type EmbeddingModelCatalogEntry,
type EmbeddingModelCatalogViewEntry,
type EmbeddingModelOperation,
type EmbeddingModelProgressSnapshot,
type EmbeddingModelSnapshot,
type EmbeddingModelStatus,
type InstalledEmbeddingModel
} from './embedding-model-contracts'
const MANIFEST_FILE_NAME = 'manifest.json'
const ARCHIVE_MANIFEST_FILE_NAME = 'goodbuddy-model.json'
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
const MAXIMUM_ARCHIVE_BYTES = 4 * 1024 * 1024 * 1024 - 1
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
const executableExtensionPattern =
/\.(?:app|bat|bin|cmd|com|cpl|dll|dmg|exe|hta|iso|jar|js|jse|lnk|msi|ps1|reg|scr|sh|sys|vb|vbe|vbs|wsf)$/iu
type ActiveOperation = {
controller: AbortController
progress: EmbeddingModelOperation
}
export type EmbeddingModelManagerOptions = {
userDataDirectory: string
catalog?: readonly EmbeddingModelCatalogEntry[]
fetch?: typeof fetch
getDownloadSource?: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
maxFileBytes?: number
}
function safeChild(parent: string, name: string): string {
return managedModelChild(
parent,
name,
'向量模型路径超出受管目录'
)
}
function unavailableDownloadAvailability(
source: ModelDownloadSource,
reason: string
): ModelDownloadAvailability {
return { source, available: false, unavailableReason: reason }
}
function toCatalogView(
entry: EmbeddingModelCatalogEntry
): EmbeddingModelCatalogViewEntry {
const { repositoryUrls, files, ...metadata } = entry
void repositoryUrls
return embeddingModelCatalogViewEntrySchema.parse({
...metadata,
files: files.map(({ targets, ...file }) => {
void targets
return file
}),
downloadAvailability: MODEL_DOWNLOAD_SOURCES.map((source) =>
entry.available
? getModelDownloadAvailability(files, source)
: unavailableDownloadAvailability(
source,
entry.unavailableReason ??
'当前内置向量模型尚不可安装'
)
)
})
}
function packageMatches(
entry: EmbeddingModelCatalogEntry,
files: readonly {
name: string
role: string
size: number
sha256: string
}[]
): boolean {
return (
files.length === entry.files.length &&
entry.files.every((expected) =>
files.some(
(actual) =>
actual.name === expected.name &&
actual.role === expected.role &&
actual.size === expected.size &&
actual.sha256 === expected.sha256
)
)
)
}
export class EmbeddingModelManager {
readonly rootDirectory: string
private readonly catalog: EmbeddingModelCatalogEntry[]
private readonly catalogViews: EmbeddingModelCatalogViewEntry[]
private readonly transport: typeof fetch
private readonly getDownloadSource: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
private readonly maxFileBytes: number
private readonly operations = new Map<string, ActiveOperation>()
constructor(options: EmbeddingModelManagerOptions) {
if (!options.userDataDirectory.trim()) {
throw new Error('userDataDirectory is required')
}
this.rootDirectory = resolve(
options.userDataDirectory,
'models',
'embedding'
)
this.catalog = (options.catalog ?? EMBEDDING_MODEL_CATALOG).map(
(entry) => embeddingModelCatalogEntrySchema.parse(entry)
)
if (
new Set(this.catalog.map((entry) => entry.id)).size !==
this.catalog.length
) {
throw new Error('向量模型目录包含重复 ID')
}
this.catalogViews = this.catalog.map(toCatalogView)
this.transport = options.fetch ?? globalThis.fetch
this.getDownloadSource =
options.getDownloadSource ?? (() => 'modelscope')
this.maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES
if (
!Number.isSafeInteger(this.maxFileBytes) ||
this.maxFileBytes <= 0 ||
this.maxFileBytes > 8 * 1024 * 1024 * 1024
) {
throw new RangeError('maxFileBytes must be a positive safe integer')
}
}
async getSnapshot(): Promise<EmbeddingModelSnapshot> {
await this.ensureRoot()
await this.cleanupStaleArtifacts()
const [selectedDownloadSource, installed] = await Promise.all([
this.getDownloadSource(),
this.readInstalled()
])
return embeddingModelSnapshotSchema.parse({
selectedDownloadSource,
catalog: this.catalogViews,
installed,
operations: [...this.operations.values()].map(({ progress }) => ({
...progress
}))
})
}
async snapshot(): Promise<EmbeddingModelSnapshot> {
return this.getSnapshot()
}
getProgressSnapshot(): EmbeddingModelProgressSnapshot {
return embeddingModelProgressSnapshotSchema.parse({
operations: [...this.operations.values()].map(({ progress }) => ({
...progress
}))
})
}
async getStatus(modelId: string): Promise<EmbeddingModelStatus> {
const id = embeddingModelIdSchema.parse(modelId)
const entry = this.catalog.find((candidate) => candidate.id === id)
if (!entry) {
return embeddingModelStatusSchema.parse({
id,
displayName: id,
catalogAvailable: false,
installed: false,
verified: false,
detail: '当前版本不提供此内置向量模型'
})
}
try {
await this.verifyInstalledModel(entry)
return embeddingModelStatusSchema.parse({
id,
displayName: entry.displayName,
catalogAvailable: entry.available,
installed: true,
verified: true,
detail: '模型已安装并通过 SHA-256 校验,可供本地推理使用'
})
} catch {
const installed = await this.hasInstalledDirectory(id)
return embeddingModelStatusSchema.parse({
id,
displayName: entry.displayName,
catalogAvailable: entry.available,
installed,
verified: false,
detail: installed
? '模型文件校验失败'
: entry.available
? '模型尚未安装'
: entry.unavailableReason!
})
}
}
/**
* Returns a Main-owned absolute path only after re-verifying every package
* file. This path must not be sent to the renderer.
*/
async getVerifiedModelDirectory(modelId: string): Promise<string> {
const entry = this.requireAvailableEntry(modelId)
await this.verifyInstalledModel(entry)
return this.modelDirectory(entry.id)
}
async registerLocalDirectory(
modelId: string,
sourceDirectory: string,
externalSignal?: AbortSignal
): Promise<InstalledEmbeddingModel> {
const entry = this.requireAvailableEntry(modelId)
const source = resolve(sourceDirectory)
const operation = this.beginOperation(entry.id, 'import', null)
const detachAbort = attachModelAbortSignal(
externalSignal,
operation.controller
)
let stagingDirectory: string | undefined
try {
await this.ensureRoot()
await this.assertNotInstalled(entry.id)
await this.validateLocalDirectory(
source,
entry,
operation.controller.signal
)
stagingDirectory = await createModelStagingDirectory(
this.rootDirectory,
entry.id,
'向量模型路径超出受管目录'
)
operation.progress.phase = 'transferring'
for (const file of entry.files) {
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.currentFile = file.name
const destination = safeChild(stagingDirectory, file.name)
await copyFile(safeChild(source, file.name), destination)
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.completedBytes += (await stat(destination)).size
}
operation.progress.totalBytes = operation.progress.completedBytes
operation.progress.phase = 'installing'
operation.progress.currentFile = null
const installed = await this.writeVerifiedManifest(
entry,
'local',
stagingDirectory,
operation.controller.signal
)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
return installed
} finally {
detachAbort()
this.operations.delete(entry.id)
if (stagingDirectory) {
await rm(stagingDirectory, { recursive: true, force: true })
}
}
}
async install(
modelId: string,
downloadSource?: ModelDownloadSource,
externalSignal?: AbortSignal
): Promise<InstalledEmbeddingModel> {
const entry = this.requireAvailableEntry(modelId)
const selectedDownloadSource =
downloadSource ?? (await this.getDownloadSource())
const resolvedPackage = resolveModelDownloadPackage(
entry.files,
selectedDownloadSource
)
const operation = this.beginOperation(
entry.id,
'download',
resolvedPackage.totalBytes,
resolvedPackage.source
)
const detachAbort = attachModelAbortSignal(
externalSignal,
operation.controller
)
let stagingDirectory: string | undefined
try {
await this.ensureRoot()
await this.assertNotInstalled(entry.id)
stagingDirectory = await createModelStagingDirectory(
this.rootDirectory,
entry.id,
'向量模型路径超出受管目录'
)
for (const file of resolvedPackage.files) {
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name
await this.downloadFile(
file,
safeChild(stagingDirectory, file.name),
operation,
operation.controller.signal
)
}
operation.progress.phase = 'installing'
operation.progress.currentFile = null
const installed = await this.writeVerifiedManifest(
entry,
'download',
stagingDirectory,
operation.controller.signal
)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
return installed
} finally {
detachAbort()
this.operations.delete(entry.id)
if (stagingDirectory) {
await rm(stagingDirectory, { recursive: true, force: true })
}
}
}
async importArchive(
modelId: string,
archivePath: string,
externalSignal?: AbortSignal
): Promise<InstalledEmbeddingModel> {
const entry = this.requireAvailableEntry(modelId)
if (
entry.files.some((file) =>
executableExtensionPattern.test(file.name)
)
) {
throw new Error('向量模型目录包含不安全文件')
}
const expectedTotal = getMaximumModelPackageBytes(entry.files)
const maximumTotalBytes = Math.min(
MAXIMUM_ARCHIVE_BYTES,
expectedTotal + ARCHIVE_OVERHEAD_BYTES
)
const operation = this.beginOperation(
entry.id,
'import',
expectedTotal
)
const detachAbort = attachModelAbortSignal(
externalSignal,
operation.controller
)
let stagingDirectory: string | undefined
try {
await this.ensureRoot()
await this.assertNotInstalled(entry.id)
stagingDirectory = await createModelStagingDirectory(
this.rootDirectory,
entry.id,
'向量模型路径超出受管目录'
)
operation.progress.phase = 'transferring'
const descriptor = await extractModelArchive({
archivePath,
destinationDirectory: stagingDirectory,
expectedKind: 'embedding',
expectedModelId: entry.id,
expectedFiles: entry.files.map((file) => ({
name: file.name,
role: file.role
})),
maximumArchiveBytes: Math.min(
MAXIMUM_ARCHIVE_BYTES,
maximumTotalBytes + ARCHIVE_OVERHEAD_BYTES
),
maximumFileBytes: this.maxFileBytes,
maximumTotalBytes,
signal: operation.controller.signal,
onProgress: (completedBytes) => {
operation.progress.completedBytes = completedBytes
}
})
if (
descriptor.files.some(
(file) => file.size > this.maxFileBytes
) ||
!packageMatches(entry, descriptor.files)
) {
throw new Error('向量模型 ZIP 与当前模型目录不匹配')
}
operation.progress.totalBytes = descriptor.files.reduce(
(total, file) => total + file.size,
0
)
operation.progress.phase = 'installing'
operation.progress.currentFile = null
await rm(
safeChild(stagingDirectory, ARCHIVE_MANIFEST_FILE_NAME),
{ force: true }
)
const installed = await this.writeVerifiedManifest(
entry,
'local',
stagingDirectory,
operation.controller.signal
)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
return installed
} finally {
detachAbort()
this.operations.delete(entry.id)
if (stagingDirectory) {
await rm(stagingDirectory, { recursive: true, force: true })
}
}
}
cancel(modelId: string): boolean {
const id = embeddingModelIdSchema.parse(modelId)
const operation = this.operations.get(id)
if (!operation) {
return false
}
operation.controller.abort()
return true
}
async remove(modelId: string): Promise<void> {
const id = embeddingModelIdSchema.parse(modelId)
this.cancel(id)
await rm(this.modelDirectory(id), { recursive: true, force: true })
}
dispose(): void {
for (const operation of this.operations.values()) {
operation.controller.abort()
}
this.operations.clear()
}
private async ensureRoot(): Promise<void> {
await mkdir(this.rootDirectory, { recursive: true })
}
private modelDirectory(modelId: string): string {
return safeChild(
this.rootDirectory,
embeddingModelIdSchema.parse(modelId)
)
}
private requireAvailableEntry(
modelId: string
): EmbeddingModelCatalogEntry {
const id = embeddingModelIdSchema.parse(modelId)
const entry = this.catalog.find((candidate) => candidate.id === id)
if (!entry) {
throw new Error('未知的向量模型')
}
if (!entry.available) {
throw new Error(
entry.unavailableReason ?? '当前向量模型尚不可安装'
)
}
return entry
}
private beginOperation(
modelId: string,
kind: EmbeddingModelOperation['kind'],
totalBytes: number | null,
downloadSource?: ModelDownloadSource
): ActiveOperation {
if (this.operations.has(modelId)) {
throw new Error('该向量模型已有进行中的操作')
}
const operation: ActiveOperation = {
controller: new AbortController(),
progress: {
modelId,
kind,
phase: 'preparing',
currentFile: null,
completedBytes: 0,
totalBytes,
...(downloadSource ? { downloadSource } : {})
}
}
this.operations.set(modelId, operation)
return operation
}
private async downloadFile(
file: ResolvedModelArtifactFile<
EmbeddingModelCatalogEntry['files'][number]['role']
>,
destination: string,
operation: ActiveOperation,
signal: AbortSignal
): Promise<void> {
if (file.size <= 0 || file.size > this.maxFileBytes) {
throw new RangeError(`向量模型文件过大:${file.name}`)
}
const response = await fetchModelDownloadResponse({
transport: this.transport,
initialUrl: file.target.url,
redirectHosts: file.target.redirectHosts,
signal,
modelLabel: '向量模型'
})
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new Error(`向量模型下载失败:HTTP ${response.status}`)
}
if (!response.body) {
throw new Error('向量模型下载响应没有内容')
}
const declaredLength = response.headers.get('content-length')
if (declaredLength !== null) {
const parsedLength = Number(declaredLength)
if (
!Number.isSafeInteger(parsedLength) ||
parsedLength !== file.size
) {
await response.body.cancel().catch(() => undefined)
throw new Error(`向量模型文件大小不匹配:${file.name}`)
}
}
const partialPath = `${destination}${MODEL_PARTIAL_SUFFIX}`
const handle = await open(partialPath, 'wx')
const reader = response.body.getReader()
const hash = createHash('sha256')
let written = 0
try {
while (true) {
ensureModelOperationNotAborted(signal)
const result = await reader.read()
if (result.done) {
break
}
if (
written + result.value.byteLength > file.size ||
written + result.value.byteLength > this.maxFileBytes
) {
await reader.cancel()
throw new RangeError(`向量模型文件过大:${file.name}`)
}
written += await writeModelBuffer(
handle,
result.value,
(persisted) => {
hash.update(persisted)
operation.progress.completedBytes += persisted.byteLength
}
)
}
} catch (error) {
await reader.cancel().catch(() => undefined)
throw error
} finally {
await handle.close()
}
if (written !== file.size) {
throw new Error(`向量模型文件大小不匹配:${file.name}`)
}
if (hash.digest('hex') !== file.sha256) {
throw new Error(`向量模型文件校验失败:${file.name}`)
}
await rename(partialPath, destination)
}
private async assertNotInstalled(modelId: string): Promise<void> {
try {
await lstat(this.modelDirectory(modelId))
throw new Error('向量模型已安装')
} catch (error) {
if (isMissingFileError(error)) {
return
}
throw error
}
}
private async hasInstalledDirectory(modelId: string): Promise<boolean> {
try {
const info = await lstat(this.modelDirectory(modelId))
return info.isDirectory() && !info.isSymbolicLink()
} catch (error) {
if (isMissingFileError(error)) {
return false
}
throw error
}
}
private async validateLocalDirectory(
source: string,
entry: EmbeddingModelCatalogEntry,
signal: AbortSignal
): Promise<void> {
const sourceInfo = await lstat(source)
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
throw new Error('本地向量模型来源必须是普通目录')
}
const directoryEntries = await readdir(source, {
withFileTypes: true
})
if (directoryEntries.length !== entry.files.length) {
throw new Error('本地向量模型目录包含未声明或缺失的文件')
}
for (const localEntry of directoryEntries) {
ensureModelOperationNotAborted(signal)
if (
localEntry.isSymbolicLink() ||
!localEntry.isFile() ||
executableExtensionPattern.test(localEntry.name)
) {
throw new Error('本地向量模型目录包含不安全文件')
}
}
for (const file of entry.files) {
ensureModelOperationNotAborted(signal)
const path = safeChild(source, file.name)
const info = await lstat(path)
if (
!info.isFile() ||
info.isSymbolicLink() ||
info.size <= 0 ||
info.size > this.maxFileBytes
) {
throw new Error(`向量模型文件无效:${file.name}`)
}
const actual = await hashModelFile(path, signal)
if (
actual.size !== file.size ||
actual.sha256 !== file.sha256
) {
throw new Error(`向量模型文件校验失败:${file.name}`)
}
}
}
private async writeVerifiedManifest(
entry: EmbeddingModelCatalogEntry,
source: InstalledEmbeddingModel['source'],
directory: string,
signal: AbortSignal
): Promise<InstalledEmbeddingModel> {
const files: InstalledEmbeddingModel['files'] = []
for (const file of entry.files) {
ensureModelOperationNotAborted(signal)
const actual = await hashModelFile(
safeChild(directory, file.name),
signal
)
files.push({ name: file.name, role: file.role, ...actual })
}
if (!packageMatches(entry, files)) {
throw new Error('向量模型文件与当前目录不匹配')
}
const installed = installedEmbeddingModelSchema.parse({
id: entry.id,
displayName: entry.displayName,
source,
installedAt: new Date().toISOString(),
files
})
await writeFile(
safeChild(directory, MANIFEST_FILE_NAME),
`${JSON.stringify(installed, null, 2)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
return installed
}
private async readInstalled(): Promise<InstalledEmbeddingModel[]> {
const entries = await readdir(this.rootDirectory, {
withFileTypes: true
})
const installed: InstalledEmbeddingModel[] = []
for (const entry of entries) {
if (
!entry.isDirectory() ||
entry.isSymbolicLink() ||
entry.name.startsWith('.install-') ||
!embeddingModelIdSchema.safeParse(entry.name).success
) {
continue
}
try {
const manifest = installedEmbeddingModelSchema.parse(
JSON.parse(
await readFile(
safeChild(
this.modelDirectory(entry.name),
MANIFEST_FILE_NAME
),
'utf8'
)
) as unknown
)
if (manifest.id === entry.name) {
installed.push(manifest)
}
} catch {
// Ignore incomplete or externally modified directories.
}
}
return installed
}
private async verifyInstalledModel(
entry: EmbeddingModelCatalogEntry
): Promise<void> {
const directory = this.modelDirectory(entry.id)
const directoryInfo = await lstat(directory)
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) {
throw new Error('向量模型目录无效')
}
const manifestPath = safeChild(directory, MANIFEST_FILE_NAME)
const manifestInfo = await lstat(manifestPath)
if (!manifestInfo.isFile() || manifestInfo.isSymbolicLink()) {
throw new Error('向量模型清单无效')
}
const manifest = installedEmbeddingModelSchema.parse(
JSON.parse(await readFile(manifestPath, 'utf8')) as unknown
)
if (manifest.id !== entry.id || !packageMatches(entry, manifest.files)) {
throw new Error('向量模型清单与目录不匹配')
}
const actualEntries = await readdir(directory, { withFileTypes: true })
if (actualEntries.length !== entry.files.length + 1) {
throw new Error('向量模型目录包含未声明文件')
}
for (const file of entry.files) {
const path = safeChild(directory, file.name)
const info = await lstat(path)
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`向量模型文件无效:${file.name}`)
}
const actual = await hashModelFile(path)
if (
actual.size !== file.size ||
actual.sha256 !== file.sha256
) {
throw new Error(`向量模型文件校验失败:${file.name}`)
}
}
}
private cleanupStaleArtifacts(): Promise<void> {
return cleanupStaleModelInstallArtifacts({
rootDirectory: this.rootDirectory,
isModelId: (value) =>
embeddingModelIdSchema.safeParse(value).success,
activeModelIds: new Set(this.operations.keys()),
partialFileNames: new Set(
this.catalog.flatMap((entry) =>
entry.files.map((file) => file.name)
)
),
escapeMessage: '向量模型路径超出受管目录'
})
}
}
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { embeddingStorageProvider } from './embedding-provider-key'
import {
embeddingProviderFingerprint,
embeddingStorageProvider
} from './embedding-provider-key'
describe('embeddingStorageProvider', () => {
it('preserves legacy provider keys without a fingerprint', () => {
@@ -23,4 +26,52 @@ describe('embeddingStorageProvider', () => {
expect(second).not.toContain('two.invalid')
expect(first.length).toBeLessThanOrEqual(128)
})
it('canonically fingerprints every vector compatibility field', () => {
const fields = {
provider: 'openai-compatible',
endpoint: 'https://vectors.invalid/v1/embeddings',
dataPath: { scope: 'network', kind: 'endpoint' },
model: 'embed-v2',
dimensions: 768,
encodingRecipe: {
recipeId: 'query-passage',
queryTemplate: 'query: {text}',
documentTemplate: 'passage: {text}'
}
} as const
const reordered = {
dimensions: 768,
model: 'embed-v2',
provider: 'openai-compatible',
encodingRecipe: {
documentTemplate: 'passage: {text}',
queryTemplate: 'query: {text}',
recipeId: 'query-passage'
},
dataPath: { kind: 'endpoint', scope: 'network' },
endpoint: 'https://vectors.invalid/v1/embeddings'
} as const
expect(embeddingProviderFingerprint(fields)).toBe(
embeddingProviderFingerprint(reordered)
)
for (const changed of [
{ ...fields, endpoint: 'https://other.invalid/v1/embeddings' },
{ ...fields, dataPath: { kind: 'endpoint', scope: 'loopback' } },
{ ...fields, model: 'embed-v3' },
{ ...fields, dimensions: 384 },
{
...fields,
encodingRecipe: {
...fields.encodingRecipe,
queryTemplate: 'search: {text}'
}
}
]) {
expect(embeddingProviderFingerprint(changed)).not.toBe(
embeddingProviderFingerprint(fields)
)
}
})
})
@@ -5,6 +5,38 @@ export interface EmbeddingProviderIdentity {
readonly fingerprint?: string
}
export interface EmbeddingProviderFingerprintFields {
readonly provider: string
readonly endpoint?: string
readonly dataPath?: unknown
readonly model: string
readonly dimensions?: number
readonly encodingRecipe?: unknown
}
function canonicalJson(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value)
}
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(',')}]`
}
const record = value as Record<string, unknown>
return `{${Object.keys(record)
.filter((key) => record[key] !== undefined)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(',')}}`
}
export function embeddingProviderFingerprint(
fields: EmbeddingProviderFingerprintFields
): string {
return `embedding-v1:${createHash('sha256')
.update(canonicalJson(fields))
.digest('hex')}`
}
export function embeddingStorageProvider(
provider: EmbeddingProviderIdentity
): string {
@@ -0,0 +1,150 @@
import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
buildGraniteInputBatch,
GraniteEmbeddingEngine,
poolGraniteClsEmbeddings,
type GraniteSessionLike,
type GraniteTokenizerLike
} from './granite-embedding-engine'
function tokenizer(encodings: Record<string, readonly number[]>): GraniteTokenizerLike {
return {
encode(text) {
const ids = encodings[text]
if (!ids) {
throw new Error('Unexpected test text')
}
return {
ids,
attention_mask: ids.map(() => 1),
token_type_ids: ids.map(() => 0)
}
},
token_to_id: () => 7
}
}
describe('GraniteEmbeddingEngine', () => {
it('pads int64 model inputs and includes token types only when requested', () => {
const batch = buildGraniteInputBatch({
tokenizer: tokenizer({ short: [101, 9, 102], long: [101, 8, 6, 102] }),
texts: ['short', 'long'],
padTokenId: 7,
includeTokenTypeIds: true
})
expect(batch.sequenceLength).toBe(4)
expect([...batch.inputIds]).toEqual([
101n, 9n, 102n, 7n, 101n, 8n, 6n, 102n
])
expect([...batch.attentionMask]).toEqual([
1n, 1n, 1n, 0n, 1n, 1n, 1n, 1n
])
expect(batch.tokenTypeIds).toBeInstanceOf(BigInt64Array)
})
it('rejects tokenized input beyond the Granite context limit', () => {
const ids = Array.from({ length: 32_769 }, () => 1)
expect(() =>
buildGraniteInputBatch({
tokenizer: tokenizer({ oversized: ids }),
texts: ['oversized'],
padTokenId: 0,
includeTokenTypeIds: false
})
).toThrow(/32768 token limit/u)
})
it('selects last_hidden_state, CLS pools, and normalizes 384 dimensions', () => {
const data = new Float32Array(2 * 384)
data[0] = 3
data[1] = 4
data[384] = 100
const vectors = poolGraniteClsEmbeddings({
outputs: {
other: {
type: 'float32',
dims: [1, 2, 384],
data: new Float32Array(768).fill(1)
},
last_hidden_state: {
type: 'float32',
dims: [1, 2, 384],
data
}
},
outputNames: ['other', 'last_hidden_state'],
batchSize: 1,
sequenceLength: 2
})
expect(vectors[0]?.slice(0, 3)).toEqual([0.6, 0.8, 0])
expect(vectors[0]).toHaveLength(384)
})
it('loads once and runs an injected ONNX session with int64 inputs', async () => {
const run = vi.fn<GraniteSessionLike['run']>(async (feeds) => {
const input = feeds.input_ids as {
readonly dimensions: readonly [number, number]
}
const [batchSize, sequenceLength] = input.dimensions
const output = new Float32Array(
batchSize * sequenceLength * 384
)
output[0] = 1
if (batchSize > 1) {
output[sequenceLength * 384] = 1
}
return {
last_hidden_state: {
type: 'float32',
dims: [batchSize, sequenceLength, 384],
data: output
}
}
})
const session: GraniteSessionLike = {
inputNames: ['input_ids', 'attention_mask'],
outputNames: ['last_hidden_state'],
run
}
const readFile = vi.fn(async (filePath: string) =>
new TextEncoder().encode(
filePath.endsWith('.onnx')
? 'model'
: filePath.endsWith('tokenizer_config.json')
? '{"pad_token":"[PAD]"}'
: '{}'
)
)
const createTensor = vi.fn(
(data: BigInt64Array, dimensions: readonly [number, number]) => ({
data,
dimensions,
type: 'int64'
})
)
const engine = new GraniteEmbeddingEngine({
readFile,
loadTokenizer: () => tokenizer({ a: [1, 2], b: [3] }),
createSession: async () => session,
createInt64Tensor: createTensor
})
const options = {
modelDirectory: path.resolve('granite-model'),
role: 'query' as const,
signal: new AbortController().signal
}
const first = await engine.embed({ ...options, texts: ['a', 'b'] })
await engine.embed({ ...options, texts: ['a'] })
expect(first).toHaveLength(2)
expect(readFile).toHaveBeenCalledTimes(3)
expect(createTensor).toHaveBeenCalledTimes(4)
expect(run).toHaveBeenCalledTimes(2)
expect(run.mock.calls[0]?.[0]).not.toHaveProperty('token_type_ids')
})
})
@@ -0,0 +1,400 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { Tokenizer } from '@huggingface/tokenizers'
import * as ort from 'onnxruntime-web/wasm'
import type {
EmbeddingInferenceEngine,
EmbeddingOnnxRuntime,
EmbeddingTokenizer
} from './embedding-inference-worker'
const MODEL_FILE = 'model_quantized.onnx'
const TOKENIZER_FILE = 'tokenizer.json'
const TOKENIZER_CONFIG_FILE = 'tokenizer_config.json'
const MAX_TOKENS = 32_768
const EMBEDDING_DIMENSIONS = 384
type Encoding = {
readonly ids: readonly number[]
readonly attention_mask: readonly number[]
readonly token_type_ids?: readonly number[]
}
export interface GraniteTokenizerLike {
encode(text: string, options?: {
readonly add_special_tokens?: boolean
readonly return_token_type_ids?: boolean | null
}): Encoding
token_to_id(token: string): number | undefined
}
export interface GraniteTensorLike {
readonly type: string
readonly dims: readonly number[]
readonly data: ArrayLike<number>
}
export interface GraniteSessionLike {
readonly inputNames: readonly string[]
readonly outputNames: readonly string[]
run(
feeds: Readonly<Record<string, unknown>>
): Promise<Readonly<Record<string, GraniteTensorLike>>>
release?(): void
}
type LoadedEngine = {
readonly tokenizer: GraniteTokenizerLike
readonly padTokenId: number
readonly session: GraniteSessionLike
}
export interface GraniteEmbeddingEngineDependencies {
readonly loadTokenizer?: (
tokenizerJson: unknown,
tokenizerConfig: unknown
) => GraniteTokenizerLike
readonly createSession?: (model: Uint8Array) => Promise<GraniteSessionLike>
readonly createInt64Tensor?: (
data: BigInt64Array,
dimensions: readonly [number, number]
) => unknown
readonly readFile?: (filePath: string) => Promise<Uint8Array>
}
type TokenizedBatch = {
readonly batchSize: number
readonly sequenceLength: number
readonly inputIds: BigInt64Array
readonly attentionMask: BigInt64Array
readonly tokenTypeIds?: BigInt64Array
}
function abortIfRequested(signal: AbortSignal): void {
if (signal.aborted) {
throw signal.reason ?? new Error('Embedding inference was cancelled')
}
}
function asJsonObject(value: unknown, name: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError(`${name} must contain a JSON object`)
}
return value as Record<string, unknown>
}
function resolvePadTokenId(
tokenizer: GraniteTokenizerLike,
rawConfig: unknown
): number {
const config = asJsonObject(rawConfig, TOKENIZER_CONFIG_FILE)
const configuredId = config.pad_token_id
if (
typeof configuredId === 'number' &&
Number.isSafeInteger(configuredId) &&
configuredId >= 0
) {
return configuredId
}
if (typeof config.pad_token === 'string') {
const tokenId = tokenizer.token_to_id(config.pad_token)
if (tokenId !== undefined) {
return tokenId
}
}
throw new Error('Tokenizer configuration does not define a valid pad token')
}
function parseJson(bytes: Uint8Array, name: string): unknown {
try {
return JSON.parse(new TextDecoder().decode(bytes)) as unknown
} catch {
throw new Error(`${name} is not valid JSON`)
}
}
export function buildGraniteInputBatch(options: {
readonly tokenizer: GraniteTokenizerLike
readonly texts: readonly string[]
readonly padTokenId: number
readonly includeTokenTypeIds: boolean
}): TokenizedBatch {
if (options.texts.length === 0) {
throw new RangeError('At least one text is required')
}
const encodings = options.texts.map((text) =>
options.tokenizer.encode(text, {
add_special_tokens: true,
return_token_type_ids: options.includeTokenTypeIds
})
)
const sequenceLength = Math.max(...encodings.map(({ ids }) => ids.length))
if (sequenceLength > MAX_TOKENS) {
throw new RangeError(
`Embedding input exceeds the ${MAX_TOKENS} token limit`
)
}
if (sequenceLength < 1) {
throw new RangeError('Tokenizer produced an empty input')
}
const itemCount = options.texts.length * sequenceLength
const inputIds = new BigInt64Array(itemCount)
inputIds.fill(BigInt(options.padTokenId))
const attentionMask = new BigInt64Array(itemCount)
const tokenTypeIds = options.includeTokenTypeIds
? new BigInt64Array(itemCount)
: undefined
for (const [batchIndex, encoding] of encodings.entries()) {
if (
encoding.attention_mask.length !== encoding.ids.length ||
(options.includeTokenTypeIds &&
encoding.token_type_ids !== undefined &&
encoding.token_type_ids.length !== encoding.ids.length)
) {
throw new Error('Tokenizer produced inconsistent input lengths')
}
const offset = batchIndex * sequenceLength
for (let index = 0; index < encoding.ids.length; index += 1) {
const tokenId = encoding.ids[index]
const mask = encoding.attention_mask[index]
if (
tokenId === undefined ||
mask === undefined ||
!Number.isSafeInteger(tokenId) ||
!Number.isSafeInteger(mask)
) {
throw new Error('Tokenizer produced an invalid token')
}
inputIds[offset + index] = BigInt(tokenId)
attentionMask[offset + index] = BigInt(mask)
if (tokenTypeIds) {
tokenTypeIds[offset + index] = BigInt(
encoding.token_type_ids?.[index] ?? 0
)
}
}
}
return {
batchSize: options.texts.length,
sequenceLength,
inputIds,
attentionMask,
tokenTypeIds
}
}
export function poolGraniteClsEmbeddings(options: {
readonly outputs: Readonly<Record<string, GraniteTensorLike>>
readonly outputNames: readonly string[]
readonly batchSize: number
readonly sequenceLength: number
}): number[][] {
const preferred = options.outputs.last_hidden_state
const output =
preferred?.dims.length === 3 &&
(preferred.type === 'float32' || preferred.type === 'float64')
? preferred
: options.outputNames
.map((name) => options.outputs[name])
.find(
(candidate) =>
candidate?.dims.length === 3 &&
(candidate.type === 'float32' ||
candidate.type === 'float64')
)
if (!output) {
throw new Error('ONNX model did not return a rank-3 float output')
}
const [batchSize, sequenceLength, dimensions] = output.dims
if (
batchSize !== options.batchSize ||
sequenceLength !== options.sequenceLength ||
dimensions !== EMBEDDING_DIMENSIONS ||
output.data.length !== batchSize * sequenceLength * dimensions
) {
throw new Error('ONNX embedding output has invalid dimensions')
}
const vectors: number[][] = []
for (let batchIndex = 0; batchIndex < batchSize; batchIndex += 1) {
const offset = batchIndex * sequenceLength * dimensions
const vector = Array.from(
{ length: dimensions },
(_, index) => output.data[offset + index] ?? Number.NaN
)
const magnitude = Math.hypot(...vector)
if (!Number.isFinite(magnitude) || magnitude <= 0) {
throw new Error('ONNX embedding output has an invalid CLS vector')
}
vectors.push(vector.map((component) => component / magnitude))
}
return vectors
}
export class GraniteEmbeddingEngine implements EmbeddingInferenceEngine {
readonly tokenizer: EmbeddingTokenizer
readonly onnx: EmbeddingOnnxRuntime
private readonly dependencies: Required<GraniteEmbeddingEngineDependencies>
private loadTask?: Promise<LoadedEngine>
private modelDirectory?: string
private disposed = false
constructor(dependencies: GraniteEmbeddingEngineDependencies = {}) {
this.dependencies = {
loadTokenizer:
dependencies.loadTokenizer ??
((tokenizerJson, tokenizerConfig) =>
new Tokenizer(
asJsonObject(tokenizerJson, TOKENIZER_FILE),
asJsonObject(tokenizerConfig, TOKENIZER_CONFIG_FILE)
)),
createSession:
dependencies.createSession ??
(async (model) =>
(await ort.InferenceSession.create(model, {
executionProviders: ['wasm']
})) as GraniteSessionLike),
createInt64Tensor:
dependencies.createInt64Tensor ??
((data, dimensions) => new ort.Tensor('int64', data, dimensions)),
readFile:
dependencies.readFile ??
(async (filePath) => new Uint8Array(await readFile(filePath)))
}
this.tokenizer = {
tokenize: async ({ texts, signal }) => {
const loaded = await this.load(this.requiredModelDirectory(), signal)
return buildGraniteInputBatch({
tokenizer: loaded.tokenizer,
texts,
padTokenId: loaded.padTokenId,
includeTokenTypeIds:
loaded.session.inputNames.includes('token_type_ids')
})
}
}
this.onnx = {
run: async ({ modelDirectory, inputs, signal }) => {
const loaded = await this.load(modelDirectory, signal)
abortIfRequested(signal)
const output = await loaded.session.run(
inputs as Readonly<Record<string, unknown>>
)
abortIfRequested(signal)
return output
}
}
}
async embed(options: Parameters<EmbeddingInferenceEngine['embed']>[0]) {
void options.role
const loaded = await this.load(options.modelDirectory, options.signal)
abortIfRequested(options.signal)
const batch = buildGraniteInputBatch({
tokenizer: loaded.tokenizer,
texts: options.texts,
padTokenId: loaded.padTokenId,
includeTokenTypeIds:
loaded.session.inputNames.includes('token_type_ids')
})
abortIfRequested(options.signal)
const dimensions: [number, number] = [
batch.batchSize,
batch.sequenceLength
]
const feeds: Record<string, unknown> = {
input_ids: this.dependencies.createInt64Tensor(
batch.inputIds,
dimensions
),
attention_mask: this.dependencies.createInt64Tensor(
batch.attentionMask,
dimensions
)
}
if (batch.tokenTypeIds) {
feeds.token_type_ids = this.dependencies.createInt64Tensor(
batch.tokenTypeIds,
dimensions
)
}
const outputs = await loaded.session.run(feeds)
abortIfRequested(options.signal)
return poolGraniteClsEmbeddings({
outputs,
outputNames: loaded.session.outputNames,
batchSize: batch.batchSize,
sequenceLength: batch.sequenceLength
})
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
const loaded = await this.loadTask?.catch(() => undefined)
loaded?.session.release?.()
}
private requiredModelDirectory(): string {
if (!this.modelDirectory) {
throw new Error('The Granite model has not been selected')
}
return this.modelDirectory
}
private load(
modelDirectory: string,
signal: AbortSignal
): Promise<LoadedEngine> {
abortIfRequested(signal)
if (this.disposed) {
throw new Error('Granite embedding engine is disposed')
}
const normalizedDirectory = path.normalize(modelDirectory)
if (
this.modelDirectory &&
this.modelDirectory !== normalizedDirectory
) {
throw new Error('Granite embedding engine cannot change model directory')
}
this.modelDirectory = normalizedDirectory
this.loadTask ??= this.loadFiles(normalizedDirectory)
return this.loadTask.then((loaded) => {
abortIfRequested(signal)
return loaded
})
}
private async loadFiles(modelDirectory: string): Promise<LoadedEngine> {
const [tokenizerBytes, tokenizerConfigBytes, modelBytes] =
await Promise.all([
this.dependencies.readFile(
path.join(modelDirectory, TOKENIZER_FILE)
),
this.dependencies.readFile(
path.join(modelDirectory, TOKENIZER_CONFIG_FILE)
),
this.dependencies.readFile(path.join(modelDirectory, MODEL_FILE))
])
const tokenizerJson = parseJson(tokenizerBytes, TOKENIZER_FILE)
const tokenizerConfig = parseJson(
tokenizerConfigBytes,
TOKENIZER_CONFIG_FILE
)
const tokenizer = this.dependencies.loadTokenizer(
tokenizerJson,
tokenizerConfig
)
const [padTokenId, session] = await Promise.all([
Promise.resolve(resolvePadTokenId(tokenizer, tokenizerConfig)),
this.dependencies.createSession(modelBytes)
])
return { tokenizer, padTokenId, session }
}
}
@@ -48,6 +48,56 @@ afterEach(async () => {
})
describe('KnowledgeService', () => {
it('uses document and query embedding roles without exposing providers to runtimes', async () => {
const embed = vi.fn(async (input: readonly string[]) =>
input.map(() => [9, 9])
)
const embedDocuments = vi.fn(async (input: readonly string[]) =>
input.map(() => [1, 0])
)
const embedQuery = vi.fn(async (input: readonly string[]) =>
input.map(() => [1, 0])
)
const provider: EmbeddingProvider = {
provider: 'openai-compatible',
model: 'role-aware',
embed,
embedDocuments,
embedQuery
}
const { directory, service } = await createService(
undefined,
provider
)
const sourcePath = join(directory, 'role-aware.md')
await writeFile(sourcePath, 'role aware knowledge content', 'utf8')
const library = service.createLibrary({
name: 'Role-aware library',
storageMode: 'reference',
graphEnabled: false
})
await service.importPaths(library.id, [sourcePath])
expect(embedDocuments).toHaveBeenCalled()
expect(embed).not.toHaveBeenCalled()
await service.retrieve({
knowledgeBaseId: library.id,
query: 'role aware',
settings: {
...library.retrievalSettings,
ftsWeight: 0,
vectorWeight: 1,
graphWeight: 0
}
})
expect(embedQuery).toHaveBeenCalledWith(
['role aware'],
expect.any(AbortSignal)
)
expect(embed).not.toHaveBeenCalled()
})
it('rebuilds vectors only for the selected knowledge base', async () => {
const embed = vi.fn(async (input: readonly string[]) =>
input.map(() => [1, 0])
+29 -3
View File
@@ -180,6 +180,26 @@ interface PreparedQueryEmbedding {
degradationReason?: string
}
function embedKnowledgeDocuments(
provider: EmbeddingProvider,
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]> {
return provider.embedDocuments
? provider.embedDocuments(input, signal)
: provider.embed(input, signal)
}
function embedKnowledgeQuery(
provider: EmbeddingProvider,
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]> {
return provider.embedQuery
? provider.embedQuery(input, signal)
: provider.embed(input, signal)
}
export class KnowledgeService {
readonly database: KnowledgeDatabase
private readonly managedRoot: string
@@ -636,7 +656,11 @@ export class KnowledgeService {
chunk.metadata?.contextPrefix
)
)
const vectors = await provider.embed(contents, effectiveSignal)
const vectors = await embedKnowledgeDocuments(
provider,
contents,
effectiveSignal
)
effectiveSignal.throwIfAborted()
if (vectors.length !== batch.length) {
throw new Error('Embedding provider returned an invalid result count')
@@ -1740,7 +1764,8 @@ export class KnowledgeService {
? AbortSignal.any([signal, this.lifecycleController.signal])
: this.lifecycleController.signal
try {
const result = await provider.embed(
const result = await embedKnowledgeQuery(
provider,
[query],
effectiveSignal
)
@@ -3164,7 +3189,8 @@ export class KnowledgeService {
) {
effectiveSignal.throwIfAborted()
const batch = chunks.slice(offset, offset + this.embeddingBatchSize)
const vectors = await provider.embed(
const vectors = await embedKnowledgeDocuments(
provider,
batch.map((chunk) => chunk.content),
effectiveSignal
)
@@ -68,6 +68,98 @@ describe('OpenAIEmbeddingClient', () => {
)
})
it('encodes query and document roles explicitly and fingerprints the recipe', async () => {
const bodies: unknown[] = []
const transport = vi.fn<typeof fetch>(async (_input, init) => {
const body = JSON.parse(String(init?.body)) as {
input: string[]
}
bodies.push(body)
return new Response(
JSON.stringify({
data: body.input.map((_, index) => ({
index,
embedding: [1, 2, 3]
}))
})
)
})
const options = {
endpoint: 'https://vectors.example/v1/embeddings',
model: 'embed-v2',
dimensions: 3,
encodingRecipe: {
recipeId: 'query-passage',
queryTemplate: 'query: {text}',
documentTemplate: 'passage: {text}'
},
fetch: transport
} as const
const client = new OpenAIEmbeddingClient(options)
const changedRecipe = new OpenAIEmbeddingClient({
...options,
encodingRecipe: {
...options.encodingRecipe,
queryTemplate: 'search: {text}'
}
})
await client.embedQuery(['question'])
await client.embedDocuments(['answer'])
await client.embed(['legacy document'])
expect(bodies).toEqual([
{
model: 'embed-v2',
input: ['query: question'],
dimensions: 3
},
{
model: 'embed-v2',
input: ['passage: answer'],
dimensions: 3
},
{
model: 'embed-v2',
input: ['passage: legacy document'],
dimensions: 3
}
])
expect(client.fingerprint).not.toBe(changedRecipe.fingerprint)
expect(client.fingerprint).not.toContain('vectors.example')
})
it('rejects vectors that do not match configured dimensions', async () => {
const client = new OpenAIEmbeddingClient({
endpoint: 'https://vectors.example/v1/embeddings',
model: 'embed-v2',
dimensions: 2,
fetch: async () =>
new Response(
JSON.stringify({
data: [{ index: 0, embedding: [1, 2, 3] }]
})
)
})
await expect(client.embedQuery(['question'])).rejects.toThrow(
'configured dimensions'
)
})
it('rejects unsupported encoding behavior instead of silently changing it', () => {
expect(
() =>
new OpenAIEmbeddingClient({
endpoint: 'https://vectors.example/v1/embeddings',
model: 'embed-v2',
encodingRecipe: {
pooling: 'mean'
} as never
})
).toThrow('pooling must be provider-managed')
})
it('distinguishes its request timeout from caller cancellation', async () => {
const waitForAbort = vi.fn<typeof fetch>(
async (_input, init) =>
+157 -4
View File
@@ -1,4 +1,5 @@
import type { EmbeddingProvider } from './types'
import { embeddingProviderFingerprint } from './embedding-provider-key'
const MAX_INPUTS = 256
const MAX_BATCH_SIZE = 32
@@ -16,11 +17,33 @@ export interface OpenAIEmbeddingClientOptions {
endpoint: string
model: string
apiKey?: string
dimensions?: number
encodingRecipe?: Partial<OpenAIEmbeddingEncodingRecipe>
batchSize?: number
timeoutMs?: number
fetch?: typeof fetch
}
export type EmbeddingInputRole = 'query' | 'document'
export interface OpenAIEmbeddingEncodingRecipe {
recipeId: string
tokenizerDigest?: string
pooling: 'provider-managed'
normalization: 'provider-managed'
queryTemplate: string
documentTemplate: string
maximumSequenceTokens?: number
}
const DEFAULT_ENCODING_RECIPE: OpenAIEmbeddingEncodingRecipe = {
recipeId: 'symmetric',
pooling: 'provider-managed',
normalization: 'provider-managed',
queryTemplate: '{text}',
documentTemplate: '{text}'
}
function boundedInteger(
value: number,
field: string,
@@ -56,6 +79,71 @@ function normalizedEndpoint(input: string): string {
return url.toString()
}
function endpointScope(endpoint: string): 'loopback' | 'network' {
const hostname = new URL(endpoint).hostname.toLowerCase()
return hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]'
? 'loopback'
: 'network'
}
function normalizedTemplate(value: string, field: string): string {
const template = requiredString(value, field, MAX_INPUT_LENGTH)
if (template.split('{text}').length !== 2) {
throw new RangeError(`${field} must contain exactly one {text} placeholder`)
}
return template
}
function normalizedEncodingRecipe(
input: Partial<OpenAIEmbeddingEncodingRecipe> | undefined
): OpenAIEmbeddingEncodingRecipe {
const recipe = { ...DEFAULT_ENCODING_RECIPE, ...input }
if (recipe.pooling !== 'provider-managed') {
throw new RangeError(
'encodingRecipe.pooling must be provider-managed'
)
}
if (recipe.normalization !== 'provider-managed') {
throw new RangeError(
'encodingRecipe.normalization must be provider-managed'
)
}
return {
recipeId: requiredString(recipe.recipeId, 'encodingRecipe.recipeId', 256),
...(recipe.tokenizerDigest
? {
tokenizerDigest: requiredString(
recipe.tokenizerDigest,
'encodingRecipe.tokenizerDigest',
256
)
}
: {}),
pooling: 'provider-managed',
normalization: 'provider-managed',
queryTemplate: normalizedTemplate(
recipe.queryTemplate,
'encodingRecipe.queryTemplate'
),
documentTemplate: normalizedTemplate(
recipe.documentTemplate,
'encodingRecipe.documentTemplate'
),
...(recipe.maximumSequenceTokens !== undefined
? {
maximumSequenceTokens: boundedInteger(
recipe.maximumSequenceTokens,
'encodingRecipe.maximumSequenceTokens',
1,
1_000_000
)
}
: {})
}
}
function embeddingAbortError(
requestSignal: AbortSignal,
timeoutError: Error
@@ -174,6 +262,8 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
readonly model: string
readonly fingerprint: string
private readonly endpoint: string
private readonly dimensions?: number
private readonly encodingRecipe: OpenAIEmbeddingEncodingRecipe
private readonly apiKey?: string
private readonly batchSize: number
private readonly timeoutMs: number
@@ -183,7 +273,22 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
this.endpoint = normalizedEndpoint(options.endpoint)
this.model = requiredString(options.model, 'model', MAX_MODEL_LENGTH)
this.apiKey = options.apiKey?.trim() || undefined
this.fingerprint = `${this.provider}:${this.endpoint}:${this.model}`
this.dimensions =
options.dimensions === undefined
? undefined
: boundedInteger(options.dimensions, 'dimensions', 1, MAX_DIMENSIONS)
this.encodingRecipe = normalizedEncodingRecipe(options.encodingRecipe)
this.fingerprint = embeddingProviderFingerprint({
provider: this.provider,
endpoint: this.endpoint,
dataPath: {
kind: 'endpoint',
scope: endpointScope(this.endpoint)
},
model: this.model,
dimensions: this.dimensions,
encodingRecipe: this.encodingRecipe
})
this.batchSize = boundedInteger(
options.batchSize ?? 16,
'batchSize',
@@ -206,6 +311,31 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]> {
return this.embedRole(input, 'document', signal)
}
async embedQuery(
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]> {
return this.embedRole(input, 'query', signal)
}
async embedDocuments(
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]> {
return this.embedRole(input, 'document', signal)
}
async embedRole(
input: readonly string[],
role: EmbeddingInputRole,
signal?: AbortSignal
): Promise<number[][]> {
if (role !== 'query' && role !== 'document') {
throw new RangeError('role must be query or document')
}
if (!Array.isArray(input) || input.length < 1 || input.length > MAX_INPUTS) {
throw new RangeError(`input must contain between 1 and ${MAX_INPUTS} items`)
}
@@ -218,7 +348,17 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
`input[${index}] must be at most ${MAX_INPUT_LENGTH} characters`
)
}
return item
const template =
role === 'query'
? this.encodingRecipe.queryTemplate
: this.encodingRecipe.documentTemplate
const encoded = template.replace('{text}', item)
if (encoded.length > MAX_INPUT_LENGTH) {
throw new RangeError(
`encoded input[${index}] must be at most ${MAX_INPUT_LENGTH} characters`
)
}
return encoded
})
const embeddings: number[][] = []
@@ -284,7 +424,13 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
response = await this.transport(this.endpoint, {
method: 'POST',
headers,
body: JSON.stringify({ model: this.model, input }),
body: JSON.stringify({
model: this.model,
input,
...(this.dimensions === undefined
? {}
: { dimensions: this.dimensions })
}),
redirect: 'error',
signal: requestSignal
})
@@ -293,10 +439,17 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
`Embedding request failed with HTTP ${response.status}`
)
}
return validateEmbeddings(
const vectors = validateEmbeddings(
await readBoundedJson(response),
input.length
)
if (
this.dimensions !== undefined &&
vectors.some((vector) => vector.length !== this.dimensions)
) {
throw new Error('Embeddings do not match configured dimensions')
}
return vectors
} catch (error) {
if (requestSignal.aborted) {
throw embeddingAbortError(requestSignal, timeoutError)
+8
View File
@@ -158,6 +158,14 @@ export interface EmbeddingProvider {
readonly model: string
readonly fingerprint?: string
embed(input: readonly string[], signal?: AbortSignal): Promise<number[][]>
embedQuery?(
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]>
embedDocuments?(
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]>
}
export interface RerankProviderResult {
+1 -1
View File
@@ -47,7 +47,7 @@ const modelArchiveFileSchema = z
const modelArchiveDescriptorSchema = z
.object({
kind: z.enum(['speech', 'document-ocr']),
kind: z.enum(['speech', 'document-ocr', 'embedding']),
modelId: z
.string()
.min(1)
+120 -6
View File
@@ -9,6 +9,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
builtinEmbeddingConnectionId,
legacyEmbeddingConnectionId,
runtimeSettingsInputSchema,
type RuntimeSettingsInput
} from '../shared/contracts'
@@ -479,7 +481,7 @@ describe('RuntimeSettingsStore', () => {
const persisted = JSON.parse(
await readFile(filePath, 'utf8')
) as Record<string, unknown>
expect(persisted.version).toBe(18)
expect(persisted.version).toBe(19)
expect(persisted).not.toHaveProperty(
'deepseekHarnessBinaryPath'
)
@@ -758,7 +760,7 @@ describe('RuntimeSettingsStore', () => {
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
version: number
}
expect(persisted.version).toBe(18)
expect(persisted.version).toBe(19)
})
it('migrates version 11 and removes the obsolete intranet toggle', async () => {
@@ -778,7 +780,7 @@ describe('RuntimeSettingsStore', () => {
version: number
intranetCompatibilityEnabled?: boolean
}
expect(persisted.version).toBe(18)
expect(persisted.version).toBe(19)
expect(persisted).not.toHaveProperty('intranetCompatibilityEnabled')
})
@@ -947,6 +949,118 @@ describe('RuntimeSettingsStore', () => {
})
})
it('migrates version 18 embedding settings into a separate user connection', async () => {
const { filePath, store } = await createStore()
await store.update(
settings({
knowledgeEmbeddingEnabled: true,
knowledgeEmbeddingBaseUrl:
'https://legacy-vectors.example/v1/embeddings',
knowledgeEmbeddingModel: 'legacy/embed-large',
knowledgeEmbeddingApiKey: {
action: 'replace',
value: 'legacy-vector-secret'
}
})
)
const previous = JSON.parse(
await readFile(filePath, 'utf8')
) as Record<string, unknown>
previous.version = 18
delete previous.embeddingConnections
delete previous.activeEmbeddingConnectionId
await writeFile(filePath, JSON.stringify(previous), 'utf8')
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
await expect(migrated.getPublicSettings()).resolves.toMatchObject({
activeEmbeddingConnectionId: legacyEmbeddingConnectionId,
embeddingConnections: [
{
id: builtinEmbeddingConnectionId,
kind: 'builtin',
apiKeyConfigured: false,
credentialSource: 'none'
},
{
id: legacyEmbeddingConnectionId,
kind: 'openai-compatible',
baseUrl: 'https://legacy-vectors.example/v1/embeddings',
modelName: 'legacy/embed-large',
apiKeyConfigured: true,
credentialSource: 'encrypted'
}
],
knowledgeEmbeddingBaseUrl:
'https://legacy-vectors.example/v1/embeddings',
knowledgeEmbeddingModel: 'legacy/embed-large'
})
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
activeEmbeddingConnection: {
id: legacyEmbeddingConnectionId,
apiKey: 'legacy-vector-secret'
},
knowledgeEmbeddingApiKey: 'legacy-vector-secret'
})
})
it('round-trips embedding connections without assigning them to Runtime profiles', async () => {
const { filePath, store } = await createStore()
const connectionId = '00000000-0000-4000-8000-000000000081'
await store.update(
settings({
embeddingConnections: [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin'
},
{
id: connectionId,
name: '团队向量模型',
kind: 'openai-compatible',
baseUrl: 'https://team-vectors.example/v1/embeddings',
modelName: 'team/embed-v2',
authentication: 'api-key',
apiKey: {
action: 'replace',
value: 'team-vector-secret'
}
}
],
activeEmbeddingConnectionId: connectionId
})
)
const reloaded = new RuntimeSettingsStore(filePath, cipher, {})
await expect(reloaded.getResolvedSettings()).resolves.toMatchObject({
activeEmbeddingConnectionId: connectionId,
activeEmbeddingConnection: {
id: connectionId,
kind: 'openai-compatible',
apiKey: 'team-vector-secret'
},
modelProfiles: [
expect.not.objectContaining({ id: connectionId })
]
})
const publicSettings = await reloaded.getPublicSettings()
expect(JSON.stringify(publicSettings)).not.toContain(
'team-vector-secret'
)
expect(publicSettings.embeddingConnections).toContainEqual(
expect.objectContaining({
id: connectionId,
apiKeyConfigured: true,
credentialSource: 'encrypted'
})
)
expect(
(JSON.parse(await readFile(filePath, 'utf8')) as {
version: number
}).version
).toBe(19)
})
it('migrates version 13 with reranking disabled by default', async () => {
const { filePath, store } = await createStore()
await store.update(settings())
@@ -1451,7 +1565,7 @@ describe('RuntimeSettingsStore', () => {
version: number
modelProfiles: Array<Record<string, unknown>>
}
expect(persisted.version).toBe(18)
expect(persisted.version).toBe(19)
expect(persisted.modelProfiles).toContainEqual(
expect.objectContaining({
id: imageId,
@@ -1750,7 +1864,7 @@ describe('RuntimeSettingsStore', () => {
unknown
>
expect(saved).toMatchObject({
version: 18,
version: 19,
provider: 'model',
continueBinaryPath: '',
continueMode: 'chat',
@@ -2029,7 +2143,7 @@ describe('RuntimeSettingsStore', () => {
version: number
modelProfiles: Array<Record<string, unknown>>
}
expect(persisted.version).toBe(18)
expect(persisted.version).toBe(19)
expect(persisted.modelProfiles[0]).not.toHaveProperty('credential')
})
+414 -71
View File
@@ -7,12 +7,14 @@ import { homedir } from 'node:os'
import { z } from 'zod'
import {
continueModeSchema,
builtinEmbeddingConnectionId,
contextCompressionSettingsSchema,
defaultContextCompressionSettings,
defaultModelProfileId,
defaultRuntimeCustomizationSettings,
defaultRuntimeSettings,
imageGenerationQualitySchema,
legacyEmbeddingConnectionId,
isAgentRuntimeModelProtocol,
isDeepSeekHarnessModelProfile,
minimumModelContextWindowTokens,
@@ -227,13 +229,47 @@ const version17StoredSettingsSchema = version16StoredSettingsSchema
contextCompression: contextCompressionSettingsSchema
})
const storedSettingsSchema = version17StoredSettingsSchema
const version18StoredSettingsSchema = version17StoredSettingsSchema
.omit({ version: true })
.extend({
version: z.literal(18),
runtimeCustomization: runtimeCustomizationSettingsSchema
})
const storedEmbeddingConnectionSchema = z.discriminatedUnion('kind', [
z
.object({
id: z.literal(builtinEmbeddingConnectionId),
name: z.string().trim().min(1).max(64),
kind: z.literal('builtin')
})
.strict(),
z
.object({
id: z.string().uuid().refine(
(id) => id !== builtinEmbeddingConnectionId
),
name: z.string().trim().min(1).max(64),
kind: z.literal('openai-compatible'),
baseUrl: z.string().url().max(2_048),
modelName: z.string().trim().min(1).max(256),
authentication: modelAuthenticationSchema,
credential: credentialSchema
})
.strict()
])
const storedSettingsSchema = version18StoredSettingsSchema
.omit({ version: true })
.extend({
version: z.literal(19),
embeddingConnections: z
.array(storedEmbeddingConnectionSchema)
.min(1)
.max(20),
activeEmbeddingConnectionId: z.string().uuid()
})
type StoredSettings = z.infer<typeof storedSettingsSchema>
export type RuntimeSettingsRollback = {
publicSettings: RuntimeSettings
@@ -242,6 +278,9 @@ export type RuntimeSettingsRollback = {
type Version17StoredSettings = z.infer<
typeof version17StoredSettingsSchema
>
type Version18StoredSettings = z.infer<
typeof version18StoredSettingsSchema
>
type Version16StoredSettings = z.infer<
typeof version16StoredSettingsSchema
>
@@ -356,6 +395,9 @@ export type ResolvedRuntimeSettings = {
knowledgeEmbeddingBaseUrl: string
knowledgeEmbeddingModel: string
knowledgeEmbeddingApiKey?: string
embeddingConnections?: ResolvedEmbeddingConnection[]
activeEmbeddingConnectionId?: string
activeEmbeddingConnection?: ResolvedEmbeddingConnection
knowledgeRerankEnabled: boolean
knowledgeRerankEndpoint: string
knowledgeRerankModel: string
@@ -384,6 +426,22 @@ export type ResolvedModelProfile = {
apiKey?: string
}
export type ResolvedEmbeddingConnection =
| {
id: typeof builtinEmbeddingConnectionId
name: string
kind: 'builtin'
}
| {
id: string
name: string
kind: 'openai-compatible'
baseUrl: string
modelName: string
authentication: RuntimeSettings['modelAuthentication']
apiKey?: string
}
type ResolvedModelCredential = {
activeApiKey?: string
configured: boolean
@@ -391,7 +449,7 @@ type ResolvedModelCredential = {
}
const defaultSettings: StoredSettings = {
version: 18,
version: 19,
provider: defaultRuntimeSettings.provider,
modelProfiles: [
{
@@ -431,6 +489,22 @@ const defaultSettings: StoredSettings = {
defaultRuntimeSettings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel:
defaultRuntimeSettings.knowledgeEmbeddingModel,
embeddingConnections: [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin'
},
{
id: legacyEmbeddingConnectionId,
name: '自定义向量模型',
kind: 'openai-compatible',
baseUrl: defaultRuntimeSettings.knowledgeEmbeddingBaseUrl,
modelName: defaultRuntimeSettings.knowledgeEmbeddingModel,
authentication: 'api-key'
}
],
activeEmbeddingConnectionId: builtinEmbeddingConnectionId,
knowledgeRerankEnabled:
defaultRuntimeSettings.knowledgeRerankEnabled,
knowledgeRerankEndpoint:
@@ -528,13 +602,13 @@ function migrateVersion14(
...current
} = settings
void _obsolete
return {
return migrateVersion18({
...current,
version: 18,
deepseekHarnessModelSource: { kind: 'platform' },
contextCompression: defaultContextCompressionSettings,
runtimeCustomization: defaultRuntimeCustomizationSettings
}
})
}
function migrateVersion15(
@@ -547,32 +621,58 @@ function migrateVersion15(
} = settings
void _obsolete
void _obsoleteSandbox
return {
return migrateVersion18({
...current,
version: 18,
contextCompression: defaultContextCompressionSettings,
runtimeCustomization: defaultRuntimeCustomizationSettings
}
})
}
function migrateVersion16(
settings: Version16StoredSettings
): StoredSettings {
return {
return migrateVersion18({
...settings,
version: 18,
contextCompression: defaultContextCompressionSettings,
runtimeCustomization: defaultRuntimeCustomizationSettings
}
})
}
function migrateVersion17(
settings: Version17StoredSettings
): StoredSettings {
return {
return migrateVersion18({
...settings,
version: 18,
runtimeCustomization: defaultRuntimeCustomizationSettings
})
}
function migrateVersion18(
settings: Version18StoredSettings
): StoredSettings {
return {
...settings,
version: 19,
embeddingConnections: [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin'
},
{
id: legacyEmbeddingConnectionId,
name: '自定义向量模型',
kind: 'openai-compatible',
baseUrl: settings.knowledgeEmbeddingBaseUrl,
modelName: settings.knowledgeEmbeddingModel,
authentication: 'api-key',
credential: settings.knowledgeEmbeddingCredential
}
],
activeEmbeddingConnectionId: legacyEmbeddingConnectionId
}
}
@@ -681,6 +781,26 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
? compressionSource
: ({ kind: 'current' } as const)
}
const userEmbeddingConnections = settings.embeddingConnections
.filter((connection) => connection.kind === 'openai-compatible')
.map((connection) => ({
...connection,
baseUrl: normalizeModelBaseUrl(connection.baseUrl)
}))
const embeddingConnections: StoredSettings['embeddingConnections'] = [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin'
},
...userEmbeddingConnections
]
const activeEmbeddingConnectionId = embeddingConnections.some(
(connection) =>
connection.id === settings.activeEmbeddingConnectionId
)
? settings.activeEmbeddingConnectionId
: builtinEmbeddingConnectionId
return {
...settings,
@@ -698,6 +818,8 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
settings.deepseekHarnessModelSource
),
contextCompression,
embeddingConnections,
activeEmbeddingConnectionId,
opencodeBaseUrl,
opencodeEmbedded: !opencodeBaseUrl
}
@@ -873,7 +995,7 @@ export class RuntimeSettingsStore {
const parsed: unknown = JSON.parse(contents)
assertSupportedSettingsVersion(
parsed,
18,
19,
(version) =>
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
)
@@ -881,76 +1003,81 @@ export class RuntimeSettingsStore {
if (current.success) {
this.settings = current.data
} else {
const version17 =
version17StoredSettingsSchema.safeParse(parsed)
if (version17.success) {
this.settings = migrateVersion17(version17.data)
const version18 =
version18StoredSettingsSchema.safeParse(parsed)
if (version18.success) {
this.settings = migrateVersion18(version18.data)
} else {
const version16 =
version16StoredSettingsSchema.safeParse(parsed)
if (version16.success) {
this.settings = migrateVersion16(version16.data)
const version17 =
version17StoredSettingsSchema.safeParse(parsed)
if (version17.success) {
this.settings = migrateVersion17(version17.data)
} else {
const version15 =
version15StoredSettingsSchema.safeParse(parsed)
if (version15.success) {
this.settings = migrateVersion15(version15.data)
const version16 =
version16StoredSettingsSchema.safeParse(parsed)
if (version16.success) {
this.settings = migrateVersion16(version16.data)
} else {
const version14 =
version14StoredSettingsSchema.safeParse(parsed)
if (version14.success) {
this.settings = migrateVersion14(version14.data)
const version15 =
version15StoredSettingsSchema.safeParse(parsed)
if (version15.success) {
this.settings = migrateVersion15(version15.data)
} else {
const version13 =
version13StoredSettingsSchema.safeParse(parsed)
if (version13.success) {
this.settings = migrateVersion13(version13.data)
const version14 =
version14StoredSettingsSchema.safeParse(parsed)
if (version14.success) {
this.settings = migrateVersion14(version14.data)
} else {
const version12 =
version12StoredSettingsSchema.safeParse(parsed)
if (version12.success) {
this.settings = migrateVersion12(version12.data)
const version13 =
version13StoredSettingsSchema.safeParse(parsed)
if (version13.success) {
this.settings = migrateVersion13(version13.data)
} else {
const version11 =
version11StoredSettingsSchema.safeParse(parsed)
if (version11.success) {
this.settings = migrateVersion11(version11.data)
const version12 =
version12StoredSettingsSchema.safeParse(parsed)
if (version12.success) {
this.settings = migrateVersion12(version12.data)
} else {
const version10 =
version10StoredSettingsSchema.safeParse(parsed)
if (version10.success) {
this.settings = migrateVersion10(version10.data)
const version11 =
version11StoredSettingsSchema.safeParse(parsed)
if (version11.success) {
this.settings = migrateVersion11(version11.data)
} else {
const version9 =
version9StoredSettingsSchema.safeParse(parsed)
if (version9.success) {
this.settings = migrateVersion9(version9.data)
const version10 =
version10StoredSettingsSchema.safeParse(parsed)
if (version10.success) {
this.settings = migrateVersion10(version10.data)
} else {
const version8 =
version8StoredSettingsSchema.safeParse(parsed)
if (version8.success) {
this.settings = migrateVersion8(version8.data)
const version9 =
version9StoredSettingsSchema.safeParse(parsed)
if (version9.success) {
this.settings = migrateVersion9(version9.data)
} else {
const version7 =
version7StoredSettingsSchema.safeParse(parsed)
if (version7.success) {
this.settings = migrateVersion7(version7.data)
const version8 =
version8StoredSettingsSchema.safeParse(parsed)
if (version8.success) {
this.settings = migrateVersion8(version8.data)
} else {
const version6 =
version6StoredSettingsSchema.safeParse(parsed)
if (version6.success) {
this.settings = migrateVersion6(version6.data)
const version7 =
version7StoredSettingsSchema.safeParse(parsed)
if (version7.success) {
this.settings = migrateVersion7(version7.data)
} else {
const version5 =
version5StoredSettingsSchema.safeParse(parsed)
if (version5.success) {
this.settings = migrateVersion5(version5.data)
const version6 =
version6StoredSettingsSchema.safeParse(parsed)
if (version6.success) {
this.settings = migrateVersion6(version6.data)
} else {
const version4 =
version4StoredSettingsSchema.safeParse(parsed)
if (version4.success) {
this.settings = migrateVersion4(version4.data)
const version5 =
version5StoredSettingsSchema.safeParse(parsed)
if (version5.success) {
this.settings = migrateVersion5(version5.data)
} else {
const version4 =
version4StoredSettingsSchema.safeParse(parsed)
if (version4.success) {
this.settings = migrateVersion4(version4.data)
} else {
const version3 =
version3StoredSettingsSchema.safeParse(parsed)
if (version3.success) {
@@ -1007,6 +1134,7 @@ export class RuntimeSettingsStore {
})
}
}
}
}
}
}
@@ -1095,6 +1223,64 @@ export class RuntimeSettingsStore {
}
}
private getEmbeddingConnectionApiKey(
connection: Extract<
StoredSettings['embeddingConnections'][number],
{ kind: 'openai-compatible' }
>
): string | undefined {
if (!connection.credential) {
return undefined
}
if (!this.cipher.isAvailable()) {
this.addWarning({
code: 'runtime-embedding-credential-unreadable'
})
return undefined
}
try {
return endpointCredentialPayloadSchema.parse(
decryptSettingsCredential(this.cipher, connection.credential)
).apiKey
} catch {
this.addWarning({
code: 'runtime-embedding-credential-unreadable'
})
return undefined
}
}
private resolveEmbeddingConnections(
settings: StoredSettings
): ResolvedEmbeddingConnection[] {
const environmentApiKey =
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim()
return settings.embeddingConnections.map((connection) => {
if (connection.kind === 'builtin') {
return connection
}
const storedApiKey =
connection.id === legacyEmbeddingConnectionId &&
environmentApiKey
? undefined
: this.getEmbeddingConnectionApiKey(connection)
return {
id: connection.id,
name: connection.name,
kind: connection.kind,
baseUrl: connection.baseUrl,
modelName: connection.modelName,
authentication: connection.authentication,
apiKey:
connection.authentication === 'api-key'
? connection.id === legacyEmbeddingConnectionId
? environmentApiKey || storedApiKey
: storedApiKey
: undefined
}
})
}
private getStoredRerankApiKey(
settings: StoredSettings
): string | undefined {
@@ -1429,6 +1615,44 @@ export class RuntimeSettingsStore {
const embeddingStoredApiKey = embeddingEnvironmentApiKey
? undefined
: this.getStoredEmbeddingApiKey(settings)
const resolvedEmbeddingConnections =
this.resolveEmbeddingConnections(settings)
const embeddingConnections = settings.embeddingConnections.map(
(connection) => {
if (connection.kind === 'builtin') {
return {
...connection,
apiKeyConfigured: false as const,
credentialSource: 'none' as const
}
}
const resolved = resolvedEmbeddingConnections.find(
(candidate) => candidate.id === connection.id
)
const environmentManaged =
connection.id === legacyEmbeddingConnectionId &&
Boolean(embeddingEnvironmentApiKey)
return {
id: connection.id,
name: connection.name,
kind: connection.kind,
baseUrl: connection.baseUrl,
modelName: connection.modelName,
authentication: connection.authentication,
apiKeyConfigured:
resolved?.kind === 'openai-compatible' &&
Boolean(resolved.apiKey),
credentialSource: environmentManaged
? ('environment' as const)
: resolved?.kind === 'openai-compatible' &&
resolved.apiKey
? ('encrypted' as const)
: connection.credential
? ('unreadable' as const)
: ('none' as const)
}
}
)
const rerankEnvironmentApiKey =
this.environment.GOODBUDDY_RERANK_API_KEY?.trim()
const rerankStoredApiKey = rerankEnvironmentApiKey
@@ -1464,6 +1688,9 @@ export class RuntimeSettingsStore {
: settings.knowledgeEmbeddingCredential
? 'unreadable'
: 'none',
embeddingConnections,
activeEmbeddingConnectionId:
settings.activeEmbeddingConnectionId,
knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
knowledgeRerankModel: settings.knowledgeRerankModel,
@@ -1588,6 +1815,16 @@ export class RuntimeSettingsStore {
settings.deepseekHarnessModelSource.profileId
)
: this.resolvePlatformHarnessProfile()
const embeddingConnections =
this.resolveEmbeddingConnections(settings)
const activeEmbeddingConnection =
embeddingConnections.find(
(connection) =>
connection.id === settings.activeEmbeddingConnectionId
)
if (!activeEmbeddingConnection) {
throw new Error('当前向量连接不存在')
}
return {
provider: settings.provider,
modelBaseUrl: effective.baseUrl,
@@ -1611,6 +1848,10 @@ export class RuntimeSettingsStore {
knowledgeEmbeddingApiKey:
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim() ||
this.getStoredEmbeddingApiKey(settings),
embeddingConnections,
activeEmbeddingConnectionId:
settings.activeEmbeddingConnectionId,
activeEmbeddingConnection,
knowledgeRerankEnabled: settings.knowledgeRerankEnabled,
knowledgeRerankEndpoint: settings.knowledgeRerankEndpoint,
knowledgeRerankModel: settings.knowledgeRerankModel,
@@ -1680,6 +1921,12 @@ export class RuntimeSettingsStore {
profile.apiKey.action === 'replace'
) ||
input.knowledgeEmbeddingApiKey?.action === 'replace' ||
input.embeddingConnections?.some(
(connection) =>
connection.kind === 'openai-compatible' &&
connection.authentication === 'api-key' &&
connection.apiKey.action === 'replace'
) ||
input.knowledgeRerankApiKey?.action === 'replace'
) &&
!this.cipher.isAvailable()
@@ -1732,6 +1979,71 @@ export class RuntimeSettingsStore {
return nextProfile
})
const embeddingConnections: StoredSettings['embeddingConnections'] =
input.embeddingConnections
? input.embeddingConnections.map((connection) => {
if (connection.kind === 'builtin') {
return {
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin'
}
}
const existing = current.embeddingConnections.find(
(candidate) =>
candidate.kind === 'openai-compatible' &&
candidate.id === connection.id
)
const nextConnection: Extract<
StoredSettings['embeddingConnections'][number],
{ kind: 'openai-compatible' }
> = {
id: connection.id,
name: connection.name,
kind: connection.kind,
baseUrl: normalizeModelBaseUrl(connection.baseUrl),
modelName: connection.modelName,
authentication: connection.authentication
}
if (
connection.apiKey.action === 'keep' &&
existing?.kind === 'openai-compatible' &&
existing.credential
) {
nextConnection.credential = existing.credential
} else if (
connection.authentication === 'api-key' &&
connection.apiKey.action === 'replace'
) {
nextConnection.credential = encryptSavedApiKey(
this.cipher,
connection.apiKey.value
)
}
return nextConnection
})
: current.embeddingConnections
if (
!embeddingConnections.some(
(connection) =>
connection.id === builtinEmbeddingConnectionId &&
connection.kind === 'builtin'
)
) {
throw new Error('系统向量连接不能删除')
}
const activeEmbeddingConnectionId =
input.activeEmbeddingConnectionId ??
current.activeEmbeddingConnectionId
if (
!embeddingConnections.some(
(connection) =>
connection.id === activeEmbeddingConnectionId
)
) {
throw new Error('当前向量连接不存在')
}
const embeddingEndpoint = new URL(
input.knowledgeEmbeddingBaseUrl
).toString()
@@ -1750,6 +2062,29 @@ export class RuntimeSettingsStore {
embeddingApiKeyUpdate.value
)
}
const synchronizedEmbeddingConnections =
input.embeddingConnections
? embeddingConnections
: embeddingConnections.map((connection) =>
connection.id === legacyEmbeddingConnectionId &&
connection.kind === 'openai-compatible'
? {
...connection,
baseUrl: embeddingEndpoint,
modelName: input.knowledgeEmbeddingModel,
credential: knowledgeEmbeddingCredential
}
: connection
)
const activeEmbeddingConnection =
synchronizedEmbeddingConnections.find(
(connection) =>
connection.id === activeEmbeddingConnectionId
)
const compatibleEmbeddingConnection =
activeEmbeddingConnection?.kind === 'openai-compatible'
? activeEmbeddingConnection
: undefined
const rerankEndpoint = new URL(
input.knowledgeRerankEndpoint
@@ -1893,7 +2228,7 @@ export class RuntimeSettingsStore {
const next: StoredSettings = {
...current,
version: 18,
version: 19,
provider: input.provider,
modelProfiles,
defaultModelProfileId,
@@ -1912,9 +2247,17 @@ export class RuntimeSettingsStore {
input.subagentSmartRoutingEnabled ??
current.subagentSmartRoutingEnabled,
knowledgeEmbeddingEnabled: input.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl: embeddingEndpoint,
knowledgeEmbeddingModel: input.knowledgeEmbeddingModel,
knowledgeEmbeddingCredential,
knowledgeEmbeddingBaseUrl:
compatibleEmbeddingConnection?.baseUrl ?? embeddingEndpoint,
knowledgeEmbeddingModel:
compatibleEmbeddingConnection?.modelName ??
input.knowledgeEmbeddingModel,
knowledgeEmbeddingCredential:
compatibleEmbeddingConnection
? compatibleEmbeddingConnection.credential
: knowledgeEmbeddingCredential,
embeddingConnections: synchronizedEmbeddingConnections,
activeEmbeddingConnectionId,
knowledgeRerankEnabled: input.knowledgeRerankEnabled,
knowledgeRerankEndpoint: rerankEndpoint,
knowledgeRerankModel: input.knowledgeRerankModel,
@@ -1946,7 +2289,7 @@ export class RuntimeSettingsStore {
const current = await this.load()
const next: StoredSettings = {
...current,
version: 18,
version: 19,
runtimeCustomization: parsed
}
await writeJsonFileAtomically(this.filePath, next)
+33 -3
View File
@@ -84,6 +84,7 @@ import type {
} from '../shared/speech-model-contracts'
import type {
EmbeddingDiagnosticResult,
EmbeddingModelSnapshot,
EmbeddingSettingsSnapshot,
KnowledgeEmbeddingIndexSnapshot
} from '../shared/embedding-contracts'
@@ -485,10 +486,39 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke(
ipcChannels.embeddingSettingsGet
) as Promise<EmbeddingSettingsSnapshot>,
diagnose: () =>
diagnose: (connectionId: string) =>
ipcRenderer.invoke(
ipcChannels.embeddingDiagnose
) as Promise<EmbeddingDiagnosticResult>
ipcChannels.embeddingDiagnose,
{ connectionId }
) as Promise<EmbeddingDiagnosticResult>,
setCurrent: (connectionId: string) =>
ipcRenderer.invoke(
ipcChannels.embeddingSetCurrent,
{ connectionId }
) as Promise<EmbeddingSettingsSnapshot>,
installModel: (
modelId: string,
expectedDownloadSource: ModelDownloadSource
) =>
ipcRenderer.invoke(
ipcChannels.embeddingModelsInstall,
{ modelId, expectedDownloadSource }
) as Promise<EmbeddingModelSnapshot>,
cancelModelOperation: (modelId: string) =>
ipcRenderer.invoke(
ipcChannels.embeddingModelsCancel,
{ modelId }
) as Promise<boolean>,
importModelArchive: (modelId: string) =>
ipcRenderer.invoke(
ipcChannels.embeddingModelsImportArchive,
{ modelId }
) as Promise<EmbeddingModelSnapshot | undefined>,
removeModel: (modelId: string) =>
ipcRenderer.invoke(
ipcChannels.embeddingModelsRemove,
{ modelId }
) as Promise<EmbeddingModelSnapshot>
},
documentParsing: {
getSnapshot: () =>
+23
View File
@@ -46,8 +46,31 @@ describe('sandboxed preload', () => {
expect(source).toContain('exportArchive: (modelId: string)')
expect(source).toContain('importOcrModelArchive: (modelId: string)')
expect(source).toContain('exportOcrModelArchive: (modelId: string)')
expect(source).toContain('importModelArchive: (modelId: string)')
expect(source).toContain('ipcChannels.embeddingModelsImportArchive')
expect(source).not.toContain('importLocalDirectory:')
expect(source).not.toContain('importOcrModel:')
expect(source).not.toMatch(
/importModelArchive:\s*\([^)]*(?:path|directory|archivePath)/u
)
})
it('exposes explicit embedding settings operations without vectors or credentials', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
)
const embeddings =
source.match(
/embeddings: \{(?<body>[\s\S]*?)\n {2}\},\n {2}documentParsing:/u
)?.groups?.body ?? ''
expect(embeddings).toContain('getSnapshot:')
expect(embeddings).toContain('diagnose: (connectionId: string)')
expect(embeddings).toContain('setCurrent: (connectionId: string)')
expect(embeddings).toContain('installModel:')
expect(embeddings).toContain('cancelModelOperation:')
expect(embeddings).toContain('removeModel:')
expect(embeddings).not.toMatch(/\b(?:apiKey|credential|vector)\b/iu)
})
it('passes an explicit parsing scenario to document diagnostics', () => {
+13 -6
View File
@@ -108,6 +108,12 @@ describe('ActivityPanel', () => {
expect(
screen.getByRole('heading', { level: 1, name: 'Run history' })
).toBeInTheDocument()
expect(screen.queryByText('RUN HISTORY')).not.toBeInTheDocument()
expect(
screen.queryByText(
'Review execution details by project, task, and conversation, or browse the activity timeline and model usage.'
)
).not.toBeInTheDocument()
expect(
screen.getByRole('tab', { name: 'Tasks and conversations' })
).toHaveAttribute('aria-selected', 'true')
@@ -234,11 +240,11 @@ describe('ActivityPanel', () => {
/>
)
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
fireEvent.click(screen.getByRole('button', { name: '进行中 1' }))
expect(screen.getByText('对话:活动 1')).toBeInTheDocument()
expect(screen.queryByText('活动 2')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '异常' }))
fireEvent.click(screen.getByRole('button', { name: '异常 2' }))
expect(screen.getByText('对话:活动 2')).toBeInTheDocument()
expect(screen.getByText('对话:活动 3')).toBeInTheDocument()
expect(screen.queryByText('活动 1')).not.toBeInTheDocument()
@@ -299,12 +305,12 @@ describe('ActivityPanel', () => {
/>
)
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
fireEvent.click(screen.getByRole('button', { name: '进行中 0' }))
expect(screen.getByText('没有匹配的活动')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(
screen.getByRole('button', { name: '全部' })
screen.getByRole('button', { name: '全部 1' })
).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('对话:活动 1')).toBeInTheDocument()
})
@@ -521,6 +527,7 @@ describe('ActivityPanel', () => {
expect(screen.getByLabelText('筛选活动')).toHaveClass(
'segmented-control'
)
expect(screen.queryByLabelText('活动统计')).not.toBeInTheDocument()
expect(
screen.queryByLabelText('Token 用量分组')
).not.toBeInTheDocument()
@@ -609,10 +616,10 @@ describe('ActivityPanel', () => {
).toBeInTheDocument()
expect(within(timeline).getByText('项目:项目甲')).toBeInTheDocument()
expect(
within(timeline).getByText(
within(timeline).queryByText(
'所有轨道共享同一执行顺序,节点按发生时间依次展开,点击节点查看身份和活动详情。'
)
).toBeInTheDocument()
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
+26 -34
View File
@@ -299,14 +299,6 @@ export function ActivityPanel({
subagent: t('kinds.subagent'),
result: t('kinds.result')
}
const filters: ReadonlyArray<{
value: ActivityFilter
label: string
}> = [
{ value: 'all', label: t('filters.all') },
{ value: 'active', label: t('filters.active') },
{ value: 'failed', label: t('filters.failed') }
]
const views: ReadonlyArray<{
id: ActivityView
label: string
@@ -428,6 +420,32 @@ export function ActivityPanel({
)
const activeCount = visibleRecords.filter(isActive).length
const failedCount = visibleRecords.filter(isFailed).length
const filters: ReadonlyArray<{
value: ActivityFilter
label: string
}> = [
{
value: 'all',
label: t('filters.all', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
})
},
{
value: 'active',
label: t('filters.active', {
count: activeCount,
formattedCount: formatCount(activeCount)
})
},
{
value: 'failed',
label: t('filters.failed', {
count: failedCount,
formattedCount: formatCount(failedCount)
})
}
]
const tokenTotals = useMemo(
() => getTokenUsageTotals(tokenUsage),
[tokenUsage]
@@ -592,25 +610,6 @@ export function ActivityPanel({
</article>
)
}
const recordSummary = (
<dl
aria-label={t('stats.ariaLabel')}
className="activity-panel__stats"
>
<div>
<dt>{t('stats.all')}</dt>
<dd>{formatCount(visibleRecords.length)}</dd>
</div>
<div>
<dt>{t('stats.active')}</dt>
<dd>{formatCount(activeCount)}</dd>
</div>
<div>
<dt>{t('stats.failed')}</dt>
<dd>{formatCount(failedCount)}</dd>
</div>
</dl>
)
const recordToolbar = (
<div className="activity-panel__toolbar">
<SegmentedControl
@@ -675,8 +674,6 @@ export function ActivityPanel({
className="activity-panel"
>
<PageHeader
description={t('header.description')}
eyebrow={t('header.eyebrow')}
headingId="activity-panel-title"
icon={<Activity size={20} />}
scope={{ kind: 'all-projects' }}
@@ -702,7 +699,6 @@ export function ActivityPanel({
role="tabpanel"
tabIndex={0}
>
{recordSummary}
{recordToolbar}
{filteredRecords.length === 0 ? (
emptyState
@@ -797,7 +793,6 @@ export function ActivityPanel({
role="tabpanel"
tabIndex={0}
>
{recordSummary}
{recordToolbar}
{filteredRecords.length === 0 ? (
emptyState
@@ -806,9 +801,6 @@ export function ActivityPanel({
aria-label={t('timeline.ariaLabel')}
className="activity-tracks"
>
<p className="activity-tracks__description">
{t('timeline.description')}
</p>
<ul
aria-label={t('timeline.legendAriaLabel')}
className="activity-tracks__legend"
+37 -27
View File
@@ -1054,8 +1054,8 @@ describe('App', () => {
expect(
screen.getByRole('button', { name: 'Chat' })
).toBeInTheDocument()
expect(screen.getByText('Desktop workspace')).toBeInTheDocument()
expect(screen.getByText('GOODBUDDY WORKSPACE')).toBeInTheDocument()
expect(screen.queryByText('Desktop workspace')).not.toBeInTheDocument()
expect(screen.queryByText('GOODBUDDY WORKSPACE')).not.toBeInTheDocument()
expect(
screen.getByRole('heading', {
level: 1,
@@ -1074,14 +1074,18 @@ describe('App', () => {
expect(screen.getByText('Project: Default project')).toHaveClass(
'scope-badge'
)
expect(
screen.getByText(/Hi, I’m GoodBuddy/u)
).toBeInTheDocument()
expect(screen.getByText('Hi, I’m GoodBuddy.')).toBeInTheDocument()
expect(
screen.getByLabelText('Message GoodBuddy')
).toHaveAttribute(
'placeholder',
'Message GoodBuddy…\nEnter to send · Shift+Enter for a new line · Ctrl+V to paste an image or text'
'Message GoodBuddy…'
)
expect(
screen.getByLabelText('Message GoodBuddy')
).toHaveAttribute(
'title',
'Enter to send, Shift+Enter for a new line'
)
} finally {
cleanup()
@@ -1236,8 +1240,12 @@ describe('App', () => {
it('renders localized workspace branding in Chinese', async () => {
render(<App />)
expect(await screen.findByText('桌面工作区')).toBeInTheDocument()
expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument()
await screen.findByRole('heading', {
name: '今天想一起完成什么?'
})
expect(screen.getByText('你好,我是 GoodBuddy。')).toBeInTheDocument()
expect(screen.queryByText('桌面工作区')).not.toBeInTheDocument()
expect(screen.queryByText('GOODBUDDY 工作台')).not.toBeInTheDocument()
expect(
document.querySelector('.composer__runtime-toolbar')
).not.toBeInTheDocument()
@@ -5316,7 +5324,7 @@ describe('App', () => {
within(modeMenu)
.getAllByRole('menuitemradio')
.map((option) => option.querySelector('span')?.textContent)
).toEqual(['Ask · 只读问答', 'Execute · 受控执行'])
).toEqual(['Ask · 只读问答', 'Execute · 完全权限'])
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
@@ -5327,7 +5335,7 @@ describe('App', () => {
within(defaultMode)
.getAllByRole('option')
.map((option) => option.textContent)
).toEqual(['Ask · 只读问答', 'Execute · 受控执行'])
).toEqual(['Ask · 只读问答', 'Execute · 完全权限'])
expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull()
})
@@ -5387,10 +5395,7 @@ describe('App', () => {
).toHaveAttribute('title', '添加附件')
expect(
within(contentTools).getByRole('button', { name: '语音输入' })
).toHaveAttribute(
'title',
'语音转文字,转写后可编辑再发送'
)
).not.toHaveAttribute('title')
const conversationSettings = within(composer!).getByRole(
'group',
@@ -5413,7 +5418,7 @@ describe('App', () => {
).toHaveTextContent(/^Ask$/u)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本'
'给 GoodBuddy 发消息…'
)
expect(
within(conversationSettings).getByRole('button', {
@@ -5449,7 +5454,7 @@ describe('App', () => {
).toHaveTextContent(secondProject.name)
expect(
screen.getByRole('button', {
name: '工作模式:Execute · 受控执行'
name: '工作模式:Execute · 完全权限'
})
).toBeEnabled()
@@ -5892,9 +5897,9 @@ describe('App', () => {
expect(
await screen.findByText('快捷唤起:', { exact: false })
).toBeInTheDocument()
selectComposerOption('工作模式', 'Execute · 受控执行')
selectComposerOption('工作模式', 'Execute · 完全权限')
expect(mode).toHaveAccessibleName(
'工作模式:Execute · 受控执行'
'工作模式:Execute · 完全权限'
)
expect(mode).toHaveTextContent(/^Execute$/u)
@@ -6325,9 +6330,9 @@ describe('App', () => {
name: '工作模式:Ask · 只读问答'
})
expect(mode).toBeEnabled()
selectComposerOption('工作模式', 'Execute · 受控执行')
selectComposerOption('工作模式', 'Execute · 完全权限')
expect(mode).toHaveAccessibleName(
'工作模式:Execute · 受控执行'
'工作模式:Execute · 完全权限'
)
fireEvent.click(await screen.findByRole('button', { name: /OpenCode/u }))
@@ -6359,7 +6364,7 @@ describe('App', () => {
const modeMenu = openComposerMenu('工作模式')
expect(
within(modeMenu).getByRole('menuitemradio', {
name: /^Execute · 受控执行/u
name: /^Execute · 完全权限/u
})
).toBeDisabled()
expect(mode).toHaveAccessibleName('工作模式:Ask · 只读问答')
@@ -6378,7 +6383,7 @@ describe('App', () => {
const mode = await screen.findByRole('button', {
name: '工作模式:Ask · 只读问答'
})
selectComposerOption('工作模式', 'Execute · 受控执行')
selectComposerOption('工作模式', 'Execute · 完全权限')
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '读取项目文件' }
})
@@ -6443,7 +6448,9 @@ describe('App', () => {
expect(cancelledDot).not.toHaveClass('message__status-dot--active')
fireEvent.click(screen.getByText('运行记录'))
expect((await screen.findAllByText('已取消')).length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
fireEvent.click(
screen.getByRole('button', { name: /^进行中 \d+$/u })
)
expect(
screen.getByText('当前没有等待中或正在运行的活动。')
).toBeInTheDocument()
@@ -7626,7 +7633,7 @@ describe('App', () => {
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'描述你想生成的图片…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本'
'描述你想生成的图片…'
)
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenCalled()
@@ -8130,17 +8137,20 @@ describe('App', () => {
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
expect(
screen.getByText(/选择文件后在当前工作区内预览/)
screen.getByRole('button', { name: '刷新工作区文件' })
).toBeInTheDocument()
expect(
screen.queryByText(/选择文件后在当前工作区内预览/)
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '浏览器' }))
expect(
screen.getByText(/Agent 打开网页后/)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '成果' }))
expect(screen.getByText('生成与导入成果')).toBeInTheDocument()
expect(
screen.getByText(/查看并预览由任务、自动化生成或手动导入/)
screen.getByRole('button', { name: '导入 PDF、图片或网页' })
).toBeInTheDocument()
expect(screen.queryByText('生成与导入成果')).not.toBeInTheDocument()
expect(
screen.queryByRole('tab', { name: '预览' })
).not.toBeInTheDocument()
+5 -10
View File
@@ -646,8 +646,7 @@ const chatBottomProximity = 96
function createConversation(
projectId?: string,
runtimeSelection?: AgentRuntimeSelection,
greeting =
'你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件或使用知识库。启用 Agent Runtime 后,我也可以在你的授权下调用工具。'
greeting = '你好,我是 GoodBuddy。'
): Conversation {
const now = Date.now()
return {
@@ -1025,11 +1024,7 @@ function ChatHistoryPane({
<div className="welcome__badge">
<Sparkles size={18} />
</div>
<p className="eyebrow">{t('chat.welcome.eyebrow')}</p>
<h2>{t('chat.welcome.title')}</h2>
<p className="welcome__description">
{t('chat.welcome.description')}
</p>
<div className="quick-actions">
{quickActions.map((action) => (
<button
@@ -7451,7 +7446,6 @@ function App(): React.JSX.Element {
</div>
<div className="brand__copy">
<strong>GoodBuddy</strong>
<span>{t('brand.desktopWorkspace')}</span>
</div>
</div>
@@ -8440,13 +8434,14 @@ function App(): React.JSX.Element {
<div className="composer__input">
<textarea
aria-label={t('composer.inputLabel')}
placeholder={`${
placeholder={
runtime?.capability === 'image-generation'
? t('composer.imagePlaceholder')
: t('composer.placeholder')
}\n${t('composer.keyboardHint')}`}
}
ref={inputRef}
rows={3}
title={t('composer.keyboardHint')}
value={input}
onChange={(event) => setInput(event.target.value)}
onInput={(event) =>
@@ -8546,7 +8541,7 @@ function App(): React.JSX.Element {
? t('composer.voice.stopAndRecognize')
: voiceListening
? t('composer.voice.cancel')
: t('composer.voice.description')
: undefined
}
type="button"
>
@@ -137,10 +137,6 @@ export function ChannelProjectSettingsFields({
})}
className={`channel-project-settings channel-project-settings--${variant}`}
>
<div className="channel-project-settings__identity">
<span>{t('channels.project.identity')}</span>
<strong>{value.name}</strong>
</div>
<label className="field">
<span>{t('channels.project.descriptionLabel')}</span>
<textarea
@@ -628,8 +628,9 @@ describe('ChannelSettingsSection', () => {
expect(dingtalkTab).toHaveAttribute('tabindex', '-1')
expect(screen.getByText('项目设置')).toBeInTheDocument()
expect(
screen.getByText('与左上角当前通道项目的设置保持同步。')
).toBeInTheDocument()
screen.queryByText('与左上角当前通道项目的设置保持同步。')
).not.toBeInTheDocument()
expect(screen.queryByText('通道项目')).not.toBeInTheDocument()
expect(
screen.queryByRole('switch', { name: '启用企业微信通道' })
).not.toBeInTheDocument()
@@ -708,7 +709,7 @@ describe('ChannelSettingsSection', () => {
expect(
screen.getByLabelText('微信 ClawBot default working directory')
).toHaveValue('C:\\Users\\tester')
expect(screen.getByText('微信 ClawBot')).toBeInTheDocument()
expect(screen.queryByText('Channel project')).not.toBeInTheDocument()
expect(
screen.getByText(
'Remote Execute operations can run only within this project directory.'
@@ -211,7 +211,6 @@ function ChannelProjectCard({
<div className="capability-card__header">
<div>
<strong>{t('channels.project.cardTitle')}</strong>
<small>{t('channels.project.cardDescription')}</small>
</div>
</div>
<ChannelProjectSettingsFields
@@ -258,6 +258,12 @@ describe('DocumentParsingSettingsSection', () => {
expect(screen.getByText('ModelScope')).toBeInTheDocument()
expect(screen.getByText('质量:基础')).toBeInTheDocument()
expect(screen.getByText('速度:快')).toBeInTheDocument()
expect(
screen.queryByText('onnxruntime-web-wasm')
).not.toBeInTheDocument()
expect(
screen.queryByText(snapshot.ocrModels.rootDirectory)
).not.toBeInTheDocument()
expect(screen.getByText('旧版 Office 转换')).toBeInTheDocument()
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
expect(screen.queryByText('隐私与云端处理')).not.toBeInTheDocument()
@@ -585,9 +585,6 @@ export function DocumentParsingSettingsSection({
label={t('documentParsing.status.conversion')}
/>
</div>
<p className="settings-notice">
{t('documentParsing.status.partialNotice')}
</p>
</section>
<section
@@ -792,19 +789,6 @@ export function DocumentParsingSettingsSection({
</small>
</label>
<p className="settings-notice document-ocr-settings__storage">
{t('documentParsing.ocr.storagePrefix')}{' '}
<code>{snapshot.ocrModels.rootDirectory}</code>
{t('documentParsing.ocr.storageSuffix')}
</p>
<p className="settings-notice">
{t('documentParsing.ocr.downloadSource', {
source: t(
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
)
})}
</p>
{model ? (
<article className="document-ocr-model">
<div className="document-ocr-model__header">
@@ -834,9 +818,6 @@ export function DocumentParsingSettingsSection({
)
.join(' / ')}
</span>
<span className="speech-model-tag">
{model.runtime}
</span>
<span className="speech-model-tag">
{t('documentParsing.ocr.quality.label', {
value: t(
@@ -888,32 +869,23 @@ export function DocumentParsingSettingsSection({
</button>
</div>
{(modelOperation || installedModel) && (
{modelOperation && (
<div className="document-ocr-model__state">
<span
className={`document-ocr-model__status${
installedModel
? ' document-ocr-model__status--installed'
: ''
}`}
className="document-ocr-model__status"
>
{installedModel && (
<CheckCircle2 aria-hidden="true" size={13} />
)}
{modelOperation
? t(
modelOperation.phase === 'installing'
? 'documentParsing.ocr.operations.installing'
: modelOperation.kind === 'import'
? 'documentParsing.ocr.operations.importing'
: 'documentParsing.ocr.operations.downloading',
{
source: t(
`modelDownloadSources.${modelOperation.downloadSource}`
)
}
{t(
modelOperation.phase === 'installing'
? 'documentParsing.ocr.operations.installing'
: modelOperation.kind === 'import'
? 'documentParsing.ocr.operations.importing'
: 'documentParsing.ocr.operations.downloading',
{
source: t(
`modelDownloadSources.${modelOperation.downloadSource}`
)
: t('documentParsing.ocr.installed')}
}
)}
</span>
</div>
)}
@@ -2,118 +2,202 @@ import {
cleanup,
fireEvent,
render,
screen
screen,
within
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
EmbeddingConfigurationSummary
} from '../../shared/embedding-contracts'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest'
import {
EmbeddingSettingsSection,
type EmbeddingSettingsSectionProps
} from './EmbeddingSettingsSection'
import { changeUiLocale } from './i18n'
const configuration: EmbeddingConfigurationSummary = {
provider: 'openai-compatible',
model: 'text-embedding-3-small',
endpoint: 'https://vectors.example/v1/embeddings',
credentialConfigured: true
const connections: EmbeddingSettingsSectionProps['connections'] = [
{
id: 'builtin',
name: 'GoodBuddy 内置',
kind: 'builtin',
model: 'granite-embedding-107m',
statusText: '尚未安装',
installed: false
},
{
id: 'custom',
name: '公司向量服务',
kind: 'openai-compatible',
model: 'bge-m3',
endpoint: 'https://vectors.example/v1/embeddings',
authentication: 'api-key',
apiKey: '',
apiKeyConfigured: true,
statusText: '已配置凭据'
}
]
function renderSection(
overrides: Partial<EmbeddingSettingsSectionProps> = {}
): EmbeddingSettingsSectionProps {
const props: EmbeddingSettingsSectionProps = {
connections,
currentConnectionId: 'custom',
selectedConnectionId: 'custom',
enabled: true,
secureStorageAvailable: true,
onEnabledChange: vi.fn(),
onAddConnection: vi.fn(),
onSelectConnection: vi.fn(),
onSetCurrent: vi.fn(),
onUpdateConnection: vi.fn(),
onDeleteConnection: vi.fn(),
onDownloadBuiltin: vi.fn(),
onCancelBuiltin: vi.fn(),
onImportBuiltin: vi.fn(),
onRemoveBuiltin: vi.fn(),
onTestConnection: vi.fn(),
...overrides
}
render(<EmbeddingSettingsSection {...props} />)
return props
}
afterEach(() => {
cleanup()
beforeEach(async () => {
await changeUiLocale('zh-CN')
})
afterEach(cleanup)
describe('EmbeddingSettingsSection', () => {
it('keeps model settings limited to connection details and diagnostics', () => {
const onTest = vi.fn()
render(
<EmbeddingSettingsSection
configuration={configuration}
onTest={onTest}
/>
)
it('uses the LLM connection manager layout with one visible title', () => {
renderSection()
expect(screen.getAllByText('向量模型连接')).toHaveLength(1)
const section = screen.getByRole('region', { name: '向量模型' })
expect(section.querySelectorAll('.model-connection-manager')).toHaveLength(1)
expect(section.querySelector('.model-connection-list')).toBeInTheDocument()
expect(section.querySelector('.model-connection-detail')).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: '向量模型连接' })
screen.getByRole('list', { name: '向量模型连接列表' })
).toBeInTheDocument()
expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument()
expect(screen.getByText('已配置凭据')).toBeInTheDocument()
expect(screen.getByText('当前使用')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /重建向量索引/u })
).not.toBeInTheDocument()
expect(screen.queryByText(/知识向量索引/u)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '测试向量模型' }))
expect(onTest).toHaveBeenCalledOnce()
screen.getByText(
'建立索引时会向 vectors.example 发送知识分块,检索时会发送查询。'
)
).toBeInTheDocument()
expect(screen.queryByText(/Agent Runtime/u)).not.toBeInTheDocument()
})
it('renders connection copy in English without translating model data', async () => {
await changeUiLocale('en-US')
render(
<EmbeddingSettingsSection
configuration={configuration}
onTest={vi.fn()}
/>
)
it('selects a peer connection without changing the current draft', () => {
const props = renderSection()
expect(
screen.getByRole('heading', {
name: 'Embedding model connection'
fireEvent.click(
screen.getByRole('button', {
name: '编辑向量模型连接 GoodBuddy 内置'
})
).toBeInTheDocument()
expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Test embedding model' })
).toBeInTheDocument()
)
expect(props.onSelectConnection).toHaveBeenCalledWith('builtin')
expect(props.onSetCurrent).not.toHaveBeenCalled()
})
it('shows dimensions and latency from a real diagnostic result', () => {
render(
<EmbeddingSettingsSection
configuration={configuration}
diagnostic={{
status: 'available',
provider: 'openai-compatible',
model: 'text-embedding-3-small',
checkedAt: 1_700_000_000_000,
latencyMs: 126,
dimensions: 1_536
}}
onTest={vi.fn()}
/>
it('keeps custom definitions editable while retrieval is disabled', () => {
const props = renderSection({ enabled: false })
expect(screen.getByRole('radio', { name: '当前连接' })).toBeDisabled()
expect(
screen.getByRole('button', {
name: '测试向量模型连接 公司向量服务'
})
).toBeDisabled()
const endpoint = screen.getByLabelText('向量接口 URL')
expect(endpoint).toBeEnabled()
fireEvent.change(endpoint, {
target: { value: 'https://new.example/v1/embeddings' }
})
fireEvent.click(
screen.getByRole('button', {
name: '删除向量模型连接 公司向量服务'
})
)
expect(props.onUpdateConnection).toHaveBeenCalledWith('custom', {
endpoint: 'https://new.example/v1/embeddings'
})
expect(props.onDeleteConnection).toHaveBeenCalledWith('custom')
})
it('shows read-only builtin metadata and model actions', () => {
const props = renderSection({
enabled: false,
selectedConnectionId: 'builtin'
})
const detail = document.querySelector<HTMLElement>(
'.model-connection-detail'
)!
expect(within(detail).getByText('granite-embedding-107m')).toBeInTheDocument()
expect(within(detail).getByText('尚未安装')).toBeInTheDocument()
expect(within(detail).queryByText(/本机知识分块/u)).not.toBeInTheDocument()
expect(within(detail).queryByText(/知识分块和查询/u)).not.toBeInTheDocument()
expect(within(detail).queryByRole('textbox')).not.toBeInTheDocument()
const download = within(detail).getByRole('button', {
name: '下载 GoodBuddy 内置'
})
expect(download).toBeEnabled()
fireEvent.click(download)
fireEvent.click(
within(detail).getByRole('button', {
name: '从 ZIP 导入 GoodBuddy 内置'
})
)
expect(props.onDownloadBuiltin).toHaveBeenCalledWith(
'granite-embedding-107m'
)
expect(props.onImportBuiltin).toHaveBeenCalledWith(
'granite-embedding-107m'
)
})
it('identifies a loopback custom endpoint without showing a remote warning', () => {
renderSection({
connections: [
{
...connections[1]!,
endpoint: 'http://127.0.0.1:11434/v1/embeddings'
}
],
currentConnectionId: 'custom',
selectedConnectionId: 'custom'
})
expect(
screen.getByText(
'知识分块和查询将发送到此设备上的 127.0.0.1:11434。'
)
).toHaveClass('settings-notice')
})
it('renders diagnostic dimensions and latency in the selected detail', () => {
renderSection({
diagnostic: {
status: 'available',
provider: 'openai-compatible',
model: 'bge-m3',
checkedAt: 1_700_000_000_000,
latencyMs: 126,
dimensions: 1_536
}
})
expect(screen.getByText('测试成功')).toBeInTheDocument()
expect(
screen.getByText('服务返回 1536 维向量,耗时 126 毫秒。')
).toBeInTheDocument()
})
it('renders a safe actionable diagnostic error', () => {
render(
<EmbeddingSettingsSection
configuration={configuration}
diagnostic={{
status: 'unavailable',
provider: 'openai-compatible',
model: 'missing-model',
checkedAt: 1,
latencyMs: 25,
error: {
code: 'model_not_found',
message: '未找到指定的向量模型。',
retryable: false,
remedy: '请确认模型名称正确。'
}
}}
onTest={vi.fn()}
/>
)
expect(screen.getByRole('alert')).toHaveTextContent(
'未找到指定的向量模型。'
)
expect(screen.getByRole('alert')).toHaveTextContent(
'处理建议:请确认模型名称正确。'
)
})
})
+397 -62
View File
@@ -1,20 +1,81 @@
import {
Activity,
FlaskConical
Download,
FlaskConical,
Plus,
Trash2,
Upload,
X
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type {
EmbeddingConfigurationSummary,
EmbeddingDiagnosticResult
} from '../../shared/embedding-contracts'
import { formatMediumDateTime } from './locale-formatters'
export interface EmbeddingConnectionPresentation {
id: string
name: string
kind: 'builtin' | 'openai-compatible'
model: string
endpoint?: string
authentication?: 'api-key' | 'none'
apiKey?: string
apiKeyConfigured?: boolean
clearApiKey?: boolean
statusText?: string
installed?: boolean
operationActive?: boolean
}
export interface EmbeddingSettingsSectionProps {
configuration: EmbeddingConfigurationSummary
connections: readonly EmbeddingConnectionPresentation[]
currentConnectionId: string
selectedConnectionId?: string
diagnostic?: EmbeddingDiagnosticResult | null
diagnosticRunning?: boolean
disabled?: boolean
onTest: () => void
enabled: boolean
secureStorageAvailable: boolean
busy?: boolean
onEnabledChange: (enabled: boolean) => void
onAddConnection: () => void
onSelectConnection: (connectionId: string) => void
onSetCurrent: (connectionId: string) => void
onUpdateConnection: (
connectionId: string,
changes: Partial<EmbeddingConnectionPresentation>
) => void
onDeleteConnection: (connectionId: string) => void
onDownloadBuiltin: (modelId: string) => void
onCancelBuiltin: (modelId: string) => void
onImportBuiltin: (modelId: string) => void
onRemoveBuiltin: (modelId: string) => void
onTestConnection: (connectionId: string) => void
}
function embeddingEndpointDestination(endpoint: string | undefined):
| { kind: 'local'; host: string }
| { kind: 'network'; host: string }
| undefined {
if (!endpoint?.trim()) {
return undefined
}
try {
const url = new URL(endpoint)
const hostname = url.hostname.toLowerCase()
return {
kind:
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]' ||
hostname === '::1'
? 'local'
: 'network',
host: url.host
}
} catch {
return undefined
}
}
function DiagnosticResult({
@@ -62,82 +123,356 @@ function DiagnosticResult({
}
export function EmbeddingSettingsSection({
configuration,
connections,
currentConnectionId,
selectedConnectionId,
diagnostic,
diagnosticRunning = false,
disabled = false,
onTest
enabled,
secureStorageAvailable,
busy = false,
onEnabledChange,
onAddConnection,
onSelectConnection,
onSetCurrent,
onUpdateConnection,
onDeleteConnection,
onDownloadBuiltin,
onCancelBuiltin,
onImportBuiltin,
onRemoveBuiltin,
onTestConnection
}: EmbeddingSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const selected =
connections.find((connection) => connection.id === selectedConnectionId) ??
connections.find((connection) => connection.id === currentConnectionId) ??
connections[0]
const selectedEndpointDestination =
selected?.kind === 'openai-compatible'
? embeddingEndpointDestination(selected.endpoint)
: undefined
return (
<section
aria-label={t('embedding.label')}
className="embedding-settings settings-section"
>
<div className="settings-section__title">
<div className="settings-section__title settings-section__title--actions">
<Activity aria-hidden="true" size={17} />
<div>
<h2 id="embedding-settings-heading">{t('embedding.title')}</h2>
<strong>{t('embedding.title')}</strong>
<small>{t('embedding.description')}</small>
</div>
<label className="toggle-row">
<input
checked={enabled}
onChange={(event) => onEnabledChange(event.target.checked)}
role="switch"
type="checkbox"
/>
<span>{t('embedding.enabled')}</span>
</label>
<button
className="secondary-button model-connection-add"
onClick={onAddConnection}
type="button"
>
<Plus aria-hidden="true" size={14} />
{t('embedding.actions.addCustom')}
</button>
</div>
<div
aria-labelledby="embedding-model-heading"
className="embedding-settings__group"
>
<div className="embedding-settings__subheading">
<div>
<FlaskConical aria-hidden="true" size={15} />
<h3 id="embedding-model-heading">
{t('embedding.model.heading')}
</h3>
<div className="model-connection-manager">
<aside
aria-label={t('embedding.connections.listLabel')}
className="model-connection-list"
>
<div className="model-connection-list__header">
<strong>{t('embedding.connections.heading')}</strong>
<span>{connections.length}</span>
</div>
</div>
<div className="embedding-settings__model">
<div className="embedding-settings__model-name">
<span>{t('embedding.model.configured')}</span>
<strong>{configuration.model}</strong>
<small>
{t('embedding.model.provider', {
provider: configuration.provider
})}
</small>
</div>
<span className="embedding-settings__credential">
{configuration.credentialConfigured
? t('embedding.model.credentialConfigured')
: t('embedding.model.credentialMissing')}
</span>
</div>
{configuration.endpoint && (
<p className="embedding-settings__endpoint">
{t('embedding.model.endpoint')}
<code>{configuration.endpoint}</code>
</p>
)}
<div className="capability-diagnostic">
<button
className="secondary-button"
disabled={disabled || diagnosticRunning}
onClick={onTest}
type="button"
<div
aria-label={t('embedding.connections.listLabel')}
role="list"
>
<FlaskConical aria-hidden="true" size={13} />
{diagnosticRunning
? t('embedding.diagnostic.testing')
: t('embedding.diagnostic.test')}
</button>
{diagnostic && <DiagnosticResult result={diagnostic} />}
{!diagnostic && !diagnosticRunning && (
<p className="settings-notice">
{t('embedding.diagnostic.notice')}
</p>
)}
</div>
</div>
{connections.map((connection) => (
<div key={connection.id} role="listitem">
<button
aria-current={
selected?.id === connection.id ? 'page' : undefined
}
aria-label={t('embedding.accessibility.select', {
name: connection.name
})}
onClick={() => onSelectConnection(connection.id)}
type="button"
>
<span className="model-connection-list__name">
<strong>{connection.name}</strong>
<small>{connection.model}</small>
</span>
<span className="model-connection-list__badges">
{currentConnectionId === connection.id && (
<span>{t('embedding.connections.current')}</span>
)}
</span>
</button>
</div>
))}
</div>
</aside>
{selected && (
<div
aria-labelledby={`embedding-connection-${selected.id}`}
className="model-connection-detail"
>
<div className="settings-section__title">
<div>
<strong id={`embedding-connection-${selected.id}`}>
{selected.name}
</strong>
<small>
{t(`embedding.connections.types.${selected.kind}`)}
</small>
</div>
<label className="check-field">
<input
checked={currentConnectionId === selected.id}
disabled={!enabled}
name="current-embedding-connection"
onChange={() => onSetCurrent(selected.id)}
type="radio"
/>
<span>{t('embedding.currentConnection')}</span>
</label>
{selected.kind === 'openai-compatible' && (
<button
aria-label={t('embedding.accessibility.delete', {
name: selected.name
})}
className="danger-button danger-button--quiet"
onClick={() => onDeleteConnection(selected.id)}
type="button"
>
<Trash2 aria-hidden="true" size={14} />
{t('embedding.actions.delete')}
</button>
)}
</div>
{selected.kind === 'builtin' ? (
<>
<dl className="runtime-overview__details">
<dt>{t('embedding.connections.modelLabel')}</dt>
<dd>{selected.model}</dd>
<dt>{t('embedding.connections.statusLabel')}</dt>
<dd>{selected.statusText}</dd>
</dl>
<div className="runtime-config-actions">
<button
aria-label={t('embedding.accessibility.test', {
name: selected.name
})}
className="secondary-button"
disabled={!enabled || busy || diagnosticRunning}
onClick={() => onTestConnection(selected.id)}
type="button"
>
<FlaskConical aria-hidden="true" size={13} />
{diagnosticRunning
? t('embedding.diagnostic.testing')
: t('embedding.actions.test')}
</button>
{selected.operationActive ? (
<button
aria-label={t('embedding.accessibility.cancel', {
name: selected.name
})}
className="secondary-button"
onClick={() => onCancelBuiltin(selected.model)}
type="button"
>
<X aria-hidden="true" size={13} />
{t('embedding.actions.cancel')}
</button>
) : (
<>
{!selected.installed && (
<button
aria-label={t(
'embedding.accessibility.download',
{ name: selected.name }
)}
className="secondary-button"
onClick={() => onDownloadBuiltin(selected.model)}
type="button"
>
<Download aria-hidden="true" size={13} />
{t('embedding.actions.download')}
</button>
)}
<button
aria-label={t('embedding.accessibility.import', {
name: selected.name
})}
className="secondary-button"
onClick={() => onImportBuiltin(selected.model)}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t('embedding.actions.importZip')}
</button>
{selected.installed && (
<button
aria-label={t('embedding.accessibility.remove', {
name: selected.name
})}
className="danger-button danger-button--quiet"
onClick={() => onRemoveBuiltin(selected.model)}
type="button"
>
<Trash2 aria-hidden="true" size={13} />
{t('embedding.actions.remove')}
</button>
)}
</>
)}
</div>
</>
) : (
<>
<label className="field">
<span>{t('embedding.fields.name')}</span>
<input
onChange={(event) =>
onUpdateConnection(selected.id, {
name: event.target.value
})
}
value={selected.name}
/>
</label>
<label className="field">
<span>{t('embedding.fields.endpoint')}</span>
<input
inputMode="url"
onChange={(event) =>
onUpdateConnection(selected.id, {
endpoint: event.target.value
})
}
value={selected.endpoint ?? ''}
/>
</label>
{selectedEndpointDestination && (
<p
className={
selectedEndpointDestination.kind === 'network'
? 'settings-warning'
: 'settings-notice'
}
>
{t(
`embedding.endpointDestination.${selectedEndpointDestination.kind}`,
{ host: selectedEndpointDestination.host }
)}
</p>
)}
<label className="field">
<span>{t('embedding.fields.model')}</span>
<input
onChange={(event) =>
onUpdateConnection(selected.id, {
model: event.target.value
})
}
value={selected.model}
/>
</label>
<label className="field">
<span>{t('embedding.fields.authentication')}</span>
<select
onChange={(event) =>
onUpdateConnection(selected.id, {
authentication: event.target
.value as EmbeddingConnectionPresentation['authentication'],
...(event.target.value === 'none'
? { apiKey: '', clearApiKey: true }
: {})
})
}
value={selected.authentication}
>
<option value="api-key">API Key</option>
<option value="none">
{t('embedding.fields.noAuthentication')}
</option>
</select>
</label>
{selected.authentication === 'api-key' && (
<>
<label className="field">
<span>{t('embedding.fields.apiKey')}</span>
<input
autoComplete="off"
disabled={!secureStorageAvailable}
onChange={(event) =>
onUpdateConnection(selected.id, {
apiKey: event.target.value,
clearApiKey: false
})
}
placeholder={
selected.apiKeyConfigured
? t('embedding.fields.configuredPlaceholder')
: t('embedding.fields.apiKeyPlaceholder')
}
type="password"
value={selected.apiKey ?? ''}
/>
</label>
{selected.apiKeyConfigured && (
<button
className="secondary-button"
onClick={() =>
onUpdateConnection(selected.id, {
apiKey: '',
clearApiKey: true
})
}
type="button"
>
{selected.clearApiKey
? t('embedding.actions.clearAfterSave')
: t('embedding.actions.clearCredential')}
</button>
)}
</>
)}
<div className="runtime-config-actions">
<button
aria-label={t('embedding.accessibility.test', {
name: selected.name
})}
className="secondary-button"
disabled={!enabled || busy || diagnosticRunning}
onClick={() => onTestConnection(selected.id)}
type="button"
>
<FlaskConical aria-hidden="true" size={13} />
{diagnosticRunning
? t('embedding.diagnostic.testing')
: t('embedding.actions.test')}
</button>
</div>
</>
)}
{diagnostic && <DiagnosticResult result={diagnostic} />}
</div>
)}
</div>
</section>
)
}
+18
View File
@@ -156,6 +156,12 @@ describe('HeartbeatCenter', () => {
name: 'Smart Heartbeat'
})
).toBeInTheDocument()
expect(
screen.getByText(
'Runs scheduled, read-only reviews of the selected scope without using tools.'
)
).toBeInTheDocument()
expect(screen.queryByText('SMART HEARTBEAT')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Run heartbeat now' })
).toBeInTheDocument()
@@ -287,6 +293,9 @@ describe('HeartbeatCenter', () => {
expect(
screen.getByRole('heading', { level: 2, name: '本次心跳' })
).toBeInTheDocument()
expect(screen.queryByText('CURRENT PULSE')).not.toBeInTheDocument()
expect(screen.queryByText('GROWTH TREND')).not.toBeInTheDocument()
expect(screen.queryByText('LATEST REPORT')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: /待处理建议/u }))
expect(
@@ -295,6 +304,8 @@ describe('HeartbeatCenter', () => {
expect(
screen.getByRole('heading', { level: 2, name: '行动建议' })
).toBeInTheDocument()
expect(screen.queryByText('MEMORY GROWTH')).not.toBeInTheDocument()
expect(screen.queryByText('NEXT ACTIONS')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '心跳轨迹' }))
expect(
@@ -303,11 +314,18 @@ describe('HeartbeatCenter', () => {
expect(
screen.getByRole('heading', { level: 2, name: '运行记录' })
).toBeInTheDocument()
expect(
screen.queryByText('HEARTBEAT TIMELINE')
).not.toBeInTheDocument()
expect(screen.queryByText('RUN AUDIT')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '心跳计划' }))
expect(
screen.getByRole('heading', { level: 2, name: '智能心跳' })
).toBeInTheDocument()
expect(
screen.getByText('心跳计划按时只读运行,不调用工具。')
).toBeInTheDocument()
})
it('turns heartbeat findings into explicit user actions', async () => {
-22
View File
@@ -415,7 +415,6 @@ export function HeartbeatCenter({
)
}
description={t('center.description')}
eyebrow={t('center.eyebrow')}
headingId="heartbeat-center-title"
icon={<HeartPulse size={22} />}
scope={heartbeatScope}
@@ -492,9 +491,6 @@ export function HeartbeatCenter({
>
<div className="heartbeat-center__section-heading">
<div>
<p className="eyebrow">
{t('center.currentStatus.eyebrow')}
</p>
<h2 id="heartbeat-status-title">
{t('center.currentStatus.title')}
</h2>
@@ -713,9 +709,6 @@ export function HeartbeatCenter({
>
<div className="heartbeat-center__section-heading">
<div>
<p className="eyebrow">
{t('center.trend.eyebrow')}
</p>
<h2 id="heartbeat-trend-title">
{t('center.trend.title')}
</h2>
@@ -807,9 +800,6 @@ export function HeartbeatCenter({
>
<div className="heartbeat-center__section-heading">
<div>
<p className="eyebrow">
{t('center.latest.eyebrow')}
</p>
<h2 id="latest-heartbeat-title">
{t('center.latest.title')}
</h2>
@@ -876,9 +866,6 @@ export function HeartbeatCenter({
>
<div className="heartbeat-center__section-heading">
<div>
<p className="eyebrow">
{t('center.suggestions.memoryEyebrow')}
</p>
<h2 id="heartbeat-memory-title">
{t('center.suggestions.memoryTitle')}
</h2>
@@ -990,9 +977,6 @@ export function HeartbeatCenter({
>
<div className="heartbeat-center__section-heading">
<div>
<p className="eyebrow">
{t('center.suggestions.taskEyebrow')}
</p>
<h2 id="heartbeat-task-title">
{t('center.suggestions.taskTitle')}
</h2>
@@ -1112,9 +1096,6 @@ export function HeartbeatCenter({
>
<div className="heartbeat-center__section-heading">
<div>
<p className="eyebrow">
{t('center.history.timelineEyebrow')}
</p>
<h2 id="heartbeat-reports-title">
<History aria-hidden="true" size={16} />
{t('center.history.timelineTitle')}
@@ -1215,9 +1196,6 @@ export function HeartbeatCenter({
>
<div className="heartbeat-center__section-heading">
<div>
<p className="eyebrow">
{t('center.history.auditEyebrow')}
</p>
<h2 id="heartbeat-runs-title">
{t('center.history.auditTitle')}
</h2>
@@ -239,10 +239,12 @@ describe('KnowledgeRetrievalWorkbench', () => {
/>
)
expect(screen.getByText('最多 24 个融合候选')).toBeInTheDocument()
expect(screen.getByText(/当前最多召回 24 个融合候选/u))
.toBeInTheDocument()
const recallMultiplier = screen.getByLabelText(/^召回倍数/)
fireEvent.change(recallMultiplier, { target: { value: '5' } })
expect(screen.getByText('最多 30 个融合候选')).toBeInTheDocument()
expect(screen.getByText(/当前最多召回 30 个融合候选/u))
.toBeInTheDocument()
expect(screen.getByLabelText(/^全文占比/)).toHaveValue(35.7)
expect(screen.getByLabelText(/^向量占比/)).toHaveValue(35.7)
expect(screen.getByLabelText(/^图谱占比/)).toHaveValue(28.6)
@@ -260,9 +262,15 @@ describe('KnowledgeRetrievalWorkbench', () => {
})
const topK = screen.getByLabelText(/^最终结果数/)
fireEvent.change(topK, { target: { value: '8' } })
expect(screen.getByText('最多 40 个融合候选')).toBeInTheDocument()
expect(screen.getByText(/当前最多召回 40 个融合候选/u))
.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '本地规则' }))
expect(screen.getByText('重排最多 40 个候选')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '本地规则' })
).toHaveAttribute('aria-pressed', 'true')
expect(
document.querySelector('.knowledge-retrieval-pipeline')
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
expect(onTest).toHaveBeenCalledWith({
@@ -459,7 +459,6 @@ export function KnowledgeRetrievalWorkbench({
<div className="knowledge-workbench-section__heading">
<div>
<h3 id={`${titleId}-query`}>{t('retrieval.query.title')}</h3>
<p>{t('retrieval.query.help')}</p>
</div>
<span className="knowledge-character-count">
{t('retrieval.query.count', { count: query.length })}
@@ -507,71 +506,6 @@ export function KnowledgeRetrievalWorkbench({
{validationErrors.weights}
</p>
)}
<div className="knowledge-retrieval-pipeline">
<div className="knowledge-retrieval-pipeline__step">
<span>1</span>
<div>
<strong>{t('retrieval.pipeline.recall.title')}</strong>
<small>
{candidateLimit === undefined
? t('retrieval.pipeline.recall.pending')
: t('retrieval.pipeline.recall.summary', {
count: candidateLimit
})}
</small>
</div>
</div>
<div className="knowledge-retrieval-pipeline__connector" />
<div
className={`knowledge-retrieval-pipeline__step${
draftSettings.rerankMode !== 'none'
? ''
: ' knowledge-retrieval-pipeline__step--muted'
}`}
>
<span>2</span>
<div>
<strong>{t('retrieval.pipeline.rerank.title')}</strong>
<small>
{draftSettings.rerankMode !== 'none'
? t('retrieval.pipeline.rerank.enabled', {
count: candidateLimit ?? '—'
})
: t('retrieval.pipeline.rerank.disabled')}
</small>
</div>
</div>
<div className="knowledge-retrieval-pipeline__connector" />
<div className="knowledge-retrieval-pipeline__step">
<span>3</span>
<div>
<strong>{t('retrieval.pipeline.select.title')}</strong>
<small>
{t('retrieval.pipeline.select.summary', {
count: Number.isSafeInteger(draftSettings.topK)
? draftSettings.topK
: '—'
})}
</small>
</div>
</div>
<div className="knowledge-retrieval-pipeline__connector" />
<div className="knowledge-retrieval-pipeline__step">
<span>4</span>
<div>
<strong>{t('retrieval.pipeline.context.title')}</strong>
<small>
{t('retrieval.pipeline.context.summary', {
count: Number.isSafeInteger(
draftSettings.contextMaxCharacters
)
? draftSettings.contextMaxCharacters.toLocaleString()
: '—'
})}
</small>
</div>
</div>
</div>
<div
aria-describedby={
showSettingsValidation && validationErrors.weights
@@ -590,7 +524,6 @@ export function KnowledgeRetrievalWorkbench({
<h4 id={`${titleId}-recall-settings`}>
{t('retrieval.settings.groups.recall.title')}
</h4>
<p>{t('retrieval.settings.groups.recall.description')}</p>
</div>
</div>
<div className="knowledge-workbench-settings__grid knowledge-workbench-settings__grid--three">
@@ -704,7 +637,6 @@ export function KnowledgeRetrievalWorkbench({
<h4 id={`${titleId}-output-settings`}>
{t('retrieval.settings.groups.output.title')}
</h4>
<p>{t('retrieval.settings.groups.output.description')}</p>
</div>
</div>
<div className="field">
@@ -317,6 +317,9 @@ describe('KnowledgeWorkspace', () => {
).toBeInTheDocument()
expect(screen.getByText('架构说明.md')).toBeInTheDocument()
expect(screen.getByText('Local file · 架构说明.md')).toBeInTheDocument()
expect(
screen.queryByText(/Imported content is parsed/u)
).not.toBeInTheDocument()
})
it('imports an HTTP URL into the selected library', async () => {
@@ -644,6 +647,11 @@ describe('KnowledgeWorkspace', () => {
fireEvent.click(screen.getByRole('tab', { name: '索引与检索' }))
expect(screen.getByRole('switch', { name: /启用知识图谱/u }))
.not.toBeChecked()
expect(
screen.getByText(
'启用后,新导入和重新同步的文档会按所选策略抽取图谱。'
)
).toBeInTheDocument()
expect(screen.queryByLabelText('知识图谱抽取策略'))
.not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: '本体定义' }))
+1 -23
View File
@@ -1269,13 +1269,6 @@ function DocumentsView({
<h3 id="sources-title">
{t('documents.sources.title')}
</h3>
<p className="knowledge-section-description">
{t(
library.graphEnabled
? 'documents.sources.descriptionWithGraph'
: 'documents.sources.description'
)}
</p>
</div>
<div className="knowledge-documents__import-actions">
<button
@@ -2230,17 +2223,8 @@ function KnowledgeSettingsView({
)}
{mode === 'index' && (
<section
aria-labelledby="knowledge-graph-capability-title"
className="knowledge-settings__section"
>
<div>
<h3 id="knowledge-graph-capability-title">
{t('settings.graphCapability.title')}
</h3>
<p className="knowledge-section-description">
{t('settings.graphCapability.description')}
</p>
</div>
<label
className={`knowledge-settings__toggle toggle-row${
saving ? ' knowledge-settings__toggle--pending' : ''
@@ -2258,9 +2242,7 @@ function KnowledgeSettingsView({
<span>
<strong>{t('graph.enable')}</strong>
<span className="knowledge-muted">
{library.graphEnabled
? t('settings.graphCapability.enabledDescription')
: t('settings.graphCapability.disabledDescription')}
{t('settings.enableDescription')}
</span>
</span>
</label>
@@ -2275,9 +2257,6 @@ function KnowledgeSettingsView({
<h3 id="knowledge-graph-settings-title">
{t('settings.graphConfiguration.title')}
</h3>
<p className="knowledge-section-description">
{t('settings.graphConfiguration.description')}
</p>
</div>
<label className="knowledge-field">
{t('fields.graphExtractionStrategy')}
@@ -4279,7 +4258,6 @@ export function KnowledgeWorkspace({
</button>
}
description={t('page.description')}
eyebrow={t('page.eyebrow')}
headingId="knowledge-workspace-title"
icon={<Database size={20} />}
scope={{ kind: 'global' }}
@@ -548,7 +548,10 @@ describe('MagicNotesWorkspace', () => {
'写完一句并停止输入 5 秒后,评论会显示在这里。'
)
).toBeInTheDocument()
expect(within(pane).getByText('暂无 AI 评论')).toBeInTheDocument()
expect(within(pane).getByText('写下一句话')).toBeInTheDocument()
expect(
within(pane).queryByText('暂无 AI 评论')
).not.toBeInTheDocument()
expect(within(pane).queryByText('正在加载')).not.toBeInTheDocument()
})
+10 -9
View File
@@ -1453,7 +1453,6 @@ export function MagicNotesWorkspace({
</>
}
description={t('page.description')}
eyebrow={t('page.eyebrow')}
headingId="magic-notes-title"
icon={<Sparkles size={20} />}
scope={{ kind: 'global' }}
@@ -1538,10 +1537,6 @@ export function MagicNotesWorkspace({
id="magic-library-panel-notes"
role="tabpanel"
>
<div className="magic-notes-pane-heading">
<strong>{t('notes.heading')}</strong>
<span>{notes.length}</span>
</div>
<label className="magic-notes-search">
<span className="sr-only">{t('notes.searchLabel')}</span>
<input
@@ -2433,14 +2428,14 @@ export function MagicNotesWorkspace({
<EmptyState
description={t('comments.selectTodo')}
icon={<Bot size={20} />}
title={t('comments.emptyTitle')}
title={t('comments.selectTodoTitle')}
/>
) : selectedTodo.comments.length === 0 ? (
!liveAnalysis && (
<EmptyState
description={t('comments.analyzeTodoHint')}
icon={<Bot size={20} />}
title={t('comments.emptyTitle')}
title={t('comments.analyzeTodoTitle')}
/>
)
) : (
@@ -2466,7 +2461,7 @@ export function MagicNotesWorkspace({
title={
loadStatus === 'loading'
? t('status.loading')
: t('comments.emptyTitle')
: t('comments.selectNoteTitle')
}
/>
) : aiEntries.length === 0 &&
@@ -2482,7 +2477,13 @@ export function MagicNotesWorkspace({
: t('comments.manualHint')
}
icon={<Bot size={20} />}
title={t('comments.emptyTitle')}
title={
commentMode === 'immediate'
? t('comments.startWritingTitle')
: commentMode === 'after-save-auto'
? t('comments.saveEntryTitle')
: t('comments.analyzeEntryTitle')
}
/>
) : (
<div className="magic-notes-ai-feed">
+8 -33
View File
@@ -457,14 +457,9 @@ export function McpSettingsSection({
>
{activeTab === 'custom' && (
<>
<p className="settings-notice">
{t('mcp.customNotice')}
</p>
<p className="settings-notice">
{t('mcp.securityNotice')}
</p>
</>
<p className="settings-notice">
{t('mcp.customNotice')}
</p>
)}
{activeTab === 'computer' && (
<section
@@ -478,9 +473,6 @@ export function McpSettingsSection({
{t('mcp.computer.title')}
</strong>
</div>
<small>
{t('mcp.computer.subtitle')}
</small>
</div>
<div className="capability-list">
{desktopCapabilities.map((capability) => {
@@ -493,11 +485,7 @@ export function McpSettingsSection({
<small>
{capability.supported
? t('mcp.computer.supported')
: t('mcp.computer.unsupported')}{' '}
·{' '}
{capability.enabled
? t('mcp.computer.enabled')
: t('mcp.computer.disabled')}
: t('mcp.computer.unsupported')}
</small>
</div>
</div>
@@ -524,11 +512,7 @@ export function McpSettingsSection({
role="switch"
type="checkbox"
/>
<span>
{capability.enabled
? t('mcp.computer.enabled')
: t('mcp.computer.disabled')}
</span>
<span>{t('mcp.computer.enable')}</span>
</label>
<p>{capability.description}</p>
<p className="computer-capability-risk">
@@ -585,12 +569,9 @@ export function McpSettingsSection({
<div className="mcp-subsection-heading">
<div>
<Database size={15} />
<span className="mcp-subsection-heading__title">
<strong id="builtin-mcp-heading">
{t('mcp.builtin.title')}
</strong>
<small>{t('mcp.builtin.availableTo')}</small>
</span>
<strong id="builtin-mcp-heading">
{t('mcp.builtin.title')}
</strong>
</div>
<small>
{t('mcp.profiles.count', {
@@ -598,9 +579,6 @@ export function McpSettingsSection({
})}
</small>
</div>
<p className="settings-notice">
{t('mcp.builtin.notice')}
</p>
<div className="mcp-server-list">
{builtinMcpServers.map((server) => {
const expansionId = `builtin:${server.id}`
@@ -1385,9 +1363,6 @@ export function McpSettingsSection({
})}
</small>
</div>
<p className="settings-notice">
{t('mcp.custom.notice')}
</p>
<div className="capability-list">
{snapshot?.mcpServers.length === 0 && !editor && (
<p className="settings-empty">{t('mcp.custom.empty')}</p>
@@ -508,22 +508,19 @@ export function PlatformFeaturesSettingsSection({
<div className="capability-card__header">
<div>
<strong>
{t('platformFeatures.modelDownloadSource.cardTitle')}
{t('platformFeatures.modelDownloadSource.title')}
</strong>
<small>
{t(
'platformFeatures.modelDownloadSource.cardDescription'
'platformFeatures.modelDownloadSource.description'
)}
</small>
</div>
</div>
<fieldset className="model-download-source">
<legend>
<legend className="sr-only">
{t('platformFeatures.modelDownloadSource.title')}
</legend>
<p>
{t('platformFeatures.modelDownloadSource.description')}
</p>
{(
['modelscope', 'hugging-face'] as const
).map((source) => (
@@ -561,16 +558,6 @@ export function PlatformFeaturesSettingsSection({
{sourceError}
</p>
)}
<p className="model-download-source__current">
{t('platformFeatures.modelDownloadSource.current', {
source: t(
`modelDownloadSources.${settings.modelDownloadSource}`
)
})}
</p>
<p className="settings-notice">
{t('platformFeatures.modelDownloadSource.activeDownloadNote')}
</p>
</article>
) : (
!error && (
+17 -41
View File
@@ -4,7 +4,6 @@ import {
CircleAlert,
ExternalLink,
FileText,
FolderTree,
ClockFading,
Monitor,
Plus,
@@ -201,8 +200,7 @@ export function RightAssistantSidebar({
() =>
tabIds.map((id) => ({
id,
label: t(`sidebar.tabs.${id}.label`),
description: t(`sidebar.tabs.${id}.description`)
label: t(`sidebar.tabs.${id}.label`)
})),
[t]
)
@@ -596,7 +594,6 @@ export function RightAssistantSidebar({
onKeyDown={(event) => moveTabFocus(event, item.id)}
role="tab"
tabIndex={tab === item.id ? 0 : -1}
title={item.description}
type="button"
>
{item.label}
@@ -622,9 +619,6 @@ export function RightAssistantSidebar({
) : null}
{tab === 'tasks' && (
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
{t('sidebar.tasks.description')}
</p>
<h3>
<ShieldAlert size={15} />
{t('sidebar.tasks.approvalsTitle')}
@@ -863,29 +857,22 @@ export function RightAssistantSidebar({
</section>
) : (
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
{t('sidebar.workspace.description')}
</p>
<h3>
<FolderTree size={15} />
{t('sidebar.workspace.projectTitle')}
<button
aria-label={t('sidebar.workspace.refreshAriaLabel')}
className="icon-button"
disabled={!workspaceProjectId}
onClick={() => {
setWorkspaceRefreshVersion((current) => current + 1)
runAction(
onRefreshChanges,
t('sidebar.errors.refreshWorkspace')
)
}}
title={t('sidebar.workspace.refresh')}
type="button"
>
<RefreshCw size={14} />
</button>
</h3>
<button
aria-label={t('sidebar.workspace.refreshAriaLabel')}
className="icon-button"
disabled={!workspaceProjectId}
onClick={() => {
setWorkspaceRefreshVersion((current) => current + 1)
runAction(
onRefreshChanges,
t('sidebar.errors.refreshWorkspace')
)
}}
title={t('sidebar.workspace.refresh')}
type="button"
>
<RefreshCw size={14} />
</button>
<WorkspaceFilesPanel
changedFiles={workspaceChanges?.files ?? emptyChangedFiles}
key={`${workspaceProjectId ?? 'none'}:${workspaceRefreshVersion}`}
@@ -972,13 +959,6 @@ export function RightAssistantSidebar({
</section>
) : (
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
{t('sidebar.results.description')}
</p>
<h3>
<FileText size={15} />
{t('sidebar.results.sectionTitle')}
</h3>
<button
className="secondary-button assistant-sidebar__import"
onClick={() =>
@@ -1031,10 +1011,6 @@ export function RightAssistantSidebar({
{tab === 'browser' && (
<section className="assistant-sidebar__browser">
<header>
<span>
<Monitor size={15} />
<strong>{t('sidebar.browser.title')}</strong>
</span>
{browserState &&
browserState.status !== 'stopped' && (
<div className="assistant-sidebar__browser-actions">
+216 -42
View File
@@ -15,6 +15,10 @@ import type {
DesktopApi,
RuntimeSettings
} from '../../shared/contracts'
import {
builtinEmbeddingConnectionId,
legacyEmbeddingConnectionId
} from '../../shared/contracts'
import type {
CapabilityAssignments,
CapabilitySnapshot
@@ -64,6 +68,24 @@ const runtimeSettings: RuntimeSettings = {
knowledgeEmbeddingModel: 'nomic-embed-text',
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none',
embeddingConnections: [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin'
},
{
id: legacyEmbeddingConnectionId,
name: '自定义向量模型',
kind: 'openai-compatible',
baseUrl: 'http://127.0.0.1:11434/v1/embeddings',
modelName: 'nomic-embed-text',
authentication: 'none',
apiKeyConfigured: false,
credentialSource: 'none'
}
],
activeEmbeddingConnectionId: legacyEmbeddingConnectionId,
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
knowledgeRerankModel: 'rerank-v3.5',
@@ -386,6 +408,31 @@ const embeddingSnapshot: EmbeddingSettingsSnapshot = {
model: 'nomic-embed-text',
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
credentialConfigured: false
},
connections: [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin',
model: 'granite-embedding-107m-multilingual',
credentialConfigured: false
},
{
id: legacyEmbeddingConnectionId,
name: '自定义向量模型',
kind: 'openai-compatible',
model: 'nomic-embed-text',
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
authentication: 'none',
credentialConfigured: false
}
],
currentConnectionId: legacyEmbeddingConnectionId,
models: {
selectedDownloadSource: 'modelscope',
catalog: [],
installed: [],
operations: []
}
}
const getEmbeddingSnapshot = vi.fn(async () => embeddingSnapshot)
@@ -399,6 +446,17 @@ const diagnoseEmbedding = vi.fn(
checkedAt: Date.UTC(2026, 7, 5, 12, 0, 0)
})
)
const setCurrentEmbedding = vi.fn(async () => embeddingSnapshot)
const installEmbeddingModel = vi.fn(
async () => embeddingSnapshot.models
)
const cancelEmbeddingModel = vi.fn(async () => true)
const importEmbeddingModel = vi.fn(
async () => embeddingSnapshot.models
)
const removeEmbeddingModel = vi.fn(
async () => embeddingSnapshot.models
)
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
updateSource: 'github',
@@ -710,7 +768,12 @@ describe('SettingsPanel runtime files', () => {
},
embeddings: {
getSnapshot: getEmbeddingSnapshot,
diagnose: diagnoseEmbedding
diagnose: diagnoseEmbedding,
setCurrent: setCurrentEmbedding,
installModel: installEmbeddingModel,
cancelModelOperation: cancelEmbeddingModel,
importModelArchive: importEmbeddingModel,
removeModel: removeEmbeddingModel
},
speechModels: {
getSnapshot: getSpeechModelSnapshot,
@@ -797,8 +860,8 @@ describe('SettingsPanel runtime files', () => {
})
).toBeInTheDocument()
expect(
screen.getByText('直连模型的历史压缩与原文保留')
).toBeInTheDocument()
screen.queryByText('直连模型的历史压缩与原文保留')
).not.toBeInTheDocument()
const enabled = screen.getByRole('switch', {
name: '自动压缩较早的对话'
})
@@ -1348,7 +1411,7 @@ describe('SettingsPanel runtime files', () => {
).not.toHaveClass('capability-card--disabled')
)
expect(
screen.getAllByText('内置 MCP Server · 按模式读写 · 按对话授权')
screen.getAllByText('按模式读写')
).not.toHaveLength(0)
})
@@ -1373,7 +1436,7 @@ describe('SettingsPanel runtime files', () => {
expect(content).toHaveClass('settings-panel__content')
expect(
screen.getByRole('tab', { name: 'Agent Runtime' })
).toHaveTextContent('配置 Agent Runtime、默认工作区与原生能力')
).toHaveTextContent(/^Agent Runtime$/u)
})
it('omits the redundant close-only footer on passive settings pages', () => {
@@ -3738,7 +3801,7 @@ describe('SettingsPanel runtime files', () => {
)
})
it('configures vector models under model connections instead of security', async () => {
it('renders vector settings synchronously under model connections instead of security', async () => {
getEmbeddingSnapshot
.mockResolvedValueOnce(embeddingSnapshot)
.mockResolvedValue({
@@ -3748,7 +3811,19 @@ describe('SettingsPanel runtime files', () => {
model: 'bge-m3',
endpoint: 'https://vectors.example/v1/embeddings',
credentialConfigured: true
}
},
connections: embeddingSnapshot.connections.map((connection) =>
connection.id === legacyEmbeddingConnectionId
? {
...connection,
model: 'bge-m3',
endpoint:
'https://vectors.example/v1/embeddings',
authentication: 'api-key' as const,
credentialConfigured: true
}
: connection
)
})
render(
<SettingsPanel
@@ -3762,26 +3837,31 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
expect(
screen.queryByRole('switch', { name: '启用向量模型' })
screen.queryByRole('switch', { name: '启用向量检索' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '向量模型' }))
expect(
screen
.getByLabelText('API Key(可选)')
.closest('.runtime-note')
).toHaveClass('model-service-form')
expect(
screen.getByText('向量模型连接', { selector: 'strong' })
screen.getByText('向量模型连接', {
selector: 'strong'
})
).toBeInTheDocument()
expect(
screen.queryByText('正在加载向量模型设置…')
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '保存并测试模型' })
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('switch', { name: '启用向量模型' })
screen.getByRole('switch', { name: '启用向量检索' })
)
fireEvent.click(
screen.getByRole('button', {
name: '编辑向量模型连接 自定义向量模型'
})
)
fireEvent.change(screen.getByLabelText('向量接口 URL'), {
target: { value: 'https://vectors.example/v1/embeddings' }
@@ -3789,6 +3869,9 @@ describe('SettingsPanel runtime files', () => {
fireEvent.change(screen.getByLabelText('模型名称'), {
target: { value: 'bge-m3' }
})
fireEvent.change(screen.getByLabelText('认证方式'), {
target: { value: 'api-key' }
})
fireEvent.change(screen.getByLabelText('API Key(可选)'), {
target: { value: 'vector-secret' }
})
@@ -3798,28 +3881,38 @@ describe('SettingsPanel runtime files', () => {
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
knowledgeEmbeddingEnabled: true,
knowledgeEmbeddingBaseUrl:
'https://vectors.example/v1/embeddings',
knowledgeEmbeddingModel: 'bge-m3',
knowledgeEmbeddingApiKey: {
action: 'replace',
value: 'vector-secret'
}
embeddingConnections: expect.arrayContaining([
expect.objectContaining({
id: legacyEmbeddingConnectionId,
baseUrl:
'https://vectors.example/v1/embeddings',
modelName: 'bge-m3',
apiKey: {
action: 'replace',
value: 'vector-secret'
}
})
])
})
)
)
const section = screen.getByRole('region', { name: '向量模型' })
await waitFor(() => {
expect(
within(section).getByText('bge-m3', { selector: 'strong' })
).toBeInTheDocument()
within(section).getAllByText('自定义向量模型', {
selector: 'strong'
})
).toHaveLength(2)
expect(
within(section).getByText(
/https:\/\/vectors\.example\/v1\/embeddings/u
within(section).getByDisplayValue(
'https://vectors.example/v1/embeddings'
)
).toBeInTheDocument()
expect(within(section).getByText('已配置凭据'))
.toBeInTheDocument()
expect(
within(section).queryByText(
/https:\/\/vectors\.example\/v1\/embeddings/u
)
).not.toBeInTheDocument()
})
})
@@ -3892,15 +3985,20 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('button', { name: '向量模型' }))
const section = screen.getByRole('region', { name: '向量模型' })
const testButton = within(section).getByRole('button', {
name: '测试向量模型连接 自定义向量模型'
})
expect(
within(section).getByRole('button', { name: '测试向量模型' })
testButton
).toBeDisabled()
fireEvent.click(
screen.getByRole('switch', { name: '启用向量模型' })
screen.getByRole('switch', { name: '启用向量检索' })
)
fireEvent.click(
within(section).getByRole('button', { name: '测试向量模型' })
within(section).getByRole('button', {
name: '测试向量模型连接 自定义向量模型'
})
)
await waitFor(() => expect(diagnoseEmbedding).toHaveBeenCalledTimes(1))
expect(
@@ -3911,6 +4009,61 @@ describe('SettingsPanel runtime files', () => {
).not.toBeInTheDocument()
})
it('preserves embedding credentials after a failed save, clears them after success, and sets the current connection explicitly', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '向量模型' }))
fireEvent.click(
screen.getByRole('button', {
name: '编辑向量模型连接 自定义向量模型'
})
)
fireEvent.change(await screen.findByLabelText('认证方式'), {
target: { value: 'api-key' }
})
const credential = screen.getByLabelText('API Key(可选)')
fireEvent.change(credential, {
target: { value: 'embedding-secret' }
})
updateRuntime.mockRejectedValueOnce(new Error('保存失败'))
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await screen.findByText('保存失败')
expect(credential).toHaveValue('embedding-secret')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => expect(credential).toHaveValue(''))
fireEvent.click(
screen.getByRole('switch', { name: '启用向量检索' })
)
fireEvent.click(
screen.getByRole('button', {
name: '编辑向量模型连接 GoodBuddy 内置向量模型'
})
)
fireEvent.click(screen.getByRole('radio', { name: '当前连接' }))
expect(setCurrentEmbedding).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenLastCalledWith(
expect.objectContaining({
activeEmbeddingConnectionId: builtinEmbeddingConnectionId
})
)
)
expect(setCurrentEmbedding).not.toHaveBeenCalled()
})
it('keeps Smart Heartbeat configuration out of Settings', () => {
render(
<SettingsPanel
@@ -4032,8 +4185,8 @@ describe('SettingsPanel runtime files', () => {
)
expect(await screen.findByText('电脑控制能力')).toBeInTheDocument()
expect(
screen.getByText('仅显示实际操作客户端电脑的能力')
).toBeInTheDocument()
screen.queryByText('仅显示实际操作客户端电脑的能力')
).not.toBeInTheDocument()
expect(
screen.queryByRole('switch', {
name: '启用直连模型内置浏览器'
@@ -4045,6 +4198,16 @@ describe('SettingsPanel runtime files', () => {
name: '启用 Linux 桌面控制'
})
).toBeDisabled()
const desktopCapabilityCard = screen
.getByRole('switch', { name: '启用 Linux 桌面控制' })
.closest('article')
expect(desktopCapabilityCard).not.toBeNull()
expect(
within(desktopCapabilityCard!).queryByText('已停用')
).not.toBeInTheDocument()
expect(
within(desktopCapabilityCard!).getByText('启用此能力')
).toBeInTheDocument()
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '直连模型' })
)
@@ -4161,7 +4324,7 @@ describe('SettingsPanel runtime files', () => {
})
expect(
await screen.findByText(
'内置 MCP Server · 未启用 · 需要开启魔法笔记'
'未启用 · 需要开启魔法笔记'
)
).toBeInTheDocument()
expect(noteServerToggle.closest('article')).toHaveClass(
@@ -4180,11 +4343,11 @@ describe('SettingsPanel runtime files', () => {
})
).toHaveLength(builtinMcpServers.length)
expect(
screen.getByText(/按请求提供,可分别控制启停与 Runtime 分配/)
).toBeInTheDocument()
screen.queryByText(/按请求提供,可分别控制启停与 Runtime 分配/)
).not.toBeInTheDocument()
expect(
screen.getByText(/不公开服务地址或凭据/)
).toBeInTheDocument()
screen.queryByText(/不公开服务地址或凭据/)
).not.toBeInTheDocument()
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '直连模型' })
)
@@ -4208,12 +4371,18 @@ describe('SettingsPanel runtime files', () => {
)
expect(
screen.getByText(
/自定义 MCP 可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness/
/可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness/
)
).toHaveTextContent('新建时默认分配给直连模型')
expect(
screen.getByText(/服务地址、命令和凭据始终由 GoodBuddy 主进程保管/)
screen.getByText(/服务地址、命令和凭据由 GoodBuddy 主进程保管/)
).toBeInTheDocument()
expect(screen.getByText(/具有当前用户权限/)).toHaveTextContent(
'请仅添加可信服务'
)
expect(
screen.getByText(/远程访问令牌由系统安全存储加密/)
).toHaveTextContent('工具调用前仍需 GoodBuddy 审批')
expect(
await screen.findByText('尚未配置 MCP Server')
).toBeInTheDocument()
@@ -4221,8 +4390,13 @@ describe('SettingsPanel runtime files', () => {
screen.getByRole('button', { name: /添加 Server/ })
).toBeInTheDocument()
expect(
screen.getByText(/自定义 stdio MCP 会以受限环境启动/)
).toHaveTextContent('不会获得桌面会话变量')
screen.getByText(/stdio Server 会以不含桌面会话变量的受限环境启动/)
).toHaveTextContent('需要电脑控制时请使用经过诊断的内置能力')
expect(
document.querySelectorAll(
'#mcp-settings-panel-custom > .settings-notice'
)
).toHaveLength(1)
const addServer = screen.getByRole('button', { name: /添加 Server/ })
fireEvent.click(addServer)
const dialog = screen.getByRole('dialog', {
+319 -141
View File
@@ -34,6 +34,7 @@ import type {
} from '../../shared/contracts'
import {
defaultContextCompressionSettings,
builtinEmbeddingConnectionId,
defaultModelProfileId as builtInDefaultModelProfileId,
defaultRuntimeSettings,
isAgentRuntimeModelProtocol,
@@ -46,7 +47,6 @@ import { ChannelSettingsSection } from './ChannelSettingsSection'
import { UpdateSettingsSection } from './UpdateSettingsSection'
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection'
import {
@@ -66,8 +66,10 @@ import type { AppearanceTheme } from './theme'
import type { AppNotificationInput } from './notifications'
import type {
EmbeddingDiagnosticResult,
EmbeddingConfigurationSummary
EmbeddingModelSnapshot,
EmbeddingSettingsSnapshot
} from '../../shared/embedding-contracts'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { useUiLocale } from './i18n/UiLocaleProvider'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
@@ -81,6 +83,36 @@ type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
apiKey: string
clearApiKey: boolean
}
type EmbeddingConnectionDraft =
Omit<
Exclude<
NonNullable<RuntimeSettings['embeddingConnections']>[number],
{ kind: 'builtin' }
>,
'apiKey'
> & {
apiKey: string
clearApiKey: boolean
}
function toEmbeddingConnectionDrafts(
settings: RuntimeSettings
): EmbeddingConnectionDraft[] {
return (settings.embeddingConnections ?? [])
.filter(
(
connection
): connection is Exclude<
NonNullable<RuntimeSettings['embeddingConnections']>[number],
{ kind: 'builtin' }
> => connection.kind === 'openai-compatible'
)
.map((connection) => ({
...connection,
apiKey: '',
clearApiKey: false
}))
}
const settingsTabs = settingsCategoryList.map(({ id }) => id)
const contextCompressionTokenScale = 1_000
@@ -276,6 +308,8 @@ function hydrateRuntimeSettings(
knowledgeEmbeddingModel: (value: string) => void
knowledgeEmbeddingApiKey: (value: string) => void
clearKnowledgeEmbeddingApiKey: (value: boolean) => void
embeddingConnections: (value: EmbeddingConnectionDraft[]) => void
activeEmbeddingConnectionId: (value: string) => void
knowledgeRerankEnabled: (value: boolean) => void
knowledgeRerankEndpoint: (value: string) => void
knowledgeRerankModel: (value: string) => void
@@ -324,6 +358,10 @@ function hydrateRuntimeSettings(
setters.knowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setters.knowledgeEmbeddingApiKey('')
setters.clearKnowledgeEmbeddingApiKey(false)
setters.embeddingConnections(toEmbeddingConnectionDrafts(value))
setters.activeEmbeddingConnectionId(
value.activeEmbeddingConnectionId ?? builtinEmbeddingConnectionId
)
setters.knowledgeRerankEnabled(
value.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled
@@ -600,6 +638,15 @@ export function SettingsPanel({
clearKnowledgeEmbeddingApiKey,
setClearKnowledgeEmbeddingApiKey
] = useState(false)
const [embeddingConnections, setEmbeddingConnections] = useState<
EmbeddingConnectionDraft[]
>([])
const [
activeEmbeddingConnectionId,
setActiveEmbeddingConnectionId
] = useState(builtinEmbeddingConnectionId)
const [editingEmbeddingConnectionId, setEditingEmbeddingConnectionId] =
useState<string>()
const [knowledgeRerankEnabled, setKnowledgeRerankEnabled] =
useState<boolean>(defaultRuntimeSettings.knowledgeRerankEnabled)
const [knowledgeRerankEndpoint, setKnowledgeRerankEndpoint] =
@@ -642,12 +689,16 @@ export function SettingsPanel({
: profile.name
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [embeddingConfiguration, setEmbeddingConfiguration] =
useState<EmbeddingConfigurationSummary>()
const [embeddingSnapshot, setEmbeddingSnapshot] =
useState<EmbeddingSettingsSnapshot>()
const [embeddingModels, setEmbeddingModels] =
useState<EmbeddingModelSnapshot>()
const [embeddingDiagnostic, setEmbeddingDiagnostic] =
useState<EmbeddingDiagnosticResult>()
const [embeddingDiagnosticRunning, setEmbeddingDiagnosticRunning] =
useState(false)
const [embeddingModelActionRunning, setEmbeddingModelActionRunning] =
useState(false)
const [error, setError] = useState<string>()
const [confirmingClear, setConfirmingClear] = useState(false)
const [clearingLocalData, setClearingLocalData] = useState(false)
@@ -721,6 +772,9 @@ export function SettingsPanel({
knowledgeEmbeddingModel: setKnowledgeEmbeddingModel,
knowledgeEmbeddingApiKey: setKnowledgeEmbeddingApiKey,
clearKnowledgeEmbeddingApiKey: setClearKnowledgeEmbeddingApiKey,
embeddingConnections: setEmbeddingConnections,
activeEmbeddingConnectionId:
setActiveEmbeddingConnectionId,
knowledgeRerankEnabled: setKnowledgeRerankEnabled,
knowledgeRerankEndpoint: setKnowledgeRerankEndpoint,
knowledgeRerankModel: setKnowledgeRerankModel,
@@ -779,6 +833,8 @@ export function SettingsPanel({
knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey,
clearKnowledgeEmbeddingApiKey,
embeddingConnections,
activeEmbeddingConnectionId,
knowledgeRerankEnabled,
knowledgeRerankEndpoint,
knowledgeRerankModel,
@@ -819,6 +875,10 @@ export function SettingsPanel({
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey: '',
clearKnowledgeEmbeddingApiKey: false,
embeddingConnections: toEmbeddingConnectionDrafts(settings),
activeEmbeddingConnectionId:
settings.activeEmbeddingConnectionId ??
builtinEmbeddingConnectionId,
knowledgeRerankEnabled:
settings.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled,
@@ -981,7 +1041,8 @@ export function SettingsPanel({
.then((snapshot) => {
if (active) {
setEmbeddingDiagnostic(undefined)
setEmbeddingConfiguration(snapshot.configuration)
setEmbeddingSnapshot(snapshot)
setEmbeddingModels(snapshot.models)
}
})
.catch((reason: unknown) => {
@@ -1001,6 +1062,27 @@ export function SettingsPanel({
}
}, [i18n, open])
useEffect(() => {
const embeddings = window.goodbuddy.embeddings
if (
!open ||
!embeddings ||
!embeddingModels?.operations.length
) {
return
}
const timer = window.setInterval(() => {
void embeddings.getSnapshot().then(
(snapshot) => {
setEmbeddingSnapshot(snapshot)
setEmbeddingModels(snapshot.models)
},
() => undefined
)
}, 300)
return () => window.clearInterval(timer)
}, [embeddingModels?.operations.length, open])
const normalizedContextCompression =
normalizeContextCompressionTokenDrafts(
contextCompression,
@@ -1035,6 +1117,14 @@ export function SettingsPanel({
)
setKnowledgeEmbeddingApiKey('')
setClearKnowledgeEmbeddingApiKey(false)
setEmbeddingConnections((connections) =>
connections.map((connection) => ({
...connection,
apiKey: '',
clearApiKey: false
}))
)
setEditingEmbeddingConnectionId(undefined)
setKnowledgeRerankApiKey('')
setClearKnowledgeRerankApiKey(false)
setSpeechModelDraftId(undefined)
@@ -1187,6 +1277,31 @@ export function SettingsPanel({
value: knowledgeEmbeddingApiKey.trim()
}
: { action: 'keep' },
embeddingConnections: [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin'
},
...embeddingConnections.map((connection) => ({
id: connection.id,
name: connection.name,
kind: connection.kind,
baseUrl: connection.baseUrl,
modelName: connection.modelName,
authentication: connection.authentication,
apiKey: connection.clearApiKey
? ({ action: 'clear' } as const)
: connection.authentication === 'api-key' &&
connection.apiKey.trim()
? ({
action: 'replace',
value: connection.apiKey.trim()
} as const)
: ({ action: 'keep' } as const)
}))
],
activeEmbeddingConnectionId,
knowledgeRerankEnabled,
knowledgeRerankEndpoint,
knowledgeRerankModel,
@@ -1231,9 +1346,9 @@ export function SettingsPanel({
const embeddings = window.goodbuddy.embeddings
if (embeddings) {
try {
setEmbeddingConfiguration(
(await embeddings.getSnapshot()).configuration
)
const snapshot = await embeddings.getSnapshot()
setEmbeddingSnapshot(snapshot)
setEmbeddingModels(snapshot.models)
} catch (reason) {
setError(
settingsErrorMessage(
@@ -1325,7 +1440,9 @@ export function SettingsPanel({
}
}
const runEmbeddingDiagnostic = async (): Promise<void> => {
const runEmbeddingDiagnostic = async (
connectionId: string
): Promise<void> => {
const embeddings = window.goodbuddy.embeddings
if (!embeddings) {
setError(t('errors.embeddingDiagnosticUnavailable'))
@@ -1338,11 +1455,11 @@ export function SettingsPanel({
if (!(await save(false))) {
return
}
const diagnostic = await embeddings.diagnose()
const diagnostic = await embeddings.diagnose(connectionId)
setEmbeddingDiagnostic(diagnostic)
setEmbeddingConfiguration(
(await embeddings.getSnapshot()).configuration
)
const snapshot = await embeddings.getSnapshot()
setEmbeddingSnapshot(snapshot)
setEmbeddingModels(snapshot.models)
} catch (reason) {
setError(settingsErrorMessage(reason, t('errors.testEmbedding')))
} finally {
@@ -1350,6 +1467,51 @@ export function SettingsPanel({
}
}
const runEmbeddingModelOperation = async (
operation: (
api: NonNullable<typeof window.goodbuddy.embeddings>
) => Promise<EmbeddingModelSnapshot | undefined>
): Promise<void> => {
const embeddings = window.goodbuddy.embeddings
if (!embeddings) {
setError(t('errors.embeddingDiagnosticUnavailable'))
return
}
setEmbeddingModelActionRunning(true)
setError(undefined)
try {
const models = await operation(embeddings)
if (models) {
setEmbeddingModels(models)
const snapshot = await embeddings.getSnapshot()
setEmbeddingSnapshot(snapshot)
}
} catch (reason) {
setError(settingsErrorMessage(reason, t('errors.saveSettings')))
} finally {
setEmbeddingModelActionRunning(false)
}
}
const cancelEmbeddingModelOperation = async (
modelId: string
): Promise<void> => {
const embeddings = window.goodbuddy.embeddings
if (!embeddings) {
setError(t('errors.embeddingDiagnosticUnavailable'))
return
}
setError(undefined)
try {
await embeddings.cancelModelOperation(modelId)
const snapshot = await embeddings.getSnapshot()
setEmbeddingSnapshot(snapshot)
setEmbeddingModels(snapshot.models)
} catch (reason) {
setError(settingsErrorMessage(reason, t('errors.saveSettings')))
}
}
const selectRuntimeFile = async (
kind: RuntimeFileSelectionKind,
setValue: (value: string) => void
@@ -1633,8 +1795,6 @@ export function SettingsPanel({
<X aria-hidden="true" size={19} />
</button>
}
description={t('center.description')}
eyebrow={t('center.eyebrow')}
headingId="settings-title"
title={t('center.title')}
/>
@@ -1670,11 +1830,6 @@ export function SettingsPanel({
<strong>
{t(`categories.${category.translationKey}.label`)}
</strong>
<small>
{t(
`categories.${category.translationKey}.navigationDescription`
)}
</small>
</button>
))}
</nav>
@@ -1974,9 +2129,6 @@ export function SettingsPanel({
</div>
<details className="settings-section">
<summary>{t('runtime.advanced')}</summary>
<p className="settings-panel__description">
{t('runtime.opencode.advancedDescription')}
</p>
<fieldset className="runtime-source-options">
<legend>{t('runtime.sourceLegend')}</legend>
<label>
@@ -2205,9 +2357,6 @@ export function SettingsPanel({
</div>
<details className="settings-section">
<summary>{t('runtime.advanced')}</summary>
<p className="settings-panel__description">
{t('runtime.continue.advancedDescription')}
</p>
<fieldset className="runtime-source-options">
<legend>{t('runtime.sourceLegend')}</legend>
<label>
@@ -2508,15 +2657,6 @@ export function SettingsPanel({
]}
value={modelType}
/>
<small>
{modelType === 'llm'
? t('model.types.llm.description')
: modelType === 'embedding'
? t('model.types.embedding.description')
: modelType === 'rerank'
? t('model.types.rerank.description')
: t('model.types.speech.description')}
</small>
</div>
{modelType === 'llm' && (
<div className="settings-section">
@@ -2963,115 +3103,153 @@ export function SettingsPanel({
</div>
)}
{modelType === 'embedding' && (
<div className="settings-section">
<div className="settings-section__title">
<KeyRound size={17} />
<div>
<strong>{t('model.embedding.title')}</strong>
<small>{t('model.embedding.description')}</small>
</div>
</div>
<div className="runtime-note model-service-form">
<label className="toggle-row">
<input
checked={knowledgeEmbeddingEnabled}
onChange={(event) =>
setKnowledgeEmbeddingEnabled(event.target.checked)
}
role="switch"
type="checkbox"
/>
<span>{t('model.embedding.enabled')}</span>
</label>
<label className="field">
<span>{t('model.embedding.endpoint')}</span>
<input
aria-label={t('model.embedding.endpoint')}
disabled={!knowledgeEmbeddingEnabled}
inputMode="url"
onChange={(event) =>
setKnowledgeEmbeddingBaseUrl(event.target.value)
}
placeholder="https://provider.example/v1/embeddings"
value={knowledgeEmbeddingBaseUrl}
/>
<small>
{t('model.embedding.endpointDescription')}
</small>
</label>
<label className="field">
<span>{t('model.embedding.modelName')}</span>
<input
aria-label={t('model.embedding.modelName')}
disabled={!knowledgeEmbeddingEnabled}
onChange={(event) =>
setKnowledgeEmbeddingModel(event.target.value)
}
value={knowledgeEmbeddingModel}
/>
</label>
<label className="field">
<span>{t('model.embedding.optionalApiKey')}</span>
<input
aria-label={t('model.embedding.optionalApiKey')}
autoComplete="off"
disabled={
!knowledgeEmbeddingEnabled ||
settings?.knowledgeEmbeddingCredentialSource ===
'environment' ||
!settings?.secureStorageAvailable
}
onChange={(event) => {
setKnowledgeEmbeddingApiKey(event.target.value)
setClearKnowledgeEmbeddingApiKey(false)
}}
placeholder={
settings?.knowledgeEmbeddingApiKeyConfigured
? t('credentials.configuredPlaceholder')
: t('model.embedding.optionalApiKeyPlaceholder')
}
type="password"
value={knowledgeEmbeddingApiKey}
/>
</label>
<div className="credential-state">
<LockKeyhole size={15} />
<span>
{settings
? t(
`credentials.${settings.knowledgeEmbeddingCredentialSource}`
)
: t('credentials.none')}
</span>
{settings?.knowledgeEmbeddingCredentialSource ===
'encrypted' && (
<button
onClick={() => {
setKnowledgeEmbeddingApiKey('')
setClearKnowledgeEmbeddingApiKey(true)
}}
type="button"
>
{clearKnowledgeEmbeddingApiKey
? t('actions.clearAfterSave')
: t('actions.clearCredential')}
</button>
)}
</div>
<small>
{t('model.embedding.privacyDescription')}
</small>
</div>
</div>
)}
{modelType === 'embedding' && embeddingConfiguration && (
<EmbeddingSettingsSection
configuration={embeddingConfiguration}
connections={[
...(embeddingSnapshot?.connections ?? [])
.filter((connection) => connection.kind === 'builtin')
.map((connection) => {
const installed = embeddingModels?.installed.some(
(candidate) => candidate.id === connection.model
)
const operation = embeddingModels?.operations.find(
(candidate) =>
candidate.modelId === connection.model
)
const progress =
operation?.totalBytes &&
Math.round(
(operation.completedBytes /
operation.totalBytes) *
100
)
return {
...connection,
installed,
operationActive: Boolean(operation),
statusText: operation
? t('model.embedding.progress', {
percent: progress ?? 0
})
: installed
? t('model.embedding.installed')
: t('model.embedding.notInstalled')
}
}),
...embeddingConnections.map((connection) => ({
...connection,
model: connection.modelName,
endpoint: connection.baseUrl
}))
]}
currentConnectionId={activeEmbeddingConnectionId}
diagnostic={embeddingDiagnostic}
diagnosticRunning={embeddingDiagnosticRunning}
disabled={saving || !knowledgeEmbeddingEnabled}
onTest={() => {
void runEmbeddingDiagnostic()
enabled={knowledgeEmbeddingEnabled}
busy={saving || embeddingModelActionRunning}
secureStorageAvailable={
settings?.secureStorageAvailable ?? false
}
selectedConnectionId={editingEmbeddingConnectionId}
onAddConnection={() => {
const id = crypto.randomUUID()
setEmbeddingConnections((connections) => [
...connections,
{
id,
name: t('model.embedding.newConnectionName'),
kind: 'openai-compatible',
baseUrl:
'https://provider.example/v1/embeddings',
modelName: 'text-embedding-3-small',
authentication: 'api-key',
apiKeyConfigured: false,
credentialSource: 'none',
apiKey: '',
clearApiKey: false
}
])
setEditingEmbeddingConnectionId(id)
}}
onCancelBuiltin={(modelId) => {
void cancelEmbeddingModelOperation(modelId)
}}
onDeleteConnection={(connectionId) => {
setEmbeddingConnections((connections) =>
connections.filter(
(connection) => connection.id !== connectionId
)
)
if (activeEmbeddingConnectionId === connectionId) {
setActiveEmbeddingConnectionId(
builtinEmbeddingConnectionId
)
}
setEditingEmbeddingConnectionId(
builtinEmbeddingConnectionId
)
}}
onDownloadBuiltin={(modelId) => {
const model = embeddingModels?.catalog.find(
(candidate) => candidate.id === modelId
)
if (model && embeddingModels) {
void runEmbeddingModelOperation((api) =>
api.installModel(
model.id,
embeddingModels.selectedDownloadSource
)
)
}
}}
onEnabledChange={setKnowledgeEmbeddingEnabled}
onImportBuiltin={(modelId) => {
void runEmbeddingModelOperation((api) =>
api.importModelArchive(modelId)
)
}}
onRemoveBuiltin={(modelId) => {
void runEmbeddingModelOperation((api) =>
api.removeModel(modelId)
)
}}
onSelectConnection={setEditingEmbeddingConnectionId}
onSetCurrent={setActiveEmbeddingConnectionId}
onTestConnection={(connectionId) => {
void runEmbeddingDiagnostic(connectionId)
}}
onUpdateConnection={(connectionId, changes) => {
setEmbeddingConnections((connections) =>
connections.map((connection) =>
connection.id === connectionId
? {
...connection,
...(changes.name === undefined
? {}
: { name: changes.name }),
...(changes.endpoint === undefined
? {}
: { baseUrl: changes.endpoint }),
...(changes.model === undefined
? {}
: { modelName: changes.model }),
...(changes.authentication === undefined
? {}
: {
authentication:
changes.authentication
}),
...(changes.apiKey === undefined
? {}
: { apiKey: changes.apiKey }),
...(changes.clearApiKey === undefined
? {}
: {
clearApiKey: changes.clearApiKey
})
}
: connection
)
)
}}
/>
)}
@@ -108,6 +108,9 @@ describe('SpeechModelSettingsSection', () => {
})
).toBeInTheDocument()
expect(screen.getByText('模型仓库自定义许可')).toBeInTheDocument()
expect(screen.queryByText(snapshot.rootDirectory)).not.toBeInTheDocument()
expect(screen.queryByText('Current model download source: ModelScope')).not.toBeInTheDocument()
expect(screen.getByText('ModelScope')).toBeInTheDocument()
expect(screen.queryByText('Model details')).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
@@ -366,18 +366,6 @@ export function SpeechModelSettingsSection({
</button>
</div>
<p className="settings-notice">
{t('speech.storagePrefix')}{' '}
<code>{snapshot.rootDirectory}</code>
{t('speech.storageSuffix')}
</p>
<p className="settings-notice">
{t('speech.downloadSource', {
source: t(
`modelDownloadSources.${snapshot.selectedDownloadSource}`
)
})}
</p>
{error && <p className="settings-warning" role="alert">{error}</p>}
<label className="field document-ocr-model-selector">
@@ -80,6 +80,17 @@ describe('WorkspacePrimitives', () => {
cleanup()
})
it('keeps shared switch rows aligned without standalone dividers', () => {
const toggleRowStyles = stylesheet.match(
/\.toggle-row\s*\{(?<rules>[^}]*)\}/u
)?.groups?.rules
expect(toggleRowStyles).toBeDefined()
expect(toggleRowStyles).toMatch(/align-items:\s*center;/u)
expect(toggleRowStyles).not.toMatch(/border-top:/u)
expect(toggleRowStyles).not.toMatch(/padding-top:/u)
})
it('uses bundled variable fonts and readable shared type tokens', () => {
expect(rendererEntry).toContain(
"@fontsource-variable/noto-sans-sc/wght.css"
@@ -93,6 +104,9 @@ describe('WorkspacePrimitives', () => {
)
expect(stylesheet).toMatch(/--font-body:\s*13px/u)
expect(stylesheet).toMatch(/--font-caption:\s*11px/u)
expect(stylesheet).toMatch(
/:root\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/--text-disabled:\s*#[\da-f]{6};/iu
)
@@ -116,6 +130,12 @@ describe('WorkspacePrimitives', () => {
expect(stylesheet).toMatch(
/\.conversation-item small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.runtime-overview__details dt\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.runtime-overview__details dd\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.markdown-content\s*\{[^}]*font-size:\s*14px;[^}]*line-height:\s*1\.65;/u
)
@@ -3,10 +3,7 @@ import type { activity as chineseActivity } from '../zh-CN/activity'
export const activity = {
header: {
eyebrow: 'RUN HISTORY',
title: 'Run history',
description:
'Review execution details by project, task, and conversation, or browse the activity timeline and model usage.'
title: 'Run history'
},
tabs: {
ariaLabel: 'Run history views',
@@ -38,9 +35,9 @@ export const activity = {
result: 'Result'
},
filters: {
all: 'All',
active: 'In progress',
failed: 'Exceptions',
all: 'All {{formattedCount}}',
active: 'In progress {{formattedCount}}',
failed: 'Exceptions {{formattedCount}}',
ariaLabel: 'Filter activity',
clear: 'Clear filter'
},
@@ -79,16 +76,8 @@ export const activity = {
unknownRuntime: 'Unknown Runtime'
}
},
stats: {
ariaLabel: 'Activity totals',
all: 'All',
active: 'In progress',
failed: 'Exceptions'
},
timeline: {
ariaLabel: 'Parallel activity tracks grouped by project and conversation',
description:
'Every track shares the same execution order. Nodes are spread by event time. Select a node to inspect its identity and details.',
lanes: 'Project / conversation',
laneAriaLabel: 'Activity track for conversation {{title}}',
nodeAriaLabel: '{{actor}}, {{kind}}, {{status}}, {{title}}, {{time}}',
+5 -14
View File
@@ -2,9 +2,6 @@ import type { TranslationShape } from '../../resource-types'
import type { app as chineseApp } from '../zh-CN/app'
export const app = {
brand: {
desktopWorkspace: 'Desktop workspace'
},
notifications: {
success: 'Success',
error: 'Error',
@@ -75,8 +72,7 @@ export const app = {
conversation: {
defaultTitle: 'New conversation',
remoteTitle: 'Remote conversation',
greeting:
'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base. With an Agent Runtime enabled, I can also use tools with your authorization.',
greeting: 'Hi, I’m GoodBuddy.',
interrupted: 'The previous run stopped unexpectedly. You can resend your question.',
active: 'Conversation is active',
unread: 'Unread',
@@ -214,10 +210,7 @@ export const app = {
user: 'You',
taskResult: 'Task result: {{title}}',
welcome: {
eyebrow: 'GOODBUDDY WORKSPACE',
title: 'What would you like to accomplish today?',
description:
'Ask a question, organize information, or connect OpenCode for file search and development tools.'
title: 'What would you like to accomplish today?'
},
quickActions: {
summarize: {
@@ -384,8 +377,7 @@ export const app = {
inputLabel: 'Message GoodBuddy',
placeholder: 'Message GoodBuddy…',
imagePlaceholder: 'Describe the image you want to generate…',
keyboardHint:
'Enter to send · Shift+Enter for a new line · Ctrl+V to paste an image or text',
keyboardHint: 'Enter to send, Shift+Enter for a new line',
addContent: 'Add content',
addAttachment: 'Add attachment',
attachmentProgress: {
@@ -478,8 +470,8 @@ export const app = {
description: 'Read-only answers without modifying files'
},
execute: {
label: 'Execute · Controlled',
description: 'Use tools after approval'
label: 'Execute · Full access',
description: 'Run tools with the account’s full permissions'
}
},
voice: {
@@ -487,7 +479,6 @@ export const app = {
cancel: 'Cancel speech recognition',
input: 'Voice input',
stopAndRecognize: 'Stop recording and recognize speech',
description: 'Convert speech to editable text before sending',
unsupported:
'Built-in speech recognition is unavailable. You can keep typing.',
downloadingPack:
@@ -8,10 +8,9 @@ export const heartbeat = {
unknownTime: 'Unknown time'
},
center: {
eyebrow: 'SMART HEARTBEAT',
title: 'Smart Heartbeat',
description:
'Periodically review experiences, retain memories, identify issues, and turn each change into an actionable growth suggestion.',
'Runs scheduled, read-only reviews of the selected scope without using tools.',
scope: {
currentProject: 'Current project',
global: 'Global'
@@ -38,7 +37,6 @@ export const heartbeat = {
plans: 'Heartbeat plans'
},
currentStatus: {
eyebrow: 'CURRENT PULSE',
title: 'Current status',
activePlans: 'Active plans: {{formattedCount}}',
disabled: 'Not enabled',
@@ -85,7 +83,6 @@ export const heartbeat = {
actionRateAriaLabel: 'Suggested task completion rate {{percent}}'
},
trend: {
eyebrow: 'GROWTH TREND',
title: 'Growth trend',
empty:
'After a heartbeat completes, changes in insights, memories, and suggested actions will appear here.',
@@ -96,7 +93,6 @@ export const heartbeat = {
'{{date}}: {{insights}} insights, {{memories}} memory suggestions, and {{actions}} action suggestions'
},
latest: {
eyebrow: 'LATEST REPORT',
title: 'Latest heartbeat',
viewHistory: 'View heartbeat history',
handleSuggestions: 'Review suggestions ({{formattedCount}})',
@@ -104,7 +100,6 @@ export const heartbeat = {
'There are no heartbeat reports yet. Run a heartbeat to see what GoodBuddy learned.'
},
suggestions: {
memoryEyebrow: 'MEMORY GROWTH',
memoryTitle: 'Memories to confirm',
memoryCount: 'Items: {{formattedCount}}',
memoryEmpty: 'There are no memory suggestions to confirm.',
@@ -114,7 +109,6 @@ export const heartbeat = {
expandContent: 'View full content',
confirmMemory: 'Confirm memory',
ignore: 'Ignore',
taskEyebrow: 'NEXT ACTIONS',
taskTitle: 'Suggested actions',
taskCount: 'Items: {{formattedCount}}',
taskEmpty: 'Smart Heartbeat has not suggested any actions.',
@@ -123,7 +117,6 @@ export const heartbeat = {
ignoreSuggestion: 'Ignore suggestion'
},
history: {
timelineEyebrow: 'HEARTBEAT TIMELINE',
timelineTitle: 'Growth history',
reportCount: 'Reports: {{formattedCount}}',
emptyTimeline:
@@ -133,7 +126,6 @@ export const heartbeat = {
collapseReport: 'Collapse report',
expandReport: 'Expand full report',
loadMoreReports: 'Load more heartbeat reports',
auditEyebrow: 'RUN AUDIT',
auditTitle: 'Run history',
runCount: 'Runs: {{formattedCount}}',
emptyRuns: 'There are no Smart Heartbeat runs yet.',
@@ -171,7 +163,7 @@ export const heartbeat = {
settings: {
title: 'Smart Heartbeat',
description:
'Periodically review experiences, retain memories, identify issues, and turn changes into actionable growth suggestions. Smart Heartbeat is read-only and never uses tools.',
'Heartbeat plans run on schedule in read-only mode without tools.',
recurrenceAriaLabel: 'Heartbeat recurrence',
recurrenceLabel: 'Recurrence',
daily: 'Daily',
@@ -17,10 +17,7 @@ export const integrations = {
},
project: {
sectionAriaLabel: '{{name}} channel project settings',
identity: 'Channel project',
cardTitle: 'Project settings',
cardDescription:
'Stays synchronized with the selected channel project in the top-left project switcher.',
descriptionLabel: 'Project description',
descriptionAriaLabel: '{{name}} project description',
rootLabel: 'Default working directory',
@@ -180,14 +177,12 @@ export const integrations = {
custom: 'Custom MCP'
},
customNotice:
'Custom MCP can be assigned to direct models, GoodBuddy-managed OpenCode, Continue Agent, or DeepSeek Harness. New servers target direct models by default and load only in Execute mode. Agent runtimes receive only a request-scoped loopback capability; GoodBuddy keeps server addresses, commands, and credentials in the main process.',
securityNotice:
'Built-in tools are provided by GoodBuddy and are not MCP servers. Custom MCP servers and tools run with the current user’s permissions, so add only trusted services. Remote access tokens are encrypted in secure system storage, and tool calls still require GoodBuddy approval.',
'Custom MCP loads only in Execute mode and can be assigned to direct models, GoodBuddy-managed OpenCode, Continue Agent, or DeepSeek Harness. New servers target direct models by default. Servers and their tools run with the current user’s permissions, so add only trusted services. GoodBuddy keeps server addresses, commands, and credentials in the main process, encrypts remote access tokens in secure system storage, and still requires approval for tool calls. stdio servers start in a restricted environment without desktop session variables; use diagnosed built-in capabilities for computer control.',
computer: {
title: 'Computer control capabilities',
subtitle: 'Only capabilities that control the client computer',
supported: 'Supported on this device',
unsupported: 'Not supported on this device',
enable: 'Enable this capability',
enabled: 'Enabled',
disabled: 'Disabled',
enableAriaLabel: 'Enable {{name}}',
@@ -202,10 +197,6 @@ export const integrations = {
},
builtin: {
title: 'Built-in GoodBuddy MCP',
availableTo:
'Provided per request with independent enablement and runtime assignment',
notice:
'GoodBuddy grants built-in MCP short-lived permissions for the current conversation in the main process without exposing service addresses or credentials. Changes apply to subsequent requests, while Ask and Execute access boundaries remain enforced by the system.',
enabled: 'Enabled',
disabled: 'Disabled',
enableAriaLabel: 'Enable the built-in {{name}} MCP',
@@ -215,12 +206,9 @@ export const integrations = {
runtimeAssignmentUnsupportedAriaLabel:
'{{name}} cannot be assigned to {{runtime}} because this runtime does not support built-in MCP',
unsupportedSuffix: ' (not supported yet)',
serverSummaryMixed:
'Built-in MCP server · Access depends on mode · Authorized per conversation',
serverSummaryReadOnly:
'Built-in MCP server · Read-only · Authorized per conversation',
serverSummaryDisabled:
'Built-in MCP server · Disabled · Enable Magic Notes first',
serverSummaryMixed: 'Read and write by mode',
serverSummaryReadOnly: 'Read-only',
serverSummaryDisabled: 'Disabled · Enable Magic Notes first',
featureDisabled:
'Magic Notes is disabled, so this built-in capability does not provide tools to any runtime.',
collapseServer: 'Collapse server {{name}}',
@@ -298,8 +286,6 @@ export const integrations = {
custom: {
title: 'Custom MCP servers (advanced)',
count: '{{count}}',
notice:
'Custom stdio MCP starts in a restricted environment without desktop session variables. For computer control, use the diagnosed built-in capabilities above.',
empty: 'No MCP servers configured',
collapseServer: 'Collapse server {{name}}',
expandServer: 'Expand server {{name}}',
@@ -3,7 +3,6 @@ import type { knowledge as chineseKnowledge } from '../zh-CN/knowledge'
export const knowledge = {
page: {
eyebrow: 'Knowledge',
title: 'Knowledge Base',
description: 'Manage files, folders, and web sources, then inspect their indexes and graph.'
},
@@ -161,49 +160,24 @@ export const knowledge = {
eyebrow: 'Current library: {{libraryName}}',
title: 'Retrieval test',
description:
'Validate retrieval from this library with temporary settings. This test does not create a conversation, call an LLM, or modify knowledge content.',
'Settings are temporary for this test. It does not create a conversation, call an LLM, or modify knowledge content.',
close: 'Close retrieval test',
query: {
title: 'Test question',
help: 'Enter a real question to inspect channels, ranking, and final context.',
label: 'Retrieval question',
placeholder: 'For example: How do I configure document parsing offline?',
count: '{{count}} / 4000 characters'
},
pipeline: {
recall: {
title: 'Recall candidates',
summary: 'Up to {{count}} fused candidates',
pending: 'Enter valid settings to calculate'
},
rerank: {
title: 'Local reranking',
enabled: 'Rerank up to {{count}} candidates',
disabled: 'Off; keep fused ranking'
},
select: {
title: 'Final results',
summary: 'Keep the top {{count}} chunks'
},
context: {
title: 'Assemble context',
summary: '{{count}} character budget'
}
},
settings: {
title: 'Settings for this test',
temporary:
'These changes apply only to this test. Save them as the current library defaults to keep using them.',
groups: {
recall: {
title: 'Candidate recall',
description:
'Control the initial search pool, filtering threshold, and each channel’s influence on fused ranking.'
title: 'Candidate recall'
},
output: {
title: 'Reranking and context',
description:
'Control candidate ordering, final result count, and the context sent to the model.'
title: 'Reranking and context'
}
},
candidateMultiplier: 'Recall multiplier',
@@ -500,10 +474,6 @@ export const knowledge = {
documents: {
sources: {
title: 'Content sources',
description:
'Imported content is parsed and added to the retrieval index automatically.',
descriptionWithGraph:
'Imported content is parsed, indexed for retrieval, and added to the knowledge graph with the current strategy.',
emptyTitle: 'No content sources connected',
emptyDescription:
'Choose files, a folder, or a URL, or drag files into the area above.',
@@ -569,19 +539,8 @@ export const knowledge = {
strategyAriaLabel: 'Knowledge graph extraction strategy',
askDescription:
'“Ask when needed” does not generate a graph automatically and cannot run re-extraction.',
graphCapability: {
title: 'Optional capabilities',
description:
'Enable extra capabilities when needed. Disabled capabilities stay out of the workspace.',
enabledDescription:
'The knowledge graph is enabled. Explore relations and manage graph settings from Knowledge graph.',
disabledDescription:
'Enable it to expose graph exploration, extraction strategy, and ontology definitions.'
},
graphConfiguration: {
title: 'Extraction method',
description:
'Control how new imports, resyncs, and explicit rebuilds generate entities, relations, and evidence.'
title: 'Extraction method'
},
chunking: {
title: 'Chunking strategy',
@@ -633,8 +592,7 @@ export const knowledge = {
},
vectorIndex: {
title: 'Embedding index',
description:
'Review coverage for this library with the current embedding model and rebuild only this library.',
description: 'Scope: current library.',
rebuild: 'Rebuild embedding index',
rebuilding: 'Rebuilding…',
cancel: 'Cancel rebuild',
@@ -3,7 +3,6 @@ import type { magicNotes as chineseMagicNotes } from '../zh-CN/magicNotes'
export const magicNotes = {
page: {
eyebrow: 'MAGIC NOTES',
title: 'Magic Notes',
description:
'Capture rich text, local media, attachments, and to-do lists globally, with read-only comments from AI.',
@@ -74,7 +73,6 @@ export const magicNotes = {
notes: {
listLabel: 'Notes list',
streamLabel: 'Note entries',
heading: 'Notes',
searchLabel: 'Search notes in the current scope',
searchPlaceholder: 'Search notes',
titleLabel: 'Note title',
@@ -126,7 +124,12 @@ export const magicNotes = {
comments: {
paneLabel: 'AI comments',
closePane: 'Close AI comments pane',
emptyTitle: 'No AI comments yet',
selectTodoTitle: 'Select a to-do',
analyzeTodoTitle: 'Analyze this to-do',
selectNoteTitle: 'Select a note',
startWritingTitle: 'Write a sentence',
saveEntryTitle: 'Save an entry for feedback',
analyzeEntryTitle: 'Analyze an entry',
directionLabel: 'Comment direction',
directionAriaLabel: 'AI comment direction',
directionHelp:
@@ -7,10 +7,7 @@ export const settings = {
'hugging-face': 'Hugging Face'
},
center: {
eyebrow: 'Settings',
title: 'Settings',
description:
'Manage model connections, Agent Runtimes, automation, extensions, and local data.',
close: 'Close settings',
categoriesAriaLabel: 'Settings categories'
},
@@ -105,6 +102,7 @@ export const settings = {
deleteConnection: 'Delete connection',
clearAfterSave: 'Clear after saving',
clearCredential: 'Clear credential',
done: 'Done',
cancel: 'Cancel',
clearing: 'Clearing…',
clearLocalData: 'Clear local data'
@@ -347,8 +345,6 @@ export const settings = {
title: 'OpenCode Agent',
recommendation:
'Following the GoodBuddy model is recommended. Use OpenCode configuration only when you need its native models, plugins, or MCP configuration.',
advancedDescription:
'Most users do not need to change these options. Switch model sources, manage OpenCode configuration, or override the bundled program and service here.',
followDescription:
'Generates a secure runtime configuration automatically, with no Runtime configuration file to maintain.',
ownDescription:
@@ -367,8 +363,6 @@ export const settings = {
title: 'Continue CLI',
recommendation:
'Following the GoodBuddy model is recommended. Use Continue configuration only when you need its native models, rules, or MCP configuration.',
advancedDescription:
'Most users do not need to change these options. Switch model sources, manage Continue configuration, or override the bundled program here.',
followDescription:
'Generates a secure temporary runtime configuration and deletes it when the task ends.',
ownDescription:
@@ -738,15 +732,28 @@ export const settings = {
embedding: {
title: 'Embedding model connection',
description:
'Uses an OpenAI-compatible Embeddings API with any provider',
'Manage built-in local inference and OpenAI-compatible embedding connections',
enabled: 'Enable embedding model',
addConnection: 'Add embedding connection',
newConnectionName: 'Embedding connection',
connectionName: 'Connection name',
authentication: 'Authentication',
noAuthentication: 'No authentication',
endpoint: 'Embedding API URL',
endpointDescription:
'Enter the complete OpenAI-compatible Embeddings endpoint.',
modelName: 'Model name',
builtinDataLocation:
'Local knowledge chunks → GoodBuddy local inference process → local SQLite',
builtinUnavailable:
'Model artifacts have not completed release validation and cannot be installed yet',
optionalApiKey: 'API Key (optional)',
optionalApiKeyPlaceholder:
'Leave blank for a local service without authentication',
installed: 'Installed and verified',
notInstalled: 'Not installed',
progress: 'Installing… {{percent}}%',
removeBuiltin: 'Remove local model {{name}}',
privacyDescription:
'Only chunks from enabled knowledge bases are sent to this endpoint. The API Key is encrypted in secure system storage. If the embedding service fails, retrieval falls back to FTS5 and the evidence graph.'
},
@@ -13,10 +13,6 @@ export const settingsSections = {
description:
'Model weights are not bundled. Download them as needed or move them offline with ZIP archives.',
openModelsDirectory: 'Open models directory',
storagePrefix: 'Models are stored in',
storageSuffix:
'. Automatic downloads pin the source revision and verify SHA-256 hashes. Export a ZIP on an online device and import it directly on an offline device.',
downloadSource: 'Current model download source: {{source}}',
modelSelector: 'Current speech model',
modelSelectorDescription:
'Choose an installed model, then select Save settings to switch speech recognition models.',
@@ -136,8 +132,65 @@ export const settingsSections = {
},
embedding: {
label: 'Embedding model',
title: 'Embedding model connection',
description: 'Review the current configuration and verify the connection',
title: 'Embedding model connections',
description:
'Choose and configure the built-in local model or an OpenAI-compatible service for vector retrieval.',
enabled: 'Enable vector retrieval',
currentConnection: 'Current connection',
connections: {
heading: 'Connections',
listLabel: 'Embedding model connection list',
empty: 'No embedding model connections are available.',
types: {
builtin: 'GoodBuddy built-in connection',
'openai-compatible': 'OpenAI-compatible connection'
},
current: 'Currently in use',
model: 'Model: {{model}}',
endpoint: 'Endpoint: {{endpoint}}',
credentialConfigured: 'Credentials configured',
credentialMissing: 'Credentials not configured',
status: 'Status: {{status}}',
modelLabel: 'Model',
statusLabel: 'Status'
},
actions: {
addCustom: 'Add custom',
test: 'Test',
delete: 'Delete',
download: 'Download',
cancel: 'Cancel',
importZip: 'Import ZIP',
remove: 'Remove model',
clearCredential: 'Clear credential',
clearAfterSave: 'Credential will be cleared on save'
},
accessibility: {
select: 'Edit embedding connection {{name}}',
test: 'Test embedding connection {{name}}',
delete: 'Delete embedding connection {{name}}',
download: 'Download {{name}}',
cancel: 'Cancel download for {{name}}',
import: 'Import {{name}} from ZIP',
remove: 'Remove local model {{name}}'
},
fields: {
name: 'Name',
endpoint: 'Embedding API URL',
model: 'Model name',
authentication: 'Authentication',
noAuthentication: 'No authentication',
apiKey: 'API Key (optional)',
apiKeyPlaceholder:
'Leave blank for a local service without authentication',
configuredPlaceholder: 'Configured — enter a new value to replace'
},
endpointDestination: {
local:
'Knowledge chunks and queries will be sent to {{host}} on this device.',
network:
'Indexing sends knowledge chunks to {{host}}; retrieval sends queries.'
},
model: {
heading: 'Current embedding model',
configured: 'Configured model',
@@ -164,7 +217,7 @@ export const settingsSections = {
description: 'Manage chat roles and their trusted system prompts',
newRole: 'New role',
notice:
'The selected role adds its system prompt to the current text conversation. Expert teams use up to three enabled roles in parallel. Synthesis mode and expert teams always inherit the default model; only an individual role uses its assigned connection. Image generation connections do not use role prompts.',
'An individual role can use an assigned model. Synthesis mode and expert teams always inherit the default model.',
listLabel: 'Role list',
listTitle: 'Roles',
editRole: 'Edit role {{name}}',
@@ -277,9 +330,6 @@ export const settingsSections = {
}
},
modelDownloadSource: {
cardTitle: 'Local models',
cardDescription:
'Manage how GoodBuddy-managed local models are downloaded',
title: 'Model download source',
description:
'Choose the platform for future GoodBuddy-managed local model downloads. Installed models, ZIP imports, Ollama models, and app updates are not affected.',
@@ -289,9 +339,6 @@ export const settingsSections = {
'hugging-face':
'Use when your network can access Hugging Face reliably.'
},
current: 'Current selection: {{source}}',
activeDownloadNote:
'Downloads already in progress keep the source they started with. New downloads use the current selection.',
notification: 'Model download source changed to {{source}}.'
},
magicNotes: {
@@ -340,7 +387,7 @@ export const settingsSections = {
},
listLabel: 'Skills list',
notice:
'Skills inject local capability instructions into selected targets without changing the Runtime’s own configuration. Newly imported Skills are enabled by default and assigned to the direct model, OpenCode, Continue, and DeepSeek Harness.',
'Newly imported Skills are enabled by default and assigned to the direct model, OpenCode, Continue, and DeepSeek Harness.',
loading: 'Loading Skills…',
source: {
builtin: 'Built in',
@@ -9,7 +9,7 @@ export const workspace = {
projectSwitcher: {
workModes: {
ask: 'Ask · Read-only',
execute: 'Execute · Controlled'
execute: 'Execute · Full access'
},
selector: {
ariaLabel: 'Current project',
@@ -75,25 +75,19 @@ export const workspace = {
categoriesAriaLabel: 'Workspace categories',
tabs: {
tasks: {
label: 'Task center',
description: 'Review approvals and manage automations'
label: 'Task center'
},
workspace: {
label: 'Files',
description: 'Browse project files, Git changes, and file contents'
label: 'Files'
},
browser: {
label: 'Browser',
description: 'View the live browser while the Agent works'
label: 'Browser'
},
results: {
label: 'Results',
description: 'View generated or imported standalone results'
label: 'Results'
}
},
tasks: {
description:
'Review pending approvals and create or manage automations.',
approvalsTitle: 'Awaiting approval',
noApprovals: 'There are no operations awaiting approval.',
deny: 'Deny',
@@ -135,9 +129,6 @@ export const workspace = {
fileSize: '{{formattedSize}} bytes',
fileFallback: 'Project workspace file',
reading: 'Reading file…',
description:
'Browse the current project files and Git changes. Select a file to preview it here.',
projectTitle: 'Project workspace',
refreshAriaLabel: 'Refresh workspace files',
refresh: 'Refresh',
gitUnavailable: 'Git status unavailable: {{error}}',
@@ -148,15 +139,11 @@ export const workspace = {
back: 'Back to results',
title: 'Results',
loadingImage: 'Loading image…',
description:
'View and preview text, images, PDFs, and web results generated by tasks or automations, or imported manually.',
sectionTitle: 'Generated and imported results',
import: 'Import PDF, image, or web page',
empty:
'Generated files, images, reports, and manually imported content will appear here.'
},
browser: {
title: 'Live browser',
interact: 'Interact',
interacting: 'Interacting',
stop: 'Stop browser',
@@ -1,9 +1,6 @@
export const activity = {
header: {
eyebrow: 'RUN HISTORY',
title: '运行记录',
description:
'按项目、任务与会话查看执行详情,或浏览活动时间线和模型用量。'
title: '运行记录'
},
tabs: {
ariaLabel: '运行记录视图',
@@ -35,9 +32,9 @@ export const activity = {
result: '结果'
},
filters: {
all: '全部',
active: '进行中',
failed: '异常',
all: '全部 {{formattedCount}}',
active: '进行中 {{formattedCount}}',
failed: '异常 {{formattedCount}}',
ariaLabel: '筛选活动',
clear: '清除筛选'
},
@@ -76,16 +73,8 @@ export const activity = {
unknownRuntime: '未知 Runtime'
}
},
stats: {
ariaLabel: '活动统计',
all: '全部',
active: '进行中',
failed: '异常'
},
timeline: {
ariaLabel: '按项目和会话分组的并行活动轨道',
description:
'所有轨道共享同一执行顺序,节点按发生时间依次展开,点击节点查看身份和活动详情。',
lanes: '项目 / 会话',
laneAriaLabel: '会话 {{title}} 的活动轨道',
nodeAriaLabel: '{{actor}},{{kind}},{{status}},{{title}},{{time}}',
+5 -14
View File
@@ -1,7 +1,4 @@
export const app = {
brand: {
desktopWorkspace: '桌面工作区'
},
notifications: {
success: '成功',
error: '错误',
@@ -72,8 +69,7 @@ export const app = {
conversation: {
defaultTitle: '新对话',
remoteTitle: '远程会话',
greeting:
'你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件或使用知识库。启用 Agent Runtime 后,我也可以在你的授权下调用工具。',
greeting: '你好,我是 GoodBuddy。',
interrupted: '上次运行意外中断,可以重新发送问题',
active: '会话正在活动',
unread: '未读',
@@ -203,10 +199,7 @@ export const app = {
user: '用户',
taskResult: '任务结果:{{title}}',
welcome: {
eyebrow: 'GOODBUDDY 工作台',
title: '今天想一起完成什么?',
description:
'快速提问、梳理信息,或连接 OpenCode 使用文件搜索和开发工具。'
title: '今天想一起完成什么?'
},
quickActions: {
summarize: {
@@ -370,8 +363,7 @@ export const app = {
inputLabel: '向 GoodBuddy 提问',
placeholder: '给 GoodBuddy 发消息…',
imagePlaceholder: '描述你想生成的图片…',
keyboardHint:
'Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本',
keyboardHint: 'Enter 发送,Shift+Enter 换行',
addContent: '添加内容',
addAttachment: '添加附件',
attachmentProgress: {
@@ -459,8 +451,8 @@ export const app = {
description: '只读问答,不修改文件'
},
execute: {
label: 'Execute · 受控执行',
description: '通过审批后执行工具操作'
label: 'Execute · 完全权限',
description: '使用当前账号的完整权限执行工具操作'
}
},
voice: {
@@ -468,7 +460,6 @@ export const app = {
cancel: '取消语音识别',
input: '语音输入',
stopAndRecognize: '停止录音并开始识别',
description: '语音转文字,转写后可编辑再发送',
unsupported: '当前系统不支持内置语音识别,可继续使用键盘输入',
downloadingPack: '正在下载中文离线语音包,完成后将自动开始听写',
transcribed: '语音已转为文字,可编辑后发送',
@@ -5,10 +5,8 @@ export const heartbeat = {
unknownTime: '时间未知'
},
center: {
eyebrow: 'SMART HEARTBEAT',
title: '智能心跳',
description:
'定期回顾经历、沉淀记忆、发现问题,并把每次变化转化为可处理的成长建议。',
description: '按计划只读回顾所选范围,不调用工具。',
scope: {
currentProject: '当前项目',
global: '全局'
@@ -35,7 +33,6 @@ export const heartbeat = {
plans: '心跳计划'
},
currentStatus: {
eyebrow: 'CURRENT PULSE',
title: '当前状态',
activePlans: '{{formattedCount}} 个计划运行中',
disabled: '尚未启用',
@@ -82,7 +79,6 @@ export const heartbeat = {
actionRateAriaLabel: '建议任务完成率 {{percent}}'
},
trend: {
eyebrow: 'GROWTH TREND',
title: '成长趋势',
empty:
'完成心跳后,这里会显示洞察、记忆与行动建议的变化。',
@@ -93,7 +89,6 @@ export const heartbeat = {
'{{date}}:{{insights}} 条洞察,{{memories}} 条记忆建议,{{actions}} 个行动建议'
},
latest: {
eyebrow: 'LATEST REPORT',
title: '本次心跳',
viewHistory: '查看心跳轨迹',
handleSuggestions: '处理 {{formattedCount}} 条建议',
@@ -101,7 +96,6 @@ export const heartbeat = {
'尚无心跳报告。运行一次心跳后,你会在这里看到本次学到了什么。'
},
suggestions: {
memoryEyebrow: 'MEMORY GROWTH',
memoryTitle: '待确认记忆',
memoryCount: '{{formattedCount}} 条',
memoryEmpty: '当前没有等待确认的记忆建议。',
@@ -111,7 +105,6 @@ export const heartbeat = {
expandContent: '查看完整内容',
confirmMemory: '确认记忆',
ignore: '忽略',
taskEyebrow: 'NEXT ACTIONS',
taskTitle: '行动建议',
taskCount: '{{formattedCount}} 个',
taskEmpty: '当前没有由智能心跳产生的行动建议。',
@@ -120,7 +113,6 @@ export const heartbeat = {
ignoreSuggestion: '忽略建议'
},
history: {
timelineEyebrow: 'HEARTBEAT TIMELINE',
timelineTitle: '成长轨迹',
reportCount: '{{formattedCount}} 份报告',
emptyTimeline: '完成心跳后,每次学习和变化都会沉淀在这里。',
@@ -129,7 +121,6 @@ export const heartbeat = {
collapseReport: '收起报告',
expandReport: '展开完整报告',
loadMoreReports: '加载更多心跳报告',
auditEyebrow: 'RUN AUDIT',
auditTitle: '运行记录',
runCount: '{{formattedCount}} 次',
emptyRuns: '尚无智能心跳运行记录。',
@@ -166,8 +157,7 @@ export const heartbeat = {
},
settings: {
title: '智能心跳',
description:
'定期回顾经历、沉淀记忆、发现问题,并把变化转化为可处理的成长建议。智能心跳只读且不调用工具。',
description: '心跳计划按时只读运行,不调用工具。',
recurrenceAriaLabel: '心跳重复规则',
recurrenceLabel: '重复规则',
daily: '每天',
@@ -14,10 +14,7 @@ export const integrations = {
},
project: {
sectionAriaLabel: '{{name}} 通道项目设置',
identity: '通道项目',
cardTitle: '项目设置',
cardDescription:
'与左上角当前通道项目的设置保持同步。',
descriptionLabel: '项目说明',
descriptionAriaLabel: '{{name}} 项目说明',
rootLabel: '默认工作目录',
@@ -167,14 +164,12 @@ export const integrations = {
custom: '自定义 MCP'
},
customNotice:
'自定义 MCP 可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness,新建时默认分配给直连模型,并仅在 Execute 模式加载。Agent Runtime 只接收按请求签发的本机回环权限;服务地址、命令和凭据始终由 GoodBuddy 主进程保管。',
securityNotice:
'内置工具由 GoodBuddy 提供,不属于 MCP Server。自定义 MCP Server 及其工具具有当前用户权限,请仅添加可信服务;远程访问令牌将由系统安全存储加密,工具调用前仍需 GoodBuddy 审批。',
'自定义 MCP 仅在 Execute 模式加载,可分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 或 DeepSeek Harness,新建时默认分配给直连模型。Server 及其工具具有当前用户权限,请仅添加可信服务;服务地址、命令和凭据由 GoodBuddy 主进程保管,远程访问令牌由系统安全存储加密,工具调用前仍需 GoodBuddy 审批。stdio Server 会以不含桌面会话变量的受限环境启动;需要电脑控制时请使用经过诊断的内置能力。',
computer: {
title: '电脑控制能力',
subtitle: '仅显示实际操作客户端电脑的能力',
supported: '当前设备支持',
unsupported: '当前设备不支持',
enable: '启用此能力',
enabled: '已启用',
disabled: '已停用',
enableAriaLabel: '启用 {{name}}',
@@ -189,9 +184,6 @@ export const integrations = {
},
builtin: {
title: 'GoodBuddy 内置 MCP',
availableTo: '按请求提供,可分别控制启停与 Runtime 分配',
notice:
'内置 MCP 由 GoodBuddy 在主进程按当前对话签发短期权限,不公开服务地址或凭据。设置更改会应用到后续请求,Ask / Execute 的读写边界仍由系统强制。',
enabled: '已启用',
disabled: '已停用',
enableAriaLabel: '启用 {{name}} 内置 MCP',
@@ -200,10 +192,9 @@ export const integrations = {
runtimeAssignmentUnsupportedAriaLabel:
'{{name}} 无法分配给 {{runtime}},当前 Runtime 不支持内置 MCP',
unsupportedSuffix: '(暂不支持)',
serverSummaryMixed: '内置 MCP Server · 按模式读写 · 按对话授权',
serverSummaryReadOnly: '内置 MCP Server · 只读 · 按对话授权',
serverSummaryDisabled:
'内置 MCP Server · 未启用 · 需要开启魔法笔记',
serverSummaryMixed: '按模式读写',
serverSummaryReadOnly: '只读',
serverSummaryDisabled: '未启用 · 需要开启魔法笔记',
featureDisabled:
'魔法笔记功能已关闭,此内置能力当前不会向任何 Runtime 提供工具。',
collapseServer: '收起服务器 {{name}}',
@@ -281,8 +272,6 @@ export const integrations = {
custom: {
title: '自定义 MCP Servers(高级)',
count: '{{count}} 个',
notice:
'自定义 stdio MCP 会以受限环境启动,不会获得桌面会话变量。需要电脑控制时请使用上方经过诊断的内置能力。',
empty: '尚未配置 MCP Server',
collapseServer: '收起服务器 {{name}}',
expandServer: '展开服务器 {{name}}',
@@ -1,6 +1,5 @@
export const knowledge = {
page: {
eyebrow: '知识',
title: '知识库',
description: '管理文件、目录和网页来源,并查看索引与图谱。'
},
@@ -156,46 +155,23 @@ export const knowledge = {
eyebrow: '当前知识库:{{libraryName}}',
title: '检索测试',
description:
'使用临时参数直接验证当前知识库的召回结果。本测试不会创建对话、调用大模型或修改知识内容。',
'临时参数仅用于本次测试;不会创建对话、调用大模型或修改知识内容。',
close: '关闭检索测试',
query: {
title: '测试问题',
help: '输入一个真实问题,查看各检索通道、排名和最终上下文。',
label: '检索问题',
placeholder: '例如:如何为离线环境配置文档解析?',
count: '{{count}} / 4000 字'
},
pipeline: {
recall: {
title: '召回候选',
summary: '最多 {{count}} 个融合候选',
pending: '设置有效参数后计算'
},
rerank: {
title: '本地重排',
enabled: '重排最多 {{count}} 个候选',
disabled: '已关闭,保留融合排名'
},
select: {
title: '最终结果',
summary: '保留前 {{count}} 个分块'
},
context: {
title: '组装上下文',
summary: '预算 {{count}} 字符'
}
},
settings: {
title: '本次测试参数',
temporary: '这些调整只影响本次测试。需要长期使用时,请保存为当前知识库默认值。',
groups: {
recall: {
title: '候选召回',
description: '控制首轮检索范围、过滤阈值和各检索通道对融合排名的影响。'
title: '候选召回'
},
output: {
title: '重排与上下文',
description: '控制候选排序、最终保留数量,以及实际送入模型的上下文范围。'
title: '重排与上下文'
}
},
candidateMultiplier: '召回倍数',
@@ -467,9 +443,6 @@ export const knowledge = {
documents: {
sources: {
title: '内容来源',
description: '导入内容后会自动解析并建立检索索引。',
descriptionWithGraph:
'导入内容后会自动解析、建立检索索引并按当前策略更新知识图谱。',
emptyTitle: '尚未连接内容来源',
emptyDescription:
'可选择文件、目录或 URL;也可以直接将文件拖入上方区域。',
@@ -531,15 +504,8 @@ export const knowledge = {
'启用后,新导入和重新同步的文档会按所选策略抽取图谱。',
strategyAriaLabel: '知识图谱抽取策略',
askDescription: '“按需询问”不会自动生成图谱,也不能执行重新抽取。',
graphCapability: {
title: '可选能力',
description: '按需开启额外能力;未启用的能力不会出现在工作区中。',
enabledDescription: '知识图谱已启用,可在“知识图谱”中探索关系和管理图谱设置。',
disabledDescription: '启用后才会显示图谱探索、抽取策略和本体定义。'
},
graphConfiguration: {
title: '抽取方式',
description: '控制新导入、重新同步和显式重建时如何生成实体、关系与证据。'
title: '抽取方式'
},
chunking: {
title: '分块策略',
@@ -588,8 +554,7 @@ export const knowledge = {
},
vectorIndex: {
title: '向量索引',
description:
'查看当前知识库在现用向量模型下的覆盖情况,并只重建这个知识库。',
description: '范围:当前知识库。',
rebuild: '重建向量索引',
rebuilding: '正在重建…',
cancel: '取消重建',
@@ -1,6 +1,5 @@
export const magicNotes = {
page: {
eyebrow: 'MAGIC NOTES',
title: '魔法笔记',
description: '全局记录富文本、本地媒体、附件和待办清单,由 AI 提供只读评论。',
contentLabel: '魔法笔记内容'
@@ -69,7 +68,6 @@ export const magicNotes = {
notes: {
listLabel: '笔记列表',
streamLabel: '笔记记录',
heading: '笔记',
searchLabel: '搜索当前范围的笔记',
searchPlaceholder: '搜索笔记',
titleLabel: '笔记标题',
@@ -117,7 +115,12 @@ export const magicNotes = {
comments: {
paneLabel: 'AI 评论',
closePane: '关闭 AI 评论面板',
emptyTitle: '暂无 AI 评论',
selectTodoTitle: '选择一个待办',
analyzeTodoTitle: '分析这个待办',
selectNoteTitle: '选择一篇笔记',
startWritingTitle: '写下一句话',
saveEntryTitle: '保存记录以获取评论',
analyzeEntryTitle: '分析一条记录',
directionLabel: '评论方向',
directionAriaLabel: 'AI 评论方向',
directionHelp: '仅影响下一次评论;评论形式可在设置中心修改。',
@@ -4,9 +4,7 @@ export const settings = {
'hugging-face': 'Hugging Face'
},
center: {
eyebrow: '设置',
title: '设置中心',
description: '管理模型连接、Agent Runtime、自动化、扩展能力和本地数据。',
close: '关闭设置',
categoriesAriaLabel: '设置分类'
},
@@ -92,6 +90,7 @@ export const settings = {
deleteConnection: '删除连接',
clearAfterSave: '保存后清除',
clearCredential: '清除凭据',
done: '完成',
cancel: '取消',
clearing: '正在清除…',
clearLocalData: '清除本地数据'
@@ -316,8 +315,6 @@ export const settings = {
title: 'OpenCode Agent',
recommendation:
'推荐直接跟随 GoodBuddy 模型。只有需要复用 OpenCode 原生模型、插件或 MCP 配置时,才切换到 Runtime 自有配置。',
advancedDescription:
'普通使用无需修改。这里可以切换模型配置来源、管理 Runtime 自有配置,或覆盖内置程序与服务。',
followDescription:
'自动生成安全的运行期配置,无需维护 Runtime 配置文件。',
ownDescription: '适合需要原生模型、插件或 MCP 配置的专业用户。',
@@ -335,8 +332,6 @@ export const settings = {
title: 'Continue CLI',
recommendation:
'推荐直接跟随 GoodBuddy 模型。只有需要复用 Continue 原生模型、规则或 MCP 配置时,才切换到 Runtime 自有配置。',
advancedDescription:
'普通使用无需修改。这里可以切换模型配置来源、管理 Runtime 自有配置,或覆盖内置程序。',
followDescription:
'自动生成安全的临时运行期配置,任务结束后立即删除。',
ownDescription: '适合需要原生模型、规则或 MCP 配置的专业用户。',
@@ -678,15 +673,27 @@ export const settings = {
},
embedding: {
title: '向量模型连接',
description: '使用 OpenAI 兼容 Embeddings 接口,不限定服务提供商',
description: '管理内置本地推理和 OpenAI 兼容向量模型连接',
enabled: '启用向量模型',
addConnection: '添加向量模型连接',
newConnectionName: '向量模型连接',
connectionName: '连接名称',
authentication: '认证方式',
noAuthentication: '无需认证',
endpoint: '向量接口 URL',
endpointDescription: '填写完整的 OpenAI 兼容 Embeddings 端点。',
modelName: '模型名称',
builtinDataLocation:
'本机知识分块 → GoodBuddy 本地推理进程 → 本地 SQLite',
builtinUnavailable: '模型工件尚未完成发布校验,当前不可安装',
optionalApiKey: 'API Key(可选)',
optionalApiKeyPlaceholder: '本地无认证服务可留空',
installed: '已安装并通过校验',
notInstalled: '尚未安装',
progress: '安装中… {{percent}}%',
removeBuiltin: '移除本地模型 {{name}}',
privacyDescription:
'仅向所填接口发送已启用知识库的分块文本。API Key 由系统安全存储加密;向量服务失败时自动回退到 FTS5 与证据图谱。'
'仅向所填接口发送已启用知识库的分块文本。API Key 由系统安全存储加密;向量服务失败时会明确标记未使用向量检索,并显示实际检索通道。'
},
rerank: {
title: '重排模型连接',
@@ -7,10 +7,6 @@ export const settingsSections = {
title: '语音模型',
description: '应用不内置模型权重,按需下载或通过 ZIP 离线迁移',
openModelsDirectory: '打开模型目录',
storagePrefix: '模型保存在',
storageSuffix:
'。自动下载会固定来源版本并校验 SHA-256;外网设备可导出 ZIP,内网设备可直接导入。',
downloadSource: '当前模型下载源:{{source}}',
modelSelector: '当前语音模型',
modelSelectorDescription:
'选择已安装模型后,点击“保存设置”切换语音识别模型。',
@@ -129,7 +125,62 @@ export const settingsSections = {
embedding: {
label: '向量模型',
title: '向量模型连接',
description: '查看当前配置并确认向量模型连接可用',
description:
'选择并配置用于向量检索的内置本地模型或 OpenAI 兼容服务。',
enabled: '启用向量检索',
currentConnection: '当前连接',
connections: {
heading: '连接列表',
listLabel: '向量模型连接列表',
empty: '尚无可用的向量模型连接。',
types: {
builtin: 'GoodBuddy 内置连接',
'openai-compatible': 'OpenAI 兼容连接'
},
current: '当前使用',
model: '模型:{{model}}',
endpoint: '服务地址:{{endpoint}}',
credentialConfigured: '已配置凭据',
credentialMissing: '未配置凭据',
status: '状态:{{status}}',
modelLabel: '模型',
statusLabel: '状态'
},
actions: {
addCustom: '添加自定义',
test: '测试',
delete: '删除',
download: '下载',
cancel: '取消',
importZip: '导入 ZIP',
remove: '移除模型',
clearCredential: '清除凭据',
clearAfterSave: '保存后清除凭据'
},
accessibility: {
select: '编辑向量模型连接 {{name}}',
test: '测试向量模型连接 {{name}}',
delete: '删除向量模型连接 {{name}}',
download: '下载 {{name}}',
cancel: '取消下载 {{name}}',
import: '从 ZIP 导入 {{name}}',
remove: '移除本地模型 {{name}}'
},
fields: {
name: '名称',
endpoint: '向量接口 URL',
model: '模型名称',
authentication: '认证方式',
noAuthentication: '无需认证',
apiKey: 'API Key(可选)',
apiKeyPlaceholder: '本地无认证服务可留空',
configuredPlaceholder: '已配置;输入新值可替换'
},
endpointDestination: {
local: '知识分块和查询将发送到此设备上的 {{host}}。',
network:
'建立索引时会向 {{host}} 发送知识分块,检索时会发送查询。'
},
model: {
heading: '当前向量模型',
configured: '已配置模型',
@@ -154,7 +205,7 @@ export const settingsSections = {
description: '管理聊天角色及其受信任系统提示词',
newRole: '新建角色',
notice:
'选中的角色会把系统提示词加入本次文本对话。专家团队会并行使用最多 3 个已启用角色;综合模式和专家团队始终继承默认模型,只有单个角色会使用指定连接。图像生成连接不使用角色提示词。',
'单个角色可使用指定模型;综合模式和专家团队始终继承默认模型。',
listLabel: '角色列表',
listTitle: '角色列表',
editRole: '编辑角色 {{name}}',
@@ -251,8 +302,6 @@ export const settingsSections = {
}
},
modelDownloadSource: {
cardTitle: '本地模型',
cardDescription: '管理 GoodBuddy 托管本地模型的获取方式',
title: '模型下载源',
description:
'选择 GoodBuddy 托管本地模型后续下载使用的平台。已安装模型、ZIP 导入、Ollama 模型和应用更新不受影响。',
@@ -260,9 +309,6 @@ export const settingsSections = {
modelscope: '默认,适合优先访问 ModelScope 的网络环境。',
'hugging-face': '适合可以稳定访问 Hugging Face 的网络环境。'
},
current: '当前选择:{{source}}',
activeDownloadNote:
'正在进行的模型下载会继续使用启动时的来源;新的下载使用当前选择。',
notification: '模型下载源已切换为 {{source}}。'
},
magicNotes: {
@@ -310,7 +356,7 @@ export const settingsSections = {
},
listLabel: 'Skills 列表',
notice:
'Skill 以本地能力说明注入所选目标,不会写入 Runtime 自有配置。新导入的 Skill 默认启用,并分配给直连模型、OpenCode、Continue 和 DeepSeek Harness。',
'新导入的 Skill 默认启用,并分配给直连模型、OpenCode、Continue 和 DeepSeek Harness。',
loading: '正在读取 Skills…',
source: {
builtin: '内置',
@@ -6,7 +6,7 @@ export const workspace = {
projectSwitcher: {
workModes: {
ask: 'Ask · 只读问答',
execute: 'Execute · 受控执行'
execute: 'Execute · 完全权限'
},
selector: {
ariaLabel: '当前项目',
@@ -72,24 +72,19 @@ export const workspace = {
categoriesAriaLabel: '工作栏分类',
tabs: {
tasks: {
label: '任务中心',
description: '处理待审批操作并管理自动化'
label: '任务中心'
},
workspace: {
label: '工作区',
description: '浏览项目文件、Git 变更与文件内容'
label: '工作区'
},
browser: {
label: '浏览器',
description: '查看 Agent 操作网页时的实时画面'
label: '浏览器'
},
results: {
label: '成果',
description: '查看生成或手动导入的独立成果'
label: '成果'
}
},
tasks: {
description: '处理当前待审批操作,并创建和管理自动化任务。',
approvalsTitle: '等待审批',
noApprovals: '当前没有等待审批的操作。',
deny: '拒绝',
@@ -130,9 +125,6 @@ export const workspace = {
fileSize: '{{formattedSize}} 字节',
fileFallback: '项目工作区文件',
reading: '正在读取文件…',
description:
'浏览当前项目文件与 Git 变更;选择文件后在当前工作区内预览。',
projectTitle: '项目工作区',
refreshAriaLabel: '刷新工作区文件',
refresh: '刷新',
gitUnavailable: 'Git 状态不可用:{{error}}',
@@ -143,14 +135,10 @@ export const workspace = {
back: '返回成果列表',
title: '成果',
loadingImage: '正在加载图片…',
description:
'查看并预览由任务、自动化生成或手动导入的文本、图片、PDF 与网页成果。',
sectionTitle: '生成与导入成果',
import: '导入 PDF、图片或网页',
empty: '生成的文件、图片、报告和手动导入内容会显示在这里。'
},
browser: {
title: '实时浏览器',
interact: '交互',
interacting: '交互中',
stop: '停止浏览器',
+4 -2
View File
@@ -78,6 +78,7 @@
--font-family-mono:
"Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-family: var(--font-family-ui);
font-size: var(--font-body);
font-optical-sizing: auto;
font-synthesis: style;
text-rendering: optimizeLegibility;
@@ -1126,6 +1127,7 @@
}
.knowledge-citation-dialog__metadata dt {
font-size: var(--font-body);
font-weight: 700;
}
@@ -6533,6 +6535,7 @@ button > svg {
.runtime-overview__details dt {
color: var(--text-primary);
font-size: var(--font-body);
font-weight: 650;
}
@@ -6540,6 +6543,7 @@ button > svg {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
font-size: var(--font-body);
}
.runtime-overview__path {
@@ -8615,8 +8619,6 @@ details.settings-section > :not(summary) + :not(summary) {
min-height: 36px;
align-items: center;
justify-content: space-between;
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-body);
+154 -1
View File
@@ -96,6 +96,7 @@ import type {
} from './speech-model-contracts'
import type {
EmbeddingDiagnosticResult,
EmbeddingModelSnapshot,
EmbeddingSettingsSnapshot,
KnowledgeEmbeddingIndexSnapshot
} from './embedding-contracts'
@@ -387,6 +388,11 @@ export type ImageGenerationQuality = z.infer<
export const defaultModelProfileId =
'00000000-0000-4000-8000-000000000001'
export const modelProfileIdSchema = z.string().uuid()
export const builtinEmbeddingConnectionId =
'00000000-0000-4000-8000-000000000002'
export const legacyEmbeddingConnectionId =
'00000000-0000-4000-8000-000000000003'
export const embeddingConnectionIdSchema = z.string().uuid()
export const contextCompressionModelSourceSchema =
z.discriminatedUnion('kind', [
@@ -554,6 +560,35 @@ const modelProfileInputSchema = z
})
.strict()
const embeddingConnectionInputSchema = z.discriminatedUnion('kind', [
z
.object({
id: z.literal(builtinEmbeddingConnectionId),
name: z.string().trim().min(1).max(64),
kind: z.literal('builtin')
})
.strict(),
z
.object({
id: embeddingConnectionIdSchema.refine(
(id) => id !== builtinEmbeddingConnectionId,
'系统向量连接 ID 不能用于用户连接'
),
name: z.string().trim().min(1).max(64),
kind: z.literal('openai-compatible'),
baseUrl: z.string().url().max(2_048),
modelName: z
.string()
.trim()
.min(1)
.max(256)
.regex(/^[\w./:-]+$/, '向量模型名称包含不支持的字符'),
authentication: modelAuthenticationSchema,
apiKey: modelApiKeyUpdateSchema
})
.strict()
])
export const runtimeModelSourceSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('platform') }).strict(),
z
@@ -597,6 +632,13 @@ export const runtimeSettingsInputSchema = z
.max(256)
.regex(/^[\w./:-]+$/, '向量模型名称包含不支持的字符'),
knowledgeEmbeddingApiKey: modelApiKeyUpdateSchema.optional(),
embeddingConnections: z
.array(embeddingConnectionInputSchema)
.min(1)
.max(20)
.optional(),
activeEmbeddingConnectionId:
embeddingConnectionIdSchema.optional(),
knowledgeRerankEnabled: z.boolean(),
knowledgeRerankEndpoint: z.string().url().max(2_048),
knowledgeRerankModel: z
@@ -797,6 +839,76 @@ export const runtimeSettingsInputSchema = z
message: '向量接口 URL 必须使用 HTTP 或 HTTPS'
})
}
if (settings.embeddingConnections) {
const ids = new Set(
settings.embeddingConnections.map((connection) => connection.id)
)
const names = new Set(
settings.embeddingConnections.map((connection) =>
connection.name.toLowerCase()
)
)
if (
ids.size !== settings.embeddingConnections.length ||
names.size !== settings.embeddingConnections.length
) {
context.addIssue({
code: 'custom',
path: ['embeddingConnections'],
message: '向量连接的 ID 和名称必须唯一'
})
}
if (
!settings.embeddingConnections.some(
(connection) =>
connection.id === builtinEmbeddingConnectionId &&
connection.kind === 'builtin'
)
) {
context.addIssue({
code: 'custom',
path: ['embeddingConnections'],
message: '系统向量连接不能删除'
})
}
for (const [index, connection] of
settings.embeddingConnections.entries()) {
if (connection.kind === 'builtin') {
continue
}
if (
!['http:', 'https:'].includes(
new URL(connection.baseUrl).protocol
)
) {
context.addIssue({
code: 'custom',
path: ['embeddingConnections', index, 'baseUrl'],
message: '向量接口 URL 必须使用 HTTP 或 HTTPS'
})
}
if (
connection.authentication === 'none' &&
connection.apiKey.action === 'replace'
) {
context.addIssue({
code: 'custom',
path: ['embeddingConnections', index, 'apiKey'],
message: '无认证向量连接不得配置 API Key'
})
}
}
const activeId =
settings.activeEmbeddingConnectionId ??
builtinEmbeddingConnectionId
if (!ids.has(activeId)) {
context.addIssue({
code: 'custom',
path: ['activeEmbeddingConnectionId'],
message: '当前向量连接不存在'
})
}
}
if (
!['http:', 'https:'].includes(
new URL(settings.knowledgeRerankEndpoint).protocol
@@ -828,6 +940,33 @@ export type ModelConnectionSettings = {
credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
}
export type EmbeddingConnectionSettings =
| {
id: typeof builtinEmbeddingConnectionId
name: string
kind: 'builtin'
apiKeyConfigured?: false
credentialSource?: 'none'
}
| {
id: string
name: string
kind: 'openai-compatible'
baseUrl: string
modelName: string
authentication: ModelAuthentication
apiKeyConfigured?: boolean
credentialSource?:
| 'none'
| 'encrypted'
| 'environment'
| 'unreadable'
apiKey?:
| { action: 'keep' }
| { action: 'replace'; value: string }
| { action: 'clear' }
}
export type ConfiguredRuntimeSettings = {
modelProfiles: ModelConnectionSettings[]
opencodeBaseUrl: string
@@ -866,6 +1005,8 @@ export type RuntimeSettings = {
| 'encrypted'
| 'environment'
| 'unreadable'
embeddingConnections?: EmbeddingConnectionSettings[]
activeEmbeddingConnectionId?: string
knowledgeRerankEnabled?: boolean
knowledgeRerankEndpoint?: string
knowledgeRerankModel?: string
@@ -1448,7 +1589,19 @@ export type DesktopApi = {
}
embeddings?: {
getSnapshot: () => Promise<EmbeddingSettingsSnapshot>
diagnose: () => Promise<EmbeddingDiagnosticResult>
diagnose: (connectionId: string) => Promise<EmbeddingDiagnosticResult>
setCurrent: (
connectionId: string
) => Promise<EmbeddingSettingsSnapshot>
installModel: (
modelId: string,
expectedDownloadSource: ModelDownloadSource
) => Promise<EmbeddingModelSnapshot>
cancelModelOperation: (modelId: string) => Promise<boolean>
importModelArchive: (
modelId: string
) => Promise<EmbeddingModelSnapshot | undefined>
removeModel: (modelId: string) => Promise<EmbeddingModelSnapshot>
}
documentParsing?: {
getSnapshot: () => Promise<DocumentParsingSnapshot>
+43
View File
@@ -3,6 +3,7 @@ import {
embeddingDiagnosticResultSchema,
embeddingIndexJobSchema,
embeddingIndexStatusSchema,
embeddingSettingsSnapshotSchema,
knowledgeEmbeddingIndexSnapshotSchema,
embeddingSafeErrorSchema,
isEmbeddingIndexJobActive
@@ -66,6 +67,48 @@ describe('embedding contracts', () => {
).toBe(false)
})
it('keeps credentials, vectors, and Main-only paths out of settings snapshots', () => {
const snapshot = {
configuration: {
provider: 'builtin',
model: 'granite-embedding',
credentialConfigured: false
},
connections: [
{
id: '00000000-0000-4000-8000-000000000002',
name: 'GoodBuddy built-in',
kind: 'builtin' as const,
model: 'granite-embedding',
credentialConfigured: false
}
],
currentConnectionId:
'00000000-0000-4000-8000-000000000002',
models: {
selectedDownloadSource: 'modelscope' as const,
catalog: [],
installed: [],
operations: []
}
}
expect(
embeddingSettingsSnapshotSchema.safeParse(snapshot).success
).toBe(true)
for (const forbidden of [
{ apiKey: 'secret' },
{ vectors: [[0.1, 0.2]] },
{ modelDirectory: 'C:\\private\\embedding' }
]) {
expect(
embeddingSettingsSnapshotSchema.safeParse({
...snapshot,
...forbidden
}).success
).toBe(false)
}
})
it('validates queued, running and terminal index jobs', () => {
const base = {
id: 'job-1',
+123 -1
View File
@@ -1,4 +1,9 @@
import { z } from 'zod'
import {
MODEL_DOWNLOAD_SOURCES,
modelDownloadAvailabilitySchema,
modelDownloadSourceSchema
} from './model-download-contracts'
const boundedLabelSchema = z.string().trim().min(1).max(256)
const timestampSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
@@ -48,6 +53,120 @@ export type EmbeddingConfigurationSummary = z.infer<
typeof embeddingConfigurationSummarySchema
>
export const embeddingConnectionIdRequestSchema = z
.object({
connectionId: z.string().uuid()
})
.strict()
export const embeddingConnectionSummarySchema = z
.object({
id: z.string().uuid(),
name: z.string().trim().min(1).max(64),
kind: z.enum(['builtin', 'openai-compatible']),
model: boundedLabelSchema,
endpoint: safeProviderEndpointSchema.optional(),
authentication: z.enum(['api-key', 'none']).optional(),
credentialConfigured: z.boolean()
})
.strict()
export type EmbeddingConnectionSummary = z.infer<
typeof embeddingConnectionSummarySchema
>
export const embeddingModelIdSchema = z
.string()
.min(1)
.max(96)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u)
const embeddingModelFileSchema = z
.object({
name: z.string().min(1).max(255),
role: z.enum([
'model',
'tokenizer',
'tokenizer-configuration',
'configuration',
'license'
]),
size: z.number().int().positive().safe(),
sha256: z.string().regex(/^[a-f0-9]{64}$/u)
})
.strict()
export const embeddingModelOperationSchema = z
.object({
modelId: embeddingModelIdSchema,
kind: z.enum(['download', 'import']),
phase: z.enum(['preparing', 'transferring', 'installing']),
currentFile: z.string().min(1).max(255).nullable(),
completedBytes: z.number().int().nonnegative().safe(),
totalBytes: z.number().int().positive().safe().nullable(),
downloadSource: modelDownloadSourceSchema.optional()
})
.strict()
export const embeddingModelSnapshotSchema = z
.object({
selectedDownloadSource: modelDownloadSourceSchema,
catalog: z.array(
z
.object({
id: embeddingModelIdSchema,
displayName: z.string().trim().min(1).max(120),
description: z.string().trim().min(1).max(1_000),
languages: z.array(z.string().trim().min(1).max(80)).min(1).max(32),
runtime: z.literal('onnxruntime-web/wasm'),
dimensions: z.number().int().positive().max(65_536),
contextTokens: z.number().int().positive().max(10_000_000),
quantization: z.string().trim().min(1).max(40),
recommended: z.boolean(),
available: z.boolean(),
unavailableReason: z.string().trim().min(1).max(1_000).optional(),
license: z
.object({
name: z.string().trim().min(1).max(120),
notice: z.string().trim().min(1).max(1_000),
url: z.url()
})
.strict(),
files: z.array(embeddingModelFileSchema).min(1).max(32),
downloadAvailability: z
.array(modelDownloadAvailabilitySchema)
.length(MODEL_DOWNLOAD_SOURCES.length)
})
.strict()
),
installed: z.array(
z
.object({
id: embeddingModelIdSchema,
displayName: z.string().trim().min(1).max(120),
source: z.enum(['download', 'local']),
installedAt: z.string().datetime(),
files: z.array(embeddingModelFileSchema).min(1).max(32)
})
.strict()
),
operations: z.array(embeddingModelOperationSchema)
})
.strict()
export type EmbeddingModelSnapshot = z.infer<
typeof embeddingModelSnapshotSchema
>
export const embeddingModelActionInputSchema = z
.object({ modelId: embeddingModelIdSchema })
.strict()
export const embeddingModelInstallInputSchema = z
.object({
modelId: embeddingModelIdSchema,
expectedDownloadSource: modelDownloadSourceSchema
})
.strict()
const embeddingDiagnosticBase = {
provider: boundedLabelSchema,
model: boundedLabelSchema,
@@ -180,7 +299,10 @@ export type EmbeddingIndexStatus = z.infer<
export const embeddingSettingsSnapshotSchema = z
.object({
configuration: embeddingConfigurationSummarySchema
configuration: embeddingConfigurationSummarySchema,
connections: z.array(embeddingConnectionSummarySchema),
currentConnectionId: z.string().uuid(),
models: embeddingModelSnapshotSchema
})
.strict()
export type EmbeddingSettingsSnapshot = z.infer<
+6
View File
@@ -67,6 +67,12 @@ export const ipcChannels = {
speechTranscriptionCancel: 'speech:transcription:cancel',
embeddingSettingsGet: 'settings:embedding:get',
embeddingDiagnose: 'settings:embedding:diagnose',
embeddingSetCurrent: 'settings:embedding:set-current',
embeddingModelsInstall: 'settings:embedding-models:install',
embeddingModelsCancel: 'settings:embedding-models:cancel',
embeddingModelsImportArchive:
'settings:embedding-models:import-archive',
embeddingModelsRemove: 'settings:embedding-models:remove',
documentParsingGet: 'settings:document-parsing:get',
documentOcrModelsProgress:
'settings:document-ocr-models:progress',
+11
View File
@@ -3,11 +3,22 @@
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
stableMainEntryFileName,
sanitizeRendererModuleId,
serializeDeepSeekHarnessBundleManifest
} from '../electron.vite.config'
describe('Electron Vite configuration', () => {
it('keeps Utility Process bootstrap entry names stable', () => {
expect(
stableMainEntryFileName({
name: 'embedding-inference-bootstrap'
})
).toBe('embedding-inference-bootstrap.js')
expect(stableMainEntryFileName({ name: 'index' })).toBe(
'[name].js'
)
})
it('serializes the DeepSeek Harness bundle manifest', () => {
const source = serializeDeepSeekHarnessBundleManifest('1.2.3')