diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index fa77fbff..76fe5d26 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -23,10 +23,23 @@ files: - '!node_modules/@node-rs/jieba/idf.txt' - '!node_modules/@node-rs/jieba/dict.js' - '!node_modules/@node-rs/jieba/dict.d.ts' + # onnxruntime-node 自带 darwin/linux/win32 全平台全架构原生二进制(合计 ~210M)。 + # 只保留当前构建目标 ${platform}/${arch} 一份,避免 mac 包含 Linux/Windows 二进制(反之亦然)。 + # 先排除整组 bin/napi-v6,再按目标平台/架构重新包含;electron-builder 后写规则覆盖先写规则。 + # ${platform} -> darwin/linux/win32,${arch} -> x64/arm64,正好匹配该包目录结构。 + - '!node_modules/onnxruntime-node/bin/napi-v6/**' + - 'node_modules/onnxruntime-node/bin/napi-v6/${platform}/${arch}/**' # 哪些文件将不会被压缩,而是解压到构建目录 +# 仅含 .dylib(无 .node)的原生包不会被 electron-builder 自动解包,必须显式列出, +# 否则 dlopen 无法从 asar 内加载: +# - sqlite-vec*:vec0.dylib(SQLite 扩展) +# - onnxruntime-node:onnxruntime_binding.node + libonnxruntime.*.dylib(本地向量模型推理) +# - @img/**:sharp 原生绑定与 libvips-cpp.dylib(transformers 本地模型 eager require sharp) asarUnpack: - resources/** - node_modules/sqlite-vec*/** + - node_modules/onnxruntime-node/** + - node_modules/@img/** # Windows 平台配置 win: diff --git a/apps/desktop/main/ai/tools/debug-executor.ts b/apps/desktop/main/ai/tools/debug-executor.ts index 6fda005e..bd03ecff 100644 --- a/apps/desktop/main/ai/tools/debug-executor.ts +++ b/apps/desktop/main/ai/tools/debug-executor.ts @@ -1,69 +1,30 @@ -import { stripAvatarFields } from '@openchatlab/core' import type { BatchSegmentOptions, SupportedLocale } from '@openchatlab/core' import type { AiToolExecuteRequest, AiToolExecuteResult } from '@openchatlab/http-routes' import { batchSegmentWithFrequency } from '@openchatlab/node-runtime' -import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' -import type { ToolExecutionContext } from '@openchatlab/tools' +import type { SemanticSearchToolService } from '@openchatlab/tools' import { t as i18nT } from '../../i18n' import { WorkerDataProvider } from './worker-data-provider' +import { executeRegistryTool } from './tool-executor-core' -const MAX_RESULT_CHARS = 500_000 - -function assertNotAborted(signal: AbortSignal): void { - if (signal.aborted) { - throw new Error('cancelled') - } -} - -export async function executeElectronAiTool(params: AiToolExecuteRequest): Promise { - const { toolName, params: toolParams, sessionId, abortSignal } = params - const entry = AGENT_TOOL_REGISTRY.find((tool) => tool.name === toolName) - if (!entry) { - return { success: false, error: `Tool not found: ${toolName}` } - } - - try { - assertNotAborted(abortSignal) - const execCtx: ToolExecutionContext = { - sessionId, - abortSignal, - dataProvider: new WorkerDataProvider(sessionId, abortSignal), +/** + * 构造 Electron 手动调试用的 AI 工具执行器。 + * + * 注入 semanticIndexService,使手动执行语义检索工具与正常 Agent stream 行为一致; + * 缺失时(向量库不可用)语义工具会优雅返回不可用。其余依赖(Worker 数据访问、分词、 + * i18n 模板)为 Electron 平台特有,统一在此装配后委托给平台无关的执行核心。 + */ +export function createExecuteElectronAiTool( + semanticIndexService?: SemanticSearchToolService +): (params: AiToolExecuteRequest) => Promise { + return (params) => + executeRegistryTool(params, { + dataProvider: new WorkerDataProvider(params.sessionId, params.abortSignal), + semanticIndexService, segmentText: (texts, locale, options) => batchSegmentWithFrequency(texts, locale as SupportedLocale, options as BatchSegmentOptions), translateTemplate: (key: string) => { const translated = i18nT(key) return translated !== key ? translated : undefined }, - } - - const startTime = Date.now() - const result = await entry.handler(toolParams, execCtx) - const elapsed = Date.now() - startTime - assertNotAborted(abortSignal) - - let details = (result.data as Record | undefined) ?? undefined - let truncated = false - - if (details) { - stripAvatarFields(details) - const raw = JSON.stringify(details) - if (raw.length > MAX_RESULT_CHARS) { - truncated = true - details = { _truncated: true, _originalSize: raw.length, _preview: raw.slice(0, MAX_RESULT_CHARS) } - } - } - - return { - success: true, - elapsed, - content: [{ type: 'text', text: result.content }], - details, - truncated, - } - } catch (error) { - if (abortSignal.aborted) { - return { success: false, error: 'cancelled' } - } - return { success: false, error: error instanceof Error ? error.message : String(error) } - } + }) } diff --git a/apps/desktop/main/ai/tools/tool-executor-core.test.ts b/apps/desktop/main/ai/tools/tool-executor-core.test.ts new file mode 100644 index 00000000..453f2950 --- /dev/null +++ b/apps/desktop/main/ai/tools/tool-executor-core.test.ts @@ -0,0 +1,69 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import type { SemanticSearchToolResult, SemanticSearchToolService } from '@openchatlab/tools' +import type { AiToolExecuteRequest } from '@openchatlab/http-routes' +import { buildToolExecutionContext, executeRegistryTool } from './tool-executor-core' + +function makeRequest(overrides: Partial = {}): AiToolExecuteRequest { + return { + testId: 't1', + toolName: 'semantic_search_current_chat', + params: { query: '排期' }, + sessionId: 'sess-1', + abortSignal: new AbortController().signal, + ...overrides, + } +} + +describe('tool-executor-core context injection', () => { + it('buildToolExecutionContext forwards semanticIndexService and request identity', () => { + const service = { + canSearch: () => true, + searchForTool: async () => ({}) as unknown as SemanticSearchToolResult, + } satisfies SemanticSearchToolService + const params = makeRequest() + const ctx = buildToolExecutionContext(params, { semanticIndexService: service }) + assert.equal(ctx.sessionId, 'sess-1') + assert.equal(ctx.abortSignal, params.abortSignal) + assert.equal(ctx.semanticIndexService, service) + }) + + it('manual execution reaches the injected semanticIndexService', async () => { + const calls: Array<{ sessionId: string; query: string }> = [] + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async (sessionId, query) => { + calls.push({ sessionId, query }) + return { + available: true, + text: 'evidence-block', + returned: 1, + hitCount: 1, + partial: false, + coverage: 1, + truncated: false, + sources: [], + } + }, + } + + const result = await executeRegistryTool(makeRequest(), { semanticIndexService: service }) + + assert.equal(result.success, true) + assert.deepEqual(calls, [{ sessionId: 'sess-1', query: '排期' }]) + assert.ok(result.content?.[0]?.text.includes('evidence-block')) + }) + + it('semantic tool degrades gracefully when service is not injected', async () => { + const result = await executeRegistryTool(makeRequest(), {}) + assert.equal(result.success, true) + assert.ok(result.content?.[0]?.text.toLowerCase().includes('not available')) + }) + + it('returns an error for unknown tools', async () => { + const result = await executeRegistryTool(makeRequest({ toolName: 'no_such_tool' }), {}) + assert.equal(result.success, false) + assert.match(result.error ?? '', /Tool not found/) + }) +}) diff --git a/apps/desktop/main/ai/tools/tool-executor-core.ts b/apps/desktop/main/ai/tools/tool-executor-core.ts new file mode 100644 index 00000000..207b4f9c --- /dev/null +++ b/apps/desktop/main/ai/tools/tool-executor-core.ts @@ -0,0 +1,89 @@ +/** + * 平台无关的 AI 工具执行核心 + * + * 从注册表查找工具、在注入的上下文上执行,并统一处理取消、超大结果截断与错误映射。 + * 不依赖 Electron / Worker,便于单测验证上下文注入(尤其是 semanticIndexService)。 + */ + +import { stripAvatarFields } from '@openchatlab/core' +import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' +import type { ToolDataProvider, ToolExecutionContext, SemanticSearchToolService } from '@openchatlab/tools' +import type { AiToolExecuteRequest, AiToolExecuteResult } from '@openchatlab/http-routes' + +const MAX_RESULT_CHARS = 500_000 + +/** 工具执行依赖:由平台注入。注入 semanticIndexService 后,手动执行也能命中语义检索工具。 */ +export interface AiToolExecutionDeps { + dataProvider?: ToolDataProvider + semanticIndexService?: SemanticSearchToolService + segmentText?: ToolExecutionContext['segmentText'] + translateTemplate?: ToolExecutionContext['translateTemplate'] +} + +function assertNotAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw new Error('cancelled') + } +} + +/** 组装工具执行上下文:sessionId/abortSignal 来自请求,其余能力由平台注入。 */ +export function buildToolExecutionContext( + params: AiToolExecuteRequest, + deps: AiToolExecutionDeps +): ToolExecutionContext { + return { + sessionId: params.sessionId, + abortSignal: params.abortSignal, + dataProvider: deps.dataProvider, + semanticIndexService: deps.semanticIndexService, + segmentText: deps.segmentText, + translateTemplate: deps.translateTemplate, + } +} + +/** 在注入的上下文上执行注册表工具,处理取消、超大结果截断与错误映射。 */ +export async function executeRegistryTool( + params: AiToolExecuteRequest, + deps: AiToolExecutionDeps +): Promise { + const { toolName, params: toolParams, abortSignal } = params + const entry = AGENT_TOOL_REGISTRY.find((tool) => tool.name === toolName) + if (!entry) { + return { success: false, error: `Tool not found: ${toolName}` } + } + + try { + assertNotAborted(abortSignal) + const execCtx = buildToolExecutionContext(params, deps) + + const startTime = Date.now() + const result = await entry.handler(toolParams, execCtx) + const elapsed = Date.now() - startTime + assertNotAborted(abortSignal) + + let details = (result.data as Record | undefined) ?? undefined + let truncated = false + + if (details) { + stripAvatarFields(details) + const raw = JSON.stringify(details) + if (raw.length > MAX_RESULT_CHARS) { + truncated = true + details = { _truncated: true, _originalSize: raw.length, _preview: raw.slice(0, MAX_RESULT_CHARS) } + } + } + + return { + success: true, + elapsed, + content: [{ type: 'text', text: result.content }], + details, + truncated, + } + } catch (error) { + if (abortSignal.aborted) { + return { success: false, error: 'cancelled' } + } + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/apps/desktop/main/internal-api.ts b/apps/desktop/main/internal-api.ts index 1801bf28..22802b6a 100644 --- a/apps/desktop/main/internal-api.ts +++ b/apps/desktop/main/internal-api.ts @@ -26,6 +26,7 @@ import { createSemanticIndexService, } from '@openchatlab/node-runtime' import type { StreamImportDeps, SemanticIndexService } from '@openchatlab/node-runtime' +import { getLoadablePath as getSqliteVecLoadablePath } from 'sqlite-vec' import multipart from '@fastify/multipart' import type { ConfigStorage } from '@openchatlab/node-runtime' import { @@ -42,7 +43,7 @@ import { getManager as getAIChatManager } from './ai/chats' import { getManager as getAssistantManager } from './ai/assistant/manager' import { getManager as getSkillManager } from './ai/skills/manager' import { createElectronRunAgentStream } from './ai/agent-stream-runner' -import { executeElectronAiTool } from './ai/tools/debug-executor' +import { createExecuteElectronAiTool } from './ai/tools/debug-executor' import { assertDesktopDataDirCompatible, getDesktopAppVersion } from './runtime-compat' export interface InternalEndpoint { @@ -58,6 +59,16 @@ let semanticIndexService: SemanticIndexService | null = null const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50 MB +/** + * 加载 sqlite-vec 原生扩展。打包后 require.resolve 返回的是 app.asar 内路径, + * 而 loadExtension -> dlopen 无法读取 asar,需指向 asarUnpack 解包出的真实路径。 + * 开发态路径不含 app.asar,replace 为空操作。 + */ +function loadSqliteVec(db: { loadExtension(file: string): void }): void { + const loadablePath = getSqliteVecLoadablePath().replace('app.asar', 'app.asar.unpacked') + db.loadExtension(loadablePath) +} + function createFileConfigStorage(aiDataDir: string): ConfigStorage { return { readJson(key: string): T | null { @@ -111,7 +122,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise string + writeAuthProfile?: (name: string, profile: AuthProfile) => void + /** Cache/storage — platform-specific (optional) */ openDirectory?: (dirPath: string) => Promise showInFolder?: (filePath: string) => Promise diff --git a/packages/http-routes/src/routes/web/ai-semantic-index.test.ts b/packages/http-routes/src/routes/web/ai-semantic-index.test.ts index faa7c655..808dd086 100644 --- a/packages/http-routes/src/routes/web/ai-semantic-index.test.ts +++ b/packages/http-routes/src/routes/web/ai-semantic-index.test.ts @@ -1,7 +1,12 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' import Fastify, { type FastifyInstance } from 'fastify' import type { SemanticIndexService } from '@openchatlab/node-runtime' +import { SEMANTIC_INDEX_CONFIG_FILE } from '@openchatlab/node-runtime' +import type { AuthProfile } from '@openchatlab/config' import type { HttpRouteContext } from '../../context' import { registerSemanticIndexRoutes } from './ai-semantic-index' @@ -132,3 +137,67 @@ describe('semantic-index routes', () => { await bare.close() }) }) + +// 向量库不可用(service 缺失)但 aiDataDir 存在时,仍注册「仅配置」降级路由。 +// 用注入的内存 auth-profile 读写验证 API Key 落引用且不写真实 ~/.chatlab。 +describe('semantic-index routes (degraded: service absent, aiDataDir present)', () => { + const SECRET = 'sk-degraded-secret' + const AUTH_PROFILE = 'semantic-index-embedding' + const authProfiles = new Map() + let app: FastifyInstance + let aiDataDir: string + + before(async () => { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + aiDataDir = fs.mkdtempSync(path.join(baseDir, 'chatlab-si-route-')) + app = Fastify() + const ctx = { + // semanticIndexService intentionally omitted -> degraded config-only routes + aiDataDir, + resolveApiKey: (_provider: string, authProfile?: string) => + authProfile ? (authProfiles.get(authProfile)?.key ?? '') : '', + writeAuthProfile: (name: string, profile: AuthProfile) => authProfiles.set(name, profile), + } as unknown as HttpRouteContext + registerSemanticIndexRoutes(app, ctx) + await app.ready() + }) + + after(async () => { + await app.close() + fs.rmSync(aiDataDir, { recursive: true, force: true }) + }) + + it('PUT API config + apiKey saves key by reference and reports apiKeySet', async () => { + const config = { + version: 1, + mode: 'api', + local: { modelId: '' }, + api: { baseUrl: 'https://emb.example', model: 'text-embedding-3-small' }, + } + const resp = await app.inject({ + method: 'PUT', + url: '/_web/ai/semantic-index/config', + payload: { config, apiKey: SECRET }, + }) + assert.equal(resp.statusCode, 200) + const body = resp.json() + assert.equal(body.config.api.authProfile, AUTH_PROFILE) + assert.equal(body.apiKeySet, true) + assert.equal(authProfiles.get(AUTH_PROFILE)?.key, SECRET) + assert.ok(!JSON.stringify(body.config).includes(SECRET)) + }) + + it('GET config reads back the same authProfile and apiKeySet', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/ai/semantic-index/config' }) + assert.equal(resp.statusCode, 200) + const body = resp.json() + assert.equal(body.config.api.authProfile, AUTH_PROFILE) + assert.equal(body.apiKeySet, true) + }) + + it('persisted config file never contains the plaintext apiKey', () => { + const raw = fs.readFileSync(path.join(aiDataDir, SEMANTIC_INDEX_CONFIG_FILE), 'utf-8') + assert.ok(!raw.includes(SECRET)) + assert.equal(JSON.parse(raw).api.authProfile, AUTH_PROFILE) + }) +}) diff --git a/packages/http-routes/src/routes/web/ai-semantic-index.ts b/packages/http-routes/src/routes/web/ai-semantic-index.ts index 5f17ee26..09af53bf 100644 --- a/packages/http-routes/src/routes/web/ai-semantic-index.ts +++ b/packages/http-routes/src/routes/web/ai-semantic-index.ts @@ -13,6 +13,8 @@ import { SemanticIndexConfigStore, SEMANTIC_INDEX_CONFIG_FILE, isSemanticIndexConfigured, + persistSemanticIndexConfig, + resolveSemanticIndexApiKeySet, } from '@openchatlab/node-runtime' export function registerSemanticIndexRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { @@ -23,15 +25,24 @@ export function registerSemanticIndexRoutes(server: FastifyInstance, ctx: HttpRo if (!service) { if (ctx.aiDataDir) { const configStore = new SemanticIndexConfigStore(path.join(ctx.aiDataDir, SEMANTIC_INDEX_CONFIG_FILE)) - server.get('/_web/ai/semantic-index/config', async () => ({ - config: configStore.get(), - apiKeySet: false, - configured: isSemanticIndexConfigured(configStore.get()), - })) - server.put<{ Body: { config: SemanticIndexConfig } }>('/_web/ai/semantic-index/config', async (request) => { - const config = configStore.set(request.body.config) - return { config, apiKeySet: false, configured: isSemanticIndexConfigured(config) } + const configResponse = (config: SemanticIndexConfig) => ({ + config, + apiKeySet: resolveSemanticIndexApiKeySet(config, ctx.resolveApiKey), + configured: isSemanticIndexConfigured(config), }) + server.get('/_web/ai/semantic-index/config', async () => configResponse(configStore.get())) + // 向量库不可用时仍允许写配置与 API Key(key 落 auth-profiles,不依赖向量库), + // 否则 API 模式在降级期无法保存 key,恢复后会出现"已配置但无法检索"。 + server.put<{ Body: { config: SemanticIndexConfig; apiKey?: string } }>( + '/_web/ai/semantic-index/config', + async (request) => { + const config = persistSemanticIndexConfig(configStore, request.body.config, { + apiKey: request.body.apiKey, + writeAuthProfile: ctx.writeAuthProfile, + }) + return configResponse(config) + } + ) } server.get('/_web/ai/semantic-index/enabled', async () => ({ sessions: [] })) server.get('/_web/ai/semantic-index/status', async () => ({ status: null })) diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 14f15b66..c1b7cf6c 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -355,6 +355,8 @@ export { SemanticIndexConfigStore, SEMANTIC_INDEX_CONFIG_FILE, isSemanticIndexConfigured, + persistSemanticIndexConfig, + resolveSemanticIndexApiKeySet, } from './semantic-index' export type { SemanticIndexServiceOptions, diff --git a/packages/node-runtime/src/semantic-index/index.ts b/packages/node-runtime/src/semantic-index/index.ts index ea50c096..4bafc0e8 100644 --- a/packages/node-runtime/src/semantic-index/index.ts +++ b/packages/node-runtime/src/semantic-index/index.ts @@ -66,6 +66,9 @@ export type { EmbedderFactoryDeps } from './embedder-factory' export { SemanticIndexService, createSemanticIndexService, + persistSemanticIndexConfig, + resolveSemanticIndexApiKeySet, + SEMANTIC_INDEX_AUTH_PROFILE, SEMANTIC_INDEX_DB_FILE, SEMANTIC_INDEX_CONFIG_FILE, } from './service' diff --git a/packages/node-runtime/src/semantic-index/service.test.ts b/packages/node-runtime/src/semantic-index/service.test.ts index 8919ee41..399c19a0 100644 --- a/packages/node-runtime/src/semantic-index/service.test.ts +++ b/packages/node-runtime/src/semantic-index/service.test.ts @@ -6,7 +6,13 @@ import test from 'node:test' import { CHAT_DB_SCHEMA, FTS_TABLE_SCHEMA } from '@openchatlab/core' import { openBetterSqliteDatabase } from '../better-sqlite3-adapter' import { buildFtsIndex } from '../fts' -import { SemanticIndexService } from './service' +import { + SemanticIndexService, + persistSemanticIndexConfig, + resolveSemanticIndexApiKeySet, + SEMANTIC_INDEX_AUTH_PROFILE, +} from './service' +import { SemanticIndexConfigStore } from './config' import { SemanticIndexStateStore } from './session-state-store' import { computeDbPathHash } from './chunker-config' import { QWEN3_PROFILE } from './embedding/profiles' @@ -518,6 +524,49 @@ test('searchForTool applies timeFilter to exclude out-of-range chunks', async () db.close() }) +function tempPersistStore(prefix: string): SemanticIndexConfigStore { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const dir = fs.mkdtempSync(path.join(baseDir, prefix)) + return new SemanticIndexConfigStore(path.join(dir, 'ai', 'semantic-index-config.json')) +} + +// 回归:向量库不可用的降级路径(HTTP stub PUT 复用此 helper)必须持久化 API Key, +// 否则 API 模式在降级期会丢 key,恢复后出现"已配置但 hasApiKey=false 无法检索"。 +test('persistSemanticIndexConfig (degraded path) saves API key by reference, never plaintext', () => { + const store = tempPersistStore('chatlab-si-persist-') + const authProfiles = new Map() + + const saved = persistSemanticIndexConfig( + store, + { version: 1, mode: 'api', local: { modelId: '' }, api: { baseUrl: 'https://x', model: 'emb' } }, + { apiKey: 'sk-degraded-1', writeAuthProfile: (name, profile) => authProfiles.set(name, { key: profile.key }) } + ) + + assert.equal(saved.api?.authProfile, SEMANTIC_INDEX_AUTH_PROFILE) + assert.ok(!JSON.stringify(saved).includes('sk-degraded-1')) + assert.equal(authProfiles.get(SEMANTIC_INDEX_AUTH_PROFILE)?.key, 'sk-degraded-1') + // 已落盘:重新读取仍保留引用 + assert.equal(store.get().api?.authProfile, SEMANTIC_INDEX_AUTH_PROFILE) + + const resolve = (_p: string, ap?: string) => (ap ? (authProfiles.get(ap)?.key ?? '') : '') + assert.equal(resolveSemanticIndexApiKeySet(saved, resolve), true) +}) + +test('persistSemanticIndexConfig ignores apiKey in local mode (no auth profile write)', () => { + const store = tempPersistStore('chatlab-si-persist-local-') + const authProfiles = new Map() + + const saved = persistSemanticIndexConfig( + store, + { version: 1, mode: 'local', local: { modelId: 'qwen3' }, api: null }, + { apiKey: 'should-be-ignored', writeAuthProfile: (name, profile) => authProfiles.set(name, { key: profile.key }) } + ) + + assert.equal(saved.mode, 'local') + assert.equal(authProfiles.size, 0) + assert.equal(resolveSemanticIndexApiKeySet(saved), false) +}) + test('recover marks stale running as paused without auto-resuming', async () => { const { service, dir, db } = setup() await enableAndBuild(service) diff --git a/packages/node-runtime/src/semantic-index/service.ts b/packages/node-runtime/src/semantic-index/service.ts index b941fb45..0d5c1b88 100644 --- a/packages/node-runtime/src/semantic-index/service.ts +++ b/packages/node-runtime/src/semantic-index/service.ts @@ -74,6 +74,34 @@ export interface SemanticIndexServiceOptions { /** 语义索引 API Key 固定存储在此 auth profile;config 只保存引用,不保存明文 */ export const SEMANTIC_INDEX_AUTH_PROFILE = 'semantic-index-embedding' +/** + * 仅持久化配置与(可选)API Key,不依赖向量库。 + * 供 SemanticIndexService.setConfig 复用,也供 service 不可用(如 sqlite-vec 加载失败) + * 的降级路径直接写配置,保证 API 模式下 key 不被丢弃。 + */ +export function persistSemanticIndexConfig( + configStore: SemanticIndexConfigStore, + config: SemanticIndexConfigInput, + options?: { apiKey?: string; writeAuthProfile?: (name: string, profile: AuthProfile) => void } +): SemanticIndexConfig { + let next = config + if (options?.apiKey && config.mode === 'api' && config.api) { + const writer = options.writeAuthProfile ?? defaultWriteAuthProfile + writer(SEMANTIC_INDEX_AUTH_PROFILE, { type: 'api_key', provider: 'semantic-index', key: options.apiKey }) + next = { ...config, api: { ...config.api, authProfile: SEMANTIC_INDEX_AUTH_PROFILE } } + } + return configStore.set(next) +} + +/** 当前配置的 API 模式是否已设置可用 Key(不返回明文)。service 与降级路径共用。 */ +export function resolveSemanticIndexApiKeySet( + config: SemanticIndexConfig, + resolveApiKey: (provider: string, authProfile?: string) => string = defaultResolveApiKey +): boolean { + if (config.mode !== 'api' || !config.api || !config.api.authProfile) return false + return resolveApiKey('semantic-index', config.api.authProfile) !== '' +} + export interface SemanticIndexSessionStatus { sessionId: string enabled: boolean @@ -242,10 +270,7 @@ export class SemanticIndexService { /** 当前 API 模式是否已配置可用的 API Key(不返回明文,仅布尔) */ hasApiKey(): boolean { - const cfg = this.configStore.get() - if (cfg.mode !== 'api' || !cfg.api || !cfg.api.authProfile) return false - const resolve = this.options.embedderFactoryDeps?.resolveApiKey ?? defaultResolveApiKey - return resolve('semantic-index', cfg.api.authProfile) !== '' + return resolveSemanticIndexApiKeySet(this.configStore.get(), this.options.embedderFactoryDeps?.resolveApiKey) } /** @@ -253,13 +278,10 @@ export class SemanticIndexService { * 传入 apiKey 时写入固定 auth profile,config 内只保存引用,绝不持久化明文。 */ setConfig(config: SemanticIndexConfigInput, options?: { apiKey?: string }): SemanticIndexConfig { - let next = config - if (options?.apiKey && config.mode === 'api' && config.api) { - const writer = this.options.writeAuthProfile ?? defaultWriteAuthProfile - writer(SEMANTIC_INDEX_AUTH_PROFILE, { type: 'api_key', provider: 'semantic-index', key: options.apiKey }) - next = { ...config, api: { ...config.api, authProfile: SEMANTIC_INDEX_AUTH_PROFILE } } - } - const saved = this.configStore.set(next) + const saved = persistSemanticIndexConfig(this.configStore, config, { + apiKey: options?.apiKey, + writeAuthProfile: this.options.writeAuthProfile, + }) this.embedder = null this.embedderModelId = null // 关闭全局开关时停止所有在跑/排队的建索引任务(保留已建立的部分数据) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7408d820..792db7ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: '@fastify/multipart': specifier: ^10.0.0 version: 10.0.0 + '@huggingface/transformers': + specifier: ^4.2.0 + version: 4.2.0 '@node-rs/jieba': specifier: ^2.0.1 version: 2.0.1 @@ -197,6 +200,9 @@ importers: i18next: specifier: ^25.8.5 version: 25.8.5(typescript@5.9.3) + sqlite-vec: + specifier: ^0.1.9 + version: 0.1.9 devDependencies: '@electron-toolkit/tsconfig': specifier: ^1.0.1