feat: add managed Granite embedding models

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

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

Release note: 新增可管理的内置 Granite 多语言向量模型,支持本地下载、离线导入、连接切换与诊断;同时优化向量设置及相关界面的文案、开关和状态显示。
This commit is contained in:
mesalogo
2026-08-23 11:26:44 +08:00
parent 58d774ae0c
commit 076b3aaa7b
90 changed files with 7820 additions and 1446 deletions
+120 -6
View File
@@ -9,6 +9,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
builtinEmbeddingConnectionId,
legacyEmbeddingConnectionId,
runtimeSettingsInputSchema,
type RuntimeSettingsInput
} from '../shared/contracts'
@@ -479,7 +481,7 @@ describe('RuntimeSettingsStore', () => {
const persisted = JSON.parse(
await readFile(filePath, 'utf8')
) as Record<string, unknown>
expect(persisted.version).toBe(18)
expect(persisted.version).toBe(19)
expect(persisted).not.toHaveProperty(
'deepseekHarnessBinaryPath'
)
@@ -758,7 +760,7 @@ describe('RuntimeSettingsStore', () => {
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
version: number
}
expect(persisted.version).toBe(18)
expect(persisted.version).toBe(19)
})
it('migrates version 11 and removes the obsolete intranet toggle', async () => {
@@ -778,7 +780,7 @@ describe('RuntimeSettingsStore', () => {
version: number
intranetCompatibilityEnabled?: boolean
}
expect(persisted.version).toBe(18)
expect(persisted.version).toBe(19)
expect(persisted).not.toHaveProperty('intranetCompatibilityEnabled')
})
@@ -947,6 +949,118 @@ describe('RuntimeSettingsStore', () => {
})
})
it('migrates version 18 embedding settings into a separate user connection', async () => {
const { filePath, store } = await createStore()
await store.update(
settings({
knowledgeEmbeddingEnabled: true,
knowledgeEmbeddingBaseUrl:
'https://legacy-vectors.example/v1/embeddings',
knowledgeEmbeddingModel: 'legacy/embed-large',
knowledgeEmbeddingApiKey: {
action: 'replace',
value: 'legacy-vector-secret'
}
})
)
const previous = JSON.parse(
await readFile(filePath, 'utf8')
) as Record<string, unknown>
previous.version = 18
delete previous.embeddingConnections
delete previous.activeEmbeddingConnectionId
await writeFile(filePath, JSON.stringify(previous), 'utf8')
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
await expect(migrated.getPublicSettings()).resolves.toMatchObject({
activeEmbeddingConnectionId: legacyEmbeddingConnectionId,
embeddingConnections: [
{
id: builtinEmbeddingConnectionId,
kind: 'builtin',
apiKeyConfigured: false,
credentialSource: 'none'
},
{
id: legacyEmbeddingConnectionId,
kind: 'openai-compatible',
baseUrl: 'https://legacy-vectors.example/v1/embeddings',
modelName: 'legacy/embed-large',
apiKeyConfigured: true,
credentialSource: 'encrypted'
}
],
knowledgeEmbeddingBaseUrl:
'https://legacy-vectors.example/v1/embeddings',
knowledgeEmbeddingModel: 'legacy/embed-large'
})
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
activeEmbeddingConnection: {
id: legacyEmbeddingConnectionId,
apiKey: 'legacy-vector-secret'
},
knowledgeEmbeddingApiKey: 'legacy-vector-secret'
})
})
it('round-trips embedding connections without assigning them to Runtime profiles', async () => {
const { filePath, store } = await createStore()
const connectionId = '00000000-0000-4000-8000-000000000081'
await store.update(
settings({
embeddingConnections: [
{
id: builtinEmbeddingConnectionId,
name: 'GoodBuddy 内置向量模型',
kind: 'builtin'
},
{
id: connectionId,
name: '团队向量模型',
kind: 'openai-compatible',
baseUrl: 'https://team-vectors.example/v1/embeddings',
modelName: 'team/embed-v2',
authentication: 'api-key',
apiKey: {
action: 'replace',
value: 'team-vector-secret'
}
}
],
activeEmbeddingConnectionId: connectionId
})
)
const reloaded = new RuntimeSettingsStore(filePath, cipher, {})
await expect(reloaded.getResolvedSettings()).resolves.toMatchObject({
activeEmbeddingConnectionId: connectionId,
activeEmbeddingConnection: {
id: connectionId,
kind: 'openai-compatible',
apiKey: 'team-vector-secret'
},
modelProfiles: [
expect.not.objectContaining({ id: connectionId })
]
})
const publicSettings = await reloaded.getPublicSettings()
expect(JSON.stringify(publicSettings)).not.toContain(
'team-vector-secret'
)
expect(publicSettings.embeddingConnections).toContainEqual(
expect.objectContaining({
id: connectionId,
apiKeyConfigured: true,
credentialSource: 'encrypted'
})
)
expect(
(JSON.parse(await readFile(filePath, 'utf8')) as {
version: number
}).version
).toBe(19)
})
it('migrates version 13 with reranking disabled by default', async () => {
const { filePath, store } = await createStore()
await store.update(settings())
@@ -1451,7 +1565,7 @@ describe('RuntimeSettingsStore', () => {
version: number
modelProfiles: Array<Record<string, unknown>>
}
expect(persisted.version).toBe(18)
expect(persisted.version).toBe(19)
expect(persisted.modelProfiles).toContainEqual(
expect.objectContaining({
id: imageId,
@@ -1750,7 +1864,7 @@ describe('RuntimeSettingsStore', () => {
unknown
>
expect(saved).toMatchObject({
version: 18,
version: 19,
provider: 'model',
continueBinaryPath: '',
continueMode: 'chat',
@@ -2029,7 +2143,7 @@ describe('RuntimeSettingsStore', () => {
version: number
modelProfiles: Array<Record<string, unknown>>
}
expect(persisted.version).toBe(18)
expect(persisted.version).toBe(19)
expect(persisted.modelProfiles[0]).not.toHaveProperty('credential')
})