1 line
65 KiB
JSON
1 line
65 KiB
JSON
{"repo": "bloodarena/bloodarena", "files": {"js/model-catalog.js": {"24ebb4ce73e27928ecca1f21124fdd1ad16e04f9": {"t": 1785746658.9876766, "n": 21872, "h": "98822f7836f3e301", "c": "/**\n * model-catalog.js\n * ----------------\n * All model-catalog related functions extracted from singleplayer_demo.html.\n * Handles parsing, normalizing, health-checking, and applying the model catalog,\n * as well as runtime model selection helpers.\n */\n\n// --- State imports ---\n// These mutable state variables are shared with the rest of the app.\n// In ES-module form we import getter/setter access; for now we import references\n// and mutate them directly.\nimport {\n loadedModelCatalog, setLoadedModelCatalog,\n customProviderMap, setCustomProviderMap,\n catalogApiKeys, setCatalogApiKeys,\n modelCatalogLoadStatus, setModelCatalogLoadStatus,\n modelCatalogHealth, setModelCatalogHealth,\n modelHealthWarned, setModelHealthWarned\n} from \"./state.js\";\n\nimport { DEFAULT_MODEL, MODEL_OPTIONS, setMODEL_OPTIONS } from \"./constants.js\";\n\n// --- DOM element and function imports ---\nimport {\n modelSelect, modelHealthBox, tempInput,\n renderAll\n} from \"./ui-helpers.js\";\nimport { initModelSelect } from \"./settings.js\";\nimport { showModal } from \"./overlays.js\";\n\n// ============================================================================\n// Embedded default catalog (fallback when YAML fetch fails)\n// ============================================================================\n\nconst DEFAULT_CATALOG_YAML = `\napi_keys:\n mimo: \"YOUR_MIMO_KEY\"\n deepseek: \"YOUR_DEEPSEEK_KEY\"\n gemini: \"YOUR_GEMINI_KEY\"\n claude: \"YOUR_CLAUDE_KEY\"\n gpt: \"YOUR_GPT_KEY\"\n openrouter: \"YOUR_OPENROUTER_KEY\"\n\nproviders:\n deepseek:\n label: \"DeepSeek\"\n base_url: \"https://api.deepseek.com/v1\"\n api_key: \"\\${deepseek}\"\n protocol: \"openai\"\n models:\n - \"deepseek-chat\"\n - \"deepseek-reasoner\"\n default_model: \"deepseek-chat\"\n\n gemini:\n label: \"Gemini\"\n base_url: \"\"\n api_key: \"\\${gemini}\"\n protocol: \"openai\"\n models:\n - \"gemini-3-pro-preview-high\"\n - \"gemini-3-pro-preview-low\"\n - \"gemini-3-pro-preview\"\n - \"gemini-3-flash-preview\"\n\n claude:\n label: \"Claude\"\n base_url: \"\"\n api_key: \"\\${claude}\"\n protocol: \"claude\"\n models:\n - \"claude-3-5-haiku-20241022\"\n - \"claude-3-7-sonnet-20250219\"\n - \"claude-3-7-sonnet-20250219-thinking\"\n - \"claude-3-haiku-20240307\"\n - \"claude-haiku-4-5-20251001\"\n - \"claude-haiku-4-5-20251001-thinking\"\n - \"claude-opus-4-1-20250805\"\n - \"claude-opus-4-1-20250805-thinking\"\n - \"claude-opus-4-20250514\"\n - \"claude-opus-4-20250514-thinking\"\n - \"claude-opus-4-5-20251101\"\n - \"claude-opus-4-5-20251101-thinking\"\n - \"claude-sonnet-4-20250514\"\n - \"claude-sonnet-4-20250514-thinking\"\n - \"claude-sonnet-4-5-20250929\"\n - \"claude-sonnet-4-5-20250929-thinking\"\n\n gpt:\n label: \"GPT\"\n base_url: \"\"\n api_key: \"\\${gpt}\"\n protocol: \"openai\"\n models:\n - \"gpt-5.1-2025-11-13\"\n - \"gpt-5-chat-2025-08-07\"\n\n mimo:\n label: \"MiMo\"\n base_url: \"https://api.xiaomimimo.com/v1\"\n api_key: \"\\${mimo}\"\n protocol: \"openai\"\n models:\n - \"mimo-v2-pro\"\n - \"mimo-v2-omni\"\n default_model: \"mimo-v2-pro\"\n\n openrouter:\n label: \"OpenRouter\"\n base_url: \"https://openrouter.ai/api/v1\"\n api_key: \"\\${openrouter}\"\n protocol: \"openai\"\n models:\n - \"xiaomi/mimo-v2-pro\"\n - \"minimax/minimax-m2.7\"\n - \"openai/gpt-5.4\"\n - \"google/gemini-3.1-pro-preview\"\n - \"anthropic/claude-sonnet-4.6\"\n default_model: \"anthropic/claude-sonnet-4.6\"\n headers:\n HTTP-Referer: \"\\${origin}\"\n`;\n\n// ============================================================================\n// localStorage API key helpers\n// ============================================================================\n\nconst LOCAL_API_KEYS_STORAGE = \"botc_user_api_keys\";\n\nlet lastRawCatalogData = null;\n\nexport function loadLocalApiKeys() {\n try {\n const raw = localStorage.getItem(LOCAL_API_KEYS_STORAGE);\n if (!raw) return {};\n return JSON.parse(raw);\n } catch { return {}; }\n}\n\nexport function saveLocalApiKeys(keys) {\n localStorage.setItem(LOCAL_API_KEYS_STORAGE, JSON.stringify(keys || {}));\n}\n\nexport function clearLocalApiKeys() {\n localStorage.removeItem(LOCAL_API_KEYS_STORAGE);\n}\n\nexport function getKnownApiKeyNames() {\n if (lastRawCatalogData) {\n const keys = lastRawCatalogData.api_keys || lastRawCatalogData.variables || lastRawCatalogData.secrets || {};\n return Object.keys(keys);\n }\n return [\"deepseek\", \"gemini\", \"claude\", \"gpt\", \"mimo\", \"kimi\", \"zhipu\", \"minimax\", \"openrouter\"];\n}\n\nexport function reapplyApiKeysFromUI(newKeys) {\n saveLocalApiKeys(newKeys);\n if (!lastRawCatalogData) return;\n const catalog = normalizeModelCatalog(lastRawCatalogData);\n if (catalog) {\n setModelCatalogLoadStatus({ ok: true, message: \"已应用用户密钥\" });\n applyModelCatalog(catalog);\n }\n}\n\n// ============================================================================\n// Pure / low-level helpers\n// ============================================================================\n\nexport function looksLikePlaceholderKey(value) {\n const raw = String(value || \"\").trim();\n if (!raw) return true;\n if (/\\$\\{[^}]+\\}/.test(raw)) return true;\n if (/^YOUR_[A-Z0-9_]*KEY$/i.test(raw)) return true;\n if (/^(your|replace|example|test)[-_ ]?(api)?[-_ ]?key$/i.test(raw)) return true;\n return false;\n}\n\nexport function buildProviderEndpoint(providerConfig) {\n if (!providerConfig) return \"\";\n const cleaned = String(providerConfig.baseUrl || \"\").trim().replace(/\\/+$/, \"\");\n const protocol = providerConfig.protocol || \"openai\";\n if (!cleaned) return \"\";\n if (protocol === \"claude\") {\n return cleaned.includes(\"/messages\") ? cleaned : `${cleaned}/v1/messages`;\n }\n if (cleaned.includes(\"/chat/completions\")) {\n return cleaned;\n }\n return cleaned.endsWith(\"/v1\") ? `${cleaned}/chat/completions` : `${cleaned}/v1/chat/completions`;\n}\n\nexport function getProviderHealth(providerId) {\n if (!providerId) return null;\n return modelCatalogHealth.find((item) => item.id === providerId) || null;\n}\n\nexport function getDefaultModelChoice() {\n return modelSelect?.value || DEFAULT_MODEL;\n}\n\nexport function getEffectiveModelChoice(actor) {\n if (actor && actor.modelChoice && actor.modelChoice !== \"default\") {\n return actor.modelChoice;\n }\n return getDefaultModelChoice();\n}\n\nexport function parseModelChoice(choice) {\n if (!choice) return { provider: \"\", model: \"\" };\n const parts = choice.split(\":\");\n if (parts.length < 2) return { provider: parts[0], model: \"\" };\n return { provider: parts[0], model: parts.slice(1).join(\":\") };\n}\n\n// ============================================================================\n// Internal normalization helpers\n// ============================================================================\n\nfunction resolveCatalogString(value, variables) {\n if (typeof value !== \"string\") return value;\n return value.replace(/\\$\\{([A-Za-z0-9_-]+)\\}/g, (match, key) => {\n if (variables && Object.prototype.hasOwnProperty.call(variables, key)) {\n return String(variables[key]);\n }\n return match;\n });\n}\n\nfunction normalizeModelEntry(entry) {\n if (!entry) return null;\n if (typeof entry === \"string\") {\n const name = entry.trim();\n return name ? { name } : null;\n }\n if (typeof entry === \"object\") {\n const name = String(entry.name || entry.id || entry.model || \"\").trim();\n return name ? { name } : null;\n }\n return null;\n}\n\nfunction normalizeProviderHeaders(rawHeaders, variables = null) {\n if (!rawHeaders || typeof rawHeaders !== \"object\" || Array.isArray(rawHeaders)) {\n return {};\n }\n const normalized = {};\n Object.entries(rawHeaders).forEach(([key, value]) => {\n const headerName = String(key || \"\").trim();\n if (!headerName) return;\n const headerValue = resolveCatalogString(String(value || \"\").trim(), variables);\n if (!headerValue) return;\n normalized[headerName] = headerValue;\n });\n return normalized;\n}\n\nfunction normalizeProviderEntry(entry, variables = null) {\n if (!entry) return null;\n const id = String(entry.id || entry.name || entry.provider || \"\").trim();\n if (!id) return null;\n const label = String(entry.label || entry.title || id).trim();\n const baseUrlRaw = String(entry.base_url || entry.baseUrl || entry.url || \"\").trim();\n const apiKeyRaw = String(entry.api_key || entry.apiKey || entry.key || \"\").trim();\n const baseUrl = resolveCatalogString(baseUrlRaw, variables);\n const apiKey = resolveCatalogString(apiKeyRaw, variables);\n const protocolRaw = String(entry.protocol || entry.connection || entry.endpoint_type || entry.type || \"\").trim().toLowerCase();\n const protocol = protocolRaw === \"claude\" || protocolRaw === \"anthropic\" ? \"claude\" : \"openai\";\n const defaultModel = resolveCatalogString(String(entry.default_model || entry.defaultModel || \"\").trim(), variables);\n const apiKeyRequired = typeof entry.api_key_required === \"boolean\" ? entry.api_key_required : null;\n const headers = normalizeProviderHeaders(entry.headers || entry.extra_headers || entry.default_headers, variables);\n let rawModels = Array.isArray(entry.models) ? entry.models : (entry.model ? [entry.model] : []);\n if (!rawModels.length && defaultModel) rawModels = [defaultModel];\n const models = rawModels\n .map((item) => {\n if (typeof item === \"string\") {\n return resolveCatalogString(item, variables);\n }\n if (item && typeof item === \"object\") {\n const name = resolveCatalogString(item.name || item.id || item.model || \"\", variables);\n return name ? { name } : null;\n }\n return item;\n })\n .map(normalizeModelEntry)\n .filter(Boolean);\n return { id, label, baseUrl, apiKey, protocol, defaultModel, apiKeyRequired, headers, models };\n}\n\n// ============================================================================\n// Health check helpers\n// ============================================================================\n\nfunction validateProviderHealth(provider) {\n const errors = [];\n const warnings = [];\n const models = Array.isArray(provider.models) ? provider.models.map((m) => m.name).filter(Boolean) : [];\n const requiresKey = typeof provider.apiKeyRequired === \"boolean\"\n ? provider.apiKeyRequired\n : true;\n const endpoint = buildProviderEndpoint(provider);\n\n if (!provider.baseUrl) {\n errors.push(\"缺少 base_url\");\n } else if (!/^https?:\\/\\//i.test(provider.baseUrl)) {\n warnings.push(\"base_url 不是 http/https 地址\");\n }\n if (!endpoint) {\n errors.push(\"无法生成请求端点\");\n }\n if (!models.length) {\n errors.push(\"models 为空\");\n }\n if (provider.defaultModel && models.length && !models.includes(provider.defaultModel)) {\n warnings.push(\"default_model 不在 models 列表中\");\n }\n if (requiresKey) {\n if (!provider.apiKey) {\n errors.push(\"缺少 api_key\");\n } else if (looksLikePlaceholderKey(provider.apiKey)) {\n errors.push(\"api_key 仍是占位值或未解析变量\");\n }\n }\n\n const status = errors.length ? \"error\" : (warnings.length ? \"warning\" : \"ready\");\n return {\n id: provider.id,\n label: provider.label || provider.id,\n protocol: provider.protocol || \"openai\",\n modelCount: models.length,\n status,\n errors,\n warnings\n };\n}\n\nfunction runModelCatalogHealthCheck() {\n const providers = loadedModelCatalog?.providers;\n if (!Array.isArray(providers) || !providers.length) {\n setModelCatalogHealth([]);\n renderModelHealthCheck();\n return modelCatalogHealth;\n }\n const seen = new Set();\n setModelCatalogHealth(providers.map((provider) => {\n const result = validateProviderHealth(provider);\n if (seen.has(result.id)) {\n result.status = \"error\";\n result.errors = result.errors.concat(\"provider id 重复\");\n }\n seen.add(result.id);\n return result;\n }));\n renderModelHealthCheck();\n\n const readyCount = modelCatalogHealth.filter((item) => item.status === \"ready\" || item.status === \"warning\").length;\n if (!readyCount && !modelHealthWarned) {\n setModelHealthWarned(true);\n showModal(\"模型配置校验未通过:当前没有可用 provider,请在设置中填写 API 密钥或检查 model_catalog.yaml。\");\n }\n return modelCatalogHealth;\n}\n\n// ============================================================================\n// Exported catalog functions\n// ============================================================================\n\nexport function parseModelCatalogText(text) {\n const trimmed = String(text || \"\").trim();\n if (!trimmed) {\n return { data: null, error: \"model_catalog.yaml 为空\" };\n }\n if (window.jsyaml && typeof window.jsyaml.load === \"function\") {\n try {\n return { data: window.jsyaml.load(trimmed), error: \"\" };\n } catch (error) {\n return { data: null, error: `YAML 解析失败:${error?.message || \"格式错误\"}` };\n }\n }\n try {\n return { data: JSON.parse(trimmed), error: \"\" };\n } catch (error) {\n return { data: null, error: `JSON 解析失败:${error?.message || \"格式错误\"}` };\n }\n}\n\nexport function normalizeModelCatalog(raw) {\n if (!raw) return null;\n lastRawCatalogData = raw;\n const userVariables = raw.api_keys || raw.variables || raw.secrets || {};\n const localKeys = loadLocalApiKeys();\n const mergedKeys = { ...userVariables };\n Object.entries(localKeys).forEach(([k, v]) => {\n if (v && typeof v === \"string\" && v.trim()) mergedKeys[k] = v.trim();\n });\n setCatalogApiKeys(mergedKeys);\n const runtimeVariables = {\n origin: window.location?.origin || \"\",\n host: window.location?.host || \"\",\n protocol: window.location?.protocol || \"\"\n };\n const variables = {\n ...runtimeVariables,\n ...mergedKeys\n };\n let providers = [];\n if (Array.isArray(raw)) {\n providers = raw;\n } else if (Array.isArray(raw.providers)) {\n providers = raw.providers;\n } else if (raw.providers && typeof raw.providers === \"object\") {\n providers = Object.entries(raw.providers).map(([id, value]) => ({\n ...(value || {}),\n id\n }));\n }\n const normalized = providers.map((entry) => normalizeProviderEntry(entry, variables)).filter(Boolean);\n return normalized.length ? { providers: normalized } : null;\n}\n\nfunction refreshModelOptionsFromCatalog() {\n const hasCatalog =\n loadedModelCatalog &&\n Array.isArray(loadedModelCatalog.providers) &&\n loadedModelCatalog.providers.length > 0;\n if (!hasCatalog) {\n setMODEL_OPTIONS([]);\n return;\n }\n const newOptions = [];\n const seen = new Set();\n loadedModelCatalog.providers.forEach((provider) => {\n const modelNames = provider.models.map((m) => m.name);\n modelNames.forEach((name) => {\n const value = `${provider.id}:${name}`;\n if (seen.has(value)) return;\n newOptions.push({ value, label: `${provider.label} / ${name}` });\n seen.add(value);\n });\n });\n setMODEL_OPTIONS(newOptions);\n}\n\nexport function applyModelCatalog(catalog) {\n setLoadedModelCatalog(catalog);\n const newMap = {};\n if (catalog && Array.isArray(catalog.providers)) {\n catalog.providers.forEach((provider) => {\n newMap[provider.id] = provider;\n });\n }\n setCustomProviderMap(newMap);\n refreshModelOptionsFromCatalog();\n initModelSelect();\n runModelCatalogHealthCheck();\n renderAll();\n}\n\nexport function getCustomProviderConfig(providerId) {\n if (!providerId) return null;\n return customProviderMap[providerId] || null;\n}\n\nexport function renderModelHealthCheck() {\n if (!modelHealthBox) return;\n modelHealthBox.innerHTML = \"\";\n const summary = document.createElement(\"div\");\n summary.className = \"model-health-summary\";\n\n if (!modelCatalogLoadStatus.ok) {\n summary.classList.add(\"error\");\n summary.textContent = `加载失败:${modelCatalogLoadStatus.message || \"无法读取 model_catalog.yaml\"}`;\n modelHealthBox.appendChild(summary);\n return;\n }\n\n const total = modelCatalogHealth.length;\n const okCount = modelCatalogHealth.filter((item) => item.status === \"ready\").length;\n const warnCount = modelCatalogHealth.filter((item) => item.status === \"warning\").length;\n const errCount = modelCatalogHealth.filter((item) => item.status === \"error\").length;\n summary.textContent = `启动健康检查:可用 ${okCount + warnCount}/${total}(通过 ${okCount},警告 ${warnCount},失败 ${errCount})`;\n modelHealthBox.appendChild(summary);\n\n const list = document.createElement(\"div\");\n list.className = \"model-health-list\";\n modelCatalogHealth.forEach((item) => {\n const card = document.createElement(\"div\");\n card.className = `model-health-item ${item.status}`;\n const head = document.createElement(\"div\");\n head.className = \"model-health-head\";\n const name = document.createElement(\"span\");\n name.className = \"model-health-provider\";\n name.textContent = `${item.label} (${item.protocol})`;\n const badge = document.createElement(\"span\");\n badge.className = \"model-health-badge\";\n badge.textContent = item.status === \"ready\" ? \"通过\" : item.status === \"warning\" ? \"警告\" : \"失败\";\n head.appendChild(name);\n head.appendChild(badge);\n card.appendChild(head);\n\n const details = [];\n details.push(`models: ${item.modelCount}`);\n if (item.errors.length) details.push(`错误: ${item.errors.join(\";\")}`);\n if (item.warnings.length) details.push(`警告: ${item.warnings.join(\";\")}`);\n const detail = document.createElement(\"div\");\n detail.className = \"model-health-detail\";\n detail.textContent = details.join(\"\\n\");\n card.appendChild(detail);\n list.appendChild(card);\n });\n modelHealthBox.appendChild(list);\n}\n\nexport function showModelCatalogFallback() {\n const el = document.getElementById(\"modelCatalogFallback\");\n if (el) el.style.display = \"block\";\n}\n\nexport function hideModelCatalogFallback() {\n const el = document.getElementById(\"modelCatalogFallback\");\n if (el) el.style.display = \"none\";\n}\n\nexport function loadModelCatalogFromText(text) {\n const parsed = parseModelCatalogText(text);\n if (!parsed.data) {\n if (typeof alert === \"function\") alert(parsed.error || \"解析失败\");\n return false;\n }\n const catalog = normalizeModelCatalog(parsed.data);\n if (!catalog) {\n if (typeof alert === \"function\") alert(\"providers 为空或格式不正确\");\n return false;\n }\n setModelCatalogLoadStatus({ ok: true, message: \"已从本地文件加载\" });\n applyModelCatalog(catalog);\n renderModelHealthCheck();\n hideModelCatalogFallback();\n return true;\n}\n\nexport async function autoLoadModelCatalog() {\n setModelCatalogLoadStatus({ ok: false, message: \"正在加载 model_catalog.yaml...\" });\n renderModelHealthCheck();\n\n let text = null;\n let fromYaml = false;\n\n try {\n const response = await fetch(\"model_catalog.yaml\", { cache: \"no-store\" });\n if (response.ok) {\n text = await response.text();\n fromYaml = true;\n }\n } catch {}\n\n if (!text) {\n text = DEFAULT_CATALOG_YAML;\n fromYaml = false;\n }\n\n const parsed = parseModelCatalogText(text);\n if (!parsed.data) {\n setModelCatalogLoadStatus({ ok: false, message: parsed.error || \"解析失败\" });\n renderModelHealthCheck();\n showModelCatalogFallback();\n return modelCatalogLoadStatus;\n }\n\n const catalog = normalizeModelCatalog(parsed.data);\n if (!catalog) {\n setModelCatalogLoadStatus({ ok: false, message: \"providers 为空或格式不正确\" });\n renderModelHealthCheck();\n showModelCatalogFallback();\n return modelCatalogLoadStatus;\n }\n\n const msg = fromYaml ? \"加载成功\" : \"已使用内置默认配置(请在页面中填写 API 密钥)\";\n setModelCatalogLoadStatus({ ok: true, message: msg });\n applyModelCatalog(catalog);\n if (fromYaml) {\n hideModelCatalogFallback();\n }\n return modelCatalogLoadStatus;\n}\n\nexport function getModelConfig(actor) {\n const choice = getEffectiveModelChoice(actor);\n const { provider, model } = parseModelChoice(choice);\n const temperature = Number(tempInput.value) || 1.0;\n const custom = getCustomProviderConfig(provider);\n if (!custom) {\n alert(\"未找到模型配置,请在设置中填写 API 密钥或检查 model_catalog.yaml。\");\n return {\n provider: provider || \"\",\n model: model || \"\",\n apiKey: \"\",\n endpoint: \"\",\n temperature,\n protocol: \"openai\",\n requiresKey: true\n };\n }\n const protocol = custom.protocol || \"openai\";\n const endpoint = buildProviderEndpoint(custom);\n const modelName = model === \"default\"\n ? (custom.defaultModel || custom.models?.[0]?.name || model)\n : model;\n const requiresKey = typeof custom.apiKeyRequired === \"boolean\"\n ? custom.apiKeyRequired\n : true;\n const providerHealth = getProviderHealth(provider);\n if (providerHealth && providerHealth.status === \"error\") {\n const reason = providerHealth.errors[0] || \"配置不完整\";\n alert(`${providerHealth.label} 配置异常:${reason}`);\n }\n return {\n provider,\n model: modelName,\n apiKey: custom.apiKey || \"\",\n endpoint,\n temperature,\n protocol,\n requiresKey,\n headers: custom.headers || {},\n label: custom.label || provider\n };\n}\n\n// Also export the additional helper functions that may be needed externally\nexport { runModelCatalogHealthCheck, validateProviderHealth };\n\nexport function getModelStartupReadiness() {\n if (!modelCatalogLoadStatus.ok) {\n return { ok: false, message: `模型配置加载失败:${modelCatalogLoadStatus.message || \"请检查 model_catalog.yaml\"}` };\n }\n if (!Array.isArray(modelCatalogHealth) || !modelCatalogHealth.length) {\n return { ok: false, message: \"未发现可检查的 provider,请检查 model_catalog.yaml.providers。\" };\n }\n const usable = modelCatalogHealth.filter((item) => item.status !== \"error\");\n if (!usable.length) {\n return { ok: false, message: \"模型配置未通过健康检查:没有可用 provider。\" };\n }\n const choice = getDefaultModelChoice();\n const parsed = parseModelChoice(choice);\n if (!parsed.provider) {\n return { ok: false, message: \"默认模型未设置,请先在设置中选择模型。\" };\n }\n const selectedHealth = getProviderHealth(parsed.provider);\n if (!selectedHealth) {\n return { ok: false, message: `默认模型 provider 未找到:${parsed.provider}` };\n }\n if (selectedHealth.status === \"error\") {\n const firstReason = selectedHealth.errors[0] || \"配置异常\";\n return { ok: false, message: `默认模型 provider 校验失败(${selectedHealth.label}):${firstReason}` };\n }\n return { ok: true, message: \"\" };\n}\n"}, "8fc5bfd9ea1713673299ba1af8b35236ceb663aa": {"t": 1785766660.649636, "n": 21844, "h": "b81be69b4f50ae5e", "c": "/**\n * model-catalog.js\n * ----------------\n * All model-catalog related functions extracted from singleplayer_demo.html.\n * Handles parsing, normalizing, health-checking, and applying the model catalog,\n * as well as runtime model selection helpers.\n */\n\n// --- State imports ---\n// These mutable state variables are shared with the rest of the app.\n// In ES-module form we import getter/setter access; for now we import references\n// and mutate them directly.\nimport {\n loadedModelCatalog, setLoadedModelCatalog,\n customProviderMap, setCustomProviderMap,\n catalogApiKeys, setCatalogApiKeys,\n modelCatalogLoadStatus, setModelCatalogLoadStatus,\n modelCatalogHealth, setModelCatalogHealth,\n modelHealthWarned, setModelHealthWarned\n} from \"./state.js\";\n\nimport { DEFAULT_MODEL, MODEL_OPTIONS, setMODEL_OPTIONS } from \"./constants.js\";\n\n// --- DOM element and function imports ---\nimport {\n modelSelect, modelHealthBox, tempInput,\n renderAll\n} from \"./ui-helpers.js\";\nimport { initModelSelect } from \"./settings.js\";\nimport { showModal } from \"./overlays.js\";\n\n// ============================================================================\n// Embedded default catalog (fallback when YAML fetch fails)\n// ============================================================================\n\nconst DEFAULT_CATALOG_YAML = `\napi_keys:\n mimo: \"YOUR_MIMO_KEY\"\n deepseek: \"YOUR_DEEPSEEK_KEY\"\n gemini: \"YOUR_GEMINI_KEY\"\n claude: \"YOUR_CLAUDE_KEY\"\n gpt: \"YOUR_GPT_KEY\"\n openrouter: \"YOUR_OPENROUTER_KEY\"\n\nproviders:\n deepseek:\n label: \"DeepSeek\"\n base_url: \"https://api.deepseek.com/v1\"\n api_key: \"\\${deepseek}\"\n protocol: \"openai\"\n models:\n - \"deepseek-chat\"\n - \"deepseek-reasoner\"\n default_model: \"deepseek-chat\"\n\n gemini:\n label: \"Gemini\"\n base_url: \"\"\n api_key: \"\\${gemini}\"\n protocol: \"openai\"\n models:\n - \"gemini-3-pro-preview-high\"\n - \"gemini-3-pro-preview-low\"\n - \"gemini-3-pro-preview\"\n - \"gemini-3-flash-preview\"\n\n claude:\n label: \"Claude\"\n base_url: \"\"\n api_key: \"\\${claude}\"\n protocol: \"claude\"\n models:\n - \"claude-3-5-haiku-20241022\"\n - \"claude-3-7-sonnet-20250219\"\n - \"claude-3-7-sonnet-20250219-thinking\"\n - \"claude-3-haiku-20240307\"\n - \"claude-haiku-4-5-20251001\"\n - \"claude-haiku-4-5-20251001-thinking\"\n - \"claude-opus-4-1-20250805\"\n - \"claude-opus-4-1-20250805-thinking\"\n - \"claude-opus-4-20250514\"\n - \"claude-opus-4-20250514-thinking\"\n - \"claude-opus-4-5-20251101\"\n - \"claude-opus-4-5-20251101-thinking\"\n - \"claude-sonnet-4-20250514\"\n - \"claude-sonnet-4-20250514-thinking\"\n - \"claude-sonnet-4-5-20250929\"\n - \"claude-sonnet-4-5-20250929-thinking\"\n\n gpt:\n label: \"GPT\"\n base_url: \"\"\n api_key: \"\\${gpt}\"\n protocol: \"openai\"\n models:\n - \"gpt-5.1-2025-11-13\"\n - \"gpt-5-chat-2025-08-07\"\n\n mimo:\n label: \"MiMo\"\n base_url: \"https://api.xiaomimimo.com/v1\"\n api_key: \"\\${mimo}\"\n protocol: \"openai\"\n models:\n - \"mimo-v2-pro\"\n - \"mimo-v2-omni\"\n default_model: \"mimo-v2-pro\"\n\n openrouter:\n label: \"OpenRouter\"\n base_url: \"https://openrouter.ai/api/v1\"\n api_key: \"\\${openrouter}\"\n protocol: \"openai\"\n models:\n - \"xiaomi/mimo-v2-pro\"\n - \"minimax/minimax-m2.7\"\n - \"openai/gpt-5.4\"\n - \"google/gemini-3.1-pro-preview\"\n - \"anthropic/claude-sonnet-4.6\"\n default_model: \"anthropic/claude-sonnet-4.6\"\n headers:\n HTTP-Referer: \"\\${origin}\"\n`;\n\n// ============================================================================\n// localStorage API key helpers\n// ============================================================================\n\nconst LOCAL_API_KEYS_STORAGE = \"botc_user_api_keys\";\n\nlet lastRawCatalogData = null;\n\nexport function loadLocalApiKeys() {\n try {\n const raw = localStorage.getItem(LOCAL_API_KEYS_STORAGE);\n if (!raw) return {};\n return JSON.parse(raw);\n } catch { return {}; }\n}\n\nexport function saveLocalApiKeys(keys) {\n localStorage.setItem(LOCAL_API_KEYS_STORAGE, JSON.stringify(keys || {}));\n}\n\nexport function clearLocalApiKeys() {\n localStorage.removeItem(LOCAL_API_KEYS_STORAGE);\n}\n\nexport function getKnownApiKeyNames() {\n if (lastRawCatalogData) {\n const keys = lastRawCatalogData.api_keys || lastRawCatalogData.variables || lastRawCatalogData.secrets || {};\n return Object.keys(keys);\n }\n return [\"deepseek\", \"gemini\", \"claude\", \"gpt\", \"mimo\", \"openrouter\"];\n}\n\nexport function reapplyApiKeysFromUI(newKeys) {\n saveLocalApiKeys(newKeys);\n if (!lastRawCatalogData) return;\n const catalog = normalizeModelCatalog(lastRawCatalogData);\n if (catalog) {\n setModelCatalogLoadStatus({ ok: true, message: \"已应用用户密钥\" });\n applyModelCatalog(catalog);\n }\n}\n\n// ============================================================================\n// Pure / low-level helpers\n// ============================================================================\n\nexport function looksLikePlaceholderKey(value) {\n const raw = String(value || \"\").trim();\n if (!raw) return true;\n if (/\\$\\{[^}]+\\}/.test(raw)) return true;\n if (/^YOUR_[A-Z0-9_]*KEY$/i.test(raw)) return true;\n if (/^(your|replace|example|test)[-_ ]?(api)?[-_ ]?key$/i.test(raw)) return true;\n return false;\n}\n\nexport function buildProviderEndpoint(providerConfig) {\n if (!providerConfig) return \"\";\n const cleaned = String(providerConfig.baseUrl || \"\").trim().replace(/\\/+$/, \"\");\n const protocol = providerConfig.protocol || \"openai\";\n if (!cleaned) return \"\";\n if (protocol === \"claude\") {\n return cleaned.includes(\"/messages\") ? cleaned : `${cleaned}/v1/messages`;\n }\n if (cleaned.includes(\"/chat/completions\")) {\n return cleaned;\n }\n return cleaned.endsWith(\"/v1\") ? `${cleaned}/chat/completions` : `${cleaned}/v1/chat/completions`;\n}\n\nexport function getProviderHealth(providerId) {\n if (!providerId) return null;\n return modelCatalogHealth.find((item) => item.id === providerId) || null;\n}\n\nexport function getDefaultModelChoice() {\n return modelSelect?.value || DEFAULT_MODEL;\n}\n\nexport function getEffectiveModelChoice(actor) {\n if (actor && actor.modelChoice && actor.modelChoice !== \"default\") {\n return actor.modelChoice;\n }\n return getDefaultModelChoice();\n}\n\nexport function parseModelChoice(choice) {\n if (!choice) return { provider: \"\", model: \"\" };\n const parts = choice.split(\":\");\n if (parts.length < 2) return { provider: parts[0], model: \"\" };\n return { provider: parts[0], model: parts.slice(1).join(\":\") };\n}\n\n// ============================================================================\n// Internal normalization helpers\n// ============================================================================\n\nfunction resolveCatalogString(value, variables) {\n if (typeof value !== \"string\") return value;\n return value.replace(/\\$\\{([A-Za-z0-9_-]+)\\}/g, (match, key) => {\n if (variables && Object.prototype.hasOwnProperty.call(variables, key)) {\n return String(variables[key]);\n }\n return match;\n });\n}\n\nfunction normalizeModelEntry(entry) {\n if (!entry) return null;\n if (typeof entry === \"string\") {\n const name = entry.trim();\n return name ? { name } : null;\n }\n if (typeof entry === \"object\") {\n const name = String(entry.name || entry.id || entry.model || \"\").trim();\n return name ? { name } : null;\n }\n return null;\n}\n\nfunction normalizeProviderHeaders(rawHeaders, variables = null) {\n if (!rawHeaders || typeof rawHeaders !== \"object\" || Array.isArray(rawHeaders)) {\n return {};\n }\n const normalized = {};\n Object.entries(rawHeaders).forEach(([key, value]) => {\n const headerName = String(key || \"\").trim();\n if (!headerName) return;\n const headerValue = resolveCatalogString(String(value || \"\").trim(), variables);\n if (!headerValue) return;\n normalized[headerName] = headerValue;\n });\n return normalized;\n}\n\nfunction normalizeProviderEntry(entry, variables = null) {\n if (!entry) return null;\n const id = String(entry.id || entry.name || entry.provider || \"\").trim();\n if (!id) return null;\n const label = String(entry.label || entry.title || id).trim();\n const baseUrlRaw = String(entry.base_url || entry.baseUrl || entry.url || \"\").trim();\n const apiKeyRaw = String(entry.api_key || entry.apiKey || entry.key || \"\").trim();\n const baseUrl = resolveCatalogString(baseUrlRaw, variables);\n const apiKey = resolveCatalogString(apiKeyRaw, variables);\n const protocolRaw = String(entry.protocol || entry.connection || entry.endpoint_type || entry.type || \"\").trim().toLowerCase();\n const protocol = protocolRaw === \"claude\" || protocolRaw === \"anthropic\" ? \"claude\" : \"openai\";\n const defaultModel = resolveCatalogString(String(entry.default_model || entry.defaultModel || \"\").trim(), variables);\n const apiKeyRequired = typeof entry.api_key_required === \"boolean\" ? entry.api_key_required : null;\n const headers = normalizeProviderHeaders(entry.headers || entry.extra_headers || entry.default_headers, variables);\n let rawModels = Array.isArray(entry.models) ? entry.models : (entry.model ? [entry.model] : []);\n if (!rawModels.length && defaultModel) rawModels = [defaultModel];\n const models = rawModels\n .map((item) => {\n if (typeof item === \"string\") {\n return resolveCatalogString(item, variables);\n }\n if (item && typeof item === \"object\") {\n const name = resolveCatalogString(item.name || item.id || item.model || \"\", variables);\n return name ? { name } : null;\n }\n return item;\n })\n .map(normalizeModelEntry)\n .filter(Boolean);\n return { id, label, baseUrl, apiKey, protocol, defaultModel, apiKeyRequired, headers, models };\n}\n\n// ============================================================================\n// Health check helpers\n// ============================================================================\n\nfunction validateProviderHealth(provider) {\n const errors = [];\n const warnings = [];\n const models = Array.isArray(provider.models) ? provider.models.map((m) => m.name).filter(Boolean) : [];\n const requiresKey = typeof provider.apiKeyRequired === \"boolean\"\n ? provider.apiKeyRequired\n : true;\n const endpoint = buildProviderEndpoint(provider);\n\n if (!provider.baseUrl) {\n errors.push(\"缺少 base_url\");\n } else if (!/^https?:\\/\\//i.test(provider.baseUrl)) {\n warnings.push(\"base_url 不是 http/https 地址\");\n }\n if (!endpoint) {\n errors.push(\"无法生成请求端点\");\n }\n if (!models.length) {\n errors.push(\"models 为空\");\n }\n if (provider.defaultModel && models.length && !models.includes(provider.defaultModel)) {\n warnings.push(\"default_model 不在 models 列表中\");\n }\n if (requiresKey) {\n if (!provider.apiKey) {\n errors.push(\"缺少 api_key\");\n } else if (looksLikePlaceholderKey(provider.apiKey)) {\n errors.push(\"api_key 仍是占位值或未解析变量\");\n }\n }\n\n const status = errors.length ? \"error\" : (warnings.length ? \"warning\" : \"ready\");\n return {\n id: provider.id,\n label: provider.label || provider.id,\n protocol: provider.protocol || \"openai\",\n modelCount: models.length,\n status,\n errors,\n warnings\n };\n}\n\nfunction runModelCatalogHealthCheck() {\n const providers = loadedModelCatalog?.providers;\n if (!Array.isArray(providers) || !providers.length) {\n setModelCatalogHealth([]);\n renderModelHealthCheck();\n return modelCatalogHealth;\n }\n const seen = new Set();\n setModelCatalogHealth(providers.map((provider) => {\n const result = validateProviderHealth(provider);\n if (seen.has(result.id)) {\n result.status = \"error\";\n result.errors = result.errors.concat(\"provider id 重复\");\n }\n seen.add(result.id);\n return result;\n }));\n renderModelHealthCheck();\n\n const readyCount = modelCatalogHealth.filter((item) => item.status === \"ready\" || item.status === \"warning\").length;\n if (!readyCount && !modelHealthWarned) {\n setModelHealthWarned(true);\n showModal(\"模型配置校验未通过:当前没有可用 provider,请在设置中填写 API 密钥或检查 model_catalog.yaml。\");\n }\n return modelCatalogHealth;\n}\n\n// ============================================================================\n// Exported catalog functions\n// ============================================================================\n\nexport function parseModelCatalogText(text) {\n const trimmed = String(text || \"\").trim();\n if (!trimmed) {\n return { data: null, error: \"model_catalog.yaml 为空\" };\n }\n if (window.jsyaml && typeof window.jsyaml.load === \"function\") {\n try {\n return { data: window.jsyaml.load(trimmed), error: \"\" };\n } catch (error) {\n return { data: null, error: `YAML 解析失败:${error?.message || \"格式错误\"}` };\n }\n }\n try {\n return { data: JSON.parse(trimmed), error: \"\" };\n } catch (error) {\n return { data: null, error: `JSON 解析失败:${error?.message || \"格式错误\"}` };\n }\n}\n\nexport function normalizeModelCatalog(raw) {\n if (!raw) return null;\n lastRawCatalogData = raw;\n const userVariables = raw.api_keys || raw.variables || raw.secrets || {};\n const localKeys = loadLocalApiKeys();\n const mergedKeys = { ...userVariables };\n Object.entries(localKeys).forEach(([k, v]) => {\n if (v && typeof v === \"string\" && v.trim()) mergedKeys[k] = v.trim();\n });\n setCatalogApiKeys(mergedKeys);\n const runtimeVariables = {\n origin: window.location?.origin || \"\",\n host: window.location?.host || \"\",\n protocol: window.location?.protocol || \"\"\n };\n const variables = {\n ...runtimeVariables,\n ...mergedKeys\n };\n let providers = [];\n if (Array.isArray(raw)) {\n providers = raw;\n } else if (Array.isArray(raw.providers)) {\n providers = raw.providers;\n } else if (raw.providers && typeof raw.providers === \"object\") {\n providers = Object.entries(raw.providers).map(([id, value]) => ({\n ...(value || {}),\n id\n }));\n }\n const normalized = providers.map((entry) => normalizeProviderEntry(entry, variables)).filter(Boolean);\n return normalized.length ? { providers: normalized } : null;\n}\n\nfunction refreshModelOptionsFromCatalog() {\n const hasCatalog =\n loadedModelCatalog &&\n Array.isArray(loadedModelCatalog.providers) &&\n loadedModelCatalog.providers.length > 0;\n if (!hasCatalog) {\n setMODEL_OPTIONS([]);\n return;\n }\n const newOptions = [];\n const seen = new Set();\n loadedModelCatalog.providers.forEach((provider) => {\n const modelNames = provider.models.map((m) => m.name);\n modelNames.forEach((name) => {\n const value = `${provider.id}:${name}`;\n if (seen.has(value)) return;\n newOptions.push({ value, label: `${provider.label} / ${name}` });\n seen.add(value);\n });\n });\n setMODEL_OPTIONS(newOptions);\n}\n\nexport function applyModelCatalog(catalog) {\n setLoadedModelCatalog(catalog);\n const newMap = {};\n if (catalog && Array.isArray(catalog.providers)) {\n catalog.providers.forEach((provider) => {\n newMap[provider.id] = provider;\n });\n }\n setCustomProviderMap(newMap);\n refreshModelOptionsFromCatalog();\n initModelSelect();\n runModelCatalogHealthCheck();\n renderAll();\n}\n\nexport function getCustomProviderConfig(providerId) {\n if (!providerId) return null;\n return customProviderMap[providerId] || null;\n}\n\nexport function renderModelHealthCheck() {\n if (!modelHealthBox) return;\n modelHealthBox.innerHTML = \"\";\n const summary = document.createElement(\"div\");\n summary.className = \"model-health-summary\";\n\n if (!modelCatalogLoadStatus.ok) {\n summary.classList.add(\"error\");\n summary.textContent = `加载失败:${modelCatalogLoadStatus.message || \"无法读取 model_catalog.yaml\"}`;\n modelHealthBox.appendChild(summary);\n return;\n }\n\n const total = modelCatalogHealth.length;\n const okCount = modelCatalogHealth.filter((item) => item.status === \"ready\").length;\n const warnCount = modelCatalogHealth.filter((item) => item.status === \"warning\").length;\n const errCount = modelCatalogHealth.filter((item) => item.status === \"error\").length;\n summary.textContent = `启动健康检查:可用 ${okCount + warnCount}/${total}(通过 ${okCount},警告 ${warnCount},失败 ${errCount})`;\n modelHealthBox.appendChild(summary);\n\n const list = document.createElement(\"div\");\n list.className = \"model-health-list\";\n modelCatalogHealth.forEach((item) => {\n const card = document.createElement(\"div\");\n card.className = `model-health-item ${item.status}`;\n const head = document.createElement(\"div\");\n head.className = \"model-health-head\";\n const name = document.createElement(\"span\");\n name.className = \"model-health-provider\";\n name.textContent = `${item.label} (${item.protocol})`;\n const badge = document.createElement(\"span\");\n badge.className = \"model-health-badge\";\n badge.textContent = item.status === \"ready\" ? \"通过\" : item.status === \"warning\" ? \"警告\" : \"失败\";\n head.appendChild(name);\n head.appendChild(badge);\n card.appendChild(head);\n\n const details = [];\n details.push(`models: ${item.modelCount}`);\n if (item.errors.length) details.push(`错误: ${item.errors.join(\";\")}`);\n if (item.warnings.length) details.push(`警告: ${item.warnings.join(\";\")}`);\n const detail = document.createElement(\"div\");\n detail.className = \"model-health-detail\";\n detail.textContent = details.join(\"\\n\");\n card.appendChild(detail);\n list.appendChild(card);\n });\n modelHealthBox.appendChild(list);\n}\n\nexport function showModelCatalogFallback() {\n const el = document.getElementById(\"modelCatalogFallback\");\n if (el) el.style.display = \"block\";\n}\n\nexport function hideModelCatalogFallback() {\n const el = document.getElementById(\"modelCatalogFallback\");\n if (el) el.style.display = \"none\";\n}\n\nexport function loadModelCatalogFromText(text) {\n const parsed = parseModelCatalogText(text);\n if (!parsed.data) {\n if (typeof alert === \"function\") alert(parsed.error || \"解析失败\");\n return false;\n }\n const catalog = normalizeModelCatalog(parsed.data);\n if (!catalog) {\n if (typeof alert === \"function\") alert(\"providers 为空或格式不正确\");\n return false;\n }\n setModelCatalogLoadStatus({ ok: true, message: \"已从本地文件加载\" });\n applyModelCatalog(catalog);\n renderModelHealthCheck();\n hideModelCatalogFallback();\n return true;\n}\n\nexport async function autoLoadModelCatalog() {\n setModelCatalogLoadStatus({ ok: false, message: \"正在加载 model_catalog.yaml...\" });\n renderModelHealthCheck();\n\n let text = null;\n let fromYaml = false;\n\n try {\n const response = await fetch(\"model_catalog.yaml\", { cache: \"no-store\" });\n if (response.ok) {\n text = await response.text();\n fromYaml = true;\n }\n } catch {}\n\n if (!text) {\n text = DEFAULT_CATALOG_YAML;\n fromYaml = false;\n }\n\n const parsed = parseModelCatalogText(text);\n if (!parsed.data) {\n setModelCatalogLoadStatus({ ok: false, message: parsed.error || \"解析失败\" });\n renderModelHealthCheck();\n showModelCatalogFallback();\n return modelCatalogLoadStatus;\n }\n\n const catalog = normalizeModelCatalog(parsed.data);\n if (!catalog) {\n setModelCatalogLoadStatus({ ok: false, message: \"providers 为空或格式不正确\" });\n renderModelHealthCheck();\n showModelCatalogFallback();\n return modelCatalogLoadStatus;\n }\n\n const msg = fromYaml ? \"加载成功\" : \"已使用内置默认配置(请在页面中填写 API 密钥)\";\n setModelCatalogLoadStatus({ ok: true, message: msg });\n applyModelCatalog(catalog);\n if (fromYaml) {\n hideModelCatalogFallback();\n }\n return modelCatalogLoadStatus;\n}\n\nexport function getModelConfig(actor) {\n const choice = getEffectiveModelChoice(actor);\n const { provider, model } = parseModelChoice(choice);\n const temperature = Number(tempInput.value) || 1.0;\n const custom = getCustomProviderConfig(provider);\n if (!custom) {\n alert(\"未找到模型配置,请在设置中填写 API 密钥或检查 model_catalog.yaml。\");\n return {\n provider: provider || \"\",\n model: model || \"\",\n apiKey: \"\",\n endpoint: \"\",\n temperature,\n protocol: \"openai\",\n requiresKey: true\n };\n }\n const protocol = custom.protocol || \"openai\";\n const endpoint = buildProviderEndpoint(custom);\n const modelName = model === \"default\"\n ? (custom.defaultModel || custom.models?.[0]?.name || model)\n : model;\n const requiresKey = typeof custom.apiKeyRequired === \"boolean\"\n ? custom.apiKeyRequired\n : true;\n const providerHealth = getProviderHealth(provider);\n if (providerHealth && providerHealth.status === \"error\") {\n const reason = providerHealth.errors[0] || \"配置不完整\";\n alert(`${providerHealth.label} 配置异常:${reason}`);\n }\n return {\n provider,\n model: modelName,\n apiKey: custom.apiKey || \"\",\n endpoint,\n temperature,\n protocol,\n requiresKey,\n headers: custom.headers || {},\n label: custom.label || provider\n };\n}\n\n// Also export the additional helper functions that may be needed externally\nexport { runModelCatalogHealthCheck, validateProviderHealth };\n\nexport function getModelStartupReadiness() {\n if (!modelCatalogLoadStatus.ok) {\n return { ok: false, message: `模型配置加载失败:${modelCatalogLoadStatus.message || \"请检查 model_catalog.yaml\"}` };\n }\n if (!Array.isArray(modelCatalogHealth) || !modelCatalogHealth.length) {\n return { ok: false, message: \"未发现可检查的 provider,请检查 model_catalog.yaml.providers。\" };\n }\n const usable = modelCatalogHealth.filter((item) => item.status !== \"error\");\n if (!usable.length) {\n return { ok: false, message: \"模型配置未通过健康检查:没有可用 provider。\" };\n }\n const choice = getDefaultModelChoice();\n const parsed = parseModelChoice(choice);\n if (!parsed.provider) {\n return { ok: false, message: \"默认模型未设置,请先在设置中选择模型。\" };\n }\n const selectedHealth = getProviderHealth(parsed.provider);\n if (!selectedHealth) {\n return { ok: false, message: `默认模型 provider 未找到:${parsed.provider}` };\n }\n if (selectedHealth.status === \"error\") {\n const firstReason = selectedHealth.errors[0] || \"配置异常\";\n return { ok: false, message: `默认模型 provider 校验失败(${selectedHealth.label}):${firstReason}` };\n }\n return { ok: true, message: \"\" };\n}\n"}, "012e820d1a98ba92e9e2f9a3cd4e29bfe80feed4": {"t": 1785766662.1206028, "n": 18104, "h": "e495ab3ee57b0c14", "c": "/**\n * model-catalog.js\n * ----------------\n * All model-catalog related functions extracted from singleplayer_demo.html.\n * Handles parsing, normalizing, health-checking, and applying the model catalog,\n * as well as runtime model selection helpers.\n */\n\n// --- State imports ---\n// These mutable state variables are shared with the rest of the app.\n// In ES-module form we import getter/setter access; for now we import references\n// and mutate them directly.\nimport {\n loadedModelCatalog, setLoadedModelCatalog,\n customProviderMap, setCustomProviderMap,\n catalogApiKeys, setCatalogApiKeys,\n modelCatalogLoadStatus, setModelCatalogLoadStatus,\n modelCatalogHealth, setModelCatalogHealth,\n modelHealthWarned, setModelHealthWarned\n} from \"./state.js\";\n\nimport { DEFAULT_MODEL, MODEL_OPTIONS, setMODEL_OPTIONS } from \"./constants.js\";\n\n// --- DOM element and function imports ---\nimport {\n modelSelect, modelHealthBox, tempInput,\n renderAll\n} from \"./ui-helpers.js\";\nimport { initModelSelect } from \"./settings.js\";\nimport { showModal } from \"./overlays.js\";\n\n// ============================================================================\n// Pure / low-level helpers\n// ============================================================================\n\nexport function looksLikePlaceholderKey(value) {\n const raw = String(value || \"\").trim();\n if (!raw) return true;\n if (/\\$\\{[^}]+\\}/.test(raw)) return true;\n if (/^YOUR_[A-Z0-9_]*KEY$/i.test(raw)) return true;\n if (/^(your|replace|example|test)[-_ ]?(api)?[-_ ]?key$/i.test(raw)) return true;\n return false;\n}\n\nexport function buildProviderEndpoint(providerConfig) {\n if (!providerConfig) return \"\";\n const cleaned = String(providerConfig.baseUrl || \"\").trim().replace(/\\/+$/, \"\");\n const protocol = providerConfig.protocol || \"openai\";\n if (!cleaned) return \"\";\n if (protocol === \"claude\") {\n return cleaned.includes(\"/messages\") ? cleaned : `${cleaned}/v1/messages`;\n }\n if (cleaned.includes(\"/chat/completions\")) {\n return cleaned;\n }\n return cleaned.endsWith(\"/v1\") ? `${cleaned}/chat/completions` : `${cleaned}/v1/chat/completions`;\n}\n\nexport function getProviderHealth(providerId) {\n if (!providerId) return null;\n return modelCatalogHealth.find((item) => item.id === providerId) || null;\n}\n\nexport function getDefaultModelChoice() {\n return modelSelect?.value || DEFAULT_MODEL;\n}\n\nexport function getEffectiveModelChoice(actor) {\n if (actor && actor.modelChoice && actor.modelChoice !== \"default\") {\n return actor.modelChoice;\n }\n return getDefaultModelChoice();\n}\n\nexport function parseModelChoice(choice) {\n if (!choice) return { provider: \"\", model: \"\" };\n const parts = choice.split(\":\");\n if (parts.length < 2) return { provider: parts[0], model: \"\" };\n return { provider: parts[0], model: parts.slice(1).join(\":\") };\n}\n\n// ============================================================================\n// Internal normalization helpers\n// ============================================================================\n\nfunction resolveCatalogString(value, variables) {\n if (typeof value !== \"string\") return value;\n return value.replace(/\\$\\{([A-Za-z0-9_-]+)\\}/g, (match, key) => {\n if (variables && Object.prototype.hasOwnProperty.call(variables, key)) {\n return String(variables[key]);\n }\n return match;\n });\n}\n\nfunction normalizeModelEntry(entry) {\n if (!entry) return null;\n if (typeof entry === \"string\") {\n const name = entry.trim();\n return name ? { name } : null;\n }\n if (typeof entry === \"object\") {\n const name = String(entry.name || entry.id || entry.model || \"\").trim();\n return name ? { name } : null;\n }\n return null;\n}\n\nfunction normalizeProviderHeaders(rawHeaders, variables = null) {\n if (!rawHeaders || typeof rawHeaders !== \"object\" || Array.isArray(rawHeaders)) {\n return {};\n }\n const normalized = {};\n Object.entries(rawHeaders).forEach(([key, value]) => {\n const headerName = String(key || \"\").trim();\n if (!headerName) return;\n const headerValue = resolveCatalogString(String(value || \"\").trim(), variables);\n if (!headerValue) return;\n normalized[headerName] = headerValue;\n });\n return normalized;\n}\n\nfunction normalizeProviderEntry(entry, variables = null) {\n if (!entry) return null;\n const id = String(entry.id || entry.name || entry.provider || \"\").trim();\n if (!id) return null;\n const label = String(entry.label || entry.title || id).trim();\n const baseUrlRaw = String(entry.base_url || entry.baseUrl || entry.url || \"\").trim();\n const apiKeyRaw = String(entry.api_key || entry.apiKey || entry.key || \"\").trim();\n const baseUrl = resolveCatalogString(baseUrlRaw, variables);\n const apiKey = resolveCatalogString(apiKeyRaw, variables);\n const protocolRaw = String(entry.protocol || entry.connection || entry.endpoint_type || entry.type || \"\").trim().toLowerCase();\n const protocol = protocolRaw === \"claude\" || protocolRaw === \"anthropic\" ? \"claude\" : \"openai\";\n const defaultModel = resolveCatalogString(String(entry.default_model || entry.defaultModel || \"\").trim(), variables);\n const apiKeyRequired = typeof entry.api_key_required === \"boolean\" ? entry.api_key_required : null;\n const headers = normalizeProviderHeaders(entry.headers || entry.extra_headers || entry.default_headers, variables);\n let rawModels = Array.isArray(entry.models) ? entry.models : (entry.model ? [entry.model] : []);\n if (!rawModels.length && defaultModel) rawModels = [defaultModel];\n const models = rawModels\n .map((item) => {\n if (typeof item === \"string\") {\n return resolveCatalogString(item, variables);\n }\n if (item && typeof item === \"object\") {\n const name = resolveCatalogString(item.name || item.id || item.model || \"\", variables);\n return name ? { name } : null;\n }\n return item;\n })\n .map(normalizeModelEntry)\n .filter(Boolean);\n return { id, label, baseUrl, apiKey, protocol, defaultModel, apiKeyRequired, headers, models };\n}\n\n// ============================================================================\n// Health check helpers\n// ============================================================================\n\nfunction validateProviderHealth(provider) {\n const errors = [];\n const warnings = [];\n const models = Array.isArray(provider.models) ? provider.models.map((m) => m.name).filter(Boolean) : [];\n const requiresKey = typeof provider.apiKeyRequired === \"boolean\"\n ? provider.apiKeyRequired\n : true;\n const endpoint = buildProviderEndpoint(provider);\n\n if (!provider.baseUrl) {\n errors.push(\"缺少 base_url\");\n } else if (!/^https?:\\/\\//i.test(provider.baseUrl)) {\n warnings.push(\"base_url 不是 http/https 地址\");\n }\n if (!endpoint) {\n errors.push(\"无法生成请求端点\");\n }\n if (!models.length) {\n errors.push(\"models 为空\");\n }\n if (provider.defaultModel && models.length && !models.includes(provider.defaultModel)) {\n warnings.push(\"default_model 不在 models 列表中\");\n }\n if (requiresKey) {\n if (!provider.apiKey) {\n errors.push(\"缺少 api_key\");\n } else if (looksLikePlaceholderKey(provider.apiKey)) {\n errors.push(\"api_key 仍是占位值或未解析变量\");\n }\n }\n\n const status = errors.length ? \"error\" : (warnings.length ? \"warning\" : \"ready\");\n return {\n id: provider.id,\n label: provider.label || provider.id,\n protocol: provider.protocol || \"openai\",\n modelCount: models.length,\n status,\n errors,\n warnings\n };\n}\n\nfunction runModelCatalogHealthCheck() {\n const providers = loadedModelCatalog?.providers;\n if (!Array.isArray(providers) || !providers.length) {\n setModelCatalogHealth([]);\n renderModelHealthCheck();\n return modelCatalogHealth;\n }\n const seen = new Set();\n setModelCatalogHealth(providers.map((provider) => {\n const result = validateProviderHealth(provider);\n if (seen.has(result.id)) {\n result.status = \"error\";\n result.errors = result.errors.concat(\"provider id 重复\");\n }\n seen.add(result.id);\n return result;\n }));\n renderModelHealthCheck();\n\n const readyCount = modelCatalogHealth.filter((item) => item.status === \"ready\" || item.status === \"warning\").length;\n if (!readyCount && !modelHealthWarned) {\n setModelHealthWarned(true);\n showModal(\"模型配置校验未通过:当前没有可用 provider,请检查 model_catalog.yaml。\");\n }\n return modelCatalogHealth;\n}\n\n// ============================================================================\n// Exported catalog functions\n// ============================================================================\n\nexport function parseModelCatalogText(text) {\n const trimmed = String(text || \"\").trim();\n if (!trimmed) {\n return { data: null, error: \"model_catalog.yaml 为空\" };\n }\n if (window.jsyaml && typeof window.jsyaml.load === \"function\") {\n try {\n return { data: window.jsyaml.load(trimmed), error: \"\" };\n } catch (error) {\n return { data: null, error: `YAML 解析失败:${error?.message || \"格式错误\"}` };\n }\n }\n try {\n return { data: JSON.parse(trimmed), error: \"\" };\n } catch (error) {\n return { data: null, error: `JSON 解析失败:${error?.message || \"格式错误\"}` };\n }\n}\n\nexport function normalizeModelCatalog(raw) {\n if (!raw) return null;\n const userVariables = raw.api_keys || raw.variables || raw.secrets || {};\n setCatalogApiKeys(userVariables);\n const runtimeVariables = {\n origin: window.location?.origin || \"\",\n host: window.location?.host || \"\",\n protocol: window.location?.protocol || \"\"\n };\n const variables = {\n ...runtimeVariables,\n ...userVariables\n };\n let providers = [];\n if (Array.isArray(raw)) {\n providers = raw;\n } else if (Array.isArray(raw.providers)) {\n providers = raw.providers;\n } else if (raw.providers && typeof raw.providers === \"object\") {\n providers = Object.entries(raw.providers).map(([id, value]) => ({\n ...(value || {}),\n id\n }));\n }\n const normalized = providers.map((entry) => normalizeProviderEntry(entry, variables)).filter(Boolean);\n return normalized.length ? { providers: normalized } : null;\n}\n\nfunction refreshModelOptionsFromCatalog() {\n const hasCatalog =\n loadedModelCatalog &&\n Array.isArray(loadedModelCatalog.providers) &&\n loadedModelCatalog.providers.length > 0;\n if (!hasCatalog) {\n setMODEL_OPTIONS([]);\n return;\n }\n const newOptions = [];\n const seen = new Set();\n loadedModelCatalog.providers.forEach((provider) => {\n const modelNames = provider.models.map((m) => m.name);\n modelNames.forEach((name) => {\n const value = `${provider.id}:${name}`;\n if (seen.has(value)) return;\n newOptions.push({ value, label: `${provider.label} / ${name}` });\n seen.add(value);\n });\n });\n setMODEL_OPTIONS(newOptions);\n}\n\nexport function applyModelCatalog(catalog) {\n setLoadedModelCatalog(catalog);\n const newMap = {};\n if (catalog && Array.isArray(catalog.providers)) {\n catalog.providers.forEach((provider) => {\n newMap[provider.id] = provider;\n });\n }\n setCustomProviderMap(newMap);\n refreshModelOptionsFromCatalog();\n initModelSelect();\n runModelCatalogHealthCheck();\n renderAll();\n}\n\nexport function getCustomProviderConfig(providerId) {\n if (!providerId) return null;\n return customProviderMap[providerId] || null;\n}\n\nexport function renderModelHealthCheck() {\n if (!modelHealthBox) return;\n modelHealthBox.innerHTML = \"\";\n const summary = document.createElement(\"div\");\n summary.className = \"model-health-summary\";\n\n if (!modelCatalogLoadStatus.ok) {\n summary.classList.add(\"error\");\n summary.textContent = `加载失败:${modelCatalogLoadStatus.message || \"无法读取 model_catalog.yaml\"}`;\n modelHealthBox.appendChild(summary);\n return;\n }\n\n const total = modelCatalogHealth.length;\n const okCount = modelCatalogHealth.filter((item) => item.status === \"ready\").length;\n const warnCount = modelCatalogHealth.filter((item) => item.status === \"warning\").length;\n const errCount = modelCatalogHealth.filter((item) => item.status === \"error\").length;\n summary.textContent = `启动健康检查:可用 ${okCount + warnCount}/${total}(通过 ${okCount},警告 ${warnCount},失败 ${errCount})`;\n modelHealthBox.appendChild(summary);\n\n const list = document.createElement(\"div\");\n list.className = \"model-health-list\";\n modelCatalogHealth.forEach((item) => {\n const card = document.createElement(\"div\");\n card.className = `model-health-item ${item.status}`;\n const head = document.createElement(\"div\");\n head.className = \"model-health-head\";\n const name = document.createElement(\"span\");\n name.className = \"model-health-provider\";\n name.textContent = `${item.label} (${item.protocol})`;\n const badge = document.createElement(\"span\");\n badge.className = \"model-health-badge\";\n badge.textContent = item.status === \"ready\" ? \"通过\" : item.status === \"warning\" ? \"警告\" : \"失败\";\n head.appendChild(name);\n head.appendChild(badge);\n card.appendChild(head);\n\n const details = [];\n details.push(`models: ${item.modelCount}`);\n if (item.errors.length) details.push(`错误: ${item.errors.join(\";\")}`);\n if (item.warnings.length) details.push(`警告: ${item.warnings.join(\";\")}`);\n const detail = document.createElement(\"div\");\n detail.className = \"model-health-detail\";\n detail.textContent = details.join(\"\\n\");\n card.appendChild(detail);\n list.appendChild(card);\n });\n modelHealthBox.appendChild(list);\n}\n\nexport function showModelCatalogFallback() {\n const el = document.getElementById(\"modelCatalogFallback\");\n if (el) el.style.display = \"block\";\n}\n\nexport function hideModelCatalogFallback() {\n const el = document.getElementById(\"modelCatalogFallback\");\n if (el) el.style.display = \"none\";\n}\n\nexport function loadModelCatalogFromText(text) {\n const parsed = parseModelCatalogText(text);\n if (!parsed.data) {\n if (typeof alert === \"function\") alert(parsed.error || \"解析失败\");\n return false;\n }\n const catalog = normalizeModelCatalog(parsed.data);\n if (!catalog) {\n if (typeof alert === \"function\") alert(\"providers 为空或格式不正确\");\n return false;\n }\n setModelCatalogLoadStatus({ ok: true, message: \"已从本地文件加载\" });\n applyModelCatalog(catalog);\n renderModelHealthCheck();\n hideModelCatalogFallback();\n return true;\n}\n\nexport async function autoLoadModelCatalog() {\n setModelCatalogLoadStatus({ ok: false, message: \"正在加载 model_catalog.yaml...\" });\n renderModelHealthCheck();\n try {\n const response = await fetch(\"model_catalog.yaml\", { cache: \"no-store\" });\n if (!response.ok) {\n setModelCatalogLoadStatus({ ok: false, message: `读取失败(HTTP ${response.status})` });\n renderModelHealthCheck();\n showModelCatalogFallback();\n return modelCatalogLoadStatus;\n }\n const text = await response.text();\n const parsed = parseModelCatalogText(text);\n if (!parsed.data) {\n setModelCatalogLoadStatus({ ok: false, message: parsed.error || \"解析失败\" });\n renderModelHealthCheck();\n showModelCatalogFallback();\n return modelCatalogLoadStatus;\n }\n const raw = parsed.data;\n const catalog = normalizeModelCatalog(raw);\n if (!catalog) {\n setModelCatalogLoadStatus({ ok: false, message: \"providers 为空或格式不正确\" });\n renderModelHealthCheck();\n showModelCatalogFallback();\n return modelCatalogLoadStatus;\n }\n setModelCatalogLoadStatus({ ok: true, message: \"加载成功\" });\n applyModelCatalog(catalog);\n hideModelCatalogFallback();\n return modelCatalogLoadStatus;\n } catch (error) {\n setModelCatalogLoadStatus({ ok: false, message: `加载异常:${error?.message || \"未知错误\"}` });\n renderModelHealthCheck();\n showModelCatalogFallback();\n return modelCatalogLoadStatus;\n }\n}\n\nexport function getModelConfig(actor) {\n const choice = getEffectiveModelChoice(actor);\n const { provider, model } = parseModelChoice(choice);\n const temperature = Number(tempInput.value) || 1.0;\n const custom = getCustomProviderConfig(provider);\n if (!custom) {\n alert(\"未找到模型配置,请检查 model_catalog.yaml。\");\n return {\n provider: provider || \"\",\n model: model || \"\",\n apiKey: \"\",\n endpoint: \"\",\n temperature,\n protocol: \"openai\",\n requiresKey: true\n };\n }\n const protocol = custom.protocol || \"openai\";\n const endpoint = buildProviderEndpoint(custom);\n const modelName = model === \"default\"\n ? (custom.defaultModel || custom.models?.[0]?.name || model)\n : model;\n const requiresKey = typeof custom.apiKeyRequired === \"boolean\"\n ? custom.apiKeyRequired\n : true;\n const providerHealth = getProviderHealth(provider);\n if (providerHealth && providerHealth.status === \"error\") {\n const reason = providerHealth.errors[0] || \"配置不完整\";\n alert(`${providerHealth.label} 配置异常:${reason}`);\n }\n return {\n provider,\n model: modelName,\n apiKey: custom.apiKey || \"\",\n endpoint,\n temperature,\n protocol,\n requiresKey,\n headers: custom.headers || {},\n label: custom.label || provider\n };\n}\n\n// Also export the additional helper functions that may be needed externally\nexport { runModelCatalogHealthCheck, validateProviderHealth };\n\nexport function getModelStartupReadiness() {\n if (!modelCatalogLoadStatus.ok) {\n return { ok: false, message: `模型配置加载失败:${modelCatalogLoadStatus.message || \"请检查 model_catalog.yaml\"}` };\n }\n if (!Array.isArray(modelCatalogHealth) || !modelCatalogHealth.length) {\n return { ok: false, message: \"未发现可检查的 provider,请检查 model_catalog.yaml.providers。\" };\n }\n const usable = modelCatalogHealth.filter((item) => item.status !== \"error\");\n if (!usable.length) {\n return { ok: false, message: \"模型配置未通过健康检查:没有可用 provider。\" };\n }\n const choice = getDefaultModelChoice();\n const parsed = parseModelChoice(choice);\n if (!parsed.provider) {\n return { ok: false, message: \"默认模型未设置,请先在设置中选择模型。\" };\n }\n const selectedHealth = getProviderHealth(parsed.provider);\n if (!selectedHealth) {\n return { ok: false, message: `默认模型 provider 未找到:${parsed.provider}` };\n }\n if (selectedHealth.status === \"error\") {\n const firstReason = selectedHealth.errors[0] || \"配置异常\";\n return { ok: false, message: `默认模型 provider 校验失败(${selectedHealth.label}):${firstReason}` };\n }\n return { ok: true, message: \"\" };\n}\n"}}}} |