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:
@@ -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