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:
mesalogo
2026-08-25 19:22:51 +08:00
parent 500f7d2735
commit 9388eb79d4
23 changed files with 804 additions and 108 deletions
+8 -2
View File
@@ -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)
}
+11
View File
@@ -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) => {
+5
View File
@@ -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,
+1
View File
@@ -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:')
+1 -1
View File
@@ -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: [
+253 -33
View File
@@ -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">
+122 -3
View File
@@ -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
+129 -30
View File
@@ -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
}
+2 -2
View File
@@ -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(() =>
+2
View File
@@ -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
+5
View File
@@ -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 })
+1
View File
@@ -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: