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:
+154
-1
@@ -96,6 +96,7 @@ import type {
|
||||
} from './speech-model-contracts'
|
||||
import type {
|
||||
EmbeddingDiagnosticResult,
|
||||
EmbeddingModelSnapshot,
|
||||
EmbeddingSettingsSnapshot,
|
||||
KnowledgeEmbeddingIndexSnapshot
|
||||
} from './embedding-contracts'
|
||||
@@ -387,6 +388,11 @@ export type ImageGenerationQuality = z.infer<
|
||||
export const defaultModelProfileId =
|
||||
'00000000-0000-4000-8000-000000000001'
|
||||
export const modelProfileIdSchema = z.string().uuid()
|
||||
export const builtinEmbeddingConnectionId =
|
||||
'00000000-0000-4000-8000-000000000002'
|
||||
export const legacyEmbeddingConnectionId =
|
||||
'00000000-0000-4000-8000-000000000003'
|
||||
export const embeddingConnectionIdSchema = z.string().uuid()
|
||||
|
||||
export const contextCompressionModelSourceSchema =
|
||||
z.discriminatedUnion('kind', [
|
||||
@@ -554,6 +560,35 @@ const modelProfileInputSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
const embeddingConnectionInputSchema = z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
id: z.literal(builtinEmbeddingConnectionId),
|
||||
name: z.string().trim().min(1).max(64),
|
||||
kind: z.literal('builtin')
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
id: embeddingConnectionIdSchema.refine(
|
||||
(id) => id !== builtinEmbeddingConnectionId,
|
||||
'系统向量连接 ID 不能用于用户连接'
|
||||
),
|
||||
name: z.string().trim().min(1).max(64),
|
||||
kind: z.literal('openai-compatible'),
|
||||
baseUrl: z.string().url().max(2_048),
|
||||
modelName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(/^[\w./:-]+$/, '向量模型名称包含不支持的字符'),
|
||||
authentication: modelAuthenticationSchema,
|
||||
apiKey: modelApiKeyUpdateSchema
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
export const runtimeModelSourceSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('platform') }).strict(),
|
||||
z
|
||||
@@ -597,6 +632,13 @@ export const runtimeSettingsInputSchema = z
|
||||
.max(256)
|
||||
.regex(/^[\w./:-]+$/, '向量模型名称包含不支持的字符'),
|
||||
knowledgeEmbeddingApiKey: modelApiKeyUpdateSchema.optional(),
|
||||
embeddingConnections: z
|
||||
.array(embeddingConnectionInputSchema)
|
||||
.min(1)
|
||||
.max(20)
|
||||
.optional(),
|
||||
activeEmbeddingConnectionId:
|
||||
embeddingConnectionIdSchema.optional(),
|
||||
knowledgeRerankEnabled: z.boolean(),
|
||||
knowledgeRerankEndpoint: z.string().url().max(2_048),
|
||||
knowledgeRerankModel: z
|
||||
@@ -797,6 +839,76 @@ export const runtimeSettingsInputSchema = z
|
||||
message: '向量接口 URL 必须使用 HTTP 或 HTTPS'
|
||||
})
|
||||
}
|
||||
if (settings.embeddingConnections) {
|
||||
const ids = new Set(
|
||||
settings.embeddingConnections.map((connection) => connection.id)
|
||||
)
|
||||
const names = new Set(
|
||||
settings.embeddingConnections.map((connection) =>
|
||||
connection.name.toLowerCase()
|
||||
)
|
||||
)
|
||||
if (
|
||||
ids.size !== settings.embeddingConnections.length ||
|
||||
names.size !== settings.embeddingConnections.length
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['embeddingConnections'],
|
||||
message: '向量连接的 ID 和名称必须唯一'
|
||||
})
|
||||
}
|
||||
if (
|
||||
!settings.embeddingConnections.some(
|
||||
(connection) =>
|
||||
connection.id === builtinEmbeddingConnectionId &&
|
||||
connection.kind === 'builtin'
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['embeddingConnections'],
|
||||
message: '系统向量连接不能删除'
|
||||
})
|
||||
}
|
||||
for (const [index, connection] of
|
||||
settings.embeddingConnections.entries()) {
|
||||
if (connection.kind === 'builtin') {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(
|
||||
new URL(connection.baseUrl).protocol
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['embeddingConnections', index, 'baseUrl'],
|
||||
message: '向量接口 URL 必须使用 HTTP 或 HTTPS'
|
||||
})
|
||||
}
|
||||
if (
|
||||
connection.authentication === 'none' &&
|
||||
connection.apiKey.action === 'replace'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['embeddingConnections', index, 'apiKey'],
|
||||
message: '无认证向量连接不得配置 API Key'
|
||||
})
|
||||
}
|
||||
}
|
||||
const activeId =
|
||||
settings.activeEmbeddingConnectionId ??
|
||||
builtinEmbeddingConnectionId
|
||||
if (!ids.has(activeId)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['activeEmbeddingConnectionId'],
|
||||
message: '当前向量连接不存在'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(
|
||||
new URL(settings.knowledgeRerankEndpoint).protocol
|
||||
@@ -828,6 +940,33 @@ export type ModelConnectionSettings = {
|
||||
credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
|
||||
}
|
||||
|
||||
export type EmbeddingConnectionSettings =
|
||||
| {
|
||||
id: typeof builtinEmbeddingConnectionId
|
||||
name: string
|
||||
kind: 'builtin'
|
||||
apiKeyConfigured?: false
|
||||
credentialSource?: 'none'
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
name: string
|
||||
kind: 'openai-compatible'
|
||||
baseUrl: string
|
||||
modelName: string
|
||||
authentication: ModelAuthentication
|
||||
apiKeyConfigured?: boolean
|
||||
credentialSource?:
|
||||
| 'none'
|
||||
| 'encrypted'
|
||||
| 'environment'
|
||||
| 'unreadable'
|
||||
apiKey?:
|
||||
| { action: 'keep' }
|
||||
| { action: 'replace'; value: string }
|
||||
| { action: 'clear' }
|
||||
}
|
||||
|
||||
export type ConfiguredRuntimeSettings = {
|
||||
modelProfiles: ModelConnectionSettings[]
|
||||
opencodeBaseUrl: string
|
||||
@@ -866,6 +1005,8 @@ export type RuntimeSettings = {
|
||||
| 'encrypted'
|
||||
| 'environment'
|
||||
| 'unreadable'
|
||||
embeddingConnections?: EmbeddingConnectionSettings[]
|
||||
activeEmbeddingConnectionId?: string
|
||||
knowledgeRerankEnabled?: boolean
|
||||
knowledgeRerankEndpoint?: string
|
||||
knowledgeRerankModel?: string
|
||||
@@ -1448,7 +1589,19 @@ export type DesktopApi = {
|
||||
}
|
||||
embeddings?: {
|
||||
getSnapshot: () => Promise<EmbeddingSettingsSnapshot>
|
||||
diagnose: () => Promise<EmbeddingDiagnosticResult>
|
||||
diagnose: (connectionId: string) => Promise<EmbeddingDiagnosticResult>
|
||||
setCurrent: (
|
||||
connectionId: string
|
||||
) => Promise<EmbeddingSettingsSnapshot>
|
||||
installModel: (
|
||||
modelId: string,
|
||||
expectedDownloadSource: ModelDownloadSource
|
||||
) => Promise<EmbeddingModelSnapshot>
|
||||
cancelModelOperation: (modelId: string) => Promise<boolean>
|
||||
importModelArchive: (
|
||||
modelId: string
|
||||
) => Promise<EmbeddingModelSnapshot | undefined>
|
||||
removeModel: (modelId: string) => Promise<EmbeddingModelSnapshot>
|
||||
}
|
||||
documentParsing?: {
|
||||
getSnapshot: () => Promise<DocumentParsingSnapshot>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
embeddingDiagnosticResultSchema,
|
||||
embeddingIndexJobSchema,
|
||||
embeddingIndexStatusSchema,
|
||||
embeddingSettingsSnapshotSchema,
|
||||
knowledgeEmbeddingIndexSnapshotSchema,
|
||||
embeddingSafeErrorSchema,
|
||||
isEmbeddingIndexJobActive
|
||||
@@ -66,6 +67,48 @@ describe('embedding contracts', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps credentials, vectors, and Main-only paths out of settings snapshots', () => {
|
||||
const snapshot = {
|
||||
configuration: {
|
||||
provider: 'builtin',
|
||||
model: 'granite-embedding',
|
||||
credentialConfigured: false
|
||||
},
|
||||
connections: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000002',
|
||||
name: 'GoodBuddy built-in',
|
||||
kind: 'builtin' as const,
|
||||
model: 'granite-embedding',
|
||||
credentialConfigured: false
|
||||
}
|
||||
],
|
||||
currentConnectionId:
|
||||
'00000000-0000-4000-8000-000000000002',
|
||||
models: {
|
||||
selectedDownloadSource: 'modelscope' as const,
|
||||
catalog: [],
|
||||
installed: [],
|
||||
operations: []
|
||||
}
|
||||
}
|
||||
expect(
|
||||
embeddingSettingsSnapshotSchema.safeParse(snapshot).success
|
||||
).toBe(true)
|
||||
for (const forbidden of [
|
||||
{ apiKey: 'secret' },
|
||||
{ vectors: [[0.1, 0.2]] },
|
||||
{ modelDirectory: 'C:\\private\\embedding' }
|
||||
]) {
|
||||
expect(
|
||||
embeddingSettingsSnapshotSchema.safeParse({
|
||||
...snapshot,
|
||||
...forbidden
|
||||
}).success
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('validates queued, running and terminal index jobs', () => {
|
||||
const base = {
|
||||
id: 'job-1',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
MODEL_DOWNLOAD_SOURCES,
|
||||
modelDownloadAvailabilitySchema,
|
||||
modelDownloadSourceSchema
|
||||
} from './model-download-contracts'
|
||||
|
||||
const boundedLabelSchema = z.string().trim().min(1).max(256)
|
||||
const timestampSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
@@ -48,6 +53,120 @@ export type EmbeddingConfigurationSummary = z.infer<
|
||||
typeof embeddingConfigurationSummarySchema
|
||||
>
|
||||
|
||||
export const embeddingConnectionIdRequestSchema = z
|
||||
.object({
|
||||
connectionId: z.string().uuid()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const embeddingConnectionSummarySchema = z
|
||||
.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().trim().min(1).max(64),
|
||||
kind: z.enum(['builtin', 'openai-compatible']),
|
||||
model: boundedLabelSchema,
|
||||
endpoint: safeProviderEndpointSchema.optional(),
|
||||
authentication: z.enum(['api-key', 'none']).optional(),
|
||||
credentialConfigured: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
export type EmbeddingConnectionSummary = z.infer<
|
||||
typeof embeddingConnectionSummarySchema
|
||||
>
|
||||
|
||||
export const embeddingModelIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(96)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u)
|
||||
|
||||
const embeddingModelFileSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
role: z.enum([
|
||||
'model',
|
||||
'tokenizer',
|
||||
'tokenizer-configuration',
|
||||
'configuration',
|
||||
'license'
|
||||
]),
|
||||
size: z.number().int().positive().safe(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const embeddingModelOperationSchema = z
|
||||
.object({
|
||||
modelId: embeddingModelIdSchema,
|
||||
kind: z.enum(['download', 'import']),
|
||||
phase: z.enum(['preparing', 'transferring', 'installing']),
|
||||
currentFile: z.string().min(1).max(255).nullable(),
|
||||
completedBytes: z.number().int().nonnegative().safe(),
|
||||
totalBytes: z.number().int().positive().safe().nullable(),
|
||||
downloadSource: modelDownloadSourceSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const embeddingModelSnapshotSchema = z
|
||||
.object({
|
||||
selectedDownloadSource: modelDownloadSourceSchema,
|
||||
catalog: z.array(
|
||||
z
|
||||
.object({
|
||||
id: embeddingModelIdSchema,
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(1_000),
|
||||
languages: z.array(z.string().trim().min(1).max(80)).min(1).max(32),
|
||||
runtime: z.literal('onnxruntime-web/wasm'),
|
||||
dimensions: z.number().int().positive().max(65_536),
|
||||
contextTokens: z.number().int().positive().max(10_000_000),
|
||||
quantization: z.string().trim().min(1).max(40),
|
||||
recommended: z.boolean(),
|
||||
available: z.boolean(),
|
||||
unavailableReason: z.string().trim().min(1).max(1_000).optional(),
|
||||
license: z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
notice: z.string().trim().min(1).max(1_000),
|
||||
url: z.url()
|
||||
})
|
||||
.strict(),
|
||||
files: z.array(embeddingModelFileSchema).min(1).max(32),
|
||||
downloadAvailability: z
|
||||
.array(modelDownloadAvailabilitySchema)
|
||||
.length(MODEL_DOWNLOAD_SOURCES.length)
|
||||
})
|
||||
.strict()
|
||||
),
|
||||
installed: z.array(
|
||||
z
|
||||
.object({
|
||||
id: embeddingModelIdSchema,
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
source: z.enum(['download', 'local']),
|
||||
installedAt: z.string().datetime(),
|
||||
files: z.array(embeddingModelFileSchema).min(1).max(32)
|
||||
})
|
||||
.strict()
|
||||
),
|
||||
operations: z.array(embeddingModelOperationSchema)
|
||||
})
|
||||
.strict()
|
||||
export type EmbeddingModelSnapshot = z.infer<
|
||||
typeof embeddingModelSnapshotSchema
|
||||
>
|
||||
|
||||
export const embeddingModelActionInputSchema = z
|
||||
.object({ modelId: embeddingModelIdSchema })
|
||||
.strict()
|
||||
|
||||
export const embeddingModelInstallInputSchema = z
|
||||
.object({
|
||||
modelId: embeddingModelIdSchema,
|
||||
expectedDownloadSource: modelDownloadSourceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const embeddingDiagnosticBase = {
|
||||
provider: boundedLabelSchema,
|
||||
model: boundedLabelSchema,
|
||||
@@ -180,7 +299,10 @@ export type EmbeddingIndexStatus = z.infer<
|
||||
|
||||
export const embeddingSettingsSnapshotSchema = z
|
||||
.object({
|
||||
configuration: embeddingConfigurationSummarySchema
|
||||
configuration: embeddingConfigurationSummarySchema,
|
||||
connections: z.array(embeddingConnectionSummarySchema),
|
||||
currentConnectionId: z.string().uuid(),
|
||||
models: embeddingModelSnapshotSchema
|
||||
})
|
||||
.strict()
|
||||
export type EmbeddingSettingsSnapshot = z.infer<
|
||||
|
||||
@@ -67,6 +67,12 @@ export const ipcChannels = {
|
||||
speechTranscriptionCancel: 'speech:transcription:cancel',
|
||||
embeddingSettingsGet: 'settings:embedding:get',
|
||||
embeddingDiagnose: 'settings:embedding:diagnose',
|
||||
embeddingSetCurrent: 'settings:embedding:set-current',
|
||||
embeddingModelsInstall: 'settings:embedding-models:install',
|
||||
embeddingModelsCancel: 'settings:embedding-models:cancel',
|
||||
embeddingModelsImportArchive:
|
||||
'settings:embedding-models:import-archive',
|
||||
embeddingModelsRemove: 'settings:embedding-models:remove',
|
||||
documentParsingGet: 'settings:document-parsing:get',
|
||||
documentOcrModelsProgress:
|
||||
'settings:document-ocr-models:progress',
|
||||
|
||||
Reference in New Issue
Block a user