mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(cli): install local embedding runtime on demand
This commit is contained in:
1 parent
93fc26fb83
commit
f96fef7b46
29 files changed
+741
-55
No files matched your search
@@ -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/*
|
||||
|
||||
+3
-1
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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<string> {
|
||||
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()
|
||||
}
|
||||
@@ -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 <component>', { 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}`)
|
||||
})
|
||||
}
|
||||
@@ -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';",
|
||||
|
||||
@@ -10,6 +10,8 @@ ChatLab CLI 提供 `linux/amd64` 和 `linux/arm64` 两种架构的容器镜像
|
||||
ghcr.io/chatlab/chatlab-cli
|
||||
```
|
||||
|
||||
官方镜像已内置本地向量模型所需的运行组件。启用本地语义索引时,只需按界面提示下载所选模型文件,不会在容器启动后再安装约 370 MB 的 Node 依赖。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 与 Desktop / 本地 CLI 共用数据(推荐)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,8 @@ ChatLab CLI 提供 `linux/amd64` 與 `linux/arm64` 兩種架構的容器映像
|
||||
ghcr.io/chatlab/chatlab-cli
|
||||
```
|
||||
|
||||
官方映像已內建本地向量模型所需的執行元件。啟用本地語意索引時,只需依照介面提示下載所選模型檔案,不會在容器啟動後再安裝約 370 MB 的 Node 相依套件。
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 與 Desktop / 本機 CLI 共用資料(建議)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<LocalEmbeddingRuntimeManagerOptions> = {}) {
|
||||
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')
|
||||
})
|
||||
@@ -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<void>
|
||||
verifyRuntime?: (runtimeDir: string) => Promise<void>
|
||||
importTransformers?: (runtimeDir: string) => Promise<typeof import('@huggingface/transformers')>
|
||||
}
|
||||
|
||||
const RUNTIME_MANIFEST_FILE = 'runtime.json'
|
||||
|
||||
function executeInstall(input: InstallCommand): Promise<void> {
|
||||
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<Record<string, unknown>> {
|
||||
const resolved = createRuntimeRequire(runtimeDir).resolve(packageName)
|
||||
return (await import(pathToFileURL(resolved).href)) as Record<string, unknown>
|
||||
}
|
||||
|
||||
async function verifyInstalledRuntime(runtimeDir: string): Promise<void> {
|
||||
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<typeof import('@huggingface/transformers')> {
|
||||
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<void>
|
||||
private readonly verifyRuntime: (runtimeDir: string) => Promise<void>
|
||||
private readonly importTransformers: (runtimeDir: string) => Promise<typeof import('@huggingface/transformers')>
|
||||
|
||||
private status: LocalEmbeddingRuntimeStatus
|
||||
private installPromise: Promise<string> | null = null
|
||||
private transformersPromise: Promise<typeof import('@huggingface/transformers')> | 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<string> {
|
||||
if (!this.installPromise) {
|
||||
this.installPromise = this.ensureInstalledOnce().finally(() => {
|
||||
this.installPromise = null
|
||||
})
|
||||
}
|
||||
return await this.installPromise
|
||||
}
|
||||
|
||||
async loadTransformers(): Promise<typeof import('@huggingface/transformers')> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<LocalEmbeddingRuntimeManifest>
|
||||
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)
|
||||
}
|
||||
@@ -40,6 +40,9 @@ export type LocalPipelineFactory = (params: {
|
||||
sessionOptions?: typeof LOCAL_ONNX_SESSION_OPTIONS
|
||||
}) => Promise<FeatureExtractFn>
|
||||
|
||||
export type TransformersModule = typeof import('@huggingface/transformers')
|
||||
export type LoadTransformers = () => Promise<TransformersModule>
|
||||
|
||||
export async function createProxyFetch(proxyUrl: string): Promise<typeof fetch> {
|
||||
const parsed = new URL(proxyUrl)
|
||||
if (SOCKS_PROXY_PROTOCOLS.has(parsed.protocol)) {
|
||||
@@ -55,34 +58,32 @@ export async function createProxyFetch(proxyUrl: string): Promise<typeof fetch>
|
||||
}) 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SemanticSearchToolOptions,
|
||||
SemanticSearchToolResult,
|
||||
} from './service'
|
||||
import type { SemanticIndexModelStatus } from './embedding/types'
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>
|
||||
|
||||
@@ -17,7 +18,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'>
|
||||
getModelStatus(): MaybePromise<SemanticIndexModelStatus>
|
||||
isConfigured(): MaybePromise<boolean>
|
||||
hasApiKey(): MaybePromise<boolean>
|
||||
|
||||
|
||||
@@ -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<string>
|
||||
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<FeatureExtractFn>(() => {}),
|
||||
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<void>((resolve) => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
// ---------- 队列执行 ----------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string | undefined>
|
||||
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<string>()
|
||||
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<SemanticIndexModelStatus> {
|
||||
if (!this.transport && !this.shouldStartLocalModelWorker()) return 'idle'
|
||||
const status = await this.call<SemanticIndexModelStatus>('getModelStatus', [], { scheduleIdle: false })
|
||||
this.hasActiveModelPreload = status === 'installing-runtime' || status === 'downloading-model'
|
||||
this.scheduleIdleCloseIfNeeded()
|
||||
return status
|
||||
}
|
||||
|
||||
enable(sessionId: string): Promise<void> {
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">{{ t('settings.ai.semanticIndex.localDownloadHint') }}</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
{{
|
||||
t(
|
||||
usesManagedLocalRuntime
|
||||
? 'settings.ai.semanticIndex.localRuntimeDownloadHint'
|
||||
: 'settings.ai.semanticIndex.localDownloadHint'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- API 模式 -->
|
||||
|
||||
@@ -30,13 +30,17 @@ const showModelModal = ref(false)
|
||||
|
||||
let modelPollTimer: ReturnType<typeof setInterval> | 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(() => {
|
||||
</div>
|
||||
<p v-if="!hasModelConfig" class="text-xs text-amber-500">{{ t('settings.ai.semanticIndex.configFirst') }}</p>
|
||||
<p
|
||||
v-if="modelStatus === 'downloading'"
|
||||
v-if="isModelPreparing(modelStatus)"
|
||||
class="flex items-center gap-1.5 text-xs text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-3.5 w-3.5 animate-spin shrink-0" />
|
||||
{{ t('settings.ai.semanticIndex.modelDownloading') }}
|
||||
{{
|
||||
modelStatus === 'installing-runtime'
|
||||
? t('settings.ai.semanticIndex.runtimeInstalling')
|
||||
: t('settings.ai.semanticIndex.modelDownloading')
|
||||
}}
|
||||
</p>
|
||||
<p v-else-if="modelStatus === 'error'" class="text-xs text-red-500">
|
||||
{{ t('settings.ai.semanticIndex.modelError') }}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 が増やせます。"
|
||||
|
||||
@@ -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 可按问题需要临时调高。"
|
||||
|
||||
@@ -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 可依問題需要臨時調高。"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user