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 adf90c17..3814cb74 100644 --- a/packages/http-routes/src/routes/web/ai-semantic-index.ts +++ b/packages/http-routes/src/routes/web/ai-semantic-index.ts @@ -30,7 +30,10 @@ export function registerSemanticIndexRoutes(server: FastifyInstance, ctx: HttpRo apiKeySet: resolveSemanticIndexApiKeySet(config, ctx.resolveApiKey), configured: isSemanticIndexConfigured(config), }) - server.get('/_web/ai/semantic-index/config', async () => configResponse(configStore.get())) + server.get('/_web/ai/semantic-index/config', async () => ({ + ...configResponse(configStore.get()), + modelStatus: 'idle' as const, + })) // 向量库不可用时仍允许写配置与 API Key(key 落 auth-profiles,不依赖向量库), // 否则 API 模式在降级期无法保存 key,恢复后会出现"已配置但无法检索"。 server.put<{ Body: { config: SemanticIndexConfig; apiKey?: string } }>( @@ -40,7 +43,7 @@ export function registerSemanticIndexRoutes(server: FastifyInstance, ctx: HttpRo apiKey: request.body.apiKey, writeAuthProfile: ctx.writeAuthProfile, }) - return configResponse(config) + return { ...configResponse(config), modelStatus: 'idle' as const } } ) } @@ -55,6 +58,7 @@ export function registerSemanticIndexRoutes(server: FastifyInstance, ctx: HttpRo config: await service.getConfig(), apiKeySet: await service.hasApiKey(), configured: await service.isConfigured(), + modelStatus: await service.getModelStatus(), } }) @@ -62,7 +66,12 @@ export function registerSemanticIndexRoutes(server: FastifyInstance, ctx: HttpRo '/_web/ai/semantic-index/config', async (request) => { const config = await service.setConfig(request.body.config, { apiKey: request.body.apiKey }) - return { config, apiKeySet: await service.hasApiKey(), configured: await service.isConfigured() } + return { + config, + apiKeySet: await service.hasApiKey(), + configured: await service.isConfigured(), + modelStatus: await service.getModelStatus(), + } } ) diff --git a/packages/node-runtime/src/semantic-index/embedder-factory.ts b/packages/node-runtime/src/semantic-index/embedder-factory.ts index c6529525..6ccf0e14 100644 --- a/packages/node-runtime/src/semantic-index/embedder-factory.ts +++ b/packages/node-runtime/src/semantic-index/embedder-factory.ts @@ -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 diff --git a/packages/node-runtime/src/semantic-index/embedding/api.ts b/packages/node-runtime/src/semantic-index/embedding/api.ts index 6a5bf77f..b64870cb 100644 --- a/packages/node-runtime/src/semantic-index/embedding/api.ts +++ b/packages/node-runtime/src/semantic-index/embedding/api.ts @@ -89,4 +89,8 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider { const [vector] = await this.embedDocuments([text]) return vector } + + async preload(): Promise { + // API mode: no local model to download + } } diff --git a/packages/node-runtime/src/semantic-index/embedding/local.ts b/packages/node-runtime/src/semantic-index/embedding/local.ts index 7cef4a40..6b4e33ea 100644 --- a/packages/node-runtime/src/semantic-index/embedding/local.ts +++ b/packages/node-runtime/src/semantic-index/embedding/local.ts @@ -101,4 +101,8 @@ export class LocalEmbeddingProvider implements EmbeddingProvider { const [vector] = await this.embedDocuments([withInstruction]) return vector } + + async preload(): Promise { + await this.getExtractor() + } } diff --git a/packages/node-runtime/src/semantic-index/embedding/types.ts b/packages/node-runtime/src/semantic-index/embedding/types.ts index 11306f89..918d1744 100644 --- a/packages/node-runtime/src/semantic-index/embedding/types.ts +++ b/packages/node-runtime/src/semantic-index/embedding/types.ts @@ -21,4 +21,6 @@ export interface EmbeddingProvider { embedDocuments(texts: string[]): Promise /** query 向量化(本地模型会加 queryInstruction) */ embedQuery(text: string): Promise + /** 预热:触发模型下载/加载而不做实际 embedding。API provider 为 no-op。 */ + preload?(): Promise } diff --git a/packages/node-runtime/src/semantic-index/runtime.ts b/packages/node-runtime/src/semantic-index/runtime.ts index 55c9b809..215d701c 100644 --- a/packages/node-runtime/src/semantic-index/runtime.ts +++ b/packages/node-runtime/src/semantic-index/runtime.ts @@ -17,6 +17,7 @@ export type MaybePromise = T | Promise export interface SemanticIndexRuntime { getConfig(): MaybePromise setConfig(config: SemanticIndexConfigInput, options?: { apiKey?: string }): MaybePromise + getModelStatus(): MaybePromise<'idle' | 'downloading' | 'ready' | 'error'> isConfigured(): MaybePromise hasApiKey(): MaybePromise diff --git a/packages/node-runtime/src/semantic-index/service.ts b/packages/node-runtime/src/semantic-index/service.ts index 0d5c1b88..e3382018 100644 --- a/packages/node-runtime/src/semantic-index/service.ts +++ b/packages/node-runtime/src/semantic-index/service.ts @@ -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 } diff --git a/packages/node-runtime/src/semantic-index/worker-client.ts b/packages/node-runtime/src/semantic-index/worker-client.ts index 5c34c96a..dabc3c2a 100644 --- a/packages/node-runtime/src/semantic-index/worker-client.ts +++ b/packages/node-runtime/src/semantic-index/worker-client.ts @@ -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 { return this.call('enable', [sessionId]) } diff --git a/src/components/common/Settings/AI/SemanticIndexSection.vue b/src/components/common/Settings/AI/SemanticIndexSection.vue index 71342634..bd923fe0 100644 --- a/src/components/common/Settings/AI/SemanticIndexSection.vue +++ b/src/components/common/Settings/AI/SemanticIndexSection.vue @@ -13,7 +13,7 @@ import SemanticIndexModelModal from './SemanticIndexModelModal.vue' import { LOCAL_MODELS, type ModelConfigDraft } from './semantic-index-models' import { buildSemanticIndexModelConfig, isSemanticIndexApiKeyRequired } from './semantic-index-config-builder' import { useDataService, useSemanticIndexService } from '@/services' -import type { SemanticIndexConfig, SemanticIndexSessionStatus } from '@/services' +import type { ModelDownloadStatus, SemanticIndexConfig, SemanticIndexSessionStatus } from '@/services' const { t } = useI18n() const service = useSemanticIndexService() @@ -25,8 +25,31 @@ const configured = ref(false) const enabled = ref(true) const saving = ref(false) const savedConfig = ref(null) +const modelStatus = ref('idle') const showModelModal = ref(false) +let modelPollTimer: ReturnType | null = null + +function startModelStatusPoll() { + if (modelPollTimer) return + modelPollTimer = setInterval(async () => { + try { + const res = await service.getConfig() + modelStatus.value = res.modelStatus + if (res.modelStatus !== 'downloading') stopModelStatusPoll() + } catch { + stopModelStatusPoll() + } + }, 2000) +} + +function stopModelStatusPoll() { + if (modelPollTimer) { + clearInterval(modelPollTimer) + modelPollTimer = null + } +} + const isApiMode = computed(() => configured.value && savedConfig.value?.mode === 'api') const hasModelConfig = computed(() => { @@ -111,6 +134,8 @@ async function persistConfig(next: SemanticIndexConfig, apiKey?: string): Promis apiKeySet.value = res.apiKeySet configured.value = res.configured enabled.value = res.config.enabled + modelStatus.value = res.modelStatus + if (res.modelStatus === 'downloading') startModelStatusPoll() await loadStatuses() return true } catch (error) { @@ -233,6 +258,8 @@ onMounted(async () => { apiKeySet.value = res.apiKeySet configured.value = res.configured enabled.value = res.config.enabled + modelStatus.value = res.modelStatus + if (res.modelStatus === 'downloading') startModelStatusPoll() } catch (error) { console.error('[semantic-index] load config failed:', error) } @@ -253,6 +280,7 @@ onMounted(async () => { onUnmounted(() => { observer?.disconnect() clearPoll() + stopModelStatusPoll() }) @@ -302,6 +330,16 @@ onUnmounted(() => {

