From 9388eb79d45efce11c81ac47c6d2043bdef51eef Mon Sep 17 00:00:00 2001 From: mesalogo Date: Tue, 25 Aug 2026 19:22:51 +0800 Subject: [PATCH] fix: restore vector model download feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- FEATURES.md | 2 +- agent-runtime-lock.json | 2 +- .../remote-host-and-goodbuddy-agent-design.md | 4 +- src/main/ipc.test.ts | 10 +- src/main/ipc.ts | 11 + src/preload/index.ts | 5 + src/preload/preload-sandbox.test.ts | 1 + src/renderer/src/App.test.tsx | 2 +- .../src/DocumentParsingSettingsSection.tsx | 15 +- .../src/EmbeddingSettingsSection.test.tsx | 164 ++++++++++ src/renderer/src/EmbeddingSettingsSection.tsx | 286 ++++++++++++++++-- src/renderer/src/SettingsPanel.test.tsx | 125 +++++++- src/renderer/src/SettingsPanel.tsx | 159 ++++++++-- .../src/SpeechModelSettingsSection.tsx | 24 +- .../src/i18n/locales/en-US/settings.ts | 1 + .../i18n/locales/en-US/settingsSections.ts | 33 +- .../src/i18n/locales/zh-CN/settings.ts | 1 + .../i18n/locales/zh-CN/settingsSections.ts | 33 +- .../src/model-download-presentation.ts | 22 ++ src/shared/agent-package-contracts.test.ts | 4 +- src/shared/contracts.ts | 2 + src/shared/embedding-contracts.ts | 5 + src/shared/ipc-channels.ts | 1 + 23 files changed, 804 insertions(+), 108 deletions(-) create mode 100644 src/renderer/src/model-download-presentation.ts diff --git a/FEATURES.md b/FEATURES.md index a73c4e09..19afd442 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 初始化隔离。 diff --git a/agent-runtime-lock.json b/agent-runtime-lock.json index 6513ebda..6fa926fa 100644 --- a/agent-runtime-lock.json +++ b/agent-runtime-lock.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "agentVersion": "0.11.2-e2e.13", + "agentVersion": "0.11.2", "protocol": { "major": 2, "minor": 0 diff --git a/docs/architecture/remote-host-and-goodbuddy-agent-design.md b/docs/architecture/remote-host-and-goodbuddy-agent-design.md index 1bf5dd44..285dc62e 100644 --- a/docs/architecture/remote-host-and-goodbuddy-agent-design.md +++ b/docs/architecture/remote-host-and-goodbuddy-agent-design.md @@ -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 模型轮次请求一个原生 diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 0324c876..5f9df8f2 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -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) } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d4264bf8..1682ee34 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 584e9af5..7d9979a6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, + getModelProgress: () => + ipcRenderer.invoke( + ipcChannels.embeddingModelsProgress + ) as Promise, diagnose: (connectionId: string) => ipcRenderer.invoke( ipcChannels.embeddingDiagnose, diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index 93c4e3ca..20540617 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -65,6 +65,7 @@ describe('sandboxed preload', () => { /embeddings: \{(?[\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:') diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index cd0e2c07..677b26e8 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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' }, diff --git a/src/renderer/src/DocumentParsingSettingsSection.tsx b/src/renderer/src/DocumentParsingSettingsSection.tsx index 2973c239..2099bcff 100644 --- a/src/renderer/src/DocumentParsingSettingsSection.tsx +++ b/src/renderer/src/DocumentParsingSettingsSection.tsx @@ -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) => diff --git a/src/renderer/src/EmbeddingSettingsSection.test.tsx b/src/renderer/src/EmbeddingSettingsSection.test.tsx index d4e369d9..07e8e26e 100644 --- a/src/renderer/src/EmbeddingSettingsSection.test.tsx +++ b/src/renderer/src/EmbeddingSettingsSection.test.tsx @@ -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 { @@ -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: [ diff --git a/src/renderer/src/EmbeddingSettingsSection.tsx b/src/renderer/src/EmbeddingSettingsSection.tsx index 100149ff..b9597aba 100644 --- a/src/renderer/src/EmbeddingSettingsSection.tsx +++ b/src/renderer/src/EmbeddingSettingsSection.tsx @@ -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 (
{selected.kind === 'builtin' ? ( - <> -
-
{t('embedding.connections.modelLabel')}
-
{selected.model}
-
{t('embedding.connections.statusLabel')}
-
{selected.statusText}
-
-
+
+
+
+
+ {modelDisplayName} + {selectedModel?.recommended && ( + + {t('embedding.tags.recommended')} + + )} +
+ {selectedModel?.description && ( +

{selectedModel.description}

+ )} +
+ {models && ( + + {t( + `modelDownloadSources.${models.selectedDownloadSource}` + )} + + )} + + {selected.model} + + {selectedModel && ( + <> + + {t('embedding.metadata.dimensions', { + count: selectedModel.dimensions + })} + + + {selectedModel.quantization.toUpperCase()} + + + {t('embedding.metadata.contextTokens', { + count: selectedModel.contextTokens + })} + + + {modelSize + ? formatModelPackageBytes(modelSize) + : t('embedding.status.unknownSize')} + + + {selectedModel.license.name} + + + )} +
+
+
+ +
+ + {modelInstalled && ( + +
+ +
+ ) : modelInstalled ? ( + ) : ( <> - {!selected.installed && ( + {sourceAvailable && ( )} + {!sourceAvailable && + onOpenModelDownloadSourceSettings && ( + + )} - {selected.installed && ( - - )} )}
- + + {!sourceAvailable && selectedModel && ( +

+ {t('embedding.sourceUnavailableDescription', { + source: t( + `modelDownloadSources.${models?.selectedDownloadSource}` + ) + })} +

+ )} + + {operation && ( +
+ + + {operation.currentFile + ? t('embedding.operations.processingFile', { + file: operation.currentFile + }) + : operationLabel(operation, t) + '…'} + {percent === undefined + ? '' + : ' · ' + percent.toFixed(0) + '%'} + +
+ )} +
) : ( <>