fix: restore vector model download feedback
Clicking the built-in embedding model download could appear to do nothing while Main waited for the full transfer to finish. The vector model UI now follows the speech model workflow with an immediate preparing state, verified source and package metadata, live progress, cancellation, unavailable-source guidance, and success notifications. Use a bounded progress-only IPC instead of overlapping full snapshot polling, and share model package size and progress presentation across embedding, speech, and OCR model settings. Promote the GoodBuddy Agent lock from the e2e prerelease identifier to the formal 0.11.2 version for production publication. Release note: 修复点击内置向量模型下载后界面无响应的问题;现在会立即显示下载来源、进度与取消操作,并将 GoodBuddy Agent 版本标识转为正式的 0.11.2。
This commit is contained in:
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。
|
||||
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,也不会在项目激活时自动下载;用户必须先在该设置页按 Linux x64/arm64 手动下载最新兼容的签名 `.gbagent`,或导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。缺少某架构包只使该架构托管 SSH 不可用。持久化验证继续精确绑定 Agent installation 与签名 Runtime digest;托管 SSH 会话只显示支持的 OpenCode 选择。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 为后续安全修复后的 Agent 0.11.2-e2e.13。
|
||||
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,也不会在项目激活时自动下载;用户必须先在该设置页按 Linux x64/arm64 手动下载最新兼容的签名 `.gbagent`,或导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。缺少某架构包只使该架构托管 SSH 不可用。持久化验证继续精确绑定 Agent installation 与签名 Runtime digest;托管 SSH 会话只显示支持的 OpenCode 选择。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已转为正式 Agent 0.11.2。
|
||||
- [x] **SSH Host 运行环境手动更新**:远程运行环境卡片只读取 Host 已安装 Agent/Runtime 与当前本地复合包所需版本,不会联网。用户显式触发后,Main 按 Agent、Runtime 顺序使用对应 Host 架构的已验证本地 `.gbagent` 强制重新校验并安装,显示阶段进度并允许在最终收尾前取消;尚未下载时提示先到平台功能设置下载或导入。更新期间保留旧版本卡片,失败或取消后刷新实际状态并允许重试;成功后定向清理该 Host 缓存并使引用项目重新验证,不删除 Host 配置、凭据、项目设置或 Workspace 文件。
|
||||
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
|
||||
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.11.2-e2e.13",
|
||||
"agentVersion": "0.11.2",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
本文记录当前代码实现,不定义额外的信任框架。Windows 到 Linux x64 的安装、Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实 Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与 OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback 路径 capability 后,当前源码 lock 已前进到 Agent `0.11.2-e2e.13`,尚未复用旧版本号。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry 已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 复合包和签名累计目录。
|
||||
本文记录当前代码实现,不定义额外的信任框架。Windows 到 Linux x64 的安装、Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实 Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与 OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback 路径 capability 后,当前源码 lock 已转为正式 Agent `0.11.2`,尚未复用旧版本号。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry 已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 复合包和签名累计目录。
|
||||
|
||||
## 产品语义
|
||||
|
||||
@@ -170,7 +170,7 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
||||
|
||||
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
|
||||
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
|
||||
的双架构验收。当前源码 lock 是 `0.11.2-e2e.13`,需要由新的复合包发布流程另行验证。
|
||||
的双架构验收。当前源码 lock 是正式版本 `0.11.2`,需要由新的复合包发布流程另行验证。
|
||||
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
|
||||
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
|
||||
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
|
||||
|
||||
@@ -35,6 +35,12 @@ describe('embedding IPC boundary', () => {
|
||||
expect(region).toContain('embeddingConnectionIdRequestSchema.parse(input)')
|
||||
expect(region).toContain('embeddingModelInstallInputSchema.parse(input)')
|
||||
expect(region).toContain('embeddingModelActionInputSchema.parse(input)')
|
||||
expect(region).toContain(
|
||||
'embeddingModelProgressSnapshotSchema.parse'
|
||||
)
|
||||
expect(region).toContain(
|
||||
'embeddingModelManager.getProgressSnapshot()'
|
||||
)
|
||||
expect(region).toContain("properties: ['openFile']")
|
||||
expect(region).toContain('filters: modelArchiveDialogFilters')
|
||||
expect(region).not.toMatch(
|
||||
@@ -1090,7 +1096,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
state: 'verified',
|
||||
version: '0.11.2-e2e.13',
|
||||
version: '0.11.2',
|
||||
remoteRuntimeVersion: '1.18.9',
|
||||
agentProtocol: { major: 2, minor: 0 }
|
||||
},
|
||||
@@ -1128,7 +1134,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
importArchive: vi.fn(async () => agentPackageInventory),
|
||||
getExportArchiveName: vi.fn(
|
||||
async () =>
|
||||
'goodbuddy-agent-0.11.2-e2e.13-linux-arm64.gbagent'
|
||||
'goodbuddy-agent-0.11.2-linux-arm64.gbagent'
|
||||
),
|
||||
exportArchive: vi.fn(async () => undefined)
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ import {
|
||||
embeddingConnectionIdRequestSchema,
|
||||
embeddingModelActionInputSchema,
|
||||
embeddingModelInstallInputSchema,
|
||||
embeddingModelProgressSnapshotSchema,
|
||||
embeddingModelSnapshotSchema,
|
||||
embeddingSettingsSnapshotSchema,
|
||||
knowledgeEmbeddingIndexCancelRequestSchema,
|
||||
@@ -4861,6 +4862,16 @@ export function registerIpcHandlers(
|
||||
return getEmbeddingSettingsSnapshot()
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.embeddingModelsProgress, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!embeddingModelManager) {
|
||||
throw new Error('内置向量模型服务不可用')
|
||||
}
|
||||
return embeddingModelProgressSnapshotSchema.parse(
|
||||
embeddingModelManager.getProgressSnapshot()
|
||||
)
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.embeddingDiagnose,
|
||||
async (event, input: unknown) => {
|
||||
|
||||
@@ -86,6 +86,7 @@ import type {
|
||||
} from '../shared/speech-model-contracts'
|
||||
import type {
|
||||
EmbeddingDiagnosticResult,
|
||||
EmbeddingModelProgressSnapshot,
|
||||
EmbeddingModelSnapshot,
|
||||
EmbeddingSettingsSnapshot,
|
||||
KnowledgeEmbeddingIndexSnapshot
|
||||
@@ -620,6 +621,10 @@ const desktopApi: DesktopApi = {
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.embeddingSettingsGet
|
||||
) as Promise<EmbeddingSettingsSnapshot>,
|
||||
getModelProgress: () =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.embeddingModelsProgress
|
||||
) as Promise<EmbeddingModelProgressSnapshot>,
|
||||
diagnose: (connectionId: string) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.embeddingDiagnose,
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('sandboxed preload', () => {
|
||||
/embeddings: \{(?<body>[\s\S]*?)\n {2}\},\n {2}documentParsing:/u
|
||||
)?.groups?.body ?? ''
|
||||
expect(embeddings).toContain('getSnapshot:')
|
||||
expect(embeddings).toContain('getModelProgress:')
|
||||
expect(embeddings).toContain('diagnose: (connectionId: string)')
|
||||
expect(embeddings).toContain('setCurrent: (connectionId: string)')
|
||||
expect(embeddings).toContain('installModel:')
|
||||
|
||||
@@ -6153,7 +6153,7 @@ describe('App', () => {
|
||||
platform: 'linux' as const,
|
||||
architecture: 'x64' as const,
|
||||
state: 'verified' as const,
|
||||
version: '0.11.2-e2e.13',
|
||||
version: '0.11.2',
|
||||
agentProtocol: { major: 2, minor: 0 },
|
||||
remoteRuntimeVersion: '1.18.9'
|
||||
},
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
DocumentParsingDiagnostic,
|
||||
DocumentOcrModelCatalogViewEntry,
|
||||
DocumentOcrModelOperation,
|
||||
DocumentParsingSettings,
|
||||
DocumentParsingSnapshot,
|
||||
DocumentParsingTestPurpose
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
} from './SettingsPrimitives'
|
||||
import { modelOperationPercent } from './model-download-presentation'
|
||||
|
||||
type DocumentParsingSettingsSectionProps = {
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
@@ -55,17 +55,6 @@ function catalogSize(entry: DocumentOcrModelCatalogViewEntry): number {
|
||||
)
|
||||
}
|
||||
|
||||
function progressPercent(
|
||||
operation: DocumentOcrModelOperation
|
||||
): number | undefined {
|
||||
return operation.totalBytes && operation.totalBytes > 0
|
||||
? Math.min(
|
||||
100,
|
||||
(operation.completedBytes / operation.totalBytes) * 100
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function StatusRow({
|
||||
available,
|
||||
detail,
|
||||
@@ -486,7 +475,7 @@ export function DocumentParsingSettingsSection({
|
||||
(operation) => operation.modelId === draft.localOcrModelId
|
||||
)
|
||||
const modelProgress = modelOperation
|
||||
? progressPercent(modelOperation)
|
||||
? modelOperationPercent(modelOperation)
|
||||
: undefined
|
||||
const modelDownloadAvailability = model?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
EmbeddingSettingsSection,
|
||||
type EmbeddingSettingsSectionProps
|
||||
} from './EmbeddingSettingsSection'
|
||||
import type { EmbeddingModelSnapshot } from '../../shared/embedding-contracts'
|
||||
import { changeUiLocale } from './i18n'
|
||||
|
||||
const connections: EmbeddingSettingsSectionProps['connections'] = [
|
||||
@@ -41,6 +42,51 @@ const connections: EmbeddingSettingsSectionProps['connections'] = [
|
||||
}
|
||||
]
|
||||
|
||||
const models: EmbeddingModelSnapshot = {
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: [
|
||||
{
|
||||
id: 'granite-embedding-107m',
|
||||
displayName: 'Granite Embedding 107M',
|
||||
description: '用于多语言本地向量检索。',
|
||||
languages: ['200+ languages'],
|
||||
runtime: 'onnxruntime-web/wasm',
|
||||
dimensions: 384,
|
||||
contextTokens: 32_768,
|
||||
quantization: 'INT8',
|
||||
recommended: true,
|
||||
available: true,
|
||||
license: {
|
||||
name: 'Apache License 2.0',
|
||||
notice: 'Apache License 2.0',
|
||||
url: 'https://example.com/license'
|
||||
},
|
||||
files: [
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model',
|
||||
size: 2 * 1024 * 1024,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: true,
|
||||
totalBytes: 2 * 1024 * 1024
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 2 * 1024 * 1024
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
installed: [],
|
||||
operations: []
|
||||
}
|
||||
|
||||
function renderSection(
|
||||
overrides: Partial<EmbeddingSettingsSectionProps> = {}
|
||||
): EmbeddingSettingsSectionProps {
|
||||
@@ -164,6 +210,124 @@ describe('EmbeddingSettingsSection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('matches speech downloads with visible progress and cancellation', () => {
|
||||
const props = renderSection({
|
||||
models: {
|
||||
...models,
|
||||
operations: [
|
||||
{
|
||||
modelId: 'granite-embedding-107m',
|
||||
kind: 'download',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: 1024 * 1024,
|
||||
totalBytes: 2 * 1024 * 1024,
|
||||
downloadSource: 'modelscope'
|
||||
}
|
||||
]
|
||||
},
|
||||
selectedConnectionId: 'builtin'
|
||||
})
|
||||
|
||||
expect(screen.getByText('Granite Embedding 107M')).toBeInTheDocument()
|
||||
expect(screen.getByText('推荐')).toBeInTheDocument()
|
||||
expect(screen.getByText('ModelScope')).toBeInTheDocument()
|
||||
expect(screen.getByText('2.0 MB')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('progressbar', {
|
||||
name: 'Granite Embedding 107M下载进度'
|
||||
})
|
||||
).toHaveValue(50)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '取消下载 Granite Embedding 107M'
|
||||
})
|
||||
)
|
||||
expect(props.onCancelBuiltin).toHaveBeenCalledWith(
|
||||
'granite-embedding-107m'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows preparation immediately after the download click', () => {
|
||||
renderSection({
|
||||
models,
|
||||
pendingOperation: {
|
||||
modelId: 'granite-embedding-107m',
|
||||
kind: 'download'
|
||||
},
|
||||
selectedConnectionId: 'builtin'
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByText('正在准备从 ModelScope 下载')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('progressbar', {
|
||||
name: 'Granite Embedding 107M下载进度'
|
||||
})
|
||||
).not.toHaveAttribute('value')
|
||||
})
|
||||
|
||||
it('keeps an unavailable source explicit and opens General settings', () => {
|
||||
const onOpenModelDownloadSourceSettings = vi.fn()
|
||||
renderSection({
|
||||
models: {
|
||||
...models,
|
||||
catalog: models.catalog.map((model) => ({
|
||||
...model,
|
||||
downloadAvailability: model.downloadAvailability.map(
|
||||
(availability) =>
|
||||
availability.source === 'modelscope'
|
||||
? {
|
||||
source: 'modelscope' as const,
|
||||
available: false as const,
|
||||
unavailableReason: '当前来源缺少完整文件'
|
||||
}
|
||||
: availability
|
||||
)
|
||||
}))
|
||||
},
|
||||
onOpenModelDownloadSourceSettings,
|
||||
selectedConnectionId: 'builtin'
|
||||
})
|
||||
|
||||
expect(screen.getByText('当前来源不可下载')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: '下载 Granite Embedding 107M'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '前往通用设置' })
|
||||
)
|
||||
expect(onOpenModelDownloadSourceSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('announces ZIP imports as import progress', () => {
|
||||
renderSection({
|
||||
models: {
|
||||
...models,
|
||||
operations: [
|
||||
{
|
||||
modelId: 'granite-embedding-107m',
|
||||
kind: 'import',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: 1024,
|
||||
totalBytes: 2048
|
||||
}
|
||||
]
|
||||
},
|
||||
selectedConnectionId: 'builtin'
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('progressbar', {
|
||||
name: 'Granite Embedding 107M导入进度'
|
||||
})
|
||||
).toHaveValue(50)
|
||||
})
|
||||
|
||||
it('identifies a loopback custom endpoint without showing a remote warning', () => {
|
||||
renderSection({
|
||||
connections: [
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FlaskConical,
|
||||
Plus,
|
||||
Square,
|
||||
Trash2,
|
||||
Upload,
|
||||
X
|
||||
Upload
|
||||
} from 'lucide-react'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
EmbeddingDiagnosticResult
|
||||
EmbeddingDiagnosticResult,
|
||||
EmbeddingModelSnapshot
|
||||
} from '../../shared/embedding-contracts'
|
||||
import { formatMediumDateTime } from './locale-formatters'
|
||||
import {
|
||||
formatModelPackageBytes,
|
||||
modelOperationPercent
|
||||
} from './model-download-presentation'
|
||||
|
||||
export interface EmbeddingConnectionPresentation {
|
||||
id: string
|
||||
@@ -25,7 +32,6 @@ export interface EmbeddingConnectionPresentation {
|
||||
clearApiKey?: boolean
|
||||
statusText?: string
|
||||
installed?: boolean
|
||||
operationActive?: boolean
|
||||
}
|
||||
|
||||
export interface EmbeddingSettingsSectionProps {
|
||||
@@ -34,6 +40,11 @@ export interface EmbeddingSettingsSectionProps {
|
||||
selectedConnectionId?: string
|
||||
diagnostic?: EmbeddingDiagnosticResult | null
|
||||
diagnosticRunning?: boolean
|
||||
models?: EmbeddingModelSnapshot
|
||||
pendingOperation?: {
|
||||
modelId: string
|
||||
kind: EmbeddingModelSnapshot['operations'][number]['kind']
|
||||
}
|
||||
enabled: boolean
|
||||
secureStorageAvailable: boolean
|
||||
busy?: boolean
|
||||
@@ -50,9 +61,35 @@ export interface EmbeddingSettingsSectionProps {
|
||||
onCancelBuiltin: (modelId: string) => void
|
||||
onImportBuiltin: (modelId: string) => void
|
||||
onRemoveBuiltin: (modelId: string) => void
|
||||
onOpenModelDownloadSourceSettings?: () => void
|
||||
onTestConnection: (connectionId: string) => void
|
||||
}
|
||||
|
||||
function operationLabel(
|
||||
operation: EmbeddingModelSnapshot['operations'][number],
|
||||
t: TFunction<'settingsSections'>
|
||||
): string {
|
||||
if (operation.phase === 'installing') {
|
||||
return t('embedding.operations.installing')
|
||||
}
|
||||
if (operation.phase === 'preparing') {
|
||||
return operation.kind === 'import'
|
||||
? t('embedding.operations.preparingImport')
|
||||
: t('embedding.operations.preparingDownloadFrom', {
|
||||
source: t(
|
||||
`modelDownloadSources.${operation.downloadSource}`
|
||||
)
|
||||
})
|
||||
}
|
||||
return operation.kind === 'import'
|
||||
? t('embedding.operations.importing')
|
||||
: t('embedding.operations.downloadingFrom', {
|
||||
source: t(
|
||||
`modelDownloadSources.${operation.downloadSource}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function embeddingEndpointDestination(endpoint: string | undefined):
|
||||
| { kind: 'local'; host: string }
|
||||
| { kind: 'network'; host: string }
|
||||
@@ -128,6 +165,8 @@ export function EmbeddingSettingsSection({
|
||||
selectedConnectionId,
|
||||
diagnostic,
|
||||
diagnosticRunning = false,
|
||||
models,
|
||||
pendingOperation,
|
||||
enabled,
|
||||
secureStorageAvailable,
|
||||
busy = false,
|
||||
@@ -141,6 +180,7 @@ export function EmbeddingSettingsSection({
|
||||
onCancelBuiltin,
|
||||
onImportBuiltin,
|
||||
onRemoveBuiltin,
|
||||
onOpenModelDownloadSourceSettings,
|
||||
onTestConnection
|
||||
}: EmbeddingSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
@@ -152,6 +192,69 @@ export function EmbeddingSettingsSection({
|
||||
selected?.kind === 'openai-compatible'
|
||||
? embeddingEndpointDestination(selected.endpoint)
|
||||
: undefined
|
||||
const selectedModel =
|
||||
selected?.kind === 'builtin'
|
||||
? models?.catalog.find((model) => model.id === selected.model)
|
||||
: undefined
|
||||
const installedModel =
|
||||
selected?.kind === 'builtin'
|
||||
? models?.installed.find((model) => model.id === selected.model)
|
||||
: undefined
|
||||
const activeOperation =
|
||||
selected?.kind === 'builtin'
|
||||
? models?.operations.find(
|
||||
(operation) => operation.modelId === selected.model
|
||||
)
|
||||
: undefined
|
||||
const optimisticOperation =
|
||||
selected?.kind === 'builtin' &&
|
||||
!activeOperation &&
|
||||
pendingOperation?.modelId === selected.model
|
||||
? {
|
||||
modelId: selected.model,
|
||||
kind: pendingOperation.kind,
|
||||
phase: 'preparing' as const,
|
||||
currentFile: null,
|
||||
completedBytes: 0,
|
||||
totalBytes: null,
|
||||
...(pendingOperation.kind === 'download' && models
|
||||
? { downloadSource: models.selectedDownloadSource }
|
||||
: {})
|
||||
}
|
||||
: undefined
|
||||
const operation = activeOperation ?? optimisticOperation
|
||||
const percent = operation
|
||||
? modelOperationPercent(operation)
|
||||
: undefined
|
||||
const downloadAvailability = selectedModel?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
availability.source === models?.selectedDownloadSource
|
||||
)
|
||||
const sourceAvailable =
|
||||
models === undefined ||
|
||||
(selectedModel !== undefined &&
|
||||
downloadAvailability?.available === true)
|
||||
const modelSize =
|
||||
installedModel?.files.reduce(
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
) ??
|
||||
downloadAvailability?.totalBytes ??
|
||||
selectedModel?.files.reduce(
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
const modelDisplayName = selectedModel?.displayName ?? selected?.name ?? ''
|
||||
const modelInstalled = Boolean(installedModel || selected?.installed)
|
||||
const modelStatus =
|
||||
operation
|
||||
? operationLabel(operation, t)
|
||||
: modelInstalled
|
||||
? t('embedding.status.installed')
|
||||
: selectedModel && !sourceAvailable
|
||||
? t('embedding.status.sourceUnavailable')
|
||||
: selected?.statusText ??
|
||||
t('embedding.status.availableToDownload')
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -263,14 +366,77 @@ export function EmbeddingSettingsSection({
|
||||
</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">
|
||||
<article className="document-ocr-model speech-model-card embedding-model-card">
|
||||
<div className="document-ocr-model__header">
|
||||
<div className="document-ocr-model__summary">
|
||||
<div className="document-ocr-model__name">
|
||||
<strong>{modelDisplayName}</strong>
|
||||
{selectedModel?.recommended && (
|
||||
<span className="speech-model-tag speech-model-tag--recommended">
|
||||
{t('embedding.tags.recommended')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedModel?.description && (
|
||||
<p>{selectedModel.description}</p>
|
||||
)}
|
||||
<div className="document-ocr-model__tags">
|
||||
{models && (
|
||||
<span className="speech-model-tag">
|
||||
{t(
|
||||
`modelDownloadSources.${models.selectedDownloadSource}`
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="speech-model-tag">
|
||||
{selected.model}
|
||||
</span>
|
||||
{selectedModel && (
|
||||
<>
|
||||
<span className="speech-model-tag">
|
||||
{t('embedding.metadata.dimensions', {
|
||||
count: selectedModel.dimensions
|
||||
})}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{selectedModel.quantization.toUpperCase()}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{t('embedding.metadata.contextTokens', {
|
||||
count: selectedModel.contextTokens
|
||||
})}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{modelSize
|
||||
? formatModelPackageBytes(modelSize)
|
||||
: t('embedding.status.unknownSize')}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{selectedModel.license.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="document-ocr-model__state">
|
||||
<span
|
||||
className={
|
||||
'document-ocr-model__status' +
|
||||
(modelInstalled
|
||||
? ' document-ocr-model__status--installed'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
{modelInstalled && (
|
||||
<CheckCircle2 aria-hidden="true" size={13} />
|
||||
)}
|
||||
{modelStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="document-ocr-model__actions">
|
||||
<button
|
||||
aria-label={t('embedding.accessibility.test', {
|
||||
name: selected.name
|
||||
@@ -285,27 +451,41 @@ export function EmbeddingSettingsSection({
|
||||
? t('embedding.diagnostic.testing')
|
||||
: t('embedding.actions.test')}
|
||||
</button>
|
||||
{selected.operationActive ? (
|
||||
{operation ? (
|
||||
<button
|
||||
aria-label={t('embedding.accessibility.cancel', {
|
||||
name: selected.name
|
||||
name: modelDisplayName
|
||||
})}
|
||||
className="secondary-button"
|
||||
onClick={() => onCancelBuiltin(selected.model)}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={13} />
|
||||
<Square aria-hidden="true" size={12} />
|
||||
{t('embedding.actions.cancel')}
|
||||
</button>
|
||||
) : modelInstalled ? (
|
||||
<button
|
||||
aria-label={t('embedding.accessibility.remove', {
|
||||
name: modelDisplayName
|
||||
})}
|
||||
className="danger-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => onRemoveBuiltin(selected.model)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
{t('embedding.actions.remove')}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{!selected.installed && (
|
||||
{sourceAvailable && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'embedding.accessibility.download',
|
||||
{ name: selected.name }
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="secondary-button"
|
||||
className="primary-button"
|
||||
disabled={busy}
|
||||
onClick={() => onDownloadBuiltin(selected.model)}
|
||||
type="button"
|
||||
>
|
||||
@@ -313,34 +493,74 @@ export function EmbeddingSettingsSection({
|
||||
{t('embedding.actions.download')}
|
||||
</button>
|
||||
)}
|
||||
{!sourceAvailable &&
|
||||
onOpenModelDownloadSourceSettings && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onOpenModelDownloadSourceSettings}
|
||||
type="button"
|
||||
>
|
||||
{t(
|
||||
'embedding.actions.openDownloadSourceSettings'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={t('embedding.accessibility.import', {
|
||||
name: selected.name
|
||||
name: modelDisplayName
|
||||
})}
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
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>
|
||||
</>
|
||||
|
||||
{!sourceAvailable && selectedModel && (
|
||||
<p className="settings-warning">
|
||||
{t('embedding.sourceUnavailableDescription', {
|
||||
source: t(
|
||||
`modelDownloadSources.${models?.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{operation && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="document-ocr-model__operation"
|
||||
>
|
||||
<progress
|
||||
aria-label={t(
|
||||
operation.kind === 'import'
|
||||
? 'embedding.accessibility.importProgress'
|
||||
: 'embedding.accessibility.downloadProgress',
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
max={100}
|
||||
{...(percent === undefined
|
||||
? {}
|
||||
: { value: percent })}
|
||||
/>
|
||||
<small>
|
||||
{operation.currentFile
|
||||
? t('embedding.operations.processingFile', {
|
||||
file: operation.currentFile
|
||||
})
|
||||
: operationLabel(operation, t) + '…'}
|
||||
{percent === undefined
|
||||
? ''
|
||||
: ' · ' + percent.toFixed(0) + '%'}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
) : (
|
||||
<>
|
||||
<label className="field">
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
} from '../../shared/shortcut'
|
||||
import type {
|
||||
EmbeddingDiagnosticResult,
|
||||
EmbeddingModelSnapshot,
|
||||
EmbeddingSettingsSnapshot
|
||||
} from '../../shared/embedding-contracts'
|
||||
import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
|
||||
@@ -406,6 +407,43 @@ const updateExpert = vi.fn<DesktopApi['experts']['update']>(
|
||||
const removeExpert = vi.fn<DesktopApi['experts']['remove']>(
|
||||
async () => {}
|
||||
)
|
||||
const embeddingCatalogEntry = {
|
||||
id: 'granite-embedding-97m-multilingual-r2-int8',
|
||||
displayName: 'Granite Embedding 97M Multilingual R2',
|
||||
description: '面向多语言检索的本地 INT8 向量模型。',
|
||||
languages: ['200+ languages'],
|
||||
runtime: 'onnxruntime-web/wasm' as const,
|
||||
dimensions: 384,
|
||||
contextTokens: 32_768,
|
||||
quantization: 'INT8',
|
||||
recommended: true,
|
||||
available: true,
|
||||
license: {
|
||||
name: 'Apache License 2.0',
|
||||
notice: 'Apache License 2.0',
|
||||
url: 'https://example.com/license'
|
||||
},
|
||||
files: [
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model' as const,
|
||||
size: 2 * 1024 * 1024,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope' as const,
|
||||
available: true as const,
|
||||
totalBytes: 2 * 1024 * 1024
|
||||
},
|
||||
{
|
||||
source: 'hugging-face' as const,
|
||||
available: true as const,
|
||||
totalBytes: 2 * 1024 * 1024
|
||||
}
|
||||
]
|
||||
}
|
||||
const embeddingSnapshot: EmbeddingSettingsSnapshot = {
|
||||
configuration: {
|
||||
provider: 'openai-compatible',
|
||||
@@ -418,7 +456,7 @@ const embeddingSnapshot: EmbeddingSettingsSnapshot = {
|
||||
id: builtinEmbeddingConnectionId,
|
||||
name: 'GoodBuddy 内置向量模型',
|
||||
kind: 'builtin',
|
||||
model: 'granite-embedding-107m-multilingual',
|
||||
model: embeddingCatalogEntry.id,
|
||||
credentialConfigured: false
|
||||
},
|
||||
{
|
||||
@@ -434,12 +472,15 @@ const embeddingSnapshot: EmbeddingSettingsSnapshot = {
|
||||
currentConnectionId: legacyEmbeddingConnectionId,
|
||||
models: {
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: [],
|
||||
catalog: [embeddingCatalogEntry],
|
||||
installed: [],
|
||||
operations: []
|
||||
}
|
||||
}
|
||||
const getEmbeddingSnapshot = vi.fn(async () => embeddingSnapshot)
|
||||
const getEmbeddingModelProgress = vi.fn(async () => ({
|
||||
operations: embeddingSnapshot.models.operations
|
||||
}))
|
||||
const diagnoseEmbedding = vi.fn(
|
||||
async (): Promise<EmbeddingDiagnosticResult> => ({
|
||||
status: 'available',
|
||||
@@ -478,7 +519,7 @@ const agentPackageInventory: AgentPackageInventory = {
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
state: 'verified',
|
||||
version: '0.11.2-e2e.13',
|
||||
version: '0.11.2',
|
||||
agentProtocol: { major: 2, minor: 0 },
|
||||
remoteRuntimeVersion: '1.18.9'
|
||||
},
|
||||
@@ -839,6 +880,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
},
|
||||
embeddings: {
|
||||
getSnapshot: getEmbeddingSnapshot,
|
||||
getModelProgress: getEmbeddingModelProgress,
|
||||
diagnose: diagnoseEmbedding,
|
||||
setCurrent: setCurrentEmbedding,
|
||||
installModel: installEmbeddingModel,
|
||||
@@ -4389,6 +4431,83 @@ describe('SettingsPanel runtime files', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows immediate progress when downloading the built-in vector model', async () => {
|
||||
let finishInstall:
|
||||
| ((snapshot: EmbeddingModelSnapshot) => void)
|
||||
| undefined
|
||||
installEmbeddingModel.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<EmbeddingModelSnapshot>((resolve) => {
|
||||
finishInstall = resolve
|
||||
})
|
||||
)
|
||||
const onNotify = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onNotify={onNotify}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
fireEvent.click(screen.getByRole('button', { name: '向量模型' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '编辑向量模型连接 GoodBuddy 内置向量模型'
|
||||
})
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '下载 Granite Embedding 97M Multilingual R2'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(installEmbeddingModel).toHaveBeenCalledWith(
|
||||
embeddingCatalogEntry.id,
|
||||
'modelscope'
|
||||
)
|
||||
)
|
||||
expect(
|
||||
screen.getByText('正在准备从 ModelScope 下载')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('progressbar', {
|
||||
name: 'Granite Embedding 97M Multilingual R2下载进度'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(getEmbeddingModelProgress).toHaveBeenCalled()
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
finishInstall?.({
|
||||
...embeddingSnapshot.models,
|
||||
installed: [
|
||||
{
|
||||
id: embeddingCatalogEntry.id,
|
||||
displayName: embeddingCatalogEntry.displayName,
|
||||
source: 'download',
|
||||
installedAt: '2026-08-25T00:00:00.000Z',
|
||||
files: embeddingCatalogEntry.files
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
message: 'Granite Embedding 97M Multilingual R2 已安装',
|
||||
dedupeKey: `embedding-model-${embeddingCatalogEntry.id}`
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves embedding credentials after a failed save, clears them after success, and sets the current connection explicitly', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
|
||||
@@ -700,6 +700,11 @@ export function SettingsPanel({
|
||||
useState(false)
|
||||
const [embeddingModelActionRunning, setEmbeddingModelActionRunning] =
|
||||
useState(false)
|
||||
const [embeddingModelPendingOperation, setEmbeddingModelPendingOperation] =
|
||||
useState<{
|
||||
modelId: string
|
||||
kind: EmbeddingModelSnapshot['operations'][number]['kind']
|
||||
}>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [confirmingClear, setConfirmingClear] = useState(false)
|
||||
const [clearingLocalData, setClearingLocalData] = useState(false)
|
||||
@@ -1012,6 +1017,7 @@ export function SettingsPanel({
|
||||
setError(undefined)
|
||||
setConfirmingClear(false)
|
||||
setClearingLocalData(false)
|
||||
setEmbeddingModelPendingOperation(undefined)
|
||||
setModelType('llm')
|
||||
setSpeechModelDraftId(undefined)
|
||||
setPersistedSpeechModelId(undefined)
|
||||
@@ -1082,26 +1088,54 @@ export function SettingsPanel({
|
||||
}
|
||||
}, [i18n, open])
|
||||
|
||||
const refreshEmbeddingModelProgress =
|
||||
useCallback(async (): Promise<void> => {
|
||||
const embeddings = window.goodbuddy.embeddings
|
||||
if (!embeddings) {
|
||||
return
|
||||
}
|
||||
const progress = await embeddings.getModelProgress()
|
||||
setEmbeddingModels((current) =>
|
||||
current
|
||||
? { ...current, operations: progress.operations }
|
||||
: current
|
||||
)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const embeddings = window.goodbuddy.embeddings
|
||||
if (
|
||||
!open ||
|
||||
!embeddings ||
|
||||
!embeddingModels?.operations.length
|
||||
(!embeddingModelPendingOperation &&
|
||||
!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])
|
||||
let active = true
|
||||
let timer: number | undefined
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
await refreshEmbeddingModelProgress()
|
||||
} catch {
|
||||
// The final operation result reports actionable errors.
|
||||
} finally {
|
||||
if (active) {
|
||||
timer = window.setTimeout(() => void poll(), 300)
|
||||
}
|
||||
}
|
||||
}
|
||||
timer = window.setTimeout(() => void poll(), 300)
|
||||
return () => {
|
||||
active = false
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
embeddingModelPendingOperation,
|
||||
embeddingModels?.operations.length,
|
||||
open,
|
||||
refreshEmbeddingModelProgress
|
||||
])
|
||||
|
||||
const normalizedContextCompression =
|
||||
normalizeContextCompressionTokenDrafts(
|
||||
@@ -1490,9 +1524,14 @@ export function SettingsPanel({
|
||||
}
|
||||
|
||||
const runEmbeddingModelOperation = async (
|
||||
modelId: string,
|
||||
kind:
|
||||
| EmbeddingModelSnapshot['operations'][number]['kind']
|
||||
| undefined,
|
||||
operation: (
|
||||
api: NonNullable<typeof window.goodbuddy.embeddings>
|
||||
) => Promise<EmbeddingModelSnapshot | undefined>
|
||||
) => Promise<EmbeddingModelSnapshot | undefined>,
|
||||
successMessage: string
|
||||
): Promise<void> => {
|
||||
const embeddings = window.goodbuddy.embeddings
|
||||
if (!embeddings) {
|
||||
@@ -1500,18 +1539,43 @@ export function SettingsPanel({
|
||||
return
|
||||
}
|
||||
setEmbeddingModelActionRunning(true)
|
||||
setEmbeddingModelPendingOperation(
|
||||
kind ? { modelId, kind } : undefined
|
||||
)
|
||||
setError(undefined)
|
||||
let reconcileSnapshot = false
|
||||
try {
|
||||
const models = await operation(embeddings)
|
||||
if (models) {
|
||||
setEmbeddingModels(models)
|
||||
const snapshot = await embeddings.getSnapshot()
|
||||
setEmbeddingSnapshot(snapshot)
|
||||
setEmbeddingSnapshot((current) =>
|
||||
current ? { ...current, models } : current
|
||||
)
|
||||
onNotify({
|
||||
tone: 'success',
|
||||
message: successMessage,
|
||||
dedupeKey: `embedding-model-${modelId}`
|
||||
})
|
||||
} else {
|
||||
reconcileSnapshot = true
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(settingsErrorMessage(reason, t('errors.saveSettings')))
|
||||
reconcileSnapshot = true
|
||||
setError(
|
||||
settingsErrorMessage(reason, t('errors.manageEmbeddingModel'))
|
||||
)
|
||||
} finally {
|
||||
setEmbeddingModelActionRunning(false)
|
||||
setEmbeddingModelPendingOperation(undefined)
|
||||
if (reconcileSnapshot) {
|
||||
void embeddings.getSnapshot().then(
|
||||
(snapshot) => {
|
||||
setEmbeddingSnapshot(snapshot)
|
||||
setEmbeddingModels(snapshot.models)
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1530,7 +1594,9 @@ export function SettingsPanel({
|
||||
setEmbeddingSnapshot(snapshot)
|
||||
setEmbeddingModels(snapshot.models)
|
||||
} catch (reason) {
|
||||
setError(settingsErrorMessage(reason, t('errors.saveSettings')))
|
||||
setError(
|
||||
settingsErrorMessage(reason, t('errors.manageEmbeddingModel'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3162,7 +3228,6 @@ export function SettingsPanel({
|
||||
return {
|
||||
...connection,
|
||||
installed,
|
||||
operationActive: Boolean(operation),
|
||||
statusText: operation
|
||||
? t('model.embedding.progress', {
|
||||
percent: progress ?? 0
|
||||
@@ -3182,6 +3247,8 @@ export function SettingsPanel({
|
||||
diagnostic={embeddingDiagnostic}
|
||||
diagnosticRunning={embeddingDiagnosticRunning}
|
||||
enabled={knowledgeEmbeddingEnabled}
|
||||
models={embeddingModels}
|
||||
pendingOperation={embeddingModelPendingOperation}
|
||||
busy={saving || embeddingModelActionRunning}
|
||||
secureStorageAvailable={
|
||||
settings?.secureStorageAvailable ?? false
|
||||
@@ -3226,29 +3293,61 @@ export function SettingsPanel({
|
||||
)
|
||||
}}
|
||||
onDownloadBuiltin={(modelId) => {
|
||||
if (!embeddingModels) {
|
||||
setError(t('errors.readEmbeddingStatus'))
|
||||
return
|
||||
}
|
||||
const model = embeddingModels?.catalog.find(
|
||||
(candidate) => candidate.id === modelId
|
||||
)
|
||||
if (model && embeddingModels) {
|
||||
void runEmbeddingModelOperation((api) =>
|
||||
void runEmbeddingModelOperation(
|
||||
modelId,
|
||||
'download',
|
||||
(api) =>
|
||||
api.installModel(
|
||||
model.id,
|
||||
modelId,
|
||||
embeddingModels.selectedDownloadSource
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
i18n.t('embedding.notifications.installed', {
|
||||
ns: 'settingsSections',
|
||||
name: model?.displayName ?? modelId
|
||||
})
|
||||
)
|
||||
}}
|
||||
onEnabledChange={setKnowledgeEmbeddingEnabled}
|
||||
onImportBuiltin={(modelId) => {
|
||||
void runEmbeddingModelOperation((api) =>
|
||||
api.importModelArchive(modelId)
|
||||
const name =
|
||||
embeddingModels?.catalog.find(
|
||||
(candidate) => candidate.id === modelId
|
||||
)?.displayName ?? modelId
|
||||
void runEmbeddingModelOperation(
|
||||
modelId,
|
||||
'import',
|
||||
(api) => api.importModelArchive(modelId),
|
||||
i18n.t('embedding.notifications.importedZip', {
|
||||
ns: 'settingsSections',
|
||||
name
|
||||
})
|
||||
)
|
||||
}}
|
||||
onRemoveBuiltin={(modelId) => {
|
||||
void runEmbeddingModelOperation((api) =>
|
||||
api.removeModel(modelId)
|
||||
const name =
|
||||
embeddingModels?.catalog.find(
|
||||
(candidate) => candidate.id === modelId
|
||||
)?.displayName ?? modelId
|
||||
void runEmbeddingModelOperation(
|
||||
modelId,
|
||||
undefined,
|
||||
(api) => api.removeModel(modelId),
|
||||
i18n.t('embedding.notifications.removed', {
|
||||
ns: 'settingsSections',
|
||||
name
|
||||
})
|
||||
)
|
||||
}}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
requestTabChange('platform-features')
|
||||
}
|
||||
onSelectConnection={setEditingEmbeddingConnectionId}
|
||||
onSetCurrent={setActiveEmbeddingConnectionId}
|
||||
onTestConnection={(connectionId) => {
|
||||
|
||||
@@ -17,6 +17,10 @@ import type {
|
||||
SpeechModelSnapshot
|
||||
} from '../../shared/speech-model-contracts'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import {
|
||||
formatModelPackageBytes,
|
||||
modelOperationPercent
|
||||
} from './model-download-presentation'
|
||||
|
||||
type SpeechModelSettingsSectionProps = {
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
@@ -30,26 +34,10 @@ type SpeechModelSettingsSectionProps = {
|
||||
onOpenModelDownloadSourceSettings?: () => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function catalogSize(entry: SpeechModelCatalogViewEntry): number {
|
||||
return entry.files.reduce((total, file) => total + file.size, 0)
|
||||
}
|
||||
|
||||
function progressPercent(operation: SpeechModelOperation): number | undefined {
|
||||
return operation.totalBytes && operation.totalBytes > 0
|
||||
? Math.min(
|
||||
100,
|
||||
(operation.completedBytes / operation.totalBytes) * 100
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function operationLabel(
|
||||
operation: SpeechModelOperation,
|
||||
t: TFunction<'settingsSections'>
|
||||
@@ -307,7 +295,7 @@ export function SpeechModelSettingsSection({
|
||||
? operationsById.get(model.id)
|
||||
: undefined
|
||||
const percent = operation
|
||||
? progressPercent(operation)
|
||||
? modelOperationPercent(operation)
|
||||
: undefined
|
||||
const downloadAvailability = model?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
@@ -488,7 +476,7 @@ export function SpeechModelSettingsSection({
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{size
|
||||
? formatBytes(size)
|
||||
? formatModelPackageBytes(size)
|
||||
: t('speech.status.unknownSize')}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
|
||||
@@ -140,6 +140,7 @@ export const settings = {
|
||||
embeddingDiagnosticUnavailable:
|
||||
'Vector diagnostics are unavailable',
|
||||
testEmbedding: 'Vector model test failed',
|
||||
manageEmbeddingModel: 'Embedding model operation failed',
|
||||
selectFile: 'Could not select the file',
|
||||
openRuntimeConfig: 'Could not open the Runtime configuration',
|
||||
selectWorkspace: 'Could not select the workspace folder',
|
||||
|
||||
@@ -346,6 +346,7 @@ export const settingsSections = {
|
||||
download: 'Download',
|
||||
cancel: 'Cancel',
|
||||
importZip: 'Import ZIP',
|
||||
openDownloadSourceSettings: 'Go to General settings',
|
||||
remove: 'Remove model',
|
||||
clearCredential: 'Clear credential',
|
||||
clearAfterSave: 'Credential will be cleared on save'
|
||||
@@ -357,8 +358,38 @@ export const settingsSections = {
|
||||
download: 'Download {{name}}',
|
||||
cancel: 'Cancel download for {{name}}',
|
||||
import: 'Import {{name}} from ZIP',
|
||||
remove: 'Remove local model {{name}}'
|
||||
remove: 'Remove local model {{name}}',
|
||||
downloadProgress: 'Download progress for {{name}}',
|
||||
importProgress: 'Import progress for {{name}}'
|
||||
},
|
||||
tags: {
|
||||
recommended: 'Recommended'
|
||||
},
|
||||
metadata: {
|
||||
dimensions: '{{count}} dimensions',
|
||||
contextTokens: '{{count}}-token context'
|
||||
},
|
||||
status: {
|
||||
installed: 'Installed',
|
||||
availableToDownload: 'Available to download',
|
||||
sourceUnavailable: 'Unavailable from current source',
|
||||
unknownSize: 'Unknown size'
|
||||
},
|
||||
operations: {
|
||||
installing: 'Installing',
|
||||
preparingImport: 'Preparing import',
|
||||
preparingDownloadFrom: 'Preparing download from {{source}}',
|
||||
importing: 'Importing',
|
||||
downloadingFrom: 'Downloading from {{source}}',
|
||||
processingFile: 'Processing {{file}}'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} installed',
|
||||
importedZip: '{{name}} imported from ZIP',
|
||||
removed: '{{name}} removed'
|
||||
},
|
||||
sourceUnavailableDescription:
|
||||
'The current download source, {{source}}, does not provide the complete verified files for this model. Switch sources or import a ZIP.',
|
||||
fields: {
|
||||
name: 'Name',
|
||||
endpoint: 'Embedding API URL',
|
||||
|
||||
@@ -120,6 +120,7 @@ export const settings = {
|
||||
testRuntime: 'Runtime 连接测试失败',
|
||||
embeddingDiagnosticUnavailable: '向量诊断服务不可用',
|
||||
testEmbedding: '向量模型测试失败',
|
||||
manageEmbeddingModel: '向量模型操作失败',
|
||||
selectFile: '选择文件失败',
|
||||
openRuntimeConfig: '打开 Runtime 配置失败',
|
||||
selectWorkspace: '选择工作区目录失败',
|
||||
|
||||
@@ -331,6 +331,7 @@ export const settingsSections = {
|
||||
download: '下载',
|
||||
cancel: '取消',
|
||||
importZip: '导入 ZIP',
|
||||
openDownloadSourceSettings: '前往通用设置',
|
||||
remove: '移除模型',
|
||||
clearCredential: '清除凭据',
|
||||
clearAfterSave: '保存后清除凭据'
|
||||
@@ -342,8 +343,38 @@ export const settingsSections = {
|
||||
download: '下载 {{name}}',
|
||||
cancel: '取消下载 {{name}}',
|
||||
import: '从 ZIP 导入 {{name}}',
|
||||
remove: '移除本地模型 {{name}}'
|
||||
remove: '移除本地模型 {{name}}',
|
||||
downloadProgress: '{{name}}下载进度',
|
||||
importProgress: '{{name}}导入进度'
|
||||
},
|
||||
tags: {
|
||||
recommended: '推荐'
|
||||
},
|
||||
metadata: {
|
||||
dimensions: '{{count}} 维',
|
||||
contextTokens: '{{count}} Token 上下文'
|
||||
},
|
||||
status: {
|
||||
installed: '已安装',
|
||||
availableToDownload: '可下载',
|
||||
sourceUnavailable: '当前来源不可下载',
|
||||
unknownSize: '大小未知'
|
||||
},
|
||||
operations: {
|
||||
installing: '正在安装',
|
||||
preparingImport: '正在准备导入',
|
||||
preparingDownloadFrom: '正在准备从 {{source}} 下载',
|
||||
importing: '正在导入',
|
||||
downloadingFrom: '正在从 {{source}} 下载',
|
||||
processingFile: '正在处理 {{file}}'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} 已安装',
|
||||
importedZip: '{{name}} 已从 ZIP 导入',
|
||||
removed: '{{name}} 已移除'
|
||||
},
|
||||
sourceUnavailableDescription:
|
||||
'当前下载源 {{source}} 不提供此模型的完整已验证文件;可切换下载源或从 ZIP 导入。',
|
||||
fields: {
|
||||
name: '名称',
|
||||
endpoint: '向量接口 URL',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export type ModelOperationProgress = {
|
||||
completedBytes: number
|
||||
totalBytes: number | null
|
||||
}
|
||||
|
||||
export function formatModelPackageBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function modelOperationPercent(
|
||||
operation: ModelOperationProgress
|
||||
): number | undefined {
|
||||
return operation.totalBytes && operation.totalBytes > 0
|
||||
? Math.min(
|
||||
100,
|
||||
(operation.completedBytes / operation.totalBytes) * 100
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
const digest = 'a'.repeat(64)
|
||||
|
||||
function catalogEntry() {
|
||||
const version = '0.11.2-e2e.13'
|
||||
const version = '0.11.2'
|
||||
const archive = agentPackageArchiveName(version, 'x64')
|
||||
return {
|
||||
format: 'goodbuddy-agent-package',
|
||||
@@ -51,7 +51,7 @@ describe('Agent package contracts', () => {
|
||||
it('binds each catalog archive name to its signed identity', () => {
|
||||
expect(agentPackageCatalogEntrySchema.parse(catalogEntry()))
|
||||
.toMatchObject({
|
||||
version: '0.11.2-e2e.13',
|
||||
version: '0.11.2',
|
||||
architecture: 'x64'
|
||||
})
|
||||
expect(() =>
|
||||
|
||||
@@ -96,6 +96,7 @@ import type {
|
||||
} from './speech-model-contracts'
|
||||
import type {
|
||||
EmbeddingDiagnosticResult,
|
||||
EmbeddingModelProgressSnapshot,
|
||||
EmbeddingModelSnapshot,
|
||||
EmbeddingSettingsSnapshot,
|
||||
KnowledgeEmbeddingIndexSnapshot
|
||||
@@ -1663,6 +1664,7 @@ export type DesktopApi = {
|
||||
}
|
||||
embeddings?: {
|
||||
getSnapshot: () => Promise<EmbeddingSettingsSnapshot>
|
||||
getModelProgress: () => Promise<EmbeddingModelProgressSnapshot>
|
||||
diagnose: (connectionId: string) => Promise<EmbeddingDiagnosticResult>
|
||||
setCurrent: (
|
||||
connectionId: string
|
||||
|
||||
@@ -155,6 +155,11 @@ export const embeddingModelSnapshotSchema = z
|
||||
export type EmbeddingModelSnapshot = z.infer<
|
||||
typeof embeddingModelSnapshotSchema
|
||||
>
|
||||
export const embeddingModelProgressSnapshotSchema =
|
||||
embeddingModelSnapshotSchema.pick({ operations: true })
|
||||
export type EmbeddingModelProgressSnapshot = z.infer<
|
||||
typeof embeddingModelProgressSnapshotSchema
|
||||
>
|
||||
|
||||
export const embeddingModelActionInputSchema = z
|
||||
.object({ modelId: embeddingModelIdSchema })
|
||||
|
||||
@@ -98,6 +98,7 @@ export const ipcChannels = {
|
||||
embeddingSettingsGet: 'settings:embedding:get',
|
||||
embeddingDiagnose: 'settings:embedding:diagnose',
|
||||
embeddingSetCurrent: 'settings:embedding:set-current',
|
||||
embeddingModelsProgress: 'settings:embedding-models:progress',
|
||||
embeddingModelsInstall: 'settings:embedding-models:install',
|
||||
embeddingModelsCancel: 'settings:embedding-models:cancel',
|
||||
embeddingModelsImportArchive:
|
||||
|
||||
Reference in New Issue
Block a user