mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(semantic-index): add local model download sources
This commit is contained in:
1 parent
ba2f09f56e
commit
dd40fc6502
21 files changed
+249
-37
No files matched your search
@@ -401,6 +401,7 @@ export type {
|
||||
SemanticSearchToolOptions,
|
||||
SemanticIndexConfig,
|
||||
SemanticIndexMode,
|
||||
SemanticIndexModelDownloadSource,
|
||||
} from './semantic-index'
|
||||
|
||||
export {
|
||||
|
||||
@@ -23,6 +23,7 @@ test('default config is enabled with no model preselected', () => {
|
||||
assert.equal(config.mode, 'local')
|
||||
assert.equal(config.enabled, true)
|
||||
assert.equal(config.local.modelId, '')
|
||||
assert.equal(config.local.downloadSource, 'huggingface')
|
||||
assert.equal(config.api, null)
|
||||
})
|
||||
|
||||
@@ -73,6 +74,7 @@ test('old config without enabled field defaults to enabled', () => {
|
||||
const store = new SemanticIndexConfigStore(filePath)
|
||||
assert.equal(store.isEnabled(), true)
|
||||
assert.equal(store.isConfigured(), true)
|
||||
assert.equal(store.get().local.downloadSource, 'huggingface')
|
||||
})
|
||||
|
||||
test('set then get roundtrips and creates directory', () => {
|
||||
@@ -93,6 +95,23 @@ test('set then get roundtrips and creates directory', () => {
|
||||
assert.equal(loaded.api?.authProfile, 'p1')
|
||||
})
|
||||
|
||||
test('local model download source roundtrips without changing model identity', () => {
|
||||
const store = new SemanticIndexConfigStore(tempConfigPath())
|
||||
const official = {
|
||||
...defaultSemanticIndexConfig(),
|
||||
local: { modelId: 'local-test', downloadSource: 'huggingface' as const },
|
||||
}
|
||||
const mirror = {
|
||||
...official,
|
||||
local: { ...official.local, downloadSource: 'hf-mirror' as const },
|
||||
}
|
||||
|
||||
store.set(mirror)
|
||||
|
||||
assert.equal(store.get().local.downloadSource, 'hf-mirror')
|
||||
assert.equal(resolveModelId(official), resolveModelId(mirror))
|
||||
})
|
||||
|
||||
test('resolveModelId reflects local model id', () => {
|
||||
const config = { ...defaultSemanticIndexConfig(), local: { modelId: 'local-test' } }
|
||||
assert.equal(resolveModelId(config), 'local-test')
|
||||
|
||||
@@ -16,10 +16,15 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export type SemanticIndexMode = 'local' | 'api'
|
||||
export type SemanticIndexModelDownloadSource = 'huggingface' | 'hf-mirror'
|
||||
|
||||
export const DEFAULT_SEMANTIC_INDEX_MODEL_DOWNLOAD_SOURCE: SemanticIndexModelDownloadSource = 'huggingface'
|
||||
|
||||
export interface SemanticIndexLocalConfig {
|
||||
/** 本地模型 profile 的 modelId */
|
||||
modelId: string
|
||||
/** 模型文件下载源;旧配置缺失时归一化为 Hugging Face 官方源 */
|
||||
downloadSource?: SemanticIndexModelDownloadSource
|
||||
}
|
||||
|
||||
export interface SemanticIndexApiConfig {
|
||||
@@ -71,7 +76,7 @@ export function defaultSemanticIndexConfig(): SemanticIndexConfig {
|
||||
version: SEMANTIC_INDEX_CONFIG_VERSION,
|
||||
enabled: true,
|
||||
mode: 'local',
|
||||
local: { modelId: '' },
|
||||
local: { modelId: '', downloadSource: DEFAULT_SEMANTIC_INDEX_MODEL_DOWNLOAD_SOURCE },
|
||||
api: null,
|
||||
searchMaxResults: SEARCH_MAX_RESULTS_DEFAULT,
|
||||
}
|
||||
@@ -129,7 +134,11 @@ function normalizeConfig(raw: Partial<SemanticIndexConfig> | null | undefined):
|
||||
// 旧配置无 enabled 字段时默认开启,保证既有用户功能不被意外关闭
|
||||
enabled: raw.enabled ?? true,
|
||||
mode,
|
||||
local: { modelId: raw.local?.modelId ?? base.local.modelId },
|
||||
local: {
|
||||
modelId: raw.local?.modelId ?? base.local.modelId,
|
||||
downloadSource:
|
||||
raw.local?.downloadSource === 'hf-mirror' ? 'hf-mirror' : DEFAULT_SEMANTIC_INDEX_MODEL_DOWNLOAD_SOURCE,
|
||||
},
|
||||
api,
|
||||
searchMaxResults: SEARCH_MAX_RESULTS_DEFAULT,
|
||||
}
|
||||
|
||||
@@ -10,17 +10,19 @@ const localConfig: SemanticIndexConfig = {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
mode: 'local',
|
||||
local: { modelId: QWEN3_PROFILE.modelId },
|
||||
local: { modelId: QWEN3_PROFILE.modelId, downloadSource: 'hf-mirror' },
|
||||
api: null,
|
||||
searchMaxResults: 5,
|
||||
}
|
||||
|
||||
test('builds local provider from profile with injected pipeline factory', async () => {
|
||||
let seenProxyUrl: string | undefined
|
||||
let seenDownloadSource: string | undefined
|
||||
const fakeFactory: LocalPipelineFactory = async () => async (texts) =>
|
||||
texts.map(() => new Array(QWEN3_PROFILE.dim).fill(0.1))
|
||||
const fakeFactoryWithCapture: LocalPipelineFactory = async (params) => {
|
||||
seenProxyUrl = params.modelDownloadProxyUrl
|
||||
seenDownloadSource = params.modelDownloadSource
|
||||
return fakeFactory(params)
|
||||
}
|
||||
const embedder = createEmbedder(localConfig, {
|
||||
@@ -32,6 +34,7 @@ test('builds local provider from profile with injected pipeline factory', async
|
||||
const [vector] = await embedder.embedDocuments(['hello'])
|
||||
assert.equal(vector.length, QWEN3_PROFILE.dim)
|
||||
assert.equal(seenProxyUrl, 'http://127.0.0.1:7890')
|
||||
assert.equal(seenDownloadSource, 'hf-mirror')
|
||||
})
|
||||
|
||||
test('throws for unknown local model', () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ export function createEmbedder(config: SemanticIndexConfig, deps: EmbedderFactor
|
||||
return new LocalEmbeddingProvider(profile, {
|
||||
cacheDir: deps.modelsCacheDir,
|
||||
modelDownloadProxyUrl: deps.modelDownloadProxyUrl,
|
||||
modelDownloadSource: config.local.downloadSource,
|
||||
pipelineFactory: deps.localPipelineFactory,
|
||||
})
|
||||
}
|
||||
@@ -11,11 +11,18 @@
|
||||
import type { EmbeddingProvider } from './types'
|
||||
import type { LocalEmbeddingProfile } from './profiles'
|
||||
import { applyQueryInstruction, clampTextChars } from './text'
|
||||
import type { SemanticIndexModelDownloadSource } from '../config'
|
||||
|
||||
type UndiciFetch = (typeof import('undici'))['fetch']
|
||||
type UndiciRequestInit = NonNullable<Parameters<UndiciFetch>[1]>
|
||||
const SOCKS_PROXY_PROTOCOLS = new Set(['socks:', 'socks4:', 'socks5:'])
|
||||
export const LOCAL_ONNX_SESSION_OPTIONS = { intraOpNumThreads: 1, interOpNumThreads: 1 } as const
|
||||
export const HUGGING_FACE_MODEL_HOST = 'https://huggingface.co/'
|
||||
export const HF_MIRROR_MODEL_HOST = 'https://hf-mirror.com/'
|
||||
|
||||
export function resolveModelDownloadRemoteHost(source?: SemanticIndexModelDownloadSource): string {
|
||||
return source === 'hf-mirror' ? HF_MIRROR_MODEL_HOST : HUGGING_FACE_MODEL_HOST
|
||||
}
|
||||
|
||||
/** 一次特征抽取调用:输入文本数组,返回每条文本的向量(number[][]) */
|
||||
export type FeatureExtractFn = (
|
||||
@@ -29,6 +36,7 @@ export type LocalPipelineFactory = (params: {
|
||||
dtype?: 'fp32' | 'q8'
|
||||
cacheDir?: string
|
||||
modelDownloadProxyUrl?: string
|
||||
modelDownloadSource?: SemanticIndexModelDownloadSource
|
||||
sessionOptions?: typeof LOCAL_ONNX_SESSION_OPTIONS
|
||||
}) => Promise<FeatureExtractFn>
|
||||
|
||||
@@ -47,8 +55,17 @@ export async function createProxyFetch(proxyUrl: string): Promise<typeof fetch>
|
||||
}) as typeof fetch
|
||||
}
|
||||
|
||||
const defaultPipelineFactory: LocalPipelineFactory = async ({ modelId, dtype, cacheDir, modelDownloadProxyUrl }) => {
|
||||
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
|
||||
@@ -69,6 +86,7 @@ const defaultPipelineFactory: LocalPipelineFactory = async ({ modelId, dtype, ca
|
||||
export interface LocalEmbeddingProviderOptions {
|
||||
cacheDir?: string
|
||||
modelDownloadProxyUrl?: string
|
||||
modelDownloadSource?: SemanticIndexModelDownloadSource
|
||||
pipelineFactory?: LocalPipelineFactory
|
||||
}
|
||||
|
||||
@@ -99,6 +117,7 @@ export class LocalEmbeddingProvider implements EmbeddingProvider {
|
||||
dtype: this.profile.dtype,
|
||||
cacheDir: this.options.cacheDir,
|
||||
modelDownloadProxyUrl: this.options.modelDownloadProxyUrl,
|
||||
modelDownloadSource: this.options.modelDownloadSource,
|
||||
sessionOptions: LOCAL_ONNX_SESSION_OPTIONS,
|
||||
}).catch((error) => {
|
||||
this.extractorPromise = null
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createProxyFetch, LocalEmbeddingProvider, type LocalPipelineFactory } from './local'
|
||||
import {
|
||||
createProxyFetch,
|
||||
LocalEmbeddingProvider,
|
||||
resolveModelDownloadRemoteHost,
|
||||
type LocalPipelineFactory,
|
||||
} from './local'
|
||||
import { OpenAICompatibleEmbeddingProvider, type FetchFn } from './api'
|
||||
import { QWEN3_PROFILE, type LocalEmbeddingProfile } from './profiles'
|
||||
|
||||
@@ -95,6 +100,12 @@ test('local model download proxy rejects SOCKS URLs explicitly', async () => {
|
||||
await assert.rejects(() => createProxyFetch('socks5://127.0.0.1:1080'), /SOCKS proxy is not supported/)
|
||||
})
|
||||
|
||||
test('local model download source resolves to the selected remote host', () => {
|
||||
assert.equal(resolveModelDownloadRemoteHost('huggingface'), 'https://huggingface.co/')
|
||||
assert.equal(resolveModelDownloadRemoteHost('hf-mirror'), 'https://hf-mirror.com/')
|
||||
assert.equal(resolveModelDownloadRemoteHost(undefined), 'https://huggingface.co/')
|
||||
})
|
||||
|
||||
test('API provider posts OpenAI-compatible request and parses ordered embeddings', async () => {
|
||||
let captured: { url: string; body: string; headers: Record<string, string> } | null = null
|
||||
const fetchFn: FetchFn = async (url, init) => {
|
||||
|
||||
@@ -53,6 +53,7 @@ export {
|
||||
SEARCH_MAX_RESULTS_DEFAULT,
|
||||
SEARCH_MAX_RESULTS_MIN,
|
||||
SEARCH_MAX_RESULTS_MAX,
|
||||
DEFAULT_SEMANTIC_INDEX_MODEL_DOWNLOAD_SOURCE,
|
||||
} from './config'
|
||||
export type {
|
||||
SemanticIndexConfig,
|
||||
@@ -60,6 +61,7 @@ export type {
|
||||
SemanticIndexMode,
|
||||
SemanticIndexLocalConfig,
|
||||
SemanticIndexApiConfig,
|
||||
SemanticIndexModelDownloadSource,
|
||||
} from './config'
|
||||
export { createEmbedder } from './embedder-factory'
|
||||
export type { EmbedderFactoryDeps } from './embedder-factory'
|
||||
|
||||
@@ -17,7 +17,7 @@ import { SemanticIndexStateStore } from './session-state-store'
|
||||
import { computeDbPathHash } from './chunker-config'
|
||||
import { BGE_BASE_PROFILE, QWEN3_PROFILE } from './embedding/profiles'
|
||||
import type { SessionRuntimeAdapter } from '../services/adapters'
|
||||
import type { LocalPipelineFactory } from './embedding/local'
|
||||
import type { FeatureExtractFn, LocalPipelineFactory } from './embedding/local'
|
||||
|
||||
const SESSION_ID = 'sess1'
|
||||
// 真实秒级基准时间(2023-11-14),用于让证据 snippet 渲染出真实年份,
|
||||
@@ -29,6 +29,7 @@ function setup(opts?: {
|
||||
failFirstPipelineCreation?: boolean
|
||||
failPipelineCreationForModelIds?: ReadonlySet<string>
|
||||
onEmbedBatchStarted?: () => void
|
||||
localPipelineFactory?: LocalPipelineFactory
|
||||
}) {
|
||||
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
|
||||
const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-si-svc-'))
|
||||
@@ -67,25 +68,27 @@ function setup(opts?: {
|
||||
// embedTextCount 统计累计嵌入文本数,用于区分 rebuild(重新嵌入) 与 build 续跑(跳过不嵌入)
|
||||
let embedTextCount = 0
|
||||
let pipelineCreateAttempts = 0
|
||||
const localPipelineFactory: LocalPipelineFactory = async ({ modelId }) => {
|
||||
pipelineCreateAttempts++
|
||||
if (opts?.failFirstPipelineCreation && pipelineCreateAttempts === 1) {
|
||||
throw new Error('temporary preload failure')
|
||||
}
|
||||
if (opts?.failPipelineCreationForModelIds?.has(modelId)) {
|
||||
throw new Error(`temporary preload failure for ${modelId}`)
|
||||
}
|
||||
return async (texts) => {
|
||||
opts?.onEmbedBatchStarted?.()
|
||||
embedTextCount += texts.length
|
||||
if (opts?.embedDelayMs) await new Promise((r) => setTimeout(r, opts.embedDelayMs))
|
||||
return texts.map((t) => {
|
||||
const v = new Array(QWEN3_PROFILE.dim).fill(0)
|
||||
v[t.length % QWEN3_PROFILE.dim] = 1
|
||||
return v
|
||||
})
|
||||
}
|
||||
}
|
||||
const localPipelineFactory: LocalPipelineFactory =
|
||||
opts?.localPipelineFactory ??
|
||||
(async ({ modelId }) => {
|
||||
pipelineCreateAttempts++
|
||||
if (opts?.failFirstPipelineCreation && pipelineCreateAttempts === 1) {
|
||||
throw new Error('temporary preload failure')
|
||||
}
|
||||
if (opts?.failPipelineCreationForModelIds?.has(modelId)) {
|
||||
throw new Error(`temporary preload failure for ${modelId}`)
|
||||
}
|
||||
return async (texts) => {
|
||||
opts?.onEmbedBatchStarted?.()
|
||||
embedTextCount += texts.length
|
||||
if (opts?.embedDelayMs) await new Promise((r) => setTimeout(r, opts.embedDelayMs))
|
||||
return texts.map((t) => {
|
||||
const v = new Array(QWEN3_PROFILE.dim).fill(0)
|
||||
v[t.length % QWEN3_PROFILE.dim] = 1
|
||||
return v
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 内存 auth profile 存储:避免测试写真实 ~/.chatlab/auth-profiles.json
|
||||
const authProfiles = new Map<string, { key: string }>()
|
||||
@@ -203,6 +206,66 @@ test('stale local warmup completion does not mark current local model ready', as
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('stale model preload failure does not overwrite the current download source status', async () => {
|
||||
let resolveMirrorPreload: (extractor: FeatureExtractFn) => void = () => {}
|
||||
let rejectOfficialPreload: (reason?: unknown) => void = () => {}
|
||||
const officialPreload = new Promise<FeatureExtractFn>((_resolve, reject) => {
|
||||
rejectOfficialPreload = reject
|
||||
})
|
||||
const mirrorPreload = new Promise<FeatureExtractFn>((resolve) => {
|
||||
resolveMirrorPreload = resolve
|
||||
})
|
||||
const localPipelineFactory: LocalPipelineFactory = ({ modelDownloadSource }) =>
|
||||
modelDownloadSource === 'hf-mirror' ? mirrorPreload : officialPreload
|
||||
const { service, db } = setup({ localPipelineFactory })
|
||||
|
||||
service.setConfig({
|
||||
version: 1,
|
||||
mode: 'local',
|
||||
local: { modelId: QWEN3_PROFILE.modelId, downloadSource: 'hf-mirror' },
|
||||
api: null,
|
||||
})
|
||||
resolveMirrorPreload(async () => [])
|
||||
await waitForModelStatus(service, 'ready')
|
||||
|
||||
rejectOfficialPreload(new Error('stale official source failure'))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
assert.equal(service.getModelStatus(), 'ready')
|
||||
service.close()
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('stale model preload success does not overwrite the current download source status', async () => {
|
||||
let resolveOfficialPreload: (extractor: FeatureExtractFn) => void = () => {}
|
||||
let rejectMirrorPreload: (reason?: unknown) => void = () => {}
|
||||
const officialPreload = new Promise<FeatureExtractFn>((resolve) => {
|
||||
resolveOfficialPreload = resolve
|
||||
})
|
||||
const mirrorPreload = new Promise<FeatureExtractFn>((_resolve, reject) => {
|
||||
rejectMirrorPreload = reject
|
||||
})
|
||||
const localPipelineFactory: LocalPipelineFactory = ({ modelDownloadSource }) =>
|
||||
modelDownloadSource === 'hf-mirror' ? mirrorPreload : officialPreload
|
||||
const { service, db } = setup({ localPipelineFactory })
|
||||
|
||||
service.setConfig({
|
||||
version: 1,
|
||||
mode: 'local',
|
||||
local: { modelId: QWEN3_PROFILE.modelId, downloadSource: 'hf-mirror' },
|
||||
api: null,
|
||||
})
|
||||
rejectMirrorPreload(new Error('current mirror source failure'))
|
||||
await waitForModelStatus(service, 'error')
|
||||
|
||||
resolveOfficialPreload(async () => [])
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
assert.equal(service.getModelStatus(), 'error')
|
||||
service.close()
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('changing model identity marks needsRebuild and blocks search until rebuilt', async () => {
|
||||
const { service, db } = setup()
|
||||
await enableAndBuild(service)
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
isKeylessSemanticIndexApiBaseUrl,
|
||||
isSemanticIndexConfigured,
|
||||
SemanticIndexConfigStore,
|
||||
DEFAULT_SEMANTIC_INDEX_MODEL_DOWNLOAD_SOURCE,
|
||||
type SemanticIndexConfig,
|
||||
type SemanticIndexConfigInput,
|
||||
} from './config'
|
||||
@@ -235,6 +236,7 @@ export class SemanticIndexService {
|
||||
private embedder: EmbeddingProvider | null = null
|
||||
private embedderModelId: string | null = null
|
||||
private modelPreloadStatus: 'idle' | 'downloading' | 'ready' | 'error' = 'idle'
|
||||
private modelPreloadGeneration = 0
|
||||
|
||||
/** 当前激活的 chunker 身份(version + 参数 hash),随索引记录用于重建判定 */
|
||||
private readonly chunkerVersion = CHUNKER_VERSION
|
||||
@@ -293,6 +295,8 @@ export class SemanticIndexService {
|
||||
apiKey: options?.apiKey,
|
||||
writeAuthProfile: this.options.writeAuthProfile,
|
||||
})
|
||||
// 每次保存配置都使旧 preload 回调失效,避免切换下载源后旧请求乱序覆盖新状态。
|
||||
const preloadGeneration = ++this.modelPreloadGeneration
|
||||
this.embedder = null
|
||||
this.embedderModelId = null
|
||||
this.modelPreloadStatus = 'idle'
|
||||
@@ -303,18 +307,29 @@ export class SemanticIndexService {
|
||||
// 本地模式已配置且功能已开启时,立即在后台触发模型下载,让用户在建索引前完成等待
|
||||
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'
|
||||
appLogger.info('semantic-index', 'local embedding model preload started', { modelId, cacheDir })
|
||||
appLogger.info('semantic-index', 'local embedding model preload started', { modelId, downloadSource, cacheDir })
|
||||
void this.getEmbedder()
|
||||
.preload?.()
|
||||
.then(() => {
|
||||
if (preloadGeneration !== this.modelPreloadGeneration) return
|
||||
this.modelPreloadStatus = 'ready'
|
||||
appLogger.info('semantic-index', 'local embedding model preload completed', { modelId, cacheDir })
|
||||
appLogger.info('semantic-index', 'local embedding model preload completed', {
|
||||
modelId,
|
||||
downloadSource,
|
||||
cacheDir,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (preloadGeneration !== this.modelPreloadGeneration) return
|
||||
this.modelPreloadStatus = 'error'
|
||||
appLogger.error('semantic-index', `local embedding model preload failed: ${modelId}`, error)
|
||||
appLogger.error(
|
||||
'semantic-index',
|
||||
`local embedding model preload failed: ${modelId} via ${downloadSource}`,
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
return saved
|
||||
@@ -839,12 +854,21 @@ export class SemanticIndexService {
|
||||
})
|
||||
// warmup 已成功写入 chunk,说明本地 extractor 通过重试加载成功;清除此前 preload 失败状态。
|
||||
const currentConfig = this.configStore.get()
|
||||
const jobDownloadSource =
|
||||
jobConfig.mode === 'local'
|
||||
? (jobConfig.local.downloadSource ?? DEFAULT_SEMANTIC_INDEX_MODEL_DOWNLOAD_SOURCE)
|
||||
: null
|
||||
const currentDownloadSource =
|
||||
currentConfig.mode === 'local'
|
||||
? (currentConfig.local.downloadSource ?? DEFAULT_SEMANTIC_INDEX_MODEL_DOWNLOAD_SOURCE)
|
||||
: null
|
||||
if (
|
||||
result.status === 'completed' &&
|
||||
result.chunksWritten > 0 &&
|
||||
jobConfig.mode === 'local' &&
|
||||
currentConfig.mode === 'local' &&
|
||||
currentConfig.local.modelId === jobConfig.local.modelId
|
||||
currentConfig.local.modelId === jobConfig.local.modelId &&
|
||||
currentDownloadSource === jobDownloadSource
|
||||
) {
|
||||
this.modelPreloadStatus = 'ready'
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import UITabs from '@/components/UI/Tabs.vue'
|
||||
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 } from '@/services'
|
||||
import type { SemanticIndexConfig, SemanticIndexModelDownloadSource } from '@/services'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -30,6 +30,7 @@ const { t, locale } = useI18n()
|
||||
|
||||
const mode = ref<'local' | 'api'>('local')
|
||||
const localModelId = ref(LOCAL_MODELS[0].modelId)
|
||||
const localDownloadSource = ref<SemanticIndexModelDownloadSource>('huggingface')
|
||||
const apiTemplate = ref('openai-compatible')
|
||||
const apiBaseUrl = ref('')
|
||||
const apiModel = ref('')
|
||||
@@ -45,6 +46,10 @@ const modeItems = computed(() => [
|
||||
{ label: t('settings.ai.semanticIndex.modeLocal'), value: 'local' },
|
||||
{ label: t('settings.ai.semanticIndex.modeApi'), value: 'api' },
|
||||
])
|
||||
const downloadSourceItems = computed(() => [
|
||||
{ label: t('settings.ai.semanticIndex.downloadSourceOfficial'), value: 'huggingface' },
|
||||
{ label: t('settings.ai.semanticIndex.downloadSourceMirror'), value: 'hf-mirror' },
|
||||
])
|
||||
const canReuseApiKey = computed(() =>
|
||||
canReuseSemanticIndexApiAuthProfile(props.config, apiBaseUrl.value, props.apiKeySet)
|
||||
)
|
||||
@@ -64,6 +69,7 @@ function initDraft() {
|
||||
const c = props.config
|
||||
mode.value = c?.mode ?? 'local'
|
||||
localModelId.value = c?.local.modelId ?? ''
|
||||
localDownloadSource.value = c?.local.downloadSource ?? 'huggingface'
|
||||
apiBaseUrl.value = c?.api?.baseUrl ?? ''
|
||||
apiModel.value = c?.api?.model ?? ''
|
||||
apiKey.value = ''
|
||||
@@ -96,6 +102,7 @@ function confirm() {
|
||||
emit('confirm', {
|
||||
mode: mode.value,
|
||||
localModelId: localModelId.value,
|
||||
localDownloadSource: localDownloadSource.value,
|
||||
apiBaseUrl: apiBaseUrl.value,
|
||||
apiModel: apiModel.value,
|
||||
apiKey: apiKey.value,
|
||||
@@ -149,6 +156,20 @@ function confirm() {
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1.5 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('settings.ai.semanticIndex.modelDownloadSource') }}
|
||||
</label>
|
||||
<UITabs
|
||||
:model-value="localDownloadSource"
|
||||
:items="downloadSourceItems"
|
||||
size="xs"
|
||||
@update:model-value="(v) => (localDownloadSource = v as SemanticIndexModelDownloadSource)"
|
||||
/>
|
||||
<p v-if="localDownloadSource === 'hf-mirror'" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('settings.ai.semanticIndex.mirrorSourceHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">{{ t('settings.ai.semanticIndex.localDownloadHint') }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -65,7 +65,12 @@ const currentModel = computed(() => {
|
||||
if (!configured.value || !c) return null
|
||||
if (c.mode === 'local') {
|
||||
const m = LOCAL_MODELS.find((x) => x.modelId === c.local.modelId)
|
||||
return { type: t('settings.ai.semanticIndex.modeLocal'), name: m?.name ?? c.local.modelId, sub: '' }
|
||||
const sourceKey = c.local.downloadSource === 'hf-mirror' ? 'downloadSourceMirror' : 'downloadSourceOfficial'
|
||||
return {
|
||||
type: t('settings.ai.semanticIndex.modeLocal'),
|
||||
name: m?.name ?? c.local.modelId,
|
||||
sub: t(`settings.ai.semanticIndex.${sourceKey}`),
|
||||
}
|
||||
}
|
||||
let host = c.api?.baseUrl ?? ''
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('buildSemanticIndexModelConfig', () => {
|
||||
const next = buildSemanticIndexModelConfig(savedApiConfig, {
|
||||
mode: 'api',
|
||||
localModelId: '',
|
||||
localDownloadSource: 'huggingface',
|
||||
apiBaseUrl: 'https://api.example.com/v1',
|
||||
apiModel: 'new-embed',
|
||||
apiKey: '',
|
||||
@@ -38,6 +39,7 @@ describe('buildSemanticIndexModelConfig', () => {
|
||||
const next = buildSemanticIndexModelConfig(savedApiConfig, {
|
||||
mode: 'api',
|
||||
localModelId: '',
|
||||
localDownloadSource: 'huggingface',
|
||||
apiBaseUrl: 'https://other.example.com/v1',
|
||||
apiModel: 'new-embed',
|
||||
apiKey: '',
|
||||
@@ -50,6 +52,19 @@ describe('buildSemanticIndexModelConfig', () => {
|
||||
assert.equal(canReuseSemanticIndexApiAuthProfile(savedApiConfig, savedApiConfig.api!.baseUrl, false), false)
|
||||
})
|
||||
|
||||
it('persists the explicitly selected local model download source', () => {
|
||||
const next = buildSemanticIndexModelConfig(null, {
|
||||
mode: 'local',
|
||||
localModelId: 'local-model',
|
||||
localDownloadSource: 'hf-mirror',
|
||||
apiBaseUrl: '',
|
||||
apiModel: '',
|
||||
apiKey: '',
|
||||
})
|
||||
|
||||
assert.equal(next.local.downloadSource, 'hf-mirror')
|
||||
})
|
||||
|
||||
it('does not require an API key for local Ollama endpoints', () => {
|
||||
assert.equal(isSemanticIndexApiKeyRequired('http://localhost:11434/v1'), false)
|
||||
assert.equal(isSemanticIndexApiKeyRequired('http://127.0.0.1:11434/v1'), false)
|
||||
|
||||
@@ -43,7 +43,7 @@ export function buildSemanticIndexModelConfig(
|
||||
version: savedConfig?.version ?? 1,
|
||||
enabled: savedConfig?.enabled ?? true,
|
||||
mode: payload.mode,
|
||||
local: { modelId: payload.localModelId },
|
||||
local: { modelId: payload.localModelId, downloadSource: payload.localDownloadSource },
|
||||
api:
|
||||
payload.mode === 'api'
|
||||
? {
|
||||
|
||||
@@ -50,6 +50,7 @@ export const API_TEMPLATES: ApiTemplate[] = [
|
||||
export interface ModelConfigDraft {
|
||||
mode: 'local' | 'api'
|
||||
localModelId: string
|
||||
localDownloadSource: 'huggingface' | 'hf-mirror'
|
||||
apiBaseUrl: string
|
||||
apiModel: string
|
||||
apiKey: string
|
||||
|
||||
@@ -261,7 +261,11 @@
|
||||
"modeApi": "API",
|
||||
"recommended": "Recommended",
|
||||
"approxSize": "~{mb} MB",
|
||||
"localDownloadHint": "The model is downloaded automatically on first index build, which may take a while.",
|
||||
"modelDownloadSource": "Model Download Source",
|
||||
"downloadSourceOfficial": "Hugging Face Official",
|
||||
"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.",
|
||||
"apiTemplate": "Service Template",
|
||||
"modelName": "Model Name",
|
||||
"apiKeySaved": "Saved; leave blank to keep",
|
||||
|
||||
@@ -261,7 +261,11 @@
|
||||
"modeApi": "API",
|
||||
"recommended": "推奨",
|
||||
"approxSize": "約 {mb} MB",
|
||||
"localDownloadHint": "初回のインデックス作成時にモデルが自動ダウンロードされます。時間がかかる場合があります。",
|
||||
"modelDownloadSource": "モデルのダウンロード元",
|
||||
"downloadSourceOfficial": "Hugging Face 公式",
|
||||
"downloadSourceMirror": "HF-Mirror(中国本土向け)",
|
||||
"mirrorSourceHint": "HF-Mirror は中国本土のネットワーク向けの第三者公開ミラーです。モデルファイルは hf-mirror.com からダウンロードされます。",
|
||||
"localDownloadHint": "保存後にモデルが自動でダウンロードされます。初回は時間がかかる場合があります。",
|
||||
"apiTemplate": "サービステンプレート",
|
||||
"modelName": "モデル名",
|
||||
"apiKeySaved": "保存済み。空欄で変更しません",
|
||||
|
||||
@@ -261,7 +261,11 @@
|
||||
"modeApi": "API",
|
||||
"recommended": "推荐",
|
||||
"approxSize": "约 {mb} MB",
|
||||
"localDownloadHint": "首次建立索引时自动下载模型,可能需要一些时间。",
|
||||
"modelDownloadSource": "模型下载源",
|
||||
"downloadSourceOfficial": "Hugging Face 官方",
|
||||
"downloadSourceMirror": "HF-Mirror 国内镜像",
|
||||
"mirrorSourceHint": "HF-Mirror 是第三方公益镜像,适合中国大陆网络环境;模型文件将从 hf-mirror.com 下载。",
|
||||
"localDownloadHint": "保存后自动下载模型,首次下载可能需要一些时间。",
|
||||
"apiTemplate": "服务模板",
|
||||
"modelName": "模型名",
|
||||
"apiKeySaved": "已保存,留空则不修改",
|
||||
|
||||
@@ -261,7 +261,11 @@
|
||||
"modeApi": "API",
|
||||
"recommended": "推薦",
|
||||
"approxSize": "約 {mb} MB",
|
||||
"localDownloadHint": "首次建立索引時自動下載模型,可能需要一些時間。",
|
||||
"modelDownloadSource": "模型下載來源",
|
||||
"downloadSourceOfficial": "Hugging Face 官方",
|
||||
"downloadSourceMirror": "HF-Mirror 中國大陸鏡像",
|
||||
"mirrorSourceHint": "HF-Mirror 是第三方公益鏡像,適合中國大陸網路環境;模型檔案將從 hf-mirror.com 下載。",
|
||||
"localDownloadHint": "儲存後會自動下載模型,首次下載可能需要一些時間。",
|
||||
"apiTemplate": "服務範本",
|
||||
"modelName": "模型名稱",
|
||||
"apiKeySaved": "已儲存,留空則不修改",
|
||||
|
||||
@@ -41,6 +41,7 @@ export { SEARCH_MAX_RESULTS_MIN, SEARCH_MAX_RESULTS_MAX, SEARCH_MAX_RESULTS_DEFA
|
||||
export type {
|
||||
SemanticIndexConfig,
|
||||
SemanticIndexMode,
|
||||
SemanticIndexModelDownloadSource,
|
||||
SemanticIndexApiConfig,
|
||||
SemanticIndexSessionStatus,
|
||||
SemanticIndexStatusValue,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
export type SemanticIndexMode = 'local' | 'api'
|
||||
export type SemanticIndexModelDownloadSource = 'huggingface' | 'hf-mirror'
|
||||
|
||||
export interface SemanticIndexApiConfig {
|
||||
baseUrl: string
|
||||
@@ -19,7 +20,7 @@ export interface SemanticIndexConfig {
|
||||
/** 全局功能开关:关闭后不暴露 AI 检索工具、不建立/检索索引(已有索引数据保留) */
|
||||
enabled: boolean
|
||||
mode: SemanticIndexMode
|
||||
local: { modelId: string }
|
||||
local: { modelId: string; downloadSource?: SemanticIndexModelDownloadSource }
|
||||
api: SemanticIndexApiConfig | null
|
||||
/** AI 单次语义检索默认返回片段数(范围 5-15,默认 10) */
|
||||
searchMaxResults: number
|
||||
|
||||
Reference in new issue
Block a user