fix(ai): complete desktop semantic index parity and local embedding packaging

Bring the Electron desktop client to parity with CLI web for semantic
index, and package the local embedding runtime correctly.

- Inject semanticIndexService into the desktop AI tool debug executor so
  manual tool runs reach semantic search tools, matching the Agent stream.
  Extract a platform-agnostic tool-executor-core with unit tests.
- Persist the API key by reference in the degraded semantic-index config
  route (vector service unavailable) so API mode keys are not dropped;
  make auth-profile read/write injectable and add route + unit regression
  tests that never touch the real ~/.chatlab.
- Package desktop local embedding deps: declare @huggingface/transformers,
  asarUnpack onnxruntime-node / @img native libs, and trim onnxruntime-node
  binaries to the build target platform/arch to avoid shipping ~175M of
  unrelated Linux/Windows binaries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-06-21 23:09:31 +08:00
1 parent 114049876f
commit 2bfae1db93
14 files changed
+396 -80

No files matched your search

+2
View File
@@ -355,6 +355,8 @@ export {
SemanticIndexConfigStore,
SEMANTIC_INDEX_CONFIG_FILE,
isSemanticIndexConfigured,
persistSemanticIndexConfig,
resolveSemanticIndexApiKeySet,
} from './semantic-index'
export type {
SemanticIndexServiceOptions,
@@ -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'
@@ -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<string, { key: string }>()
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<string, { key: string }>()
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)
@@ -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
// 关闭全局开关时停止所有在跑/排队的建索引任务(保留已建立的部分数据)