Files

1 line
65 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}