{"repo": "atom112/aio", "files": {"src/core/utils/models.ts": {"e77a3a145e375788b9e2f3e2ddd5606713ff185e": {"t": 1786067560.6301908, "n": 12026, "h": "a096d7cb4205538d", "c": "/**\n * 模型目录加载与查询工具\n * @description 包装 @aio/models-data 包,提供 catalog 加载、模型查询、能力补全\n * @data-source D:/base/aio-models-data (每周日 04:00 UTC 自动同步)\n */\n\nimport { invoke } from '@tauri-apps/api/core';\nimport type { ModelMeta, ProviderMeta, Catalog } from '@aio/models-data';\nimport {\n formatDateTime as formatLocaleDateTime,\n formatRelativeTime as formatLocaleRelativeTime,\n t,\n} from '../i18n';\n\nexport type { ModelMeta, ProviderMeta, Catalog } from '@aio/models-data';\n\n/** 从 provider API 拉取并持久化的模型条目(仿 LobeHub 列表行) */\nexport interface FetchedModel {\n id: string;\n ownedBy: string;\n /** 厂商返回的展示名(Google `displayName` / Anthropic `display_name`),OpenAI/Ollama 无 */\n displayName?: string;\n /** 发布日期 YYYY-MM-DD,OpenAI `created` 转 / Anthropic `created_at` 原样 */\n releasedAt?: string;\n}\n\nexport interface ProviderConfig {\n id: string;\n enabled: boolean;\n displayName: string;\n apiUrl: string;\n apiKey: string;\n enabledModels: string[];\n isCustom: boolean;\n customModelIds: string[];\n /** per-provider HTTP/HTTPS 代理 (例如 `http://127.0.0.1:7890`),用于解决国内访问 OpenAI/Google 的网络问题 */\n proxyUrl?: string;\n /** 从 API 持久化拉取的模型列表,用于仿 LobeHub 风格的双段 toggle 列表 */\n fetchedModels?: FetchedModel[];\n /** 本地引擎专属:模型文件路径映射 { modelId -> localPath } */\n localModelPaths?: Record;\n}\n\nexport interface ProviderConfigFile {\n version: number;\n updatedAt: string;\n providers: Record;\n}\n\n// ==================== 本地推理引擎供应商定义 ====================\n\n/** 内置本地推理引擎供应商定义 */\nexport interface LocalEngineProvider {\n id: string; // provider identifier, e.g. 'local-llamacpp'\n name: string; // display name, e.g. 'llama.cpp'\n defaultApiUrl: string; // pre-filled URL, e.g. 'http://127.0.0.1:8080/v1'\n engineType: string; // 'llama_cpp' | 'vllm' | 'ollama'\n fileExtensions: string[]; // ['gguf'] for file picker, [] for no file picker\n allowModelFileSelection: boolean;\n engineInstallUrl?: string; // docs URL for manual install (not AIO auto-install)\n /** 是否支持从 API 拉取模型列表(Ollama 的 /api/tags;llama.cpp/vLLM 启动时固定模型,无需拉取) */\n supportsFetchModels?: boolean;\n}\n\nexport const LOCAL_ENGINE_PROVIDERS: LocalEngineProvider[] = [\n {\n id: 'local-llamacpp',\n name: 'llama.cpp',\n defaultApiUrl: 'http://127.0.0.1:8080/v1',\n engineType: 'llama_cpp',\n fileExtensions: ['gguf'],\n allowModelFileSelection: true,\n engineInstallUrl: 'https://github.com/ggml-org/llama.cpp',\n supportsFetchModels: false,\n },\n {\n id: 'local-vllm',\n name: 'vLLM',\n defaultApiUrl: 'http://127.0.0.1:8000/v1',\n engineType: 'vllm',\n fileExtensions: [],\n allowModelFileSelection: false,\n engineInstallUrl: 'https://docs.vllm.ai',\n supportsFetchModels: false,\n },\n {\n id: 'ollama',\n name: 'Ollama',\n defaultApiUrl: 'http://localhost:11434',\n engineType: 'ollama',\n fileExtensions: ['gguf'],\n allowModelFileSelection: true,\n engineInstallUrl: 'https://ollama.ai',\n supportsFetchModels: true,\n },\n];\n\n/** 判断 provider id 是否是本地引擎 provider */\nexport const isLocalEngineProvider = (id: string): boolean =>\n LOCAL_ENGINE_PROVIDERS.some((p) => p.id === id);\n\n/** 根据 provider id 取 LocalEngineProvider 定义 */\nexport const getLocalEngineProvider = (id: string): LocalEngineProvider | undefined =>\n LOCAL_ENGINE_PROVIDERS.find((p) => p.id === id);\n\nexport interface TestConnectionResult {\n success: boolean;\n modelCount: number;\n sampleModelIds: string[];\n error: string | null;\n elapsedMs: number;\n}\n\nexport interface FetchLiveModelsResult {\n success: boolean;\n models: Array<{ id: string; owned_by: string; display_name?: string; released_at?: string }>;\n error: string | null;\n elapsedMs: number;\n}\n\nexport type CatalogSourceTag = 'appdata' | 'bundled' | 'dev_fallback' | 'empty';\n\nexport interface CatalogResponse {\n source: CatalogSourceTag;\n json: string;\n path: string | null;\n version: string | null;\n generatedAt: string | null;\n}\n\nexport interface UpdateResult {\n success: boolean;\n modelCount: number;\n providerCount: number;\n version: string;\n cachedPath: string;\n error: string | null;\n bytes: number;\n elapsedMs: number;\n}\n\nlet cachedCatalog: Catalog | null = null;\nlet cachedSource: CatalogSourceTag = 'empty';\nlet cachedPath: string | null = null;\nlet cachedVersion: string | null = null;\nlet cachedGeneratedAt: string | null = null;\nlet loadPromise: Promise | null = null;\n\nconst EMPTY_CATALOG: Catalog = {\n version: '0.0.0',\n generatedAt: '1970-01-01T00:00:00.000Z',\n source: '',\n providerCount: 0,\n modelCount: 0,\n providers: [],\n models: [],\n};\n\nfunction applyResponse(resp: CatalogResponse): Catalog {\n const cat = JSON.parse(resp.json) as Catalog;\n cachedCatalog = cat;\n cachedSource = resp.source;\n cachedPath = resp.path;\n cachedVersion = resp.version;\n cachedGeneratedAt = resp.generatedAt;\n return cat;\n}\n\nasync function loadCatalogFromBackend(): Promise {\n try {\n const resp = await invoke('load_models_catalog_full');\n return applyResponse(resp);\n } catch (e) {\n console.warn('[models] 从后端加载 catalog 失败:', e);\n return EMPTY_CATALOG;\n }\n}\n\nexport async function loadModelsCatalog(): Promise {\n if (cachedCatalog) return cachedCatalog;\n if (loadPromise) return loadPromise;\n loadPromise = loadCatalogFromBackend().then((c) => {\n loadPromise = null;\n return c;\n });\n return loadPromise;\n}\n\nexport function getCachedCatalog(): Catalog | null {\n return cachedCatalog;\n}\n\nexport function getCatalogMeta(): {\n source: CatalogSourceTag;\n path: string | null;\n version: string | null;\n generatedAt: string | null;\n} {\n return {\n source: cachedSource,\n path: cachedPath,\n version: cachedVersion,\n generatedAt: cachedGeneratedAt,\n };\n}\n\nexport async function refreshModelsCatalog(): Promise {\n loadPromise = null;\n return loadModelsCatalog();\n}\n\nexport async function updateModelsCatalog(url?: string): Promise {\n const result = await invoke('update_models_catalog', { url: url ?? null });\n if (result.success) {\n await refreshModelsCatalog();\n }\n return result;\n}\n\nexport async function getCatalogUrl(): Promise {\n return await invoke('get_catalog_url');\n}\n\nexport function findModel(catalog: Catalog, provider: string, id: string): ModelMeta | null {\n const prov = (provider || '').toLowerCase();\n const target = (id || '').toLowerCase();\n\n const direct = catalog.models.find(\n (m: ModelMeta) => m.provider.toLowerCase() === prov && m.id.toLowerCase() === target,\n );\n if (direct) return direct;\n\n return (\n catalog.models.find(\n (m: ModelMeta) =>\n m.provider.toLowerCase() === prov &&\n m.aliases.some((a: string) => a.toLowerCase() === target),\n ) ?? null\n );\n}\n\nexport function listProvidersForApiUrl(catalog: Catalog, apiUrl: string): ProviderMeta[] {\n if (!apiUrl) return catalog.providers.slice(0, 50);\n const u = apiUrl.toLowerCase();\n const knownHosts: Record = {\n 'api.openai.com': 'openai',\n 'api.anthropic.com': 'anthropic',\n 'generativelanguage.googleapis.com': 'google',\n 'api.deepseek.com': 'deepseek',\n 'api.groq.com': 'groq',\n 'api.mistral.ai': 'mistral',\n 'api.x.ai': 'xai',\n 'openrouter.ai': 'openrouter',\n 'api.cohere.ai': 'cohere',\n };\n for (const [host, prov] of Object.entries(knownHosts)) {\n if (u.includes(host)) {\n return catalog.providers.filter((p: ProviderMeta) => p.id === prov);\n }\n }\n return catalog.providers.filter((p: ProviderMeta) => !p.isAggregator).slice(0, 50);\n}\n\nexport function listCatalogModels(catalog: Catalog, providerId?: string): ModelMeta[] {\n if (!providerId || providerId === 'all') return catalog.models;\n return catalog.models.filter((m: ModelMeta) => m.provider === providerId);\n}\n\n/** 格式化上下文窗口(1234567 → \"1.2M\") */\nexport function formatContextWindow(tokens: number): string {\n if (tokens >= 1_000_000)\n return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`;\n if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}K`;\n return String(tokens);\n}\n\n/** 格式化单条价格(per 1M tokens) */\nexport function formatPricing(input: number, output: number): string {\n if (input === 0 && output === 0) return t('common.free');\n return `$${input}/${output}/1M`;\n}\n\n/** 把 ISO 时间格式化为「X 天前」之类的相对描述 */\nexport function formatRelativeTime(iso: string | null): string {\n if (!iso) return t('common.neverUpdated');\n try {\n const d = new Date(iso);\n const now = Date.now();\n const diff = now - d.getTime();\n const minutes = Math.floor(diff / 60_000);\n if (minutes < 1) return formatLocaleRelativeTime(0, 'minute');\n if (minutes < 60) return formatLocaleRelativeTime(-minutes, 'minute');\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return formatLocaleRelativeTime(-hours, 'hour');\n const days = Math.floor(hours / 24);\n if (days < 30) return formatLocaleRelativeTime(-days, 'day');\n const months = Math.floor(days / 30);\n if (months < 12) return formatLocaleRelativeTime(-months, 'month');\n return formatLocaleRelativeTime(-Math.floor(months / 12), 'year');\n } catch {\n return iso;\n }\n}\n\n/** 格式化 releaseDate(\"2025-01-15\" → \"2025-01\")只取年月 */\nexport function formatReleaseDate(iso: string | null | undefined): string {\n if (!iso) return '';\n return formatLocaleDateTime(`${iso.slice(0, 10)}T00:00:00`, { year: 'numeric', month: 'short' });\n}\n\n// ==================== v2 工具函数 ====================\n\n/**\n * 合并 catalog 模型元数据 + 用户启用状态, 拆分为三段\n * - enabled: 已启用的 (按 enabledIds 原顺序)\n * - available: catalog 中有但未启用的 (按 releaseDate desc, 同日期按 id 字母序)\n * - orphans: enabledModels 中有但 catalog 没有的 (遗留 custom / 老 config)\n */\nexport function listProviderModels(\n catalog: Catalog,\n providerId: string,\n enabledIds: readonly string[],\n): {\n enabled: ModelMeta[];\n available: ModelMeta[];\n orphans: string[];\n} {\n const catalogModels = catalog.models.filter((m: ModelMeta) => m.provider === providerId);\n const enabledSet = new Set(enabledIds);\n const enabledOrder = new Map(enabledIds.map((id, i) => [id, i]));\n\n const enabled: ModelMeta[] = [];\n const available: ModelMeta[] = [];\n const seen = new Set();\n\n for (const m of catalogModels) {\n if (enabledSet.has(m.id)) {\n enabled.push(m);\n seen.add(m.id);\n }\n }\n // 按 enabledIds 顺序排序 enabled\n enabled.sort((a, b) => (enabledOrder.get(a.id) ?? 0) - (enabledOrder.get(b.id) ?? 0));\n\n for (const m of catalogModels) {\n if (!seen.has(m.id)) {\n available.push(m);\n }\n }\n // 按 releaseDate desc, 同日期按 id 字母序\n available.sort((a, b) => {\n const ra = a.releaseDate ?? '';\n const rb = b.releaseDate ?? '';\n if (ra !== rb) return rb.localeCompare(ra); // desc\n return a.id.localeCompare(b.id);\n });\n\n // orphans: enabledIds 中有但 catalog 没有的\n const catalogIds = new Set(catalogModels.map((m: ModelMeta) => m.id));\n const orphans = enabledIds.filter((id) => !catalogIds.has(id));\n\n return { enabled, available, orphans };\n}\n\n/**\n * 通过 host 反查 provider id (统一入口, 替代三处重复实现)\n * 优先匹配 catalog 中 ProviderMeta 的 api 字段, 兜底用 9 个常见 host 映射\n */\nconst KNOWN_HOSTS: ReadonlyArray = [\n ['api.openai.com', 'openai'],\n ['api.anthropic.com', 'anthropic'],\n ['generativelanguage.googleapis.com', 'google'],\n ['api.deepseek.com', 'deepseek'],\n ['api.groq.com', 'groq'],\n ['api.mistral.ai', 'mistral'],\n ['api.x.ai', 'xai'],\n ['api.cohere.ai', 'cohere'],\n ['openrouter.ai', 'openrouter'],\n];\n\nexport function detectProviderByUrl(url: string): string | null {\n if (!url) return null;\n const u = url.toLowerCase();\n for (const [host, pid] of KNOWN_HOSTS) {\n if (u.includes(host)) return pid;\n }\n return null;\n}\n\n/** 列表页搜索: 仅匹配 provider id / name, 不含模型名 */\nexport function searchProviders(catalog: Catalog, query: string): ProviderMeta[] {\n if (!query.trim()) return catalog.providers;\n const q = query.toLowerCase();\n return catalog.providers.filter(\n (p: ProviderMeta) => p.id.includes(q) || p.name.toLowerCase().includes(q),\n );\n}\n"}}}}