{{ t('settings.ai.semanticIndex.configFirst') }}

+

+ + {{ t('settings.ai.semanticIndex.modelDownloading') }} +

+

+ {{ t('settings.ai.semanticIndex.modelError') }} +

diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index 954a455d..51f84895 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -263,6 +263,8 @@ "apiKeyHint": "API keys are stored separately and securely; the config keeps only a reference.", "apiCostWarning": "API mode may incur provider costs. Please confirm model pricing.", "changeRebuildHint": "After changing the model or API, enabled conversations need to be rebuilt.", + "modelError": "Model download failed. It will be retried when building the index.", + "modelDownloading": "Downloading model, this may take a few minutes on first use…", "searchMaxResults": { "label": "Excerpts per search", "hint": "Default number of relevant excerpts returned when the AI runs a semantic search (5-15). The AI may raise it as needed." diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 624ee38e..95cbe7e6 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -263,6 +263,8 @@ "apiKeyHint": "API キーは別途安全に保存され、設定には参照のみが保存されます。", "apiCostWarning": "API モードはプロバイダー費用が発生する場合があります。価格をご確認ください。", "changeRebuildHint": "モデルや API を変更すると、有効な会話は再構築が必要です。", + "modelError": "モデルのダウンロードに失敗しました。インデックス構築時に再試行されます。", + "modelDownloading": "モデルをダウンロード中です。初回は数分かかる場合があります…", "searchMaxResults": { "label": "1回の検索で返す断片数", "hint": "AI がセマンティック検索を実行する際に返す関連断片のデフォルト数(5〜15)。必要に応じて AI が増やせます。" diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index fe7fa78b..0cb9a2c2 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -263,6 +263,8 @@ "apiKeyHint": "API Key 单独安全存储,配置文件只保存引用。", "apiCostWarning": "API 模式可能产生服务商费用,请确认模型价格。", "changeRebuildHint": "更换模型或 API 后,已启用对话需要重建索引。", + "modelDownloading": "正在下载模型,首次使用需要几分钟,请稍候…", + "modelError": "模型下载失败,建立索引时将重试。", "searchMaxResults": { "label": "单次检索片段数", "hint": "AI 调用语义检索时默认返回的相关片段数(5-15)。AI 可按问题需要临时调高。" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index c77cad34..e41df128 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -263,6 +263,8 @@ "apiKeyHint": "API Key 單獨安全儲存,設定檔只保存引用。", "apiCostWarning": "API 模式可能產生服務商費用,請確認模型價格。", "changeRebuildHint": "更換模型或 API 後,已啟用對話需要重建索引。", + "modelError": "模型下載失敗,建立索引時將重試。", + "modelDownloading": "正在下載模型,首次使用需要幾分鐘,請稍候…", "searchMaxResults": { "label": "單次檢索片段數", "hint": "AI 呼叫語意檢索時預設回傳的相關片段數(5-15)。AI 可依問題需要臨時調高。" diff --git a/src/services/index.ts b/src/services/index.ts index cd559162..ed3a1df5 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -33,6 +33,7 @@ export type { } from './import/types' export { useSessionIndexService } from './session-index/service' export { useSemanticIndexService } from './semantic-index/service' +export type { ModelDownloadStatus } from './semantic-index/service' export { SEARCH_MAX_RESULTS_MIN, SEARCH_MAX_RESULTS_MAX, SEARCH_MAX_RESULTS_DEFAULT } from './semantic-index/types' export type { SemanticIndexConfig, diff --git a/src/services/semantic-index/service.ts b/src/services/semantic-index/service.ts index 9c59ac18..93d02e10 100644 --- a/src/services/semantic-index/service.ts +++ b/src/services/semantic-index/service.ts @@ -10,12 +10,18 @@ import type { SemanticIndexConfig, SemanticIndexSessionStatus } from './types' const BASE = '/ai/semantic-index' +export type ModelDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error' + +export interface SemanticIndexConfigResult { + config: SemanticIndexConfig + apiKeySet: boolean + configured: boolean + modelStatus: ModelDownloadStatus +} + export interface SemanticIndexService { - getConfig(): Promise<{ config: SemanticIndexConfig; apiKeySet: boolean; configured: boolean }> - setConfig( - config: SemanticIndexConfig, - apiKey?: string - ): Promise<{ config: SemanticIndexConfig; apiKeySet: boolean; configured: boolean }> + getConfig(): Promise + setConfig(config: SemanticIndexConfig, apiKey?: string): Promise listEnabled(): Promise status(sessionId: string): Promise statusForSessions(sessionIds: string[]): Promise