From f96fef7b46216834ac0c8d9cdc0ab7b73dbbd454 Mon Sep 17 00:00:00 2001 From: digua Date: Sat, 1 Aug 2026 12:12:45 +0800 Subject: [PATCH] feat(cli): install local embedding runtime on demand --- .github/docker/chatlab-cli.Dockerfile | 2 + apps/cli/src/cli.ts | 4 +- apps/cli/src/http/index.ts | 2 + .../src/semantic-index/local-runtime.test.ts | 23 ++ apps/cli/src/semantic-index/local-runtime.ts | 34 +++ .../cli/src/semantic-index/runtime-command.ts | 20 ++ apps/cli/tsup.config.ts | 12 +- docs/cn/usage/docker.md | 2 + docs/en/usage/docker.md | 2 + docs/tw/usage/docker.md | 2 + packages/node-runtime/src/index.ts | 2 + .../embedding/local-runtime.test.ts | 140 +++++++++ .../semantic-index/embedding/local-runtime.ts | 282 ++++++++++++++++++ .../src/semantic-index/embedding/local.ts | 51 ++-- .../src/semantic-index/embedding/types.ts | 2 + .../node-runtime/src/semantic-index/index.ts | 11 + .../src/semantic-index/runtime.ts | 3 +- .../src/semantic-index/service.test.ts | 12 + .../src/semantic-index/service.ts | 40 ++- .../src/semantic-index/worker-client.test.ts | 65 ++++ .../src/semantic-index/worker-client.ts | 26 +- .../src/semantic-index/worker-runtime.ts | 11 + .../Settings/AI/SemanticIndexModelModal.vue | 12 +- .../Settings/AI/SemanticIndexSection.vue | 18 +- src/i18n/locales/en-US/settings.json | 4 +- src/i18n/locales/ja-JP/settings.json | 4 +- src/i18n/locales/zh-CN/settings.json | 4 +- src/i18n/locales/zh-TW/settings.json | 4 +- src/services/semantic-index/service.ts | 2 +- 29 files changed, 741 insertions(+), 55 deletions(-) create mode 100644 apps/cli/src/semantic-index/local-runtime.test.ts create mode 100644 apps/cli/src/semantic-index/local-runtime.ts create mode 100644 apps/cli/src/semantic-index/runtime-command.ts create mode 100644 packages/node-runtime/src/semantic-index/embedding/local-runtime.test.ts create mode 100644 packages/node-runtime/src/semantic-index/embedding/local-runtime.ts diff --git a/.github/docker/chatlab-cli.Dockerfile b/.github/docker/chatlab-cli.Dockerfile index 2001a4ee..df313e4e 100644 --- a/.github/docker/chatlab-cli.Dockerfile +++ b/.github/docker/chatlab-cli.Dockerfile @@ -3,11 +3,13 @@ FROM node:24-bookworm-slim ARG CHATLAB_VERSION +ENV CHATLAB_LOCAL_EMBEDDING_RUNTIME_DIR=/opt/chatlab/local-embedding RUN test -n "$CHATLAB_VERSION" \ && apt-get update \ && apt-get install --yes --no-install-recommends g++ make python3 \ && npm install --global --omit=dev "chatlab-cli@${CHATLAB_VERSION}" \ + && CHATLAB_SKIP_UPDATE_CHECK=1 chatlab runtime install local-embedding \ && npm cache clean --force \ && apt-get purge --yes --auto-remove g++ make python3 \ && rm -rf /var/lib/apt/lists/* diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index b6c7e02f..46a684cf 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -17,13 +17,14 @@ import { registerQueryCommands } from './query/register' import { registerManifestCommand } from './query/manifest' import { registerImportCommand } from './import/command' import { registerValidateCommand } from './validate/command' +import { registerRuntimeCommand } from './semantic-index/runtime-command' const program = new Command() program.name('chatlab').description('ChatLab - Chat history analysis tool').version(getVersion(), '-v, --version') program.hook('preAction', async (_thisCommand, actionCommand) => { - if (actionCommand.name() === 'update') return + if (actionCommand.name() === 'update' || actionCommand.parent?.name() === 'runtime') return const { checkForUpdatesInteractive } = await import('./update-checker') await checkForUpdatesInteractive() }) @@ -53,6 +54,7 @@ registerManifestCommand(program, getVersion()) registerImportCommand(program) registerValidateCommand(program) +registerRuntimeCommand(program) program .command('formats') diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index 43a782bb..d1ed8e53 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -34,6 +34,7 @@ import { createCliRunAgentStream } from '../ai/agent-stream-runner' import { initSync, cleanupSync } from '../sync' import { resolveCliPath } from '../paths' import { assertCliDataDirCompatible } from '../runtime-compat' +import { resolveCliLocalEmbeddingRuntimeConfig } from '../semantic-index/local-runtime' let server: FastifyInstance | null = null let dbManager: DatabaseManager | null = null @@ -169,6 +170,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ pathProvider, runtime, nativeBinding, + localEmbeddingRuntime: resolveCliLocalEmbeddingRuntimeConfig(pathProvider.getAiDataDir()), workerEntryUrl: import.meta.url.endsWith('.ts') ? undefined : new URL('./semantic-index-worker.mjs', import.meta.url), diff --git a/apps/cli/src/semantic-index/local-runtime.test.ts b/apps/cli/src/semantic-index/local-runtime.test.ts new file mode 100644 index 00000000..7a6493bc --- /dev/null +++ b/apps/cli/src/semantic-index/local-runtime.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' +import { LOCAL_EMBEDDING_RUNTIME_DIR_ENV, resolveCliLocalEmbeddingRuntimeConfig } from './local-runtime' + +test('uses a platform-specific managed runtime below the CLI AI directory by default', () => { + assert.deepEqual(resolveCliLocalEmbeddingRuntimeConfig('/tmp/chatlab-ai', {}), { + baseDir: path.join('/tmp/chatlab-ai', 'runtime', 'local-embedding'), + installMode: 'auto', + }) +}) + +test('treats an explicit runtime directory as preinstalled', () => { + assert.deepEqual( + resolveCliLocalEmbeddingRuntimeConfig('/tmp/chatlab-ai', { + [LOCAL_EMBEDDING_RUNTIME_DIR_ENV]: '/opt/chatlab/local-embedding', + }), + { + baseDir: '/opt/chatlab/local-embedding', + installMode: 'preinstalled', + } + ) +}) diff --git a/apps/cli/src/semantic-index/local-runtime.ts b/apps/cli/src/semantic-index/local-runtime.ts new file mode 100644 index 00000000..63e0aecf --- /dev/null +++ b/apps/cli/src/semantic-index/local-runtime.ts @@ -0,0 +1,34 @@ +import path from 'node:path' +import { createLocalEmbeddingRuntimeManager, type LocalEmbeddingRuntimeConfig } from '@openchatlab/node-runtime' + +export const LOCAL_EMBEDDING_RUNTIME_DIR_ENV = 'CHATLAB_LOCAL_EMBEDDING_RUNTIME_DIR' + +export function resolveCliLocalEmbeddingRuntimeConfig( + aiDataDir: string, + env: NodeJS.ProcessEnv = process.env +): LocalEmbeddingRuntimeConfig { + const preinstalledDir = env[LOCAL_EMBEDDING_RUNTIME_DIR_ENV]?.trim() + if (preinstalledDir) { + return { + baseDir: path.resolve(preinstalledDir), + installMode: 'preinstalled', + } + } + return { + baseDir: path.join(aiDataDir, 'runtime', 'local-embedding'), + installMode: 'auto', + } +} + +export async function installCliLocalEmbeddingRuntime( + aiDataDir: string, + env: NodeJS.ProcessEnv = process.env +): Promise { + const config = resolveCliLocalEmbeddingRuntimeConfig(aiDataDir, env) + const manager = createLocalEmbeddingRuntimeManager({ + ...config, + // The build-time installer populates a directory that normal app startup treats as read-only. + installMode: 'auto', + }) + return await manager.ensureInstalled() +} diff --git a/apps/cli/src/semantic-index/runtime-command.ts b/apps/cli/src/semantic-index/runtime-command.ts new file mode 100644 index 00000000..a2ec4bfa --- /dev/null +++ b/apps/cli/src/semantic-index/runtime-command.ts @@ -0,0 +1,20 @@ +import path from 'node:path' +import os from 'node:os' +import type { Command } from 'commander' +import { installCliLocalEmbeddingRuntime } from './local-runtime' + +const LOCAL_EMBEDDING_COMPONENT = 'local-embedding' + +export function registerRuntimeCommand(program: Command): void { + program + .command('runtime', { hidden: true }) + .command('install ', { hidden: true }) + .action(async (component: string) => { + if (component !== LOCAL_EMBEDDING_COMPONENT) { + throw new Error(`Unknown ChatLab runtime component: ${component}`) + } + const aiDataDir = path.join(os.homedir(), '.chatlab', 'ai') + const runtimeDir = await installCliLocalEmbeddingRuntime(aiDataDir) + console.log(`Local embedding runtime installed: ${runtimeDir}`) + }) +} diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index 8f061c59..2f112a10 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -25,9 +25,15 @@ export default defineConfig({ // parser-native 是本地构建的可选 Rust 内核:不打包也不声明依赖, // 运行时 require 失败会自动回退 TS 解析器。 // sqlite-vec resolves its platform extension through sqlite-vec-* and must remain an external production dependency. - // Keep Transformers bundled until CLI local embeddings have an on-demand runtime; - // externalizing it makes every CLI installation pull the full ONNX runtime. - external: ['better-sqlite3', '@node-rs/jieba', 'sqlite-vec', '@openchatlab/parser-native'], + // Transformers is supplied by the CLI-managed local embedding runtime or preinstalled by the Docker image. + // Keeping it external avoids adding the full ONNX runtime to every normal CLI installation. + external: [ + 'better-sqlite3', + '@node-rs/jieba', + 'sqlite-vec', + '@openchatlab/parser-native', + '@huggingface/transformers', + ], banner: { js: [ "import { createRequire as __createRequire } from 'module';", diff --git a/docs/cn/usage/docker.md b/docs/cn/usage/docker.md index a170ae5f..7dd91d7f 100644 --- a/docs/cn/usage/docker.md +++ b/docs/cn/usage/docker.md @@ -10,6 +10,8 @@ ChatLab CLI 提供 `linux/amd64` 和 `linux/arm64` 两种架构的容器镜像 ghcr.io/chatlab/chatlab-cli ``` +官方镜像已内置本地向量模型所需的运行组件。启用本地语义索引时,只需按界面提示下载所选模型文件,不会在容器启动后再安装约 370 MB 的 Node 依赖。 + ## 快速开始 ### 与 Desktop / 本地 CLI 共用数据(推荐) diff --git a/docs/en/usage/docker.md b/docs/en/usage/docker.md index 7d0b7f6f..811ac474 100644 --- a/docs/en/usage/docker.md +++ b/docs/en/usage/docker.md @@ -11,6 +11,8 @@ ChatLab CLI is available as a multi-architecture container image for ghcr.io/chatlab/chatlab-cli ``` +The official image already includes the runtime required by local embedding models. Enabling a local semantic index only downloads the selected model files; it does not install another ~370 MB of Node dependencies after the container starts. + ## Quick start ### Share data with Desktop and a local CLI (recommended) diff --git a/docs/tw/usage/docker.md b/docs/tw/usage/docker.md index 7b792248..2208d3f3 100644 --- a/docs/tw/usage/docker.md +++ b/docs/tw/usage/docker.md @@ -10,6 +10,8 @@ ChatLab CLI 提供 `linux/amd64` 與 `linux/arm64` 兩種架構的容器映像 ghcr.io/chatlab/chatlab-cli ``` +官方映像已內建本地向量模型所需的執行元件。啟用本地語意索引時,只需依照介面提示下載所選模型檔案,不會在容器啟動後再安裝約 370 MB 的 Node 相依套件。 + ## 快速開始 ### 與 Desktop / 本機 CLI 共用資料(建議) diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index e71529f7..d85536a7 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -463,6 +463,7 @@ export { persistSemanticIndexConfig, resolveSemanticIndexApiKeySet, createSemanticIndexWorkerRuntimeClient, + createLocalEmbeddingRuntimeManager, } from './semantic-index' export type { SemanticIndexServiceOptions, @@ -477,6 +478,7 @@ export type { SemanticIndexConfig, SemanticIndexMode, SemanticIndexModelDownloadSource, + LocalEmbeddingRuntimeConfig, } from './semantic-index' export { diff --git a/packages/node-runtime/src/semantic-index/embedding/local-runtime.test.ts b/packages/node-runtime/src/semantic-index/embedding/local-runtime.test.ts new file mode 100644 index 00000000..c5780649 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedding/local-runtime.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test, { type TestContext } from 'node:test' +import { + createLocalEmbeddingRuntimeManager, + LOCAL_EMBEDDING_RUNTIME_PACKAGE, + LOCAL_EMBEDDING_RUNTIME_VERSION, + type LocalEmbeddingRuntimeManagerOptions, +} from './local-runtime' + +function setupRuntimeTest(t: TestContext, overrides: Partial = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-local-runtime-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const installs: Array<{ command: string; args: string[]; cwd: string }> = [] + let verifies = 0 + const manager = createLocalEmbeddingRuntimeManager({ + baseDir: root, + installMode: 'auto', + platform: 'test-platform', + arch: 'test-arch', + runInstall: async (input) => { + installs.push(input) + const packageDir = path.join(input.cwd, 'node_modules', '@huggingface', 'transformers') + fs.mkdirSync(packageDir, { recursive: true }) + fs.writeFileSync(path.join(packageDir, 'package.json'), '{}\n') + }, + verifyRuntime: async () => { + verifies++ + }, + ...overrides, + }) + return { root, manager, installs, getVerifyCount: () => verifies } +} + +test('installs a versioned platform runtime atomically and reuses it', async (t) => { + const fixture = setupRuntimeTest(t) + + const [firstDir, concurrentDir] = await Promise.all([ + fixture.manager.ensureInstalled(), + fixture.manager.ensureInstalled(), + ]) + const reusedDir = await fixture.manager.ensureInstalled() + + assert.equal( + firstDir, + path.join(fixture.root, `transformers-${LOCAL_EMBEDDING_RUNTIME_VERSION}`, 'test-platform-test-arch') + ) + assert.equal(concurrentDir, firstDir) + assert.equal(reusedDir, firstDir) + assert.equal(fixture.installs.length, 1) + assert.equal(fixture.getVerifyCount(), 2) + assert.equal(fixture.manager.getStatus(), 'ready') + assert.deepEqual(fixture.installs[0].args, [ + 'install', + '--omit=dev', + '--no-audit', + '--no-fund', + '--save-exact', + '--loglevel=error', + '--prefix', + fixture.installs[0].cwd, + `${LOCAL_EMBEDDING_RUNTIME_PACKAGE}@${LOCAL_EMBEDDING_RUNTIME_VERSION}`, + ]) + assert.equal(fs.existsSync(path.join(firstDir, 'runtime.json')), true) + assert.equal( + fs.readdirSync(path.dirname(firstDir)).some((name) => name.startsWith('.installing-')), + false + ) +}) + +test('cleans a failed staging install and retries on the next request', async (t) => { + let attempts = 0 + const fixture = setupRuntimeTest(t, { + runInstall: async (input) => { + attempts++ + if (attempts === 1) throw new Error('install failed') + const packageDir = path.join(input.cwd, 'node_modules', '@huggingface', 'transformers') + fs.mkdirSync(packageDir, { recursive: true }) + fs.writeFileSync(path.join(packageDir, 'package.json'), '{}\n') + }, + }) + + await assert.rejects(fixture.manager.ensureInstalled(), /install failed/) + assert.equal(fixture.manager.getStatus(), 'error') + const parentDir = path.dirname(fixture.manager.getRuntimeDir()) + assert.equal( + fs.readdirSync(parentDir).some((name) => name.startsWith('.installing-')), + false + ) + + await fixture.manager.ensureInstalled() + assert.equal(attempts, 2) + assert.equal(fixture.manager.getStatus(), 'ready') +}) + +test('preinstalled mode fails clearly instead of invoking npm', async (t) => { + let installCalled = false + const fixture = setupRuntimeTest(t, { + installMode: 'preinstalled', + runInstall: async () => { + installCalled = true + }, + }) + + await assert.rejects(fixture.manager.ensureInstalled(), /Preinstalled local embedding runtime is missing/) + + assert.equal(installCalled, false) + assert.equal(fixture.manager.getStatus(), 'error') +}) + +test('repairs an invalid managed runtime instead of keeping a broken install', async (t) => { + const seeded = setupRuntimeTest(t) + await seeded.manager.ensureInstalled() + let verifyAttempts = 0 + let reinstallAttempts = 0 + const repairManager = createLocalEmbeddingRuntimeManager({ + baseDir: seeded.root, + installMode: 'auto', + platform: 'test-platform', + arch: 'test-arch', + verifyRuntime: async () => { + verifyAttempts++ + if (verifyAttempts === 1) throw new Error('corrupt native runtime') + }, + runInstall: async (input) => { + reinstallAttempts++ + const packageDir = path.join(input.cwd, 'node_modules', '@huggingface', 'transformers') + fs.mkdirSync(packageDir, { recursive: true }) + fs.writeFileSync(path.join(packageDir, 'package.json'), '{}\n') + }, + }) + + await repairManager.ensureInstalled() + + assert.equal(reinstallAttempts, 1) + assert.equal(verifyAttempts, 3) + assert.equal(repairManager.getStatus(), 'ready') +}) diff --git a/packages/node-runtime/src/semantic-index/embedding/local-runtime.ts b/packages/node-runtime/src/semantic-index/embedding/local-runtime.ts new file mode 100644 index 00000000..82a0f2ac --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedding/local-runtime.ts @@ -0,0 +1,282 @@ +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { appLogger } from '../../logging/app-logger' + +export const LOCAL_EMBEDDING_RUNTIME_PACKAGE = '@huggingface/transformers' +export const LOCAL_EMBEDDING_RUNTIME_VERSION = '4.2.0' + +export type LocalEmbeddingRuntimeInstallMode = 'auto' | 'preinstalled' +export type LocalEmbeddingRuntimeStatus = 'idle' | 'installing' | 'ready' | 'error' + +export interface LocalEmbeddingRuntimeConfig { + baseDir: string + installMode: LocalEmbeddingRuntimeInstallMode +} + +interface LocalEmbeddingRuntimeManifest { + formatVersion: 1 + packageName: typeof LOCAL_EMBEDDING_RUNTIME_PACKAGE + packageVersion: typeof LOCAL_EMBEDDING_RUNTIME_VERSION + platform: string + arch: string +} + +interface InstallCommand { + command: string + args: string[] + cwd: string +} + +export interface LocalEmbeddingRuntimeManagerOptions extends LocalEmbeddingRuntimeConfig { + platform?: string + arch?: string + npmCommand?: string + runInstall?: (input: InstallCommand) => Promise + verifyRuntime?: (runtimeDir: string) => Promise + importTransformers?: (runtimeDir: string) => Promise +} + +const RUNTIME_MANIFEST_FILE = 'runtime.json' + +function executeInstall(input: InstallCommand): Promise { + return new Promise((resolve, reject) => { + execFile( + input.command, + input.args, + { + cwd: input.cwd, + env: { ...process.env, npm_config_update_notifier: 'false' }, + maxBuffer: 10 * 1024 * 1024, + shell: process.platform === 'win32', + }, + (error, _stdout, stderr) => { + if (!error) { + resolve() + return + } + reject(new Error(stderr.trim() || error.message, { cause: error })) + } + ) + }) +} + +function createRuntimeRequire(runtimeDir: string): NodeJS.Require { + return createRequire(path.join(runtimeDir, 'package.json')) +} + +async function importFromRuntime(runtimeDir: string, packageName: string): Promise> { + const resolved = createRuntimeRequire(runtimeDir).resolve(packageName) + return (await import(pathToFileURL(resolved).href)) as Record +} + +async function verifyInstalledRuntime(runtimeDir: string): Promise { + const transformers = await importFromRuntime(runtimeDir, LOCAL_EMBEDDING_RUNTIME_PACKAGE) + if (typeof transformers.pipeline !== 'function' || typeof transformers.env !== 'object') { + throw new Error('Installed Transformers runtime does not expose the expected Node API.') + } + + const onnxRuntime = await importFromRuntime(runtimeDir, 'onnxruntime-node') + if (typeof onnxRuntime.listSupportedBackends !== 'function') { + throw new Error('Installed ONNX runtime does not expose listSupportedBackends().') + } +} + +async function loadInstalledTransformers(runtimeDir: string): Promise { + return (await importFromRuntime( + runtimeDir, + LOCAL_EMBEDDING_RUNTIME_PACKAGE + )) as unknown as typeof import('@huggingface/transformers') +} + +export class LocalEmbeddingRuntimeManager { + private readonly baseDir: string + private readonly installMode: LocalEmbeddingRuntimeInstallMode + private readonly platform: string + private readonly arch: string + private readonly npmCommand: string + private readonly runInstall: (input: InstallCommand) => Promise + private readonly verifyRuntime: (runtimeDir: string) => Promise + private readonly importTransformers: (runtimeDir: string) => Promise + + private status: LocalEmbeddingRuntimeStatus + private installPromise: Promise | null = null + private transformersPromise: Promise | null = null + private verifiedRuntimeDir: string | null = null + + constructor(options: LocalEmbeddingRuntimeManagerOptions) { + this.baseDir = options.baseDir + this.installMode = options.installMode + this.platform = options.platform ?? process.platform + this.arch = options.arch ?? process.arch + this.npmCommand = options.npmCommand ?? (this.platform === 'win32' ? 'npm.cmd' : 'npm') + this.runInstall = options.runInstall ?? executeInstall + this.verifyRuntime = options.verifyRuntime ?? verifyInstalledRuntime + this.importTransformers = options.importTransformers ?? loadInstalledTransformers + this.status = 'idle' + } + + getStatus(): LocalEmbeddingRuntimeStatus { + return this.status + } + + getRuntimeDir(): string { + return path.join(this.baseDir, `transformers-${LOCAL_EMBEDDING_RUNTIME_VERSION}`, `${this.platform}-${this.arch}`) + } + + async ensureInstalled(): Promise { + if (!this.installPromise) { + this.installPromise = this.ensureInstalledOnce().finally(() => { + this.installPromise = null + }) + } + return await this.installPromise + } + + async loadTransformers(): Promise { + if (!this.transformersPromise) { + this.transformersPromise = this.ensureInstalled() + .then((runtimeDir) => this.importTransformers(runtimeDir)) + .catch((error) => { + this.transformersPromise = null + throw error + }) + } + return await this.transformersPromise + } + + private async ensureInstalledOnce(): Promise { + const runtimeDir = this.getRuntimeDir() + try { + if (this.hasCompleteLayout(runtimeDir)) { + try { + await this.verifyOnce(runtimeDir) + this.status = 'ready' + return runtimeDir + } catch (error) { + if (this.installMode === 'preinstalled') throw error + appLogger.warn('semantic-index', 'Existing local embedding runtime is invalid; reinstalling', { + packageVersion: LOCAL_EMBEDDING_RUNTIME_VERSION, + platform: this.platform, + arch: this.arch, + error: error instanceof Error ? error.message : String(error), + }) + fs.rmSync(runtimeDir, { recursive: true, force: true }) + this.verifiedRuntimeDir = null + } + } + + if (this.installMode === 'preinstalled') { + throw new Error(`Preinstalled local embedding runtime is missing or incomplete: ${runtimeDir}`) + } + + this.status = 'installing' + appLogger.info('semantic-index', 'Local embedding runtime installation started', { + packageVersion: LOCAL_EMBEDDING_RUNTIME_VERSION, + platform: this.platform, + arch: this.arch, + }) + + await this.installAtomically(runtimeDir) + await this.verifyOnce(runtimeDir) + this.status = 'ready' + appLogger.info('semantic-index', 'Local embedding runtime installation completed', { + packageVersion: LOCAL_EMBEDDING_RUNTIME_VERSION, + platform: this.platform, + arch: this.arch, + }) + return runtimeDir + } catch (error) { + this.status = 'error' + appLogger.error('semantic-index', 'Local embedding runtime installation failed', error) + throw error + } + } + + private async installAtomically(runtimeDir: string): Promise { + const parentDir = path.dirname(runtimeDir) + fs.mkdirSync(parentDir, { recursive: true }) + const stagingDir = fs.mkdtempSync(path.join(parentDir, '.installing-')) + + try { + fs.writeFileSync( + path.join(stagingDir, 'package.json'), + `${JSON.stringify({ private: true, type: 'module' }, null, 2)}\n`, + 'utf8' + ) + await this.runInstall({ + command: this.npmCommand, + args: [ + 'install', + '--omit=dev', + '--no-audit', + '--no-fund', + '--save-exact', + '--loglevel=error', + '--prefix', + stagingDir, + `${LOCAL_EMBEDDING_RUNTIME_PACKAGE}@${LOCAL_EMBEDDING_RUNTIME_VERSION}`, + ], + cwd: stagingDir, + }) + await this.verifyRuntime(stagingDir) + fs.writeFileSync(path.join(stagingDir, RUNTIME_MANIFEST_FILE), `${JSON.stringify(this.manifest(), null, 2)}\n`) + + if (fs.existsSync(runtimeDir)) { + if (this.hasCompleteLayout(runtimeDir)) return + fs.rmSync(runtimeDir, { recursive: true, force: true }) + } + try { + fs.renameSync(stagingDir, runtimeDir) + } catch (error) { + // Another ChatLab process may have completed the same atomic install first. + if (!this.hasCompleteLayout(runtimeDir)) throw error + } + } finally { + fs.rmSync(stagingDir, { recursive: true, force: true }) + } + } + + private async verifyOnce(runtimeDir: string): Promise { + if (this.verifiedRuntimeDir === runtimeDir) return + await this.verifyRuntime(runtimeDir) + this.verifiedRuntimeDir = runtimeDir + } + + private hasCompleteLayout(runtimeDir: string): boolean { + const manifestPath = path.join(runtimeDir, RUNTIME_MANIFEST_FILE) + const packagePath = path.join(runtimeDir, 'node_modules', '@huggingface', 'transformers', 'package.json') + if (!fs.existsSync(manifestPath) || !fs.existsSync(packagePath)) return false + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Partial + const expected = this.manifest() + return ( + manifest.formatVersion === expected.formatVersion && + manifest.packageName === expected.packageName && + manifest.packageVersion === expected.packageVersion && + manifest.platform === expected.platform && + manifest.arch === expected.arch + ) + } catch { + return false + } + } + + private manifest(): LocalEmbeddingRuntimeManifest { + return { + formatVersion: 1, + packageName: LOCAL_EMBEDDING_RUNTIME_PACKAGE, + packageVersion: LOCAL_EMBEDDING_RUNTIME_VERSION, + platform: this.platform, + arch: this.arch, + } + } +} + +export function createLocalEmbeddingRuntimeManager( + options: LocalEmbeddingRuntimeManagerOptions +): LocalEmbeddingRuntimeManager { + return new LocalEmbeddingRuntimeManager(options) +} diff --git a/packages/node-runtime/src/semantic-index/embedding/local.ts b/packages/node-runtime/src/semantic-index/embedding/local.ts index 505cf98d..f2019505 100644 --- a/packages/node-runtime/src/semantic-index/embedding/local.ts +++ b/packages/node-runtime/src/semantic-index/embedding/local.ts @@ -40,6 +40,9 @@ export type LocalPipelineFactory = (params: { sessionOptions?: typeof LOCAL_ONNX_SESSION_OPTIONS }) => Promise +export type TransformersModule = typeof import('@huggingface/transformers') +export type LoadTransformers = () => Promise + export async function createProxyFetch(proxyUrl: string): Promise { const parsed = new URL(proxyUrl) if (SOCKS_PROXY_PROTOCOLS.has(parsed.protocol)) { @@ -55,34 +58,32 @@ export async function createProxyFetch(proxyUrl: string): Promise }) as typeof fetch } -const defaultPipelineFactory: LocalPipelineFactory = async ({ - modelId, - dtype, - cacheDir, - modelDownloadProxyUrl, - modelDownloadSource, -}) => { - const transformers = await import('@huggingface/transformers') - // Transformers.js 的 env 是 worker 进程级全局对象,因此每次创建 pipeline 都显式覆盖 host, - // 避免用户从镜像切回官方源后继续复用旧的 remoteHost。 - transformers.env.remoteHost = resolveModelDownloadRemoteHost(modelDownloadSource) - if (cacheDir) { - transformers.env.cacheDir = cacheDir - transformers.env.allowRemoteModels = true - } - if (modelDownloadProxyUrl) { - transformers.env.fetch = await createProxyFetch(modelDownloadProxyUrl) - } - const extractor = await transformers.pipeline('feature-extraction', modelId, { - ...(dtype ? { dtype } : {}), - session_options: LOCAL_ONNX_SESSION_OPTIONS, - }) - return async (texts, options) => { - const out = await extractor(texts, { pooling: options.pooling, normalize: options.normalize }) - return out.tolist() as number[][] +export function createTransformersPipelineFactory(loadTransformers: LoadTransformers): LocalPipelineFactory { + return async ({ modelId, dtype, cacheDir, modelDownloadProxyUrl, modelDownloadSource }) => { + const transformers = await loadTransformers() + // Transformers.js 的 env 是 worker 进程级全局对象,因此每次创建 pipeline 都显式覆盖 host, + // 避免用户从镜像切回官方源后继续复用旧的 remoteHost。 + transformers.env.remoteHost = resolveModelDownloadRemoteHost(modelDownloadSource) + if (cacheDir) { + transformers.env.cacheDir = cacheDir + transformers.env.allowRemoteModels = true + } + if (modelDownloadProxyUrl) { + transformers.env.fetch = await createProxyFetch(modelDownloadProxyUrl) + } + const extractor = await transformers.pipeline('feature-extraction', modelId, { + ...(dtype ? { dtype } : {}), + session_options: LOCAL_ONNX_SESSION_OPTIONS, + }) + return async (texts, options) => { + const out = await extractor(texts, { pooling: options.pooling, normalize: options.normalize }) + return out.tolist() as number[][] + } } } +const defaultPipelineFactory = createTransformersPipelineFactory(() => import('@huggingface/transformers')) + export interface LocalEmbeddingProviderOptions { cacheDir?: string modelDownloadProxyUrl?: string diff --git a/packages/node-runtime/src/semantic-index/embedding/types.ts b/packages/node-runtime/src/semantic-index/embedding/types.ts index 918d1744..5bc4706a 100644 --- a/packages/node-runtime/src/semantic-index/embedding/types.ts +++ b/packages/node-runtime/src/semantic-index/embedding/types.ts @@ -8,6 +8,8 @@ export type EmbeddingPooling = 'cls' | 'last_token' | 'mean' +export type SemanticIndexModelStatus = 'idle' | 'installing-runtime' | 'downloading-model' | 'ready' | 'error' + export interface EmbeddingProvider { /** 用于 chunk 分区与重建判定的稳定模型标识 */ readonly modelId: string diff --git a/packages/node-runtime/src/semantic-index/index.ts b/packages/node-runtime/src/semantic-index/index.ts index 58fd5619..5c877063 100644 --- a/packages/node-runtime/src/semantic-index/index.ts +++ b/packages/node-runtime/src/semantic-index/index.ts @@ -75,6 +75,17 @@ export { SEMANTIC_INDEX_CONFIG_FILE, } from './service' export type { SemanticIndexRuntime, MaybePromise } from './runtime' +export { + createLocalEmbeddingRuntimeManager, + LOCAL_EMBEDDING_RUNTIME_PACKAGE, + LOCAL_EMBEDDING_RUNTIME_VERSION, +} from './embedding/local-runtime' +export type { + LocalEmbeddingRuntimeConfig, + LocalEmbeddingRuntimeInstallMode, + LocalEmbeddingRuntimeManagerOptions, + LocalEmbeddingRuntimeStatus, +} from './embedding/local-runtime' export { SemanticIndexWorkerClient, createSemanticIndexWorkerClient, diff --git a/packages/node-runtime/src/semantic-index/runtime.ts b/packages/node-runtime/src/semantic-index/runtime.ts index 65562093..5731a7f7 100644 --- a/packages/node-runtime/src/semantic-index/runtime.ts +++ b/packages/node-runtime/src/semantic-index/runtime.ts @@ -5,6 +5,7 @@ import type { SemanticSearchToolOptions, SemanticSearchToolResult, } from './service' +import type { SemanticIndexModelStatus } from './embedding/types' export type MaybePromise = T | Promise @@ -17,7 +18,7 @@ export type MaybePromise = T | Promise export interface SemanticIndexRuntime { getConfig(): MaybePromise setConfig(config: SemanticIndexConfigInput, options?: { apiKey?: string }): MaybePromise - getModelStatus(): MaybePromise<'idle' | 'downloading' | 'ready' | 'error'> + getModelStatus(): MaybePromise isConfigured(): MaybePromise hasApiKey(): MaybePromise diff --git a/packages/node-runtime/src/semantic-index/service.test.ts b/packages/node-runtime/src/semantic-index/service.test.ts index b36f8cd4..3e0c137c 100644 --- a/packages/node-runtime/src/semantic-index/service.test.ts +++ b/packages/node-runtime/src/semantic-index/service.test.ts @@ -18,6 +18,7 @@ import { computeDbPathHash } from './chunker-config' import { BGE_BASE_PROFILE, QWEN3_PROFILE } from './embedding/profiles' import type { SessionRuntimeAdapter } from '../services/adapters' import type { FeatureExtractFn, LocalPipelineFactory } from './embedding/local' +import type { LocalEmbeddingRuntimeStatus } from './embedding/local-runtime' const SESSION_ID = 'sess1' const SERVICE_TEMP_DIR_PREFIX = 'chatlab-si-svc-' @@ -31,6 +32,7 @@ interface SetupOptions { failPipelineCreationForModelIds?: ReadonlySet onEmbedBatchStarted?: () => void localPipelineFactory?: LocalPipelineFactory + getLocalEmbeddingRuntimeStatus?: () => LocalEmbeddingRuntimeStatus } interface ServiceFixture { @@ -163,6 +165,7 @@ function setup(t: TestContext, opts?: SetupOptions): ServiceFixture { localPipelineFactory, resolveApiKey: (_provider, authProfile) => (authProfile ? (authProfiles.get(authProfile)?.key ?? '') : ''), }, + getLocalEmbeddingRuntimeStatus: opts?.getLocalEmbeddingRuntimeStatus, }) cleanup.service = service // 默认配置不再预选模型;测试显式选择 Qwen3 本地模型,使建索引可执行 @@ -298,6 +301,15 @@ test('successful warmup retry clears a previous local model preload error', asyn assert.equal(service.getModelStatus(), 'ready') }) +test('reports runtime installation separately from model file download', (t) => { + const { service } = setup(t, { + localPipelineFactory: async () => await new Promise(() => {}), + getLocalEmbeddingRuntimeStatus: () => 'installing', + }) + + assert.equal(service.getModelStatus(), 'installing-runtime') +}) + test('stale local warmup completion does not mark current local model ready', async (t) => { let resolveFirstBatchStarted: () => void = () => {} const firstBatchStarted = new Promise((resolve) => { diff --git a/packages/node-runtime/src/semantic-index/service.ts b/packages/node-runtime/src/semantic-index/service.ts index 0d049fae..b15f7159 100644 --- a/packages/node-runtime/src/semantic-index/service.ts +++ b/packages/node-runtime/src/semantic-index/service.ts @@ -36,7 +36,8 @@ import { type SemanticIndexConfigInput, } from './config' import { createEmbedder, type EmbedderFactoryDeps } from './embedder-factory' -import type { EmbeddingProvider } from './embedding/types' +import type { EmbeddingProvider, SemanticIndexModelStatus } from './embedding/types' +import type { LocalEmbeddingRuntimeStatus } from './embedding/local-runtime' import { EmbeddingIndexStore, type LoadSqliteVec } from './store' import { SemanticIndexStateStore, @@ -74,6 +75,8 @@ export interface SemanticIndexServiceOptions { nativeBinding?: string /** auth profile 写入注入(测试用,避免写真实 ~/.chatlab) */ writeAuthProfile?: (name: string, profile: AuthProfile) => void + /** Optional managed local runtime status for CLI; Desktop uses its bundled runtime. */ + getLocalEmbeddingRuntimeStatus?: () => LocalEmbeddingRuntimeStatus } /** 语义索引 API Key 固定存储在此 auth profile;config 只保存引用,不保存明文 */ @@ -210,6 +213,7 @@ export function createSemanticIndexService(params: { nativeBinding?: string loadSqliteVec?: LoadSqliteVec embedderFactoryDeps?: EmbedderFactoryDeps + getLocalEmbeddingRuntimeStatus?: () => LocalEmbeddingRuntimeStatus modelDownloadProxyUrl?: string }): SemanticIndexService { const { pathProvider } = params @@ -222,6 +226,7 @@ export function createSemanticIndexService(params: { nativeBinding: params.nativeBinding, loadSqliteVec: params.loadSqliteVec, embedderFactoryDeps: params.embedderFactoryDeps, + getLocalEmbeddingRuntimeStatus: params.getLocalEmbeddingRuntimeStatus, }) } @@ -286,8 +291,9 @@ export class SemanticIndexService { * 保存配置;模型身份变化会使已启用对话的索引在重建完成前不被检索使用。 * 传入 apiKey 时写入固定 auth profile,config 内只保存引用,绝不持久化明文。 */ - getModelStatus(): 'idle' | 'downloading' | 'ready' | 'error' { - return this.modelPreloadStatus + getModelStatus(): SemanticIndexModelStatus { + if (this.modelPreloadStatus !== 'downloading') return this.modelPreloadStatus + return this.options.getLocalEmbeddingRuntimeStatus?.() === 'installing' ? 'installing-runtime' : 'downloading-model' } setConfig(config: SemanticIndexConfigInput, options?: { apiKey?: string }): SemanticIndexConfig { @@ -304,12 +310,23 @@ export class SemanticIndexService { if (!saved.enabled) { for (const state of this.stateStore.listEnabled()) this.queue.pause(state.dbPathHash) } - // 本地模式已配置且功能已开启时,立即在后台触发模型下载,让用户在建索引前完成等待 - if (saved.enabled && isSemanticIndexConfigured(saved) && saved.mode === 'local') { - const modelId = saved.local.modelId - const downloadSource = saved.local.downloadSource ?? DEFAULT_SEMANTIC_INDEX_MODEL_DOWNLOAD_SOURCE - const cacheDir = this.options.modelsCacheDir - this.modelPreloadStatus = 'downloading' + this.startLocalModelPreload(saved, preloadGeneration) + return saved + } + + private startLocalModelPreload( + config: SemanticIndexConfig, + preloadGeneration: number, + options: { defer?: boolean } = {} + ): void { + if (!config.enabled || !isSemanticIndexConfigured(config) || config.mode !== 'local') return + + const modelId = config.local.modelId + const downloadSource = config.local.downloadSource ?? DEFAULT_SEMANTIC_INDEX_MODEL_DOWNLOAD_SOURCE + const cacheDir = this.options.modelsCacheDir + this.modelPreloadStatus = 'downloading' + const run = () => { + if (preloadGeneration !== this.modelPreloadGeneration) return appLogger.info('semantic-index', 'local embedding model preload started', { modelId, downloadSource, cacheDir }) void this.getEmbedder() .preload?.() @@ -332,7 +349,8 @@ export class SemanticIndexService { ) }) } - return saved + if (options.defer) queueMicrotask(run) + else run() } private currentModelId(): string { @@ -793,6 +811,8 @@ export class SemanticIndexService { this.stateStore.setIndexStatus(state.dbPathHash, 'paused') } } + const config = this.configStore.get() + this.startLocalModelPreload(config, ++this.modelPreloadGeneration, { defer: true }) } // ---------- 队列执行 ---------- diff --git a/packages/node-runtime/src/semantic-index/worker-client.test.ts b/packages/node-runtime/src/semantic-index/worker-client.test.ts index 38f42d48..2e3c05da 100644 --- a/packages/node-runtime/src/semantic-index/worker-client.test.ts +++ b/packages/node-runtime/src/semantic-index/worker-client.test.ts @@ -105,6 +105,71 @@ test('worker client returns unavailable for unconfigured canSearch without start assert.equal(instances.length, 0) }) +test('worker client starts configured local model recovery when status is requested', async () => { + const instances: FakeTransport[] = [] + const configStore = makeConfigStore() + configStore.set({ + version: 1, + enabled: true, + mode: 'local', + local: { modelId: 'model-a' }, + api: null, + }) + const client = createSemanticIndexWorkerClient({ + configStore, + transportFactory: makeFactory(instances, (method) => { + if (method === 'getModelStatus') return 'installing-runtime' + throw new Error(`unexpected method: ${method}`) + }), + idleTimeoutMs: 1000, + }) + + const status = await client.getModelStatus() + + assert.equal(status, 'installing-runtime') + assert.equal(instances.length, 1) + assert.deepEqual(instances[0].requests, [{ method: 'getModelStatus', args: [] }]) +}) + +test('worker client keeps a model preload worker alive until the model is ready', async () => { + const instances: FakeTransport[] = [] + const timers: Array<() => void> = [] + const statuses: Array<'downloading-model' | 'ready'> = ['downloading-model', 'ready'] + const configStore = makeConfigStore() + configStore.set({ + version: 1, + enabled: true, + mode: 'local', + local: { modelId: 'model-a' }, + api: null, + }) + const client = createSemanticIndexWorkerClient({ + configStore, + transportFactory: makeFactory(instances, (method) => { + if (method === 'getModelStatus') return statuses.shift() ?? 'ready' + throw new Error(`unexpected method: ${method}`) + }), + idleTimeoutMs: 1000, + timers: { + setTimeout(callback) { + timers.push(callback) + return callback + }, + clearTimeout() { + /* test timer is manually triggered */ + }, + }, + }) + + assert.equal(await client.getModelStatus(), 'downloading-model') + assert.equal(timers.length, 0) + + assert.equal(await client.getModelStatus(), 'ready') + assert.equal(timers.length, 1) + timers.shift()?.() + assert.equal(instances[0].closed, true) +}) + test('worker client treats canSearch worker failures as unavailable', async () => { const instances: FakeTransport[] = [] const configStore = makeConfigStore() diff --git a/packages/node-runtime/src/semantic-index/worker-client.ts b/packages/node-runtime/src/semantic-index/worker-client.ts index e3d5be7d..1c212adb 100644 --- a/packages/node-runtime/src/semantic-index/worker-client.ts +++ b/packages/node-runtime/src/semantic-index/worker-client.ts @@ -14,6 +14,8 @@ import { type SemanticSearchToolResult, } from './service' import type { SemanticIndexRuntime } from './runtime' +import type { SemanticIndexModelStatus } from './embedding/types' +import type { LocalEmbeddingRuntimeConfig } from './embedding/local-runtime' import type { RuntimeIdentity } from '../data-dir-compat' import { snapshotPathProvider } from './static-path-provider' import { createSemanticIndexWorkerThreadTransport } from './worker-thread-transport' @@ -50,6 +52,7 @@ export interface SemanticIndexWorkerRuntimeClientOptions { workerEntryUrl?: string | URL idleTimeoutMs?: number getModelDownloadProxyUrl?: () => MaybePromise + localEmbeddingRuntime?: LocalEmbeddingRuntimeConfig resolveApiKey?: (provider: string, authProfile?: string) => string writeAuthProfile?: (name: string, profile: AuthProfile) => void } @@ -67,6 +70,7 @@ export class SemanticIndexWorkerClient implements SemanticIndexRuntime { private idleTimer: unknown = null private activeBuildSessionIds = new Set() private hasUnknownActiveBuild = false + private hasActiveModelPreload = false private activeModelDownloadProxyUrl: string | undefined private readonly configStore: SemanticIndexConfigStore private readonly transportFactory: SemanticIndexWorkerTransportFactory @@ -95,7 +99,10 @@ export class SemanticIndexWorkerClient implements SemanticIndexRuntime { apiKey: options?.apiKey, writeAuthProfile: this.writeAuthProfile, }) - if (!saved.enabled) this.clearActiveBuildTracking() + if (!saved.enabled) { + this.clearActiveBuildTracking() + this.hasActiveModelPreload = false + } // 本地模式且功能开启时,即使 worker 未运行也需启动它以触发 preload; // 其余情况(disabled / api 模式)无需唤醒 worker,直接返回本地保存结果。 const needsWorker = saved.enabled && saved.mode === 'local' && this.configStore.isConfigured() @@ -112,9 +119,12 @@ 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', []) + async getModelStatus(): Promise { + if (!this.transport && !this.shouldStartLocalModelWorker()) return 'idle' + const status = await this.call('getModelStatus', [], { scheduleIdle: false }) + this.hasActiveModelPreload = status === 'installing-runtime' || status === 'downloading-model' + this.scheduleIdleCloseIfNeeded() + return status } enable(sessionId: string): Promise { @@ -288,6 +298,11 @@ export class SemanticIndexWorkerClient implements SemanticIndexRuntime { return resolveSemanticIndexApiKeySet(config, this.resolveApiKey) } + private shouldStartLocalModelWorker(): boolean { + const config = this.configStore.get() + return config.enabled && config.mode === 'local' && this.configStore.isConfigured() + } + private updateActiveBuildFromCompleteSnapshot(statuses: SemanticIndexSessionStatus[]): void { this.hasUnknownActiveBuild = false this.activeBuildSessionIds = new Set( @@ -321,6 +336,7 @@ export class SemanticIndexWorkerClient implements SemanticIndexRuntime { !this.transport || this.pendingRequests > 0 || this.hasUnknownActiveBuild || + this.hasActiveModelPreload || this.activeBuildSessionIds.size > 0 ) return @@ -343,6 +359,7 @@ export class SemanticIndexWorkerClient implements SemanticIndexRuntime { const transport = this.transport this.transport = null this.activeModelDownloadProxyUrl = undefined + this.hasActiveModelPreload = false this.clearActiveBuildTracking() if (transport) await transport.close() } @@ -382,6 +399,7 @@ export function createSemanticIndexWorkerRuntimeClient( nativeBinding: options.nativeBinding, sqliteVecLoadablePath: options.sqliteVecLoadablePath, modelDownloadProxyUrl, + localEmbeddingRuntime: options.localEmbeddingRuntime, }, }), }) diff --git a/packages/node-runtime/src/semantic-index/worker-runtime.ts b/packages/node-runtime/src/semantic-index/worker-runtime.ts index 81b474d2..e2871504 100644 --- a/packages/node-runtime/src/semantic-index/worker-runtime.ts +++ b/packages/node-runtime/src/semantic-index/worker-runtime.ts @@ -2,6 +2,8 @@ import type { RuntimeIdentity } from '../data-dir-compat' import { DatabaseManager } from '../database-manager' import { initAppLogger } from '../logging/app-logger' import { createDatabaseManagerAdapter } from '../services' +import { createLocalEmbeddingRuntimeManager, type LocalEmbeddingRuntimeConfig } from './embedding/local-runtime' +import { createTransformersPipelineFactory } from './embedding/local' import { createSemanticIndexService } from './service' import type { LoadSqliteVec } from './store' import { StaticPathProvider, type StaticPathProviderSnapshot } from './static-path-provider' @@ -13,6 +15,7 @@ export interface SemanticIndexWorkerStartupOptions { nativeBinding?: string sqliteVecLoadablePath?: string modelDownloadProxyUrl?: string + localEmbeddingRuntime?: LocalEmbeddingRuntimeConfig } export type SemanticIndexWorkerServiceFactory = () => SemanticIndexRuntime @@ -72,6 +75,12 @@ export function createSemanticIndexWorkerServiceFactory( ): SemanticIndexWorkerServiceFactory { const initializeLogger = deps.initLogger ?? initAppLogger initializeLogger(options.paths.logsDir) + const localRuntimeManager = options.localEmbeddingRuntime + ? createLocalEmbeddingRuntimeManager(options.localEmbeddingRuntime) + : undefined + const localPipelineFactory = localRuntimeManager + ? createTransformersPipelineFactory(() => localRuntimeManager.loadTransformers()) + : undefined return () => { const pathProvider = new StaticPathProvider(options.paths) @@ -90,6 +99,8 @@ export function createSemanticIndexWorkerServiceFactory( nativeBinding: options.nativeBinding, loadSqliteVec, modelDownloadProxyUrl: options.modelDownloadProxyUrl, + embedderFactoryDeps: localPipelineFactory ? { localPipelineFactory } : undefined, + getLocalEmbeddingRuntimeStatus: localRuntimeManager ? () => localRuntimeManager.getStatus() : undefined, }) } } diff --git a/src/components/common/Settings/AI/SemanticIndexModelModal.vue b/src/components/common/Settings/AI/SemanticIndexModelModal.vue index c95c7cf6..961d173a 100644 --- a/src/components/common/Settings/AI/SemanticIndexModelModal.vue +++ b/src/components/common/Settings/AI/SemanticIndexModelModal.vue @@ -13,6 +13,7 @@ import ApiKeyInput from './ApiKeyInput.vue' import { LOCAL_MODELS, API_TEMPLATES, type ModelConfigDraft } from './semantic-index-models' import { canReuseSemanticIndexApiAuthProfile, isSemanticIndexApiKeyRequired } from './semantic-index-config-builder' import type { SemanticIndexConfig, SemanticIndexModelDownloadSource } from '@/services' +import { IS_ELECTRON, IS_WEB_WASM } from '@/utils/platform' const props = defineProps<{ open: boolean @@ -37,6 +38,7 @@ const apiModel = ref('') const apiKey = ref('') const isZh = computed(() => locale.value.startsWith('zh')) +const usesManagedLocalRuntime = !IS_ELECTRON && !IS_WEB_WASM const localModels = computed(() => LOCAL_MODELS.filter((m) => !m.zhOnly || isZh.value)) // 非中文 locale 隐藏中国区模板,但保留当前已选项避免标签错位 const apiTemplates = computed(() => @@ -170,7 +172,15 @@ function confirm() { {{ t('settings.ai.semanticIndex.mirrorSourceHint') }}

-

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

+

+ {{ + t( + usesManagedLocalRuntime + ? 'settings.ai.semanticIndex.localRuntimeDownloadHint' + : 'settings.ai.semanticIndex.localDownloadHint' + ) + }} +

diff --git a/src/components/common/Settings/AI/SemanticIndexSection.vue b/src/components/common/Settings/AI/SemanticIndexSection.vue index 9a396b80..314467fb 100644 --- a/src/components/common/Settings/AI/SemanticIndexSection.vue +++ b/src/components/common/Settings/AI/SemanticIndexSection.vue @@ -30,13 +30,17 @@ const showModelModal = ref(false) let modelPollTimer: ReturnType | null = null +function isModelPreparing(status: ModelDownloadStatus): boolean { + return status === 'installing-runtime' || status === 'downloading-model' +} + function startModelStatusPoll() { if (modelPollTimer) return modelPollTimer = setInterval(async () => { try { const res = await service.getConfig() modelStatus.value = res.modelStatus - if (res.modelStatus !== 'downloading') stopModelStatusPoll() + if (!isModelPreparing(res.modelStatus)) stopModelStatusPoll() } catch { stopModelStatusPoll() } @@ -140,7 +144,7 @@ async function persistConfig(next: SemanticIndexConfig, apiKey?: string): Promis configured.value = res.configured enabled.value = res.config.enabled modelStatus.value = res.modelStatus - if (res.modelStatus === 'downloading') startModelStatusPoll() + if (isModelPreparing(res.modelStatus)) startModelStatusPoll() await loadStatuses() return true } catch (error) { @@ -264,7 +268,7 @@ onMounted(async () => { configured.value = res.configured enabled.value = res.config.enabled modelStatus.value = res.modelStatus - if (res.modelStatus === 'downloading') startModelStatusPoll() + if (isModelPreparing(res.modelStatus)) startModelStatusPoll() } catch (error) { console.error('[semantic-index] load config failed:', error) } @@ -336,11 +340,15 @@ onUnmounted(() => {

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

- {{ t('settings.ai.semanticIndex.modelDownloading') }} + {{ + modelStatus === 'installing-runtime' + ? t('settings.ai.semanticIndex.runtimeInstalling') + : 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 3555b831..61e2190a 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -271,14 +271,16 @@ "downloadSourceMirror": "HF-Mirror (China)", "mirrorSourceHint": "HF-Mirror is a third-party public mirror for networks in mainland China. Model files will be downloaded from hf-mirror.com.", "localDownloadHint": "The model downloads automatically after saving. The first download may take a while.", + "localRuntimeDownloadHint": "The CLI Web local-model runtime is about 370 MB. If it is not preinstalled, ChatLab installs it automatically on first use. The selected model downloads after saving.", "apiTemplate": "Service Template", "modelName": "Model Name", "apiKeySaved": "Saved; leave blank to keep", "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…", + "runtimeInstalling": "Installing the local-model runtime. First use may take a while…", + "modelError": "Local model preparation failed. It will be retried when building the index.", "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 31b71dd6..6bae8b2d 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -271,14 +271,16 @@ "downloadSourceMirror": "HF-Mirror(中国本土向け)", "mirrorSourceHint": "HF-Mirror は中国本土のネットワーク向けの第三者公開ミラーです。モデルファイルは hf-mirror.com からダウンロードされます。", "localDownloadHint": "保存後にモデルが自動でダウンロードされます。初回は時間がかかる場合があります。", + "localRuntimeDownloadHint": "CLI Web のローカルモデル実行コンポーネントは約 370 MB です。プリインストールされていない場合、初回使用時に自動インストールされます。保存後に選択したモデルもダウンロードされます。", "apiTemplate": "サービステンプレート", "modelName": "モデル名", "apiKeySaved": "保存済み。空欄で変更しません", "apiKeyHint": "API キーは別途安全に保存され、設定には参照のみが保存されます。", "apiCostWarning": "API モードはプロバイダー費用が発生する場合があります。価格をご確認ください。", "changeRebuildHint": "モデルや API を変更すると、有効な会話は再構築が必要です。", - "modelError": "モデルのダウンロードに失敗しました。インデックス構築時に再試行されます。", "modelDownloading": "モデルをダウンロード中です。初回は数分かかる場合があります…", + "runtimeInstalling": "ローカルモデル実行コンポーネントをインストールしています。初回は時間がかかる場合があります…", + "modelError": "ローカルモデルの準備に失敗しました。インデックス構築時に再試行されます。", "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 1b458edf..a70970e4 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -271,6 +271,7 @@ "downloadSourceMirror": "HF-Mirror 国内镜像", "mirrorSourceHint": "HF-Mirror 是第三方公益镜像,适合中国大陆网络环境;模型文件将从 hf-mirror.com 下载。", "localDownloadHint": "保存后自动下载模型,首次下载可能需要一些时间。", + "localRuntimeDownloadHint": "CLI Web 的本地模型运行组件约 370 MB;未预装时会在首次使用时自动安装。保存后还会下载所选模型。", "apiTemplate": "服务模板", "modelName": "模型名", "apiKeySaved": "已保存,留空则不修改", @@ -278,7 +279,8 @@ "apiCostWarning": "API 模式可能产生服务商费用,请确认模型价格。", "changeRebuildHint": "更换模型或 API 后,已启用对话需要重建索引。", "modelDownloading": "正在下载模型,首次使用需要几分钟,请稍候…", - "modelError": "模型下载失败,建立索引时将重试。", + "runtimeInstalling": "正在安装本地模型运行组件,首次使用需要一些时间,请稍候…", + "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 d7e31957..48ba65f4 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -271,14 +271,16 @@ "downloadSourceMirror": "HF-Mirror 中國大陸鏡像", "mirrorSourceHint": "HF-Mirror 是第三方公益鏡像,適合中國大陸網路環境;模型檔案將從 hf-mirror.com 下載。", "localDownloadHint": "儲存後會自動下載模型,首次下載可能需要一些時間。", + "localRuntimeDownloadHint": "CLI Web 的本地模型執行元件約 370 MB;未預先安裝時會在首次使用時自動安裝。儲存後還會下載所選模型。", "apiTemplate": "服務範本", "modelName": "模型名稱", "apiKeySaved": "已儲存,留空則不修改", "apiKeyHint": "API Key 單獨安全儲存,設定檔只保存引用。", "apiCostWarning": "API 模式可能產生服務商費用,請確認模型價格。", "changeRebuildHint": "更換模型或 API 後,已啟用對話需要重建索引。", - "modelError": "模型下載失敗,建立索引時將重試。", "modelDownloading": "正在下載模型,首次使用需要幾分鐘,請稍候…", + "runtimeInstalling": "正在安裝本地模型執行元件,首次使用需要一些時間,請稍候…", + "modelError": "本地模型準備失敗,建立索引時將重試。", "searchMaxResults": { "label": "單次檢索片段數", "hint": "AI 呼叫語意檢索時預設回傳的相關片段數(5-15)。AI 可依問題需要臨時調高。" diff --git a/src/services/semantic-index/service.ts b/src/services/semantic-index/service.ts index 70ffab28..4ec1a39c 100644 --- a/src/services/semantic-index/service.ts +++ b/src/services/semantic-index/service.ts @@ -10,7 +10,7 @@ import type { SemanticIndexConfig, SemanticIndexSessionStatus } from './types' const BASE = '/ai/semantic-index' -export type ModelDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error' +export type ModelDownloadStatus = 'idle' | 'installing-runtime' | 'downloading-model' | 'ready' | 'error' export interface SemanticIndexConfigResult { config: SemanticIndexConfig