feat(semantic-index): move model to ai dir and preload on config save

Move local embedding model storage from getCacheDir() to getAiDataDir()
(~/.chatlab/ai/models/semantic-index) so the "clear cache" button in
settings no longer wipes downloaded models.

Also trigger model download immediately in the background when a local
model is configured, instead of waiting for the first index build.
Download status (idle/downloading/ready/error) is tracked in
SemanticIndexService, exposed via the SemanticIndexRuntime interface and
worker RPC, and surfaced in the settings UI as a pulsing indicator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
diguaandClaude Opus 4.8 committed 2026-06-24 14:41:33 +08:00
1 parent cabb794ff7
commit d6896bf09a
15 files changed
+109 -12

No files matched your search

@@ -16,7 +16,7 @@ import type { EmbeddingProvider } from './embedding/types'
import type { SemanticIndexConfig } from './config'
export interface EmbedderFactoryDeps {
/** 本地模型缓存目录,例如 ~/.chatlab/cache/models/semantic-index */
/** 本地模型目录,例如 ~/.chatlab/ai/models/semantic-index */
modelsCacheDir?: string
/** auth-profiles 解析,默认走 @openchatlab/config */
resolveApiKey?: (provider: string, authProfile?: string) => string
@@ -89,4 +89,8 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider {
const [vector] = await this.embedDocuments([text])
return vector
}
async preload(): Promise<void> {
// API mode: no local model to download
}
}
@@ -101,4 +101,8 @@ export class LocalEmbeddingProvider implements EmbeddingProvider {
const [vector] = await this.embedDocuments([withInstruction])
return vector
}
async preload(): Promise<void> {
await this.getExtractor()
}
}
@@ -21,4 +21,6 @@ export interface EmbeddingProvider {
embedDocuments(texts: string[]): Promise<Float32Array[]>
/** query 向量化(本地模型会加 queryInstruction) */
embedQuery(text: string): Promise<Float32Array>
/** 预热:触发模型下载/加载而不做实际 embedding。API provider 为 no-op。 */
preload?(): Promise<void>
}
@@ -17,6 +17,7 @@ export type MaybePromise<T> = T | Promise<T>
export interface SemanticIndexRuntime {
getConfig(): MaybePromise<SemanticIndexConfig>
setConfig(config: SemanticIndexConfigInput, options?: { apiKey?: string }): MaybePromise<SemanticIndexConfig>
getModelStatus(): MaybePromise<'idle' | 'downloading' | 'ready' | 'error'>
isConfigured(): MaybePromise<boolean>
hasApiKey(): MaybePromise<boolean>
@@ -29,6 +29,7 @@ import {
import type { ChunkSource } from './chunker'
import {
isKeylessSemanticIndexApiBaseUrl,
isSemanticIndexConfigured,
SemanticIndexConfigStore,
type SemanticIndexConfig,
type SemanticIndexConfigInput,
@@ -197,7 +198,7 @@ export const SEMANTIC_INDEX_CONFIG_FILE = 'semantic-index-config.json'
* 按 PathProvider 约定路径创建 service:
* - 向量库:{vectorDir}/embedding_index.db
* - 配置:{aiDataDir}/semantic-index-config.json
* - 本地模型缓存:{cacheDir}/models/semantic-index
* - 本地模型:{aiDataDir}/models/semantic-index(不在 cache 目录,不随清理缓存丢失)
*/
export function createSemanticIndexService(params: {
pathProvider: PathProvider
@@ -210,7 +211,7 @@ export function createSemanticIndexService(params: {
return new SemanticIndexService({
vectorDbPath: path.join(pathProvider.getVectorDir(), SEMANTIC_INDEX_DB_FILE),
configPath: path.join(pathProvider.getAiDataDir(), SEMANTIC_INDEX_CONFIG_FILE),
modelsCacheDir: path.join(pathProvider.getCacheDir(), 'models', 'semantic-index'),
modelsCacheDir: path.join(pathProvider.getAiDataDir(), 'models', 'semantic-index'),
sessionAdapter: params.sessionAdapter,
nativeBinding: params.nativeBinding,
loadSqliteVec: params.loadSqliteVec,
@@ -228,6 +229,7 @@ export class SemanticIndexService {
private embedder: EmbeddingProvider | null = null
private embedderModelId: string | null = null
private modelPreloadStatus: 'idle' | 'downloading' | 'ready' | 'error' = 'idle'
/** 当前激活的 chunker 身份(version + 参数 hash),随索引记录用于重建判定 */
private readonly chunkerVersion = CHUNKER_VERSION
@@ -277,6 +279,10 @@ export class SemanticIndexService {
* 保存配置;模型身份变化会使已启用对话的索引在重建完成前不被检索使用。
* 传入 apiKey 时写入固定 auth profile,config 内只保存引用,绝不持久化明文。
*/
getModelStatus(): 'idle' | 'downloading' | 'ready' | 'error' {
return this.modelPreloadStatus
}
setConfig(config: SemanticIndexConfigInput, options?: { apiKey?: string }): SemanticIndexConfig {
const saved = persistSemanticIndexConfig(this.configStore, config, {
apiKey: options?.apiKey,
@@ -284,10 +290,23 @@ export class SemanticIndexService {
})
this.embedder = null
this.embedderModelId = null
this.modelPreloadStatus = 'idle'
// 关闭全局开关时停止所有在跑/排队的建索引任务(保留已建立的部分数据)
if (!saved.enabled) {
for (const state of this.stateStore.listEnabled()) this.queue.pause(state.dbPathHash)
}
// 本地模式已配置时,立即在后台触发模型下载,让用户在建索引前完成等待
if (isSemanticIndexConfigured(saved) && saved.mode === 'local') {
this.modelPreloadStatus = 'downloading'
void this.getEmbedder()
.preload?.()
.then(() => {
this.modelPreloadStatus = 'ready'
})
.catch(() => {
this.modelPreloadStatus = 'error'
})
}
return saved
}
@@ -101,6 +101,11 @@ export class SemanticIndexWorkerClient implements SemanticIndexRuntime {
return resolveSemanticIndexApiKeySet(this.configStore.get(), this.resolveApiKey)
}
async getModelStatus(): Promise<'idle' | 'downloading' | 'ready' | 'error'> {
if (!this.transport) return 'idle'
return this.call<'idle' | 'downloading' | 'ready' | 'error'>('getModelStatus', [])
}
enable(sessionId: string): Promise<void> {
return this.call('enable', [sessionId])
}