feat(semantic-index): add local model download sources

This commit is contained in:
digua committed 2026-07-10 15:11:51 +08:00
1 parent ba2f09f56e
commit dd40fc6502
21 files changed
+249 -37

No files matched your search

+1
View File
@@ -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
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "保存済み。空欄で変更しません",
+5 -1
View File
@@ -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": "已保存,留空则不修改",
+5 -1
View File
@@ -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": "已儲存,留空則不修改",
+1
View File
@@ -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,
+2 -1
View File
@@ -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