diff --git a/app.py b/app.py index 81521e1..44d7048 100644 --- a/app.py +++ b/app.py @@ -135,34 +135,29 @@ def leaderboard(): keys.add(("", item)) rows = [r for r in rows if (r["provider"], r["model"]) in keys] - # 按排行榜生成柱状图 CSV(解码速度 TOP,默认全部模型,可指定) - chart_rows = rows[: int(request.args.get("chart_top", 20) or 20)] - csv_lines = ["模型, 解码速度(tok/s), 预填充速度(tok/s), 首字延迟(ms)"] + chart_rows = rows[: int(request.args.get("chart_top", 30) or 30)] + chart_csv = _speed_csv(chart_rows) + return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv}) + + +def _speed_csv(chart_rows): + """模型 运行速度 CSV:预填充 / 解码 / 首字 三列(前端据此配置柱/折线/双轴/颜色)""" + csv_lines = ["模型, 预填充速度(tok/s), 解码速度(tok/s), 首字延迟(ms)"] for r in chart_rows: label = "%s %s" % (r["provider"], r["model"]) csv_lines.append("%s, %s, %s, %s" % ( - label.replace(",", " "), _fmt(r["avg_decode_speed"]), - _fmt(r["avg_prefill_speed"]), _fmt(r["avg_ttft_ms"]))) - chart_csv = "\n".join(csv_lines) - bar_payload = { - "data": chart_csv, "chartType": "bar", - "title": "模型解码速度对比(%d 个模型)" % len(chart_rows), - "theme": "default", "showLegend": True, "showGrid": True, "showLabel": True, - "seriesTypes": ["bar", "bar", "line"], - "seriesAxis": [0, 0, 1], - "seriesStyles": ["solid", "hollow", "dashed"], - "width": 1100, "height": 560, "pixelRatio": 2, - } - return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv, - "bar_payload": bar_payload}) + label.replace(",", " "), _fmt(r["avg_prefill_speed"]), + _fmt(r["avg_decode_speed"]), _fmt(r["avg_ttft_ms"]))) + return "\n".join(csv_lines) # ───────────────────────── 模型对比(硬件 + 可去留对比项) ───────────────────────── @app.route("/api/compare") def compare(): - """返回指定模型(默认全部)的聚合对比行 + 图表数据。 - models: 逗号分隔 provider|model;chart_metric: 解码/预填充/首字;chart_type: bar/line + """返回指定模型(默认全部)的聚合对比行 + 运行速度 CSV。 + models: 逗号分隔 provider|model(保持传入顺序=对比车添加顺序); + 不传时默认全部(按解码速度排序,前端默认取前5)。 """ sel = request.args.get("models", "").strip() keys = [] @@ -176,35 +171,15 @@ def compare(): keys.append((p.strip(), m.strip())) else: keys.append(("", item)) + rows = db.compare_models(keys) # 保持传入顺序(对比车顺序) else: lb = db.leaderboard(limit=500) keys = [(r["provider"], r["model"]) for r in lb] - rows = db.compare_models(keys) - # 有硬件优先展示 - rows.sort(key=lambda r: (r.get("avg_decode_speed") or 0), reverse=True) + rows = db.compare_models(keys) + rows.sort(key=lambda r: (r.get("avg_decode_speed") or 0), reverse=True) - # 图表数据(按所选指标) - metric = request.args.get("chart_metric", "avg_decode_speed") - ctype = request.args.get("chart_type", "bar") - metric_name = {"avg_decode_speed": "解码速度", "avg_prefill_speed": "预填充速度", - "avg_ttft_ms": "首字延迟", "best_decode": "最佳解码"}.get(metric, "解码速度") - unit = "tok/s" if metric in ("avg_decode_speed", "avg_prefill_speed", "best_decode") else "ms" - chart_rows = rows[: int(request.args.get("chart_top", 50) or 50)] - csv_lines = ["模型, %s(%s)" % (metric_name, unit)] - for r in chart_rows: - label = "%s %s" % (r["provider"], r["model"]) - csv_lines.append("%s, %s" % (label.replace(",", " "), _fmt(r.get(metric)))) - chart_csv = "\n".join(csv_lines) - chart_payload = { - "data": chart_csv, "chartType": "bar" if ctype != "line" else "line", - "title": "模型%s对比(%d 个模型)" % (metric_name, len(chart_rows)), - "theme": "default", "showLegend": False, "showGrid": True, "showLabel": True, - "smoothLine": True, - "seriesTypes": ["bar"] if ctype != "line" else ["line"], - "width": 1100, "height": 520, "pixelRatio": 2, - } - return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv, - "chart_payload": chart_payload, "metric": metric}) + chart_csv = _speed_csv(rows[: int(request.args.get("chart_top", 60) or 60)]) + return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv}) @app.route("/api/model") diff --git a/static/capabilities.html b/static/capabilities.html index 6d6633e..01f2c8c 100644 --- a/static/capabilities.html +++ b/static/capabilities.html @@ -15,7 +15,6 @@ ⚖️ 模型对比
- ⚙️ 后台管理
diff --git a/static/compare.html b/static/compare.html index acea4a2..ce429fd 100644 --- a/static/compare.html +++ b/static/compare.html @@ -15,21 +15,21 @@ ⚖️ 模型对比
- ⚙️ 后台管理
-

⚖️ 模型对比 (默认对比全部模型;支持硬件显示;下方勾选可去留对比项)

+

⚖️ 模型对比 (默认对比前5个;有对比车则按加入顺序对比;默认只对比预填充+解码)

-
① 选择要对比的模型(默认全部):
+
① 选择要对比的模型(来自对比车,或默认前5;可勾选增删):
+
-
② 勾选需要展示的对比项(可去留):
+
② 勾选需要展示的对比项(可去留,默认预填充+解码):
@@ -43,25 +43,48 @@
-

📊 对比图表

-
- + + + + + + - + +
-
选择指标后点击生成(默认对比全部选中模型)
+
配置好后点击生成;底部横轴字太多可旋转/加间隔避免挤在一起
+ + +
diff --git a/static/css/style.css b/static/css/style.css index bda1954..69e285d 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -238,3 +238,18 @@ label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px .cap-edit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } @media (max-width: 760px) { .cap-edit-grid { grid-template-columns: 1fr; } } textarea.items-editor { min-height: 160px; font-family: ui-monospace, monospace; font-size: 12px; width: 100%; } + +/* 对比车(浮动条) */ +#cart-bar { + position: fixed; right: 18px; bottom: 18px; z-index: 90; + display: flex; align-items: center; gap: 10px; max-width: 640px; + background: var(--panel); border: 1px solid var(--accent); border-radius: 12px; + padding: 9px 14px; box-shadow: 0 10px 30px rgba(0,0,0,.45); +} +#cart-bar[hidden] { display: none; } +#cart-bar .cart-label { font-weight: 700; color: var(--text-strong); white-space: nowrap; } +#cart-bar .cart-items { + color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; max-width: 260px; +} +#cart-bar .cart-items b { color: var(--text); font-weight: 600; } diff --git a/static/index.html b/static/index.html index cdb88c5..cac8f9e 100644 --- a/static/index.html +++ b/static/index.html @@ -15,7 +15,6 @@ ⚖️ 模型对比
- ⚙️ 后台管理
@@ -43,15 +42,16 @@ 最佳解码 tok/s 硬件 最后测试 + 对比车 - 加载中… + 加载中…
-

📊 解码速度对比图 (默认对比全部模型,可勾选指定模型后重新生成)

-
选择要对比的模型(默认全部):
+

📊 运行速度对比图 (默认对比全部模型,可勾选指定模型后重新生成)

+
选择要对比的模型(默认全部;也可点排行榜每行「🛒 加入对比车」后到「⚖️ 模型对比」页对比):
@@ -60,11 +60,19 @@
-
点击上方按钮绘制:模型解码速度(实心柱)+ 预填充(空心柱)+ 首字延迟(虚线)
+
运行速度对比:预填充(左轴·空心柱)+ 解码(右轴·实心柱)+ 首字延迟(虚线)
+ + +
diff --git a/static/js/app.js b/static/js/app.js index dfd66c3..d76ef86 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -35,6 +35,76 @@ function providerLabel(p) { const hwTag = (hw) => (hw ? `🖥 ${esc(hw)}` : "—"); +/* ───────────────────────── 对比车(localStorage 持久化) ───────────────────────── */ +const CART_KEY = "evalCompareCart"; + +function getCart() { + try { const c = JSON.parse(localStorage.getItem(CART_KEY) || "[]"); return Array.isArray(c) ? c : []; } + catch (e) { return []; } +} +function saveCart(c) { localStorage.setItem(CART_KEY, JSON.stringify(c)); } +function cartAdd(key) { + const c = getCart(); + if (!c.includes(key)) { c.push(key); saveCart(c); toast("🛒 已加入对比车"); } + else { toast("已在对比车中"); } + renderCartBar(); +} +function cartRemove(key) { saveCart(getCart().filter((k) => k !== key)); renderCartBar(); } +function cartClear() { saveCart([]); renderCartBar(); } +function cartModelName(key) { + const i = String(key).indexOf("|"); + return i >= 0 ? String(key).slice(i + 1) : String(key); +} +function renderCartBar() { + const bar = $("#cart-bar"); + if (!bar) return; + const c = getCart(); + if (!c.length) { bar.hidden = true; return; } + bar.hidden = false; + const cnt = $("#cart-count"); + if (cnt) cnt.textContent = c.length; + const items = $("#cart-items"); + if (items) items.innerHTML = c.map((k) => `${esc(cartModelName(k))}`).join("、"); +} +function bindCartBar() { + const bar = $("#cart-bar"); + if (!bar) return; + const clear = $("#cart-clear"); + if (clear) clear.addEventListener("click", cartClear); + const go = $("#cart-go"); + if (go) go.addEventListener("click", () => { location.href = "/compare.html"; }); + renderCartBar(); +} + +/* 运行速度对比:3 个系列(颜色深浅三色),前端可配置类型/轴 */ +const RUN_SERIES = [ + { key: "avg_prefill_speed", label: "预填充速度", unit: "tok/s", color: "#5b94ff", depth: "浅" }, + { key: "avg_decode_speed", label: "解码速度", unit: "tok/s", color: "#2ee67a", depth: "深" }, + { key: "avg_ttft_ms", label: "首字延迟", unit: "ms", color: "#ffb43c", depth: "中" }, +]; + +function buildRunPayload(opts) { + const series = (opts.series || RUN_SERIES).filter((s) => s.show); + const payload = { + data: opts.csv, chartType: "bar", title: opts.title || "模型运行速度对比", + theme: "default", showLegend: series.length > 1, showGrid: true, + showLabel: opts.showLabel ?? false, smoothLine: true, + dualYAxis: series.some((s) => s.axis === 1), + leftAxisName: opts.leftAxisName || "预填充速度(tok/s)", + rightAxisName: opts.rightAxisName || "解码速度(tok/s)", + seriesTypes: series.map((s) => s.type), + seriesStyles: series.map((s) => s.style), + seriesAxis: series.map((s) => s.axis), + seriesColors: series.map((s) => s.color), + width: 1100, height: 560, pixelRatio: 2, + }; + if (opts.xRotate !== undefined && opts.xRotate !== "" && opts.xRotate !== null) payload.xLabelRotate = Number(opts.xRotate); + if (opts.xFont) payload.xLabelFontSize = Number(opts.xFont); + if (opts.xInterval !== undefined && opts.xInterval !== "" && opts.xInterval !== null) payload.xLabelInterval = Number(opts.xInterval); + if (!payload.dualYAxis) payload.seriesAxis = null; + return payload; +} + function navActive() { const path = location.pathname.split("/").pop() || "index.html"; $$(".nav a").forEach((a) => { @@ -89,19 +159,19 @@ function renderLbChips() { async function loadLeaderboard() { const tb = $("#lb tbody"); if (!tb) return; - tb.innerHTML = '加载中…'; + tb.innerHTML = '加载中…'; let d; try { d = await api(`/api/leaderboard?sort=${sortKey}&order=${sortOrder}`); } catch (e) { - tb.innerHTML = `加载失败:${esc(e.message)}`; + tb.innerHTML = `加载失败:${esc(e.message)}`; return; } $$("#lb th[data-sort]").forEach((th) => { th.querySelector(".arrow").textContent = (th.dataset.sort === sortKey) ? (sortOrder === "desc" ? " ▼" : " ▲") : ""; }); if (!d.rows.length) { - tb.innerHTML = '暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。'; + tb.innerHTML = '暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。'; return; } // 记录模型列表,用于对比图选择 @@ -125,6 +195,7 @@ async function loadLeaderboard() { ${fmt(r.best_decode)} ${hwTag(r.hardware)} ${esc((r.last_tested || "").slice(5, 16))} + `; }).join(""); } @@ -140,9 +211,24 @@ async function genLbChart() { const models = [...lbSelected].join(","); const d = await api(`/api/leaderboard?sort=avg_decode_speed&order=desc&models=${encodeURIComponent(models)}`); if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; } + const n = d.rows.length; + // 运行速度对比:预填充(左轴·空心柱) + 解码(右轴·实心柱) + 首字(虚线),三色 + const series = RUN_SERIES.map((s, i) => ({ + ...s, show: true, + type: ["bar", "bar", "line"][i], + style: ["hollow", "solid", "dashed"][i], + axis: [0, 1, 1][i], + })); + const payload = buildRunPayload({ + csv: d.chart_csv, + title: `模型运行速度对比(${n} 个模型)`, + series, + xRotate: n > 14 ? 45 : (n > 8 ? 30 : 0), + xFont: n > 14 ? 11 : 12, + }); const resp = await fetch("/api/chart", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(d.bar_payload), + body: JSON.stringify(payload), }); if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } const blob = await resp.blob(); @@ -151,8 +237,8 @@ async function genLbChart() { imgWrap.innerHTML = ""; const img = document.createElement("img"); img.src = lbChartUrl; - img.alt = "模型解码速度对比图"; - img.onload = () => { status.textContent = `✅ 已生成(${d.rows.length} 个模型)`; }; + img.alt = "模型运行速度对比图"; + img.onload = () => { status.textContent = `✅ 已生成(${n} 个模型)`; }; imgWrap.appendChild(img); } catch (e) { status.textContent = "❌ " + e.message; } } @@ -287,11 +373,13 @@ function openCapDetail(c) { `; } -/* ───────────────────────── 模型对比页 ───────────────────────── */ +/* ───────────────────────── 模型对比页(对比车驱动) ───────────────────────── */ let cmpModels = []; let cmpSelected = new Set(); -let cmpCols = new Set(["provider", "model", "hardware", "avg_decode_speed", "avg_prefill_speed", "avg_ttft_ms"]); +let cmpOrder = []; // 对比顺序:对比车加入顺序 或 默认前5 +let cmpCols = new Set(["model", "avg_prefill_speed", "avg_decode_speed"]); // 默认只对比预填充+解码 let cmpRows = []; +let cmpCsv = ""; let cmpChartUrl = ""; const CMP_COL_DEFS = [ @@ -325,15 +413,27 @@ function renderCmpColToggles() { }); } -async function loadCmpModels() { +async function initCompare() { const box = $("#cmp-models"); if (!box) return; let d; try { d = await api("/api/leaderboard?limit=500"); } catch (e) { box.innerHTML = `加载失败`; return; } cmpModels = d.rows.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model) })); - if (!cmpSelected.size) cmpModels.forEach((m) => cmpSelected.add(m.key)); + // 对比车优先(按加入顺序);否则默认前5 + const cart = getCart(); + const cartValid = cart.filter((k) => cmpModels.some((m) => m.key === k)); + if (cartValid.length) { + cmpOrder = cartValid; + cmpSelected = new Set(cartValid); + } else { + cmpOrder = cmpModels.slice(0, 5).map((m) => m.key); + cmpSelected = new Set(cmpOrder); + } renderCmpChips(); + renderCmpColToggles(); + renderSeriesCfg(); + runCompare(); } function renderCmpChips() { @@ -346,7 +446,8 @@ function renderCmpChips() { c.className = "chip" + (on ? " on" : ""); c.innerHTML = `${esc(m.model)}`; c.addEventListener("click", () => { - if (cmpSelected.has(m.key)) cmpSelected.delete(m.key); else cmpSelected.add(m.key); + if (cmpSelected.has(m.key)) { cmpSelected.delete(m.key); cmpOrder = cmpOrder.filter((k) => k !== m.key); } + else { cmpSelected.add(m.key); if (!cmpOrder.includes(m.key)) cmpOrder.push(m.key); } c.classList.toggle("on"); }); box.appendChild(c); @@ -356,14 +457,15 @@ function renderCmpChips() { async function runCompare() { const status = $("#cmp-status"); const tbody = $("#cmp-table tbody"); - if (!cmpSelected.size) { status.textContent = "请至少选择一个模型"; return; } + if (!cmpOrder.length) { status.textContent = "请至少选择一个模型"; return; } status.textContent = "⏳ 正在对比..."; - const models = [...cmpSelected].join(","); + const models = cmpOrder.join(","); try { const d = await api(`/api/compare?models=${encodeURIComponent(models)}`); cmpRows = d.rows; + cmpCsv = d.chart_csv; renderCmpTable(); - status.textContent = `✅ 已对比 ${cmpRows.length} 个模型`; + status.textContent = `✅ 已对比 ${cmpRows.length} 个模型(${cmpOrder.length} 个选中)`; } catch (e) { status.textContent = "❌ " + e.message; } @@ -387,20 +489,71 @@ function renderCmpTable() { }).join("") + "").join(""); } +/* 对比图系列配置:柱状(实心/空心) / 折线(实线/虚线) / 左右双轴 / 三色深度 */ +function renderSeriesCfg() { + const tbody = $("#cmp-series-cfg tbody"); + if (!tbody) return; + tbody.innerHTML = RUN_SERIES.map((s, i) => { + const show = i < 2; // 默认预填充+解码 + const type = ["bar", "bar", "line"][i]; + const style = ["hollow", "solid", "dashed"][i]; + const axis = [0, 1, 1][i]; + return ` + ${esc(s.label)} ${s.unit}·${s.depth}色 + + + + ${s.color} + `; + }).join(""); +} + +function collectSeriesCfg() { + return RUN_SERIES.map((s) => { + const tr = $(`#cmp-series-cfg tr[data-skey="${s.key}"]`); + if (!tr) return { ...s, show: false }; + const show = tr.querySelector(".sf-show").checked; + const [type, style] = tr.querySelector(".sf-type").value.split("|"); + const axis = Number(tr.querySelector(".sf-axis").value); + return { ...s, show, type, style, axis }; + }); +} + async function genCmpChart() { const status = $("#cmp-chart-status"); const imgWrap = $("#cmp-chart-img"); - if (!cmpSelected.size) { status.textContent = "请至少选择一个模型"; return; } - const metric = $("#cmp-metric").value; - const ctype = $("#cmp-chart-type").value; + if (!cmpOrder.length) { status.textContent = "请至少选择一个模型"; return; } + const series = collectSeriesCfg(); + if (!series.some((s) => s.show)) { status.textContent = "请至少勾选一个系列"; return; } status.textContent = "⏳ 正在生成..."; + const xRotate = $("#cmp-xrotate").value; + const xFont = parseInt($("#cmp-xfont").value) || 12; + const xInterval = $("#cmp-xinterval").value; try { - const models = [...cmpSelected].join(","); - const d = await api(`/api/compare?models=${encodeURIComponent(models)}&chart_metric=${metric}&chart_type=${ctype}`); - if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; } + if (!cmpCsv) { + const models = cmpOrder.join(","); + const d = await api(`/api/compare?models=${encodeURIComponent(models)}`); + cmpCsv = d.chart_csv; + } + const payload = buildRunPayload({ + csv: cmpCsv, + title: `模型运行速度对比(${cmpOrder.length} 个模型)`, + series, + xRotate: xRotate === "auto" ? "" : xRotate, + xFont, + xInterval: xInterval === "auto" ? "" : xInterval, + }); const resp = await fetch("/api/chart", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(d.chart_payload), + body: JSON.stringify(payload), }); if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } const blob = await resp.blob(); @@ -409,7 +562,7 @@ async function genCmpChart() { imgWrap.innerHTML = ""; const img = document.createElement("img"); img.src = cmpChartUrl; - img.onload = () => { status.textContent = `✅ 已生成(${d.rows.length} 个模型)`; }; + img.onload = () => { status.textContent = "✅ 已生成"; }; imgWrap.appendChild(img); } catch (e) { status.textContent = "❌ " + e.message; } } @@ -683,6 +836,13 @@ function bindIndex() { if (all) all.addEventListener("click", () => { lbModels.forEach((m) => lbSelected.add(m.key)); renderLbChips(); }); const none = $("#lb-chart-none"); if (none) none.addEventListener("click", () => { lbSelected.clear(); renderLbChips(); }); + // 对比车按钮 + const tb = $("#lb tbody"); + if (tb) tb.addEventListener("click", (e) => { + const btn = e.target.closest("[data-cart]"); + if (btn) cartAdd(btn.dataset.cart); + }); + bindCartBar(); } function bindModel() { @@ -720,14 +880,18 @@ function bindCapabilities() { function bindCompare() { const all = $("#cmp-all"); - if (all) all.addEventListener("click", () => { cmpModels.forEach((m) => cmpSelected.add(m.key)); renderCmpChips(); }); + if (all) all.addEventListener("click", () => { cmpModels.forEach((m) => { cmpSelected.add(m.key); if (!cmpOrder.includes(m.key)) cmpOrder.push(m.key); }); renderCmpChips(); }); const none = $("#cmp-none"); - if (none) none.addEventListener("click", () => { cmpSelected.clear(); renderCmpChips(); }); + if (none) none.addEventListener("click", () => { cmpSelected.clear(); cmpOrder = []; renderCmpChips(); }); + const clearCart = $("#cmp-clear-cart"); + if (clearCart) clearCart.addEventListener("click", () => { cartClear(); initCompare(); }); const run = $("#cmp-run"); if (run) run.addEventListener("click", runCompare); const gen = $("#cmp-chart-gen"); if (gen) gen.addEventListener("click", genCmpChart); renderCmpColToggles(); + renderSeriesCfg(); + bindCartBar(); } function bindAdmin() { @@ -876,7 +1040,7 @@ document.addEventListener("DOMContentLoaded", () => { loadCapabilities(); bindCapabilities(); } else if (page === "compare.html") { - loadCmpModels(); + initCompare(); bindCompare(); } else if (page === "admin.html") { checkAdmin(); diff --git a/static/model.html b/static/model.html index fa32b65..3fee749 100644 --- a/static/model.html +++ b/static/model.html @@ -15,7 +15,7 @@ ⚖️ 模型对比
- ⚙️ 后台管理 + ← 返回排行