diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 70f168bc..b3340388 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -401,6 +401,7 @@ export type { SemanticSearchToolOptions, SemanticIndexConfig, SemanticIndexMode, + SemanticIndexModelDownloadSource, } from './semantic-index' export { diff --git a/packages/node-runtime/src/semantic-index/config.test.ts b/packages/node-runtime/src/semantic-index/config.test.ts index 9ce49259..47e79585 100644 --- a/packages/node-runtime/src/semantic-index/config.test.ts +++ b/packages/node-runtime/src/semantic-index/config.test.ts @@ -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') diff --git a/packages/node-runtime/src/semantic-index/config.ts b/packages/node-runtime/src/semantic-index/config.ts index 827051eb..22738078 100644 --- a/packages/node-runtime/src/semantic-index/config.ts +++ b/packages/node-runtime/src/semantic-index/config.ts @@ -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 | 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, } diff --git a/packages/node-runtime/src/semantic-index/embedder-factory.test.ts b/packages/node-runtime/src/semantic-index/embedder-factory.test.ts index ac52aa9a..367d0f82 100644 --- a/packages/node-runtime/src/semantic-index/embedder-factory.test.ts +++ b/packages/node-runtime/src/semantic-index/embedder-factory.test.ts @@ -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', () => { diff --git a/packages/node-runtime/src/semantic-index/embedder-factory.ts b/packages/node-runtime/src/semantic-index/embedder-factory.ts index 9f96d35a..1dfa7856 100644 --- a/packages/node-runtime/src/semantic-index/embedder-factory.ts +++ b/packages/node-runtime/src/semantic-index/embedder-factory.ts @@ -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, }) } diff --git a/packages/node-runtime/src/semantic-index/embedding/local.ts b/packages/node-runtime/src/semantic-index/embedding/local.ts index fbbf1362..505cf98d 100644 --- a/packages/node-runtime/src/semantic-index/embedding/local.ts +++ b/packages/node-runtime/src/semantic-index/embedding/local.ts @@ -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[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 @@ -47,8 +55,17 @@ export async function createProxyFetch(proxyUrl: string): Promise }) 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 diff --git a/packages/node-runtime/src/semantic-index/embedding/providers.test.ts b/packages/node-runtime/src/semantic-index/embedding/providers.test.ts index c9ef020d..8866beb2 100644 --- a/packages/node-runtime/src/semantic-index/embedding/providers.test.ts +++ b/packages/node-runtime/src/semantic-index/embedding/providers.test.ts @@ -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 } | null = null const fetchFn: FetchFn = async (url, init) => { diff --git a/packages/node-runtime/src/semantic-index/index.ts b/packages/node-runtime/src/semantic-index/index.ts index a940216c..58fd5619 100644 --- a/packages/node-runtime/src/semantic-index/index.ts +++ b/packages/node-runtime/src/semantic-index/index.ts @@ -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' diff --git a/packages/node-runtime/src/semantic-index/service.test.ts b/packages/node-runtime/src/semantic-index/service.test.ts index 5d63bcf2..dfe954d6 100644 --- a/packages/node-runtime/src/semantic-index/service.test.ts +++ b/packages/node-runtime/src/semantic-index/service.test.ts @@ -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 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() @@ -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((_resolve, reject) => { + rejectOfficialPreload = reject + }) + const mirrorPreload = new Promise((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((resolve) => { + resolveOfficialPreload = resolve + }) + const mirrorPreload = new Promise((_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) diff --git a/packages/node-runtime/src/semantic-index/service.ts b/packages/node-runtime/src/semantic-index/service.ts index a10a999d..0d049fae 100644 --- a/packages/node-runtime/src/semantic-index/service.ts +++ b/packages/node-runtime/src/semantic-index/service.ts @@ -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' } diff --git a/src/components/common/Settings/AI/SemanticIndexModelModal.vue b/src/components/common/Settings/AI/SemanticIndexModelModal.vue index 3d908bd9..c95c7cf6 100644 --- a/src/components/common/Settings/AI/SemanticIndexModelModal.vue +++ b/src/components/common/Settings/AI/SemanticIndexModelModal.vue @@ -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('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() { +
+ + +

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

+

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

diff --git a/src/components/common/Settings/AI/SemanticIndexSection.vue b/src/components/common/Settings/AI/SemanticIndexSection.vue index ab963a6b..9a396b80 100644 --- a/src/components/common/Settings/AI/SemanticIndexSection.vue +++ b/src/components/common/Settings/AI/SemanticIndexSection.vue @@ -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 { diff --git a/src/components/common/Settings/AI/semantic-index-config-builder.test.ts b/src/components/common/Settings/AI/semantic-index-config-builder.test.ts index fd111617..9606faca 100644 --- a/src/components/common/Settings/AI/semantic-index-config-builder.test.ts +++ b/src/components/common/Settings/AI/semantic-index-config-builder.test.ts @@ -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) diff --git a/src/components/common/Settings/AI/semantic-index-config-builder.ts b/src/components/common/Settings/AI/semantic-index-config-builder.ts index 56df5403..bac2a78d 100644 --- a/src/components/common/Settings/AI/semantic-index-config-builder.ts +++ b/src/components/common/Settings/AI/semantic-index-config-builder.ts @@ -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' ? { diff --git a/src/components/common/Settings/AI/semantic-index-models.ts b/src/components/common/Settings/AI/semantic-index-models.ts index aef9d07b..f2212b1f 100644 --- a/src/components/common/Settings/AI/semantic-index-models.ts +++ b/src/components/common/Settings/AI/semantic-index-models.ts @@ -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 diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index 13eaf2cf..59918ab8 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -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", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index c95721a9..92c1565a 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -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": "保存済み。空欄で変更しません", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 0ba72b1a..c38f1de0 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -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": "已保存,留空则不修改", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 1e54346d..5046cf11 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -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": "已儲存,留空則不修改", diff --git a/src/services/index.ts b/src/services/index.ts index 04e49177..68843c31 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -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, diff --git a/src/services/semantic-index/types.ts b/src/services/semantic-index/types.ts index 31755309..f15ef9f5 100644 --- a/src/services/semantic-index/types.ts +++ b/src/services/semantic-index/types.ts @@ -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