Files
hunjing/frontend/design/model-gateway-preview.html
T
2026-08-21 16:00:13 +08:00

613 lines
39 KiB
HTML

<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>魂镜 · 模型网关页设计预览</title>
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="_base.css" />
<link rel="stylesheet" href="_chrome.css" />
<style>
/* ============================================================
* 铜镜鉴心 v2.1 — 模型网关页
* 能力槽位(默认路由)+ 模型清单(多供应商端点池)+ 定价对照 + 能力矩阵
* 数据源:config/models · providers · pricing example
* 对齐 docs/architecture/service-boundaries.md §6/§7/§8/§13/§14。
* ============================================================ */
.page-header .spacer { flex: 1; }
.stat-pills { display: flex; gap: 8px; flex-wrap: wrap; }
.stat-pill .v { color: var(--patina); font-weight: 600; }
.stat-pill .v.gilt { color: var(--gilt); }
.section-label .sub { font-size: 12px; letter-spacing: 0.04em; color: var(--fg-dim); opacity: 0.75; }
.section-label .glyph { color: var(--gilt); opacity: 0.8; }
.section-header { font-family: "JetBrains Mono", monospace; font-size: 12px; letter-spacing: 0.1em; color: var(--fg-faint); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.section-header .sub { font-size: 12px; letter-spacing: 0.04em; color: var(--fg-dim); opacity: 0.75; }
.section-header .glyph { color: var(--gilt); opacity: 0.8; }
.section-caption { font-size: 11.5px; color: var(--fg-faint); margin-top: 8px; line-height: 1.5; }
/* ── 能力槽位(默认路由)── */
.slot-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.slot-card { border: 1px solid var(--rule); border-radius: 8px; background: var(--surface); padding: 13px 15px; display: flex; flex-direction: column; gap: 7px; transition: border-color 0.12s; }
.slot-card.set { border-color: var(--patina-dim); background: var(--patina-wash); }
.slot-card.unset { border-style: dashed; }
.slot-top { display: flex; align-items: baseline; gap: 8px; }
.slot-label { font-family: "Songti SC", serif; font-size: 14px; font-weight: 700; color: var(--fg); }
.slot-hint { font-family: "JetBrains Mono", monospace; font-size: 10px; color: var(--fg-faint); letter-spacing: 0.04em; }
.slot-current { font-family: "JetBrains Mono", monospace; font-size: 12.5px; color: var(--patina-bright); font-weight: 600; line-height: 1.4; }
.slot-card.unset .slot-current { color: var(--fg-faint); font-weight: 400; }
.slot-display { font-size: 11.5px; color: var(--fg-dim); font-family: -apple-system, "PingFang SC", sans-serif; font-weight: 400; }
.slot-select { font-family: "JetBrains Mono", monospace; font-size: 11.5px; padding: 6px 9px; border-radius: 6px; border: 1px solid var(--rule); background: var(--ink); color: var(--fg); width: 100%; margin-top: auto; }
.slot-select:focus { outline: none; border-color: var(--patina-dim); }
.slot-empty { font-size: 11px; color: var(--fg-faint); font-family: "JetBrains Mono", monospace; padding: 6px 0; margin-top: auto; line-height: 1.5; }
/* ── 供应商分组 + 模型卡 ── */
.provider-group { display: flex; flex-direction: column; gap: 10px; margin-bottom: 4px; }
.provider-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; font-family: "Songti SC", serif; font-size: 14px; font-weight: 700; color: var(--fg); }
.provider-head .ph-key { color: var(--fg-faint); font-family: "JetBrains Mono", monospace; font-size: 12px; font-weight: 500; }
.provider-head .ph-meta { font-family: "JetBrains Mono", monospace; font-size: 10.5px; font-weight: 400; color: var(--fg-faint); letter-spacing: 0.04em; }
.provider-head .ph-meta .sep { opacity: 0.5; margin: 0 5px; }
.provider-head .ph-line { flex: 1; height: 1px; background: var(--rule); min-width: 20px; }
.provider-head .ph-key-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--patina); flex-shrink: 0; align-self: center; }
.model-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.model-card { border: 1px solid var(--rule); border-radius: 8px; background: var(--surface); padding: 14px 16px; display: flex; flex-direction: column; gap: 11px; transition: border-color 0.12s; }
.model-card:hover { border-color: var(--gilt-dim); }
.model-card.is-default { border-color: var(--patina-dim); background: var(--patina-wash); }
.model-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.model-star { color: var(--patina); font-size: 13px; line-height: 1; flex-shrink: 0; }
.model-star.off { visibility: hidden; }
.model-name { font-family: "JetBrains Mono", monospace; font-size: 13.5px; font-weight: 600; color: var(--fg); }
.model-real { font-family: "JetBrains Mono", monospace; font-size: 10.5px; color: var(--fg-faint); }
.model-real .arr { opacity: 0.6; margin-right: 2px; }
.model-tag { font-family: "JetBrains Mono", monospace; font-size: 9.5px; padding: 2px 7px; border-radius: 3px; border: 1px solid var(--rule); color: var(--fg-faint); background: transparent; }
.model-tag.fmt { border-color: var(--rule); color: var(--fg-faint); }
.model-tag.default-tag { border-color: var(--patina-dim); color: var(--patina); background: var(--patina-wash); }
.model-tag.fallback { border-color: var(--gilt-dim); color: var(--gilt); background: var(--gilt-faint); }
.model-tag.fast { border-color: var(--cinnabar-dim); color: var(--cinnabar); background: rgba(184, 57, 46, 0.08); }
.model-desc { font-size: 12.5px; color: var(--fg-dim); line-height: 1.55; min-height: 30px; }
/* spec 微网格(全字段,空值显 —) */
.model-specs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--rule); border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
.model-specs .cell { background: color-mix(in srgb, var(--ink) 55%, transparent); padding: 6px 9px; }
.model-specs .cell .k { font-family: "JetBrains Mono", monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-faint); }
.model-specs .cell .v { font-family: "JetBrains Mono", monospace; font-size: 12px; color: var(--fg); margin-top: 1px; }
.model-specs .cell .v .sub { color: var(--fg-faint); font-size: 9.5px; display: block; margin-top: 1px; }
.model-specs .cell .v.na { color: var(--fg-faint); opacity: 0.55; }
/* 计价条(原值不四舍五入) */
.model-price { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 9px 11px; border: 1px solid var(--gilt-dim); border-radius: 6px; background: var(--gilt-faint); }
.model-price .price-cell { display: flex; flex-direction: column; gap: 1px; }
.model-price .pk { font-family: "JetBrains Mono", monospace; font-size: 8.5px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--gilt); opacity: 0.78; }
.model-price .pv { font-family: "JetBrains Mono", monospace; font-size: 13px; color: var(--gilt); font-weight: 600; }
.model-price .pv .ccy { font-size: 10px; opacity: 0.7; margin-left: 1px; }
.model-price .pv.na { color: var(--fg-faint); font-size: 12px; font-weight: 400; opacity: 0.7; }
/* 能力点阵 */
.model-caps { display: flex; flex-wrap: wrap; gap: 5px 11px; }
.model-caps .cap { display: inline-flex; align-items: center; gap: 4px; font-size: 10.5px; color: var(--fg-dim); font-family: "JetBrains Mono", monospace; }
.model-caps .cap .d { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.model-caps .cap.on .d { background: var(--patina); }
.model-caps .cap.off { color: var(--fg-faint); opacity: 0.6; }
.model-caps .cap.off .d { background: var(--fg-faint); opacity: 0.32; }
/* 多供应商端点池 */
.ep-pool { display: flex; flex-direction: column; gap: 4px; border: 1px solid var(--rule); border-radius: 6px; padding: 8px 10px; background: color-mix(in srgb, var(--ink) 40%, transparent); }
.ep-pool .ep-title { font-family: "JetBrains Mono", monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--fg-faint); margin-bottom: 3px; }
.ep-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-family: "JetBrains Mono", monospace; font-size: 11px; color: var(--fg-dim); line-height: 1.5; }
.ep-row .ep-status { width: 7px; height: 7px; border-radius: 50%; background: var(--patina); flex-shrink: 0; }
.ep-row .ep-status.off { background: var(--fg-faint); opacity: 0.4; }
.ep-row .ep-name { color: var(--patina); }
.ep-row .ep-fmt { color: var(--gilt); font-size: 10px; }
.ep-row .ep-env { color: var(--fg-faint); font-size: 10px; }
.ep-row .ep-override { color: var(--cinnabar); font-size: 10px; }
.ep-row .ep-f { display: inline-flex; align-items: baseline; gap: 2px; }
.ep-row .ep-f i { font-style: normal; color: var(--fg-faint); font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; }
.ep-row .ep-f .na { color: var(--fg-faint); opacity: 0.5; }
.ep-row .ep-sep { color: var(--rule); }
/* ── 通用表格 ── */
.cap-table { border: 1px solid var(--rule); border-radius: 8px; overflow: hidden; background: var(--surface); }
.cap-table table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.cap-table th, .cap-table td { padding: 9px 14px; text-align: left; border-bottom: 1px solid var(--rule); }
.cap-table tr:last-child td { border-bottom: none; }
.cap-table th { font-family: "JetBrains Mono", monospace; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-faint); font-weight: 600; background: var(--surface-hi); white-space: nowrap; }
.cap-table td.model-col { font-family: "JetBrains Mono", monospace; color: var(--fg); font-weight: 500; white-space: nowrap; }
.cap-table td.model-col .star { color: var(--patina); margin-right: 4px; }
.cap-table td.num, .cap-table th.num { text-align: right; font-family: "JetBrains Mono", monospace; }
.cap-table td.num.cost { color: var(--gilt); font-weight: 600; }
.cap-table tr.default-row td { background: var(--patina-wash); }
.cap-table tr.default-row td.model-col { color: var(--patina); }
.cap-table td .ccy { color: var(--fg-faint); font-size: 10.5px; }
.cap-table td .na { color: var(--fg-faint); opacity: 0.55; }
.cap-dot { display: inline-flex; align-items: center; gap: 6px; }
.cap-dot .d { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.cap-dot .d.full { background: var(--patina); }
.cap-dot .d.no { background: var(--fg-faint); opacity: 0.4; }
.cap-dot span { font-size: 11px; color: var(--fg-dim); }
.cap-table .grp td { font-family: "Songti SC", serif; font-size: 12px; font-weight: 700; color: var(--fg-dim); background: color-mix(in srgb, var(--surface-hi) 60%, transparent); padding: 6px 14px; }
.cap-table .grp td .gkey { font-family: "JetBrains Mono", monospace; font-size: 10px; color: var(--fg-faint); font-weight: 400; margin-left: 6px; }
/* ── 语音 / 专用能力 ── */
.audio-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.audio-card { border: 1px solid var(--rule); border-radius: 8px; background: var(--surface); padding: 12px 14px; display: flex; flex-direction: column; gap: 5px; }
.audio-card.is-default { border-color: var(--patina-dim); background: var(--patina-wash); }
.audio-card .a-head { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.audio-card .a-name { font-family: "JetBrains Mono", monospace; font-size: 12px; font-weight: 600; color: var(--fg); }
.audio-card .a-kind { font-size: 9px; font-family: "JetBrains Mono", monospace; text-transform: uppercase; letter-spacing: 0.07em; color: var(--cinnabar); border: 1px solid var(--cinnabar-dim); padding: 1px 6px; border-radius: 3px; }
.audio-card .a-tag { font-size: 12px; color: var(--fg-dim); }
.audio-card .a-spec { font-size: 10.5px; color: var(--fg-faint); font-family: "JetBrains Mono", monospace; display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 2px; }
.audio-card .a-spec .na { opacity: 0.5; }
@media (max-width: 980px) { .slot-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 900px) { .model-grid { grid-template-columns: 1fr; } .audio-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 768px) { .sidebar-wrap { display: none; } .page-body { padding: 18px 16px 40px; } .cap-table { overflow-x: auto; } .cap-table table { min-width: 540px; } .slot-grid, .audio-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="app-shell">
<!-- ════════ 侧栏(active=模型网关)════════ -->
<div class="sidebar-mount" data-active="模型网关"></div>
<!-- ════════ 主区 ════════ -->
<div class="main-area">
<div class="page-header">
<span class="page-title">模型网关</span>
<span class="page-sub">神识 · 多供应商统一接入与路由</span>
<span class="spacer"></span>
<div class="stat-pills">
<span class="stat-pill">对话模型 <span class="v">7</span></span>
<span class="stat-pill">专用槽 <span class="v">4</span></span>
<span class="stat-pill">供应商 <span class="v">3</span></span>
<span class="stat-pill">已设默认 <span class="v gilt" id="stat-set">4</span>/6</span>
</div>
</div>
<div class="page-body">
<!-- 能力槽位(默认路由) -->
<div>
<div class="section-header"><span class="glyph">⟡</span> 能力槽位 · 默认路由 <span class="sub">model-gateway §13/§14 · 按能力解析为默认模型</span></div>
<div class="slot-grid" id="slot-grid"></div>
<p class="section-caption">业务层只引用能力槽位(model_hint),网关将其解析为下列默认模型;空槽表示该能力尚未配置可用模型,<code>models.yaml</code> 增配后自动出现。</p>
</div>
<!-- 模型清单 -->
<div>
<h3 style="font-family:&quot;Songti SC&quot;,serif;font-size:14px;font-weight:600;color:var(--fg);margin-bottom:14px">模型清单 <span style="font-size:12px;font-weight:400;color:var(--fg-dim);font-family:&quot;JetBrains Mono&quot;,monospace;margin-left:6px">models.yaml + providers.yaml + pricing.yaml · 全字段 · 多供应商端点池</span></h3>
<div id="model-list"></div>
</div>
<!-- 定价对照 -->
<div>
<h3 style="font-family:&quot;Songti SC&quot;,serif;font-size:14px;font-weight:600;color:var(--fg);margin-bottom:14px">定价对照 <span style="font-size:12px;font-weight:400;color:var(--fg-dim);font-family:&quot;JetBrains Mono&quot;,monospace;margin-left:6px">pricing.yaml · 每 1M tokens · 原值不四舍五入</span></h3>
<div class="cap-table">
<table>
<thead>
<tr>
<th>模型</th>
<th class="num">输入 / 1M</th>
<th class="num">缓存命中 / 1M</th>
<th class="num">输出 / 1M</th>
<th class="num">混合(1M 入 + 1M 出)</th>
</tr>
</thead>
<tbody id="price-body"></tbody>
</table>
</div>
<p class="section-caption">混合列为同币种 input + output 之和,便于横向比较;保持厂商原始币种,不做跨币种换算(§7.3)。缓存命中价缺省记 -,按输入价计费。</p>
</div>
<!-- 能力矩阵 -->
<div>
<h3 style="font-family:&quot;Songti SC&quot;,serif;font-size:14px;font-weight:600;color:var(--fg);margin-bottom:14px">能力矩阵 <span style="font-size:12px;font-weight:400;color:var(--fg-dim);font-family:&quot;JetBrains Mono&quot;,monospace;margin-left:6px">model-gateway §6/§13 · supports_* 显式声明</span></h3>
<div class="cap-table">
<table>
<thead>
<tr>
<th>模型</th>
<th class="num">上下文</th>
<th>工具</th>
<th>思考</th>
<th>推理 effort</th>
<th>结构化</th>
<th>流式</th>
<th>视觉</th>
</tr>
</thead>
<tbody id="cap-body"></tbody>
</table>
</div>
</div>
<!-- 语音 / 专用能力 -->
<div>
<h3 style="font-family:&quot;Songti SC&quot;,serif;font-size:14px;font-weight:600;color:var(--fg);margin-bottom:14px">语音 / 专用能力 <span style="font-size:12px;font-weight:400;color:var(--fg-dim);font-family:&quot;JetBrains Mono&quot;,monospace;margin-left:6px">asr · tts · 声纹 · 音色 · 后续 embedding/reranker 槽位预留</span></h3>
<div class="audio-grid" id="audio-grid"></div>
</div>
</div>
</div>
</div>
<script>
/* ── 数据:取自 config/models · providers · pricing example ── */
const PROVIDERS = {
deepseek: { label: "深度求索", format: "openai", baseEnv: "DEEPSEEK_BASE_URL", apiKeyEnv: "DEEPSEEK_API_KEY", timeout: 300, retries: 2 },
anthropic: { label: "Anthropic", format: "anthropic", baseEnv: "ANTHROPIC_BASE_URL", apiKeyEnv: "ANTHROPIC_API_KEY", timeout: 120, retries: 1 },
xiaomi: { label: "小米 MiMo", format: "openai", baseEnv: "XIAOMI_MIMO_BASE_URL", apiKeyEnv: "XIAOMI_MIMO_API_KEY", timeout: 300, retries: 2 },
};
// 对话模型(deepseek-v4-pro 已合并 anthropic 格式端点;同模型不同消息格式不重复成卡)
const MODELS = [
{
name: "deepseek-v4-flash", display: "DeepSeek V4 Flash", provider: "deepseek",
real: "deepseek-v4-flash", tags: [{ k: "fallback", t: "默认" }],
ctx: 1000000, reserve: 8192, maxOut: 384000, timeout: 300,
privacy: null, effort: null,
routing: "priority_failover", pool: true,
caps: { tools: 1, think: 1, effort: 1, struct: 1, stream: 1, vision: 0 },
endpoints: [
{ name: "primary", model: "deepseek-v4-flash", fmt: "openai", baseEnv: "DEEPSEEK_BASE_URL", status: "active", p: 10, w: 100, rpm: 2500, tpm: null, conc: null },
{ name: "fallback", model: "deepseek-v4-pro", fmt: "openai", baseEnv: "DEEPSEEK_BASE_URL", status: "active", p: 20, w: 50, rpm: null, tpm: null, conc: null },
],
price: { ccy: "CNY", in: 1.0, cache: 0.02, out: 2.0 },
desc: "默认对话与生成主模型;长上下文、低成本,工具与思考皆备,端点池带 failover。",
},
{
name: "deepseek-v4-pro", display: "DeepSeek V4 Pro", provider: "deepseek",
real: "deepseek-v4-pro", tags: [{ k: "fallback", t: "备选" }],
ctx: 1000000, reserve: 8192, maxOut: 384000, timeout: 300,
privacy: null, effort: null,
routing: "priority_failover", pool: true,
caps: { tools: 1, think: 1, effort: 1, struct: 1, stream: 1, vision: 0 },
endpoints: [
{ name: "primary", model: "deepseek-v4-pro", fmt: "openai", baseEnv: "DEEPSEEK_BASE_URL", status: "active", p: 10, w: 100, rpm: null, tpm: null, conc: null },
{ name: "anthropic", model: "deepseek-v4-pro", fmt: "anthropic", baseEnv: "DEEPSEEK_ANTHROPIC_BASE_URL", status: "active", p: 10, w: 100, rpm: null, tpm: null, conc: null },
],
price: { ccy: "CNY", in: 3.0, cache: 0.025, out: 6.0 },
desc: "高难推理与长文写作主力;同一模型提供 openai 与 anthropic 两种消息协议端点,价格与能力一致。",
},
{
name: "claude-sonnet", display: "Claude Sonnet", provider: "anthropic",
real: "claude-sonnet-4-6", tags: [],
ctx: 1000000, reserve: 16000, maxOut: 16000, timeout: 60,
privacy: null, effort: "medium",
routing: null, pool: false,
caps: { tools: 1, think: 1, effort: 1, struct: 1, stream: 1, vision: 1 },
endpoints: null,
price: { ccy: "USD", in: 3.0, cache: 0.30, out: 15.0 },
desc: "均衡型主力,推理与工具调用稳定;中文叙事自然,可读图。默认 effort: medium。",
},
{
name: "claude-haiku", display: "Claude Haiku", provider: "anthropic",
real: "claude-haiku-4-5", tags: [{ k: "fast", t: "轻量" }],
ctx: 200000, reserve: 4096, maxOut: 4096, timeout: 60,
privacy: null, effort: null,
routing: null, pool: false,
caps: { tools: 1, think: 0, effort: 0, struct: 1, stream: 1, vision: 1 },
endpoints: null,
price: { ccy: "USD", in: 1.0, cache: 0.10, out: 5.0 },
desc: "低延迟轻量档,适合快速回复与短抽取;不支持思考模式。",
},
{
name: "claude-fable", display: "Claude Fable 5", provider: "anthropic",
real: "claude-fable-5", tags: [{ k: "fallback", t: "推理" }],
ctx: 1000000, reserve: 16000, maxOut: 16000, timeout: 120,
privacy: null, effort: "high",
routing: null, pool: false,
caps: { tools: 1, think: 1, effort: 1, struct: 1, stream: 1, vision: 1 },
endpoints: null,
price: { ccy: "USD", in: 10.0, cache: 1.00, out: 50.0 },
desc: "最强推理与长程规划,思考常开、effort: high;成本最高,留作关键决策。",
},
{
name: "mimo-v2.5-pro", display: "MiMo v2.5 Pro", provider: "xiaomi",
real: "mimo-v2.5-pro", tags: [],
ctx: 128000, reserve: 8192, maxOut: 32768, timeout: 300,
privacy: null, effort: null,
routing: "priority_failover", pool: true,
caps: { tools: 1, think: 0, effort: 1, struct: 1, stream: 1, vision: 1 },
endpoints: [
{ name: "primary", model: "mimo-v2.5-pro", fmt: "openai", baseEnv: "XIAOMI_MIMO_BASE_URL", status: "active", p: 10, w: 100, rpm: null, tpm: null, conc: null },
],
price: { ccy: "CNY", in: 4.0, cache: null, out: 12.0 },
desc: "国产多模态主力,视觉与工具兼顾;中文场景性价比高。",
},
{
name: "mimo-v2.5", display: "MiMo v2.5", provider: "xiaomi",
real: "mimo-v2.5", tags: [{ k: "fast", t: "轻量" }],
ctx: 128000, reserve: 4096, maxOut: 16384, timeout: 180,
privacy: null, effort: null,
routing: null, pool: false,
caps: { tools: 1, think: 0, effort: 0, struct: 1, stream: 1, vision: 1 },
endpoints: null,
price: { ccy: "CNY", in: 1.0, cache: null, out: 4.0 },
desc: "日常对话轻量档,响应快、单价低;视觉可读图。",
},
];
// 语音 / 专用能力
const AUDIO = [
{ name: "mimo-v2.5-asr", display: "MiMo v2.5 ASR", provider: "xiaomi", kind: "asr", kindLabel: "ASR", tag: "语音转文字", stream: 0, timeout: 120, fmt: "openai", baseEnv: "XIAOMI_MIMO_BASE_URL", price: null },
{ name: "mimo-v2.5-tts", display: "MiMo v2.5 TTS", provider: "xiaomi", kind: "tts", kindLabel: "TTS", tag: "文字转语音", stream: 1, timeout: 120, fmt: "openai", baseEnv: "XIAOMI_MIMO_BASE_URL", price: null },
{ name: "mimo-v2.5-tts-voiceclone", display: "MiMo v2.5 声纹克隆", provider: "xiaomi", kind: "tts", kindLabel: "TTS", tag: "声纹克隆", stream: 1, timeout: 300, fmt: "openai", baseEnv: "XIAOMI_MIMO_BASE_URL", price: null },
{ name: "mimo-v2.5-tts-voicedesign", display: "MiMo v2.5 音色设计", provider: "xiaomi", kind: "tts", kindLabel: "TTS", tag: "音色设计", stream: 1, timeout: 300, fmt: "openai", baseEnv: "XIAOMI_MIMO_BASE_URL", price: null },
];
// 能力槽位(默认路由)
const SLOTS = [
{ key: "chat", label: "默认对话", hint: "default-chat", pick: () => MODELS },
{ key: "reasoning", label: "默认推理", hint: "reasoning", pick: () => MODELS.filter((m) => m.caps.think) },
{ key: "vision", label: "视觉理解", hint: "vision", pick: () => MODELS.filter((m) => m.caps.vision) },
{ key: "tts", label: "语音合成", hint: "tts", pick: () => AUDIO.filter((a) => a.kind === "tts") },
{ key: "embedding", label: "向量化", hint: "embedding", pick: () => AUDIO.filter((a) => a.kind === "embedding") },
{ key: "reranker", label: "重排序", hint: "reranker", pick: () => AUDIO.filter((a) => a.kind === "reranker") },
];
const state = {
defaults: {
chat: "deepseek-v4-flash",
reasoning: "claude-fable",
vision: "mimo-v2.5-pro",
tts: "mimo-v2.5-tts",
embedding: null,
reranker: null,
},
};
/* ── 工具函数 ── */
const fmt = (n) => (n == null ? "-" : n.toLocaleString("en-US"));
const ccySym = (c) => (c === "USD" ? "$" : "¥");
// 原值原样,不四舍五入
function money(c, v) { return v == null ? "-" : ccySym(c) + v; }
function fmtCtx(n) {
if (n == null) return "-";
if (n >= 1000000) return (n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(0) + "K";
return String(n);
}
function fmtCtxFull(n) { return n == null ? "-" : n >= 1000 ? n.toLocaleString("en-US") : String(n); }
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const orDash = (v) => (v == null || v === "" ? "-" : v);
function findChat(name) { return MODELS.find((m) => m.name === name); }
function findAudio(name) { return AUDIO.find((a) => a.name === name); }
function labelOf(name) { const m = findChat(name) || findAudio(name); return m ? m.display : "-"; }
// 解析端点池:无 endpoints 时合成隐式单端点(继承 provider 配置)
function endpointsOf(m) {
const p = PROVIDERS[m.provider];
if (m.endpoints && m.endpoints.length) {
return m.endpoints.map((e) => ({
name: e.name,
fmt: e.fmt || p.format,
baseEnv: e.baseEnv || p.baseEnv,
apiKeyEnv: e.apiKeyEnv || p.apiKeyEnv,
model: e.model && e.model !== m.real ? e.model : null,
status: e.status || "active",
p: e.p, w: e.w, rpm: e.rpm, tpm: e.tpm, conc: e.conc,
implicit: false,
}));
}
return [{ name: "default", fmt: p.format, baseEnv: p.baseEnv, apiKeyEnv: p.apiKeyEnv, model: null, status: "active", p: null, w: null, rpm: null, tpm: null, conc: null, implicit: true }];
}
function modelFmts(m) { return [...new Set(endpointsOf(m).map((e) => e.fmt))]; }
// 该模型是哪些槽位的默认
function defaultSlotsOf(name) {
return Object.entries(state.defaults)
.filter(([, v]) => v === name)
.map(([k]) => k);
}
const slotLabel = (key) => SLOTS.find((s) => s.key === key).label;
/* ── 渲染:能力槽位 ── */
function renderSlots() {
const setCount = Object.values(state.defaults).filter((v) => v).length;
document.getElementById("stat-set").textContent = setCount;
document.getElementById("slot-grid").innerHTML = SLOTS.map((s) => {
const eligible = s.pick();
const cur = state.defaults[s.key];
const isSet = !!cur;
if (!eligible.length) {
return `
<div class="slot-card unset">
<div class="slot-top"><span class="slot-label">${s.label}</span><span class="slot-hint">${s.hint}</span></div>
<div class="slot-current">未设置<span class="slot-display">暂无可用模型</span></div>
<div class="slot-empty">配置 models.yaml 后自动出现</div>
</div>`;
}
const curLabel = isSet ? labelOf(cur) : "未设置";
return `
<div class="slot-card ${isSet ? "set" : "unset"}">
<div class="slot-top"><span class="slot-label">${s.label}</span><span class="slot-hint">${s.hint}</span></div>
<div class="slot-current">${isSet ? esc(cur) : "未设置"}<span class="slot-display">${esc(curLabel)}</span></div>
<select class="slot-select" onchange="setSlot('${s.key}', this.value)">
<option value="">- 未设置 -</option>
${eligible.map((m) => `<option value="${m.name}" ${cur === m.name ? "selected" : ""}>${esc(m.display)} · ${m.name}</option>`).join("")}
</select>
</div>`;
}).join("");
}
function setSlot(key, val) {
state.defaults[key] = val || null;
renderSlots();
renderModelList();
renderCapMatrix();
renderAudio();
}
/* ── 渲染:模型清单 ── */
function renderModelList() {
const groups = {};
for (const m of MODELS) (groups[m.provider] = groups[m.provider] || []).push(m);
document.getElementById("model-list").innerHTML = Object.entries(groups).map(([key, list]) => {
const p = PROVIDERS[key];
return `
<div class="provider-group">
<div class="provider-head">
<span class="ph-key-dot"></span>${esc(p.label)}
<span class="ph-key">${key}</span>
<span class="ph-meta">${p.format}<span class="sep">·</span>base ${p.baseEnv}<span class="sep">·</span>key ${p.apiKeyEnv}<span class="sep">·</span>超时 ${p.timeout}s<span class="sep">·</span>重试 ${p.retries}</span>
<span class="ph-line"></span>
</div>
<div class="model-grid">${list.map(cardHtml).join("")}</div>
</div>`;
}).join("");
}
function cardHtml(m) {
const defSlots = defaultSlotsOf(m.name);
const isAnyDefault = defSlots.length > 0;
const usable = m.ctx - m.reserve;
const fmts = modelFmts(m);
const fmtTag = `<span class="model-tag fmt">${fmts.join(" · ")}</span>`;
const defTags = defSlots.map((k) => `<span class="model-tag default-tag">★ ${esc(slotLabel(k))}</span>`).join("");
const tags = m.tags.map((t) => `<span class="model-tag ${t.k}">${esc(t.t)}</span>`).join("") + fmtTag + defTags;
const effortCell = m.effort || (m.caps.effort ? "可调" : (m.caps.think ? "思考" : null));
return `
<div class="model-card${isAnyDefault ? " is-default" : ""}" data-name="${m.name}">
<div class="model-head">
<span class="model-star ${isAnyDefault ? "" : "off"}">${isAnyDefault ? "★" : "★"}</span>
<span class="model-name">${esc(m.name)}</span>
<span class="model-real"><span class="arr">→</span>${esc(m.real)}</span>
${tags}
</div>
<div class="model-desc">${esc(m.desc)}</div>
<div class="model-specs">
${specCell("上下文", fmtCtxFull(m.ctx), `预留 ${fmt(m.reserve)}`)}
${specCell("可用上下文", fmtCtxFull(usable))}
${specCell("最大输出", fmtCtxFull(m.maxOut))}
${specCell("超时", m.timeout + "s")}
${specCell("隐私", orDash(m.privacy), null, m.privacy == null)}
${specCell("推理", effortCell, null, effortCell == null)}
${specCell("路由", m.routing || "隐式单端点", m.pool ? "端点池已启用" : null, !m.routing)}
${specCell("rpm / tpm", (m.endpoints && m.endpoints.find((e) => e.rpm) ? fmt(m.endpoints.find((e) => e.rpm).rpm) : "-") + " / -", null, !(m.endpoints && m.endpoints.find((e) => e.rpm)))}
${specCell("并发 / quota", "-", null, true)}
</div>
<div class="model-price">
<div class="price-cell"><span class="pk">输入 / 1M</span><span class="pv">${money(m.price.ccy, m.price.in)}<span class="ccy">${m.price.ccy}</span></span></div>
<div class="price-cell"><span class="pk">缓存命中 / 1M</span><span class="pv ${m.price.cache == null ? "na" : ""}">${m.price.cache == null ? "-" : money(m.price.ccy, m.price.cache) + `<span class="ccy">${m.price.ccy}</span>`}</span></div>
<div class="price-cell"><span class="pk">输出 / 1M</span><span class="pv">${money(m.price.ccy, m.price.out)}<span class="ccy">${m.price.ccy}</span></span></div>
</div>
<div class="model-caps">
${capDot("工具", m.caps.tools)}${capDot("思考", m.caps.think)}${capDot("推理 effort", m.caps.effort)}${capDot("结构化", m.caps.struct)}${capDot("流式", m.caps.stream)}${capDot("视觉", m.caps.vision)}
</div>
<div class="ep-pool">
<div class="ep-title">多供应商端点池 · ${endpointsOf(m).length} 个端点</div>
${endpointsOf(m).map(epRowHtml).join("")}
</div>
</div>`;
}
function specCell(k, v, sub, na) {
return `<div class="cell"><div class="k">${k}</div><div class="v ${na ? "na" : ""}">${v == null ? "-" : v}${sub ? `<span class="sub">${sub}</span>` : ""}</div></div>`;
}
function capDot(label, on) {
return `<span class="cap ${on ? "on" : "off"}"><span class="d"></span>${label}</span>`;
}
function epRowHtml(e) {
const f = (label, val) => `<span class="ep-f"><i>${label}</i>${val == null || val === "" ? '<span class="na">-</span>' : val}</span>`;
const override = e.model ? `<span class="ep-override">→ ${esc(e.model)}</span>` : "";
const env = e.implicit ? `<span class="ep-env">继承 ${esc(e.baseEnv)}</span>` : `<span class="ep-env">${esc(e.baseEnv)}</span>`;
return `
<div class="ep-row">
<span class="ep-status ${e.status === "active" ? "" : "off"}" title="${e.status}"></span>
<span class="ep-name">${esc(e.name)}</span>
<span class="ep-fmt">${esc(e.fmt)}</span>
${env}
${override}
<span class="ep-sep">│</span>
${f("p", e.p)}${f("w", e.w)}${f("rpm", e.rpm == null ? null : fmt(e.rpm))}${f("tpm", e.tpm == null ? null : fmt(e.tpm))}${f("并发", e.conc == null ? null : fmt(e.conc))}
</div>`;
}
/* ── 渲染:定价对照表 ── */
function renderPriceTable() {
const rows = [];
let curKey = null;
for (const m of MODELS) {
if (m.provider !== curKey) {
const p = PROVIDERS[m.provider];
rows.push(`<tr class="grp"><td colspan="5">${esc(p.label)}<span class="gkey">${m.provider} · ${p.format}</span></td></tr>`);
curKey = m.provider;
}
const isDef = defaultSlotsOf(m.name).length > 0;
const blend = m.price.in + m.price.out;
rows.push(`
<tr class="${isDef ? "default-row" : ""}">
<td class="model-col">${isDef ? '<span class="star">★</span>' : ""}${esc(m.name)}</td>
<td class="num">${money(m.price.ccy, m.price.in)} <span class="ccy">${m.price.ccy}</span></td>
<td class="num">${m.price.cache == null ? '<span class="na">-</span>' : money(m.price.ccy, m.price.cache) + ` <span class="ccy">${m.price.ccy}</span>`}</td>
<td class="num">${money(m.price.ccy, m.price.out)} <span class="ccy">${m.price.ccy}</span></td>
<td class="num cost">${money(m.price.ccy, blend)} <span class="ccy">${m.price.ccy}</span></td>
</tr>`);
}
document.getElementById("price-body").innerHTML = rows.join("");
}
/* ── 渲染:能力矩阵 ── */
function renderCapMatrix() {
const dot = (on, label) =>
on
? `<td><span class="cap-dot"><span class="d full"></span><span>${label}</span></span></td>`
: `<td><span class="cap-dot"><span class="d no"></span><span>-</span></span></td>`;
const rows = MODELS.map((m) => {
const isDef = defaultSlotsOf(m.name).length > 0;
const effort = m.effort
? `<span>${esc(m.effort)}</span>`
: m.caps.effort
? `<span>可调</span>`
: `<span class="na">-</span>`;
return `
<tr class="${isDef ? "default-row" : ""}">
<td class="model-col">${isDef ? '<span class="star">★</span>' : ""}${esc(m.name)}</td>
<td class="num">${fmtCtx(m.ctx)}</td>
${dot(m.caps.tools, "支持")}
${dot(m.caps.think, "支持")}
<td><span class="cap-dot"><span class="d ${m.caps.effort ? "full" : "no"}"></span>${effort}</span></td>
${dot(m.caps.struct, "支持")}
${dot(m.caps.stream, "SSE")}
${dot(m.caps.vision, "支持")}
</tr>`;
}).join("");
document.getElementById("cap-body").innerHTML = rows;
}
/* ── 渲染:语音 / 专用能力 ── */
function renderAudio() {
document.getElementById("audio-grid").innerHTML = AUDIO.map((a) => {
const isDef = defaultSlotsOf(a.name).length > 0;
const defTags = defaultSlotsOf(a.name).map((k) => `★ ${esc(slotLabel(k))}`).join(" · ");
return `
<div class="audio-card ${isDef ? "is-default" : ""}">
<div class="a-head"><span class="a-name">${esc(a.name)}</span><span class="a-kind">${a.kindLabel}</span></div>
<div class="a-tag">${esc(a.tag)}${isDef ? ` <span style="color:var(--patina)">· ${esc(defTags)}</span>` : ""}</div>
<div class="a-spec">
<span>ctx <span class="na">0</span></span>
<span>超时 ${a.timeout}s</span>
<span>${a.stream ? "流式" : "非流式"}</span>
<span>${esc(a.fmt)}</span>
<span class="na">对话计费 -</span>
</div>
</div>`;
}).join("");
}
/* ── 初始化 ── */
function init() {
renderSlots();
renderModelList();
renderPriceTable();
renderCapMatrix();
renderAudio();
}
init();
</script>
<script src="_theme.js" defer></script>
</body>
</html>