From b0dd109a137bb5e8e00e7f8b6f7d0a5660b1dd9c Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Wed, 2 Sep 2026 17:51:56 +0800 Subject: [PATCH] =?UTF-8?q?v1.3.0=20=E7=83=AD=E5=BA=A6=E6=8E=92=E8=A1=8C+?= =?UTF-8?q?=E6=90=9C=E7=B4=A2+=E8=AF=A6=E6=83=85+=E6=B0=B4=E5=8D=B0?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=EF=BC=9A=E9=80=9F=E5=BA=A6=E9=A1=B9=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=94=B9+=E5=AF=B9=E6=AF=94(=E5=8E=BB=E8=B4=AD?= =?UTF-8?q?=E7=89=A9=E8=BD=A6=E5=9B=BE=E6=A0=87)=EF=BC=9B=E6=8E=92?= =?UTF-8?q?=E8=A1=8C=E9=BB=98=E8=AE=A4=E6=8C=89=E7=83=AD=E5=BA=A6=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=89=8D10(=E5=90=8E=E5=8F=B0=E5=8F=AF=E9=85=8D)+?= =?UTF-8?q?=E9=A1=B6=E9=83=A8=E6=A8=A1=E5=9E=8B=E6=90=9C=E7=B4=A2=E6=A1=86?= =?UTF-8?q?(=E7=82=B9=E5=87=BB=E6=98=BE=E7=A4=BA=E7=83=AD=E9=97=A85?= =?UTF-8?q?=E4=B8=AA:=E9=85=8D=E7=BD=AE=E7=AE=80=E4=BB=8B+=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B,=E6=94=AF=E6=8C=81=E6=89=8B=E5=8A=A8=E6=90=9C?= =?UTF-8?q?=E7=B4=A2,=E9=80=9F=E5=BA=A6=E9=A1=B9=E7=83=AD=E5=BA=A6/?= =?UTF-8?q?=E4=B8=AA=E6=95=B0/=E7=AE=80=E4=BB=8B=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=8F=AF=E6=94=B9)=EF=BC=9B=E6=AF=8F=E8=A1=8C=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=93=BE=E6=8E=A5=E5=88=B0=E5=85=B7=E4=BD=93=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=EF=BC=9B=E5=9B=BE=E8=A1=A8=E5=B8=A6=E6=B0=B4?= =?UTF-8?q?=E5=8D=B0(=E9=BB=98=E8=AE=A4=E9=A6=96=E9=A1=B5=E7=BD=91?= =?UTF-8?q?=E5=9D=80,=E5=90=8E=E5=8F=B0=E5=8F=AF=E9=85=8D)+=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=9B=BE=E7=89=87=E4=B8=8B=E8=BD=BD=E6=8C=89=E9=92=AE?= =?UTF-8?q?(=E9=A6=96=E9=A1=B5/=E5=AF=B9=E6=AF=94=E9=A1=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 150 ++++++++++++++++++++++++- config.py | 4 + database.py | 178 ++++++++++++++++++++++++++++- static/admin.html | 33 ++++++ static/compare.html | 1 + static/css/style.css | 13 +++ static/index.html | 16 ++- static/js/app.js | 259 ++++++++++++++++++++++++++++++++++++++----- 8 files changed, 615 insertions(+), 39 deletions(-) diff --git a/app.py b/app.py index 44d7048..7e8e4e2 100644 --- a/app.py +++ b/app.py @@ -23,6 +23,69 @@ app.secret_key = config.SESSION_SECRET db.init_db() +# 水印字体(支持中文) +_WATERMARK_FONT = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" + + +def _watermark_text(): + return db.get_setting("watermark_text", config.DEFAULT_WATERMARK) + + +def _top_n(): + try: + return max(1, int(db.get_setting("top_n", str(config.DEFAULT_TOP_N)))) + except Exception: + return config.DEFAULT_TOP_N + + +def apply_watermark(img_bytes: bytes, text: str) -> bytes: + """给图片叠加简单水印:全图浅淡斜纹 + 右下角标注。颜色自适应当前背景(浅底深字/深底浅字)""" + if not text: + return img_bytes + try: + from PIL import Image, ImageDraw, ImageFont + except Exception: + return img_bytes + try: + img = Image.open(io.BytesIO(img_bytes)).convert("RGBA") + except Exception: + return img_bytes + w, h = img.size + # 自适应水印颜色:取样左上角判断背景深浅 + bg = img.getpixel((4, 4))[:3] + bg_luma = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2] + dark_bg = bg_luma < 140 + fill = (50, 50, 60) if not dark_bg else (255, 255, 255) + diag_alpha = 30 if not dark_bg else 26 + corner_alpha = 120 if not dark_bg else 128 + + overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + d = ImageDraw.Draw(overlay) + try: + font = ImageFont.truetype(_WATERMARK_FONT, 16) + font_big = ImageFont.truetype(_WATERMARK_FONT, 22) + except Exception: + font = ImageFont.load_default() + font_big = ImageFont.load_default() + + # 全图浅淡斜纹水印(45° 重复) + step = 170 + for y in range(-h, h, step): + for x in range(-w, w, step): + d.text((x + y * 0.4, y), text, font=font, fill=fill + (diag_alpha,)) + + # 右下角较清晰标注 + box = d.textbbox((0, 0), text, font=font_big) + tw = box[2] - box[0] + th = box[3] - box[1] + d.text((w - tw - 16, h - th - 12), text, font=font_big, fill=fill + (corner_alpha,)) + + out = Image.alpha_composite(img, overlay) + buf = io.BytesIO() + out.convert("RGB").save(buf, format="PNG") + buf.seek(0) + return buf.getvalue() + @app.after_request def _cors(resp): @@ -94,6 +157,7 @@ def submit(): account_name = (body.get("account") or "").strip() or "默认账号" aid = db.get_or_create_account(account_name, remark="来自 llm-speed-tester") sid = db.add_submission(aid, body) + db.touch_speed_item(body.get("provider") or "", model) # 速度项热度/个数自动关联 account = db.list_accounts() acc = next((a for a in account if a["id"] == aid), None) return jsonify({ @@ -115,9 +179,9 @@ def stats(): @app.route("/api/leaderboard") def leaderboard(): - sort = request.args.get("sort", "avg_decode_speed") + sort = request.args.get("sort", "heat") # 默认按热度 order = request.args.get("order", "desc") - limit = min(int(request.args.get("limit", 200) or 200), 500) + limit = min(int(request.args.get("limit", 500) or 500), 1000) rows = db.leaderboard(sort=sort, order=order, limit=limit) # 可选:指定对比哪些模型(provider|model 或 仅 model,逗号分隔) @@ -135,9 +199,18 @@ def leaderboard(): keys.add(("", item)) rows = [r for r in rows if (r["provider"], r["model"]) in keys] + # 可选:搜索过滤(模型/提供商/配置简介) + q = (request.args.get("q") or "").strip().lower() + if q: + rows = [r for r in rows + if q in (r["model"] or "").lower() + or q in (r["provider"] or "").lower() + or q in (r.get("intro") or "").lower()] + 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}) + return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv, "top_n": _top_n(), + "watermark": _watermark_text()}) def _speed_csv(chart_rows): @@ -312,6 +385,72 @@ def account_delete(aid): return jsonify({"ok": True}) +# ───────────────────────── 速度项管理(后台) / 设置 ───────────────────────── + +@app.route("/api/settings") +def public_settings(): + return jsonify({"ok": True, "watermark": _watermark_text(), "top_n": _top_n()}) + + +@app.route("/api/admin/speed-items") +@admin_required +def admin_speed_items(): + return jsonify(db.list_speed_items()) + + +@app.route("/api/admin/speed-items", methods=["POST"]) +@admin_required +def admin_speed_item_add(): + body = request.get_json(force=True) or {} + try: + sid = db.add_speed_item(body.get("provider", ""), body.get("model", ""), + body.get("heat", 0), body.get("intro", ""), + body.get("count", 0), body.get("remark", "")) + return jsonify({"ok": True, "id": sid}) + except ValueError as e: + return jsonify({"ok": False, "error": str(e)}), 400 + + +@app.route("/api/admin/speed-items/", methods=["PUT"]) +@admin_required +def admin_speed_item_update(sid): + body = request.get_json(force=True) or {} + db.update_speed_item(sid, body) + return jsonify({"ok": True}) + + +@app.route("/api/admin/speed-items/", methods=["DELETE"]) +@admin_required +def admin_speed_item_delete(sid): + db.delete_speed_item(sid) + return jsonify({"ok": True}) + + +@app.route("/api/admin/speed-items/sync", methods=["POST"]) +@admin_required +def admin_speed_item_sync(): + added = db.sync_speed_items() + return jsonify({"ok": True, "added": added}) + + +@app.route("/api/admin/settings", methods=["GET"]) +@admin_required +def admin_settings_get(): + return jsonify({"ok": True, "settings": db.get_all_settings(), + "watermark": _watermark_text(), "top_n": _top_n()}) + + +@app.route("/api/admin/settings", methods=["PUT"]) +@admin_required +def admin_settings_put(): + body = request.get_json(force=True) or {} + allow = {"watermark_text", "top_n"} + for k, v in body.items(): + if k in allow: + db.set_setting(k, v) + return jsonify({"ok": True}) + + # ───────────────────────── 模型能力测试模块(前台只读,内容后台编辑) ───────────────────────── @app.route("/api/capabilities") @@ -361,7 +500,9 @@ def _chart_proxy(payload): return jsonify({"ok": False, "error": "图表服务不可用: %s" % e}), 502 if resp.status_code != 200: return jsonify({"ok": False, "error": "图表生成失败(%d): %s" % (resp.status_code, resp.text[:300])}), 502 - return send_file(io.BytesIO(resp.content), mimetype="image/png") + # 叠加水印(默认首页网址,后台可配置) + data = apply_watermark(resp.content, _watermark_text()) + return send_file(io.BytesIO(data), mimetype="image/png") @app.route("/api/chart", methods=["POST"]) @@ -435,6 +576,7 @@ def seed_demo(): "runs": [], } db.add_submission(aid, payload) + db.touch_speed_item(provider, model) cnt += 1 return jsonify({"ok": True, "seeded": cnt, "stats": db.get_stats()}) diff --git a/config.py b/config.py index 3051d35..d11327d 100644 --- a/config.py +++ b/config.py @@ -18,5 +18,9 @@ ADMIN_USER = "admin" ADMIN_PASSWORD = "admin123" SESSION_SECRET = "model-eval-site-session-secret" +# 图片水印(默认=首页网址,后台可改) +DEFAULT_WATERMARK = "http://121.40.164.32:16066" +DEFAULT_TOP_N = 10 # 排行榜默认显示热度最高的前 N 个 + # data-chart-tool 图表服务地址(画速度对比图) CHART_API_BASE = "http://127.0.0.1:16016" diff --git a/database.py b/database.py index 807fbee..5a54a27 100644 --- a/database.py +++ b/database.py @@ -61,6 +61,23 @@ CREATE TABLE IF NOT EXISTS capabilities( updated_at TEXT DEFAULT (datetime('now','localtime')) ); +CREATE TABLE IF NOT EXISTS speed_items( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT DEFAULT '', + model TEXT DEFAULT '', + heat INTEGER DEFAULT 0, + intro TEXT DEFAULT '', + count INTEGER DEFAULT 0, + remark TEXT DEFAULT '', + updated_at TEXT DEFAULT (datetime('now','localtime')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_si_model ON speed_items(provider, model); + +CREATE TABLE IF NOT EXISTS settings( + key TEXT PRIMARY KEY, + value TEXT DEFAULT '' +); + CREATE INDEX IF NOT EXISTS idx_sub_model ON submissions(model, provider); CREATE INDEX IF NOT EXISTS idx_sub_account ON submissions(account_id); CREATE INDEX IF NOT EXISTS idx_sub_created ON submissions(created_at); @@ -313,14 +330,16 @@ def get_stats(): conn.close() -def leaderboard(sort="avg_decode_speed", order="desc", limit=200): - """按 (provider, model) 聚合所有提交 → 模型速度排行(含硬件)""" +def leaderboard(sort="heat", order="desc", limit=500): + """按 (provider, model) 聚合所有提交 → 模型速度排行(含热度/配置简介/硬件) + 默认按热度(heat)倒序;sort 支持 heat/avg_decode_speed 等""" sort_whitelist = { + "heat": "heat", "avg_decode_speed": "avg_decode_speed", "avg_prefill_speed": "avg_prefill_speed", "avg_ttft_ms": "avg_ttft_ms", "best_decode": "best_decode", "cnt": "cnt", "last_tested": "last_tested", "avg_stream_decode": "avg_stream_decode", } - srt = sort_whitelist.get(sort, "avg_decode_speed") + srt = sort_whitelist.get(sort, "heat") od = "DESC" if order == "asc" and srt not in ("avg_ttft_ms", "avg_total_ms") else ( "ASC" if order == "asc" else "DESC") with _lock: @@ -338,10 +357,15 @@ def leaderboard(sort="avg_decode_speed", order="desc", limit=200): "AVG(s.avg_total_ms) AS avg_total_ms, " "MAX(s.avg_decode_speed) AS best_decode, " "MAX(s.created_at) AS last_tested, " + "COALESCE(MAX(si.heat), 0) AS heat, " + "MAX(si.intro) AS intro, " + "COALESCE(MAX(si.count), 0) AS count_override, " "(SELECT s2.hardware FROM submissions s2 " " WHERE s2.provider=s.provider AND s2.model=s.model AND s2.hardware<>'' " " ORDER BY s2.id DESC LIMIT 1) AS hardware " - "FROM submissions s WHERE s.model<>'' " + "FROM submissions s " + "LEFT JOIN speed_items si ON si.provider=s.provider AND si.model=s.model " + "WHERE s.model<>'' " "GROUP BY s.provider, s.model ORDER BY %s %s LIMIT ?" % (srt, od), (limit,)).fetchall() return [dict(r) for r in rows] @@ -502,3 +526,149 @@ def delete_capability(cid: int): conn.commit() finally: conn.close() + + +# ───────────────────────── 速度项(热度/配置简介/个数,后台可改) ───────────────────────── + +def touch_speed_item(provider, model): + """提交到达时确保速度项存在;不存在则以当前提交数为默认热度""" + provider = provider or "" + model = (model or "").strip() + if not model: + return None + with _lock: + conn = _connect() + try: + r = conn.execute("SELECT id FROM speed_items WHERE provider=? AND model=?", + (provider, model)).fetchone() + if r: + return r["id"] + cnt = conn.execute("SELECT COUNT(*) c FROM submissions WHERE provider=? AND model=?", + (provider, model)).fetchone()["c"] + cur = conn.execute( + "INSERT INTO speed_items(provider,model,heat,intro,count) VALUES(?,?,?,?,?)", + (provider, model, cnt, "", cnt)) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def list_speed_items(): + with _lock: + conn = _connect() + try: + rows = conn.execute( + "SELECT si.*, " + "(SELECT COUNT(*) FROM submissions s WHERE s.provider=si.provider AND s.model=si.model) AS real_cnt " + "FROM speed_items si ORDER BY si.heat DESC, si.model").fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def add_speed_item(provider, model, heat=0, intro="", count=0, remark=""): + provider = provider or "" + model = (model or "").strip() + if not model: + raise ValueError("模型名称不能为空") + with _lock: + conn = _connect() + try: + r = conn.execute("SELECT id FROM speed_items WHERE provider=? AND model=?", + (provider, model)).fetchone() + if r: + raise ValueError("该速度项已存在") + cur = conn.execute( + "INSERT INTO speed_items(provider,model,heat,intro,count,remark) VALUES(?,?,?,?,?,?)", + (provider, model, int(heat or 0), intro or "", int(count or 0), remark or "")) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def update_speed_item(sid: int, data: dict): + with _lock: + conn = _connect() + try: + conn.execute( + "UPDATE speed_items SET provider=?,model=?,heat=?,intro=?,count=?,remark=?," + "updated_at=datetime('now','localtime') WHERE id=?", + ((data.get("provider") or "").strip(), + (data.get("model") or "").strip(), + int(data.get("heat") or 0), + (data.get("intro") or "").strip(), + int(data.get("count") or 0), + (data.get("remark") or "").strip(), + sid)) + conn.commit() + finally: + conn.close() + + +def delete_speed_item(sid: int): + with _lock: + conn = _connect() + try: + conn.execute("DELETE FROM speed_items WHERE id=?", (sid,)) + conn.commit() + finally: + conn.close() + + +def sync_speed_items(): + """从提交自动同步速度项:为所有有提交的模型建速度项(默认热度=提交数)""" + added = 0 + with _lock: + conn = _connect() + try: + rows = conn.execute( + "SELECT provider, model, COUNT(*) c FROM submissions WHERE model<>'' " + "GROUP BY provider, model").fetchall() + for r in rows: + r0 = conn.execute("SELECT id FROM speed_items WHERE provider=? AND model=?", + (r["provider"], r["model"])).fetchone() + if r0: + continue + conn.execute( + "INSERT INTO speed_items(provider,model,heat,intro,count) VALUES(?,?,?,?,?)", + (r["provider"], r["model"], r["c"], "", r["c"])) + added += 1 + conn.commit() + return added + finally: + conn.close() + + +# ───────────────────────── 设置(水印/默认条数等) ───────────────────────── + +def get_setting(key, default=""): + with _lock: + conn = _connect() + try: + r = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() + return r["value"] if r else default + finally: + conn.close() + + +def set_setting(key, value): + with _lock: + conn = _connect() + try: + conn.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)", + (key, str(value or ""))) + conn.commit() + finally: + conn.close() + + +def get_all_settings(): + with _lock: + conn = _connect() + try: + rows = conn.execute("SELECT key,value FROM settings").fetchall() + return {r["key"]: r["value"] for r in rows} + finally: + conn.close() diff --git a/static/admin.html b/static/admin.html index c51cb1b..e0b5820 100644 --- a/static/admin.html +++ b/static/admin.html @@ -36,6 +36,8 @@ + + @@ -90,6 +92,37 @@ 加载中… + + + + + + diff --git a/static/compare.html b/static/compare.html index ce429fd..0c17f20 100644 --- a/static/compare.html +++ b/static/compare.html @@ -71,6 +71,7 @@ +
配置好后点击生成;底部横轴字太多可旋转/加间隔避免挤在一起
diff --git a/static/css/style.css b/static/css/style.css index 69e285d..4b9648b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -253,3 +253,16 @@ textarea.items-editor { min-height: 160px; font-family: ui-monospace, monospace; white-space: nowrap; max-width: 260px; } #cart-bar .cart-items b { color: var(--text); font-weight: 600; } + +/* 搜索下拉 */ +.search-drop { + position: absolute; top: 100%; left: 0; margin-top: 4px; width: 100%; max-width: 480px; + background: var(--panel2); border: 1px solid var(--accent); border-radius: 10px; + z-index: 30; box-shadow: 0 10px 30px rgba(0,0,0,.45); overflow: hidden; +} +.search-drop[hidden] { display: none; } +.search-item { padding: 9px 14px; cursor: pointer; border-bottom: 1px solid var(--border); } +.search-item:last-child { border-bottom: none; } +.search-item:hover { background: rgba(91,148,255,.12); } +.search-item .si-main { font-weight: 700; color: var(--text-strong); } +.search-item .si-sub { color: var(--muted); font-size: 12px; margin-top: 2px; } diff --git a/static/index.html b/static/index.html index cac8f9e..f5cd89b 100644 --- a/static/index.html +++ b/static/index.html @@ -27,13 +27,18 @@
-

🏆 模型运行速度排行 (点击表头可排序;按模型+提供商聚合所有提交)

+

🏆 模型运行速度排行 (默认显示热度最高的前10个;点表头可排序)

+
+ + +
- + + @@ -42,21 +47,22 @@ - + - +
# 提供商模型模型 / 配置简介🔥热度 提交 账号 平均解码 tok/s最佳解码 tok/s 硬件 最后测试对比车操作
加载中…
加载中…

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

-
选择要对比的模型(默认全部;也可点排行榜每行「🛒 加入对比车」后到「⚖️ 模型对比」页对比):
+
选择要对比的模型(默认全部;也可点排行榜每行「+对比」加入对比车后到「⚖️ 模型对比」页对比):
+
diff --git a/static/js/app.js b/static/js/app.js index d76ef86..6fd2f08 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -45,8 +45,8 @@ function getCart() { 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("已在对比车中"); } + if (!c.includes(key)) { c.push(key); saveCart(c); toast("+ 已加入对比"); } + else { toast("已在对比中"); } renderCartBar(); } function cartRemove(key) { saveCart(getCart().filter((k) => k !== key)); renderCartBar(); } @@ -112,14 +112,24 @@ function navActive() { }); } -/* ───────────────────────── 首页:排行 ───────────────────────── */ -let sortKey = "avg_decode_speed"; +/* ───────────────────────── 首页:排行(热度前10 + 搜索) ───────────────────────── */ +let sortKey = "heat"; let sortOrder = "desc"; -let lbModels = []; // [{provider, model, key}] -let lbSelected = new Set(); // selected keys +let lbModels = []; // 全部模型(含 heat/intro/count_override),已按热度排序 +let lbAll = []; +let lbSelected = new Set(); // selected keys(对比图用) +let lbTopN = 10; +let lbQuery = ""; const modelKey = (p, m) => `${p}|${m}`; +const LB_SORT_GETTER = { + heat: (r) => r.heat || 0, cnt: (r) => r.cnt, accounts: (r) => r.accounts, + avg_decode_speed: (r) => r.avg_decode_speed || 0, avg_prefill_speed: (r) => r.avg_prefill_speed || 0, + avg_ttft_ms: (r) => r.avg_ttft_ms || 0, best_decode: (r) => r.best_decode || 0, + last_tested: (r) => r.last_tested || "", +}; + async function loadStats() { try { const s = await api("/api/stats"); @@ -134,7 +144,16 @@ async function loadStats() { function sortClick(key) { if (sortKey === key) sortOrder = sortOrder === "desc" ? "asc" : "desc"; else { sortKey = key; sortOrder = "desc"; } - loadLeaderboard(); + applyView(); +} + +function cmpLb(a, b) { + const g = LB_SORT_GETTER[sortKey] || LB_SORT_GETTER.heat; + const va = g(a), vb = g(b); + let c = 0; + if (typeof va === "number" && typeof vb === "number") c = va - vb; + else c = String(va).localeCompare(String(vb)); + return sortOrder === "desc" ? -c : c; } function renderLbChips() { @@ -159,35 +178,53 @@ 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}`); + d = await api(`/api/leaderboard?sort=heat&order=desc&limit=500`); } catch (e) { - tb.innerHTML = `加载失败:${esc(e.message)}`; + tb.innerHTML = `加载失败:${esc(e.message)}`; return; } + if (d.top_n) lbTopN = d.top_n; + lbAll = d.rows; + lbModels = lbAll.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model), intro: r.intro || "", heat: r.heat || 0 })); + if (!lbSelected.size) lbModels.forEach((m) => lbSelected.add(m.key)); + else lbModels.forEach((m) => { if (!lbSelected.has(m.key)) lbSelected.add(m.key); }); + renderLbChips(); + applyView(); +} + +function applyView() { + const tb = $("#lb tbody"); + if (!tb) return; + let rows = lbAll; + const q = lbQuery.trim().toLowerCase(); + if (q) { + rows = lbAll.filter((r) => + (r.model || "").toLowerCase().includes(q) || + (r.provider || "").toLowerCase().includes(q) || + ((r.intro || "") || "").toLowerCase().includes(q)); + } + rows = [...rows].sort(cmpLb); + if (!q) rows = rows.slice(0, lbTopN); // 默认显示热度前 N 个 $$("#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 一键发送。'; + if (!rows.length) { + tb.innerHTML = '' + (q ? `未找到匹配「${esc(lbQuery)}」的模型` : "暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。") + ''; return; } - // 记录模型列表,用于对比图选择 - lbModels = d.rows.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model) })); - if (!lbSelected.size) lbModels.forEach((m) => lbSelected.add(m.key)); - else lbModels.forEach((m) => { if (!lbSelected.has(m.key)) lbSelected.add(m.key); }); // 新模型默认选中 - renderLbChips(); - - const maxDecode = Math.max(...d.rows.map((r) => r.avg_decode_speed || 0)); - tb.innerHTML = d.rows.map((r, i) => { + const maxDecode = Math.max(...rows.map((r) => r.avg_decode_speed || 0)); + tb.innerHTML = rows.map((r, i) => { const meter = r.avg_decode_speed ? `
` : ""; + const intro = r.intro ? `
${esc(r.intro)}
` : ""; return ` ${i + 1} ${esc(providerLabel(r.provider))} - ${esc(r.model)} - ${fmtInt(r.cnt)} + ${esc(r.model)}${intro} + 🔥 ${fmtInt(r.heat || 0)} + ${fmtInt(r.count_override > 0 ? r.count_override : r.cnt)} ${fmtInt(r.accounts)} ${fmt(r.avg_decode_speed)}${meter} ${fmt(r.avg_prefill_speed)} @@ -195,11 +232,59 @@ async function loadLeaderboard() { ${fmt(r.best_decode)} ${hwTag(r.hardware)} ${esc((r.last_tested || "").slice(5, 16))} - + + 详情 + + `; }).join(""); } +/* 搜索框:点击显示热门5个,输入过滤 */ +function renderSearchDrop(items) { + const drop = $("#lb-search-drop"); + if (!drop) return; + if (!items || !items.length) { drop.hidden = true; return; } + drop.hidden = false; + drop.innerHTML = items.map((r) => { + const key = modelKey(r.provider, r.model); + const intro = r.intro ? `${esc(r.intro)}` : `${esc(providerLabel(r.provider))}`; + return `
+
${esc(r.model)} 🔥${fmtInt(r.heat || 0)}
+
${intro} · 提交 ${fmtInt(r.cnt)} · 解码 ${fmt(r.avg_decode_speed)} tok/s
+
`; + }).join(""); +} + +function bindSearch() { + const input = $("#lb-search"); + const drop = $("#lb-search-drop"); + if (!input || !drop) return; + input.addEventListener("focus", () => { + // 点击输入框 → 显示最常见的5个速度项(按热度) + renderSearchDrop(lbAll.slice(0, 5)); + }); + input.addEventListener("input", () => { + lbQuery = input.value; + applyView(); + const q = lbQuery.trim().toLowerCase(); + if (q) renderSearchDrop(lbAll.filter((r) => + (r.model || "").toLowerCase().includes(q) || + (r.provider || "").toLowerCase().includes(q) || + ((r.intro || "") || "").toLowerCase().includes(q)).slice(0, 5)); + else renderSearchDrop(lbAll.slice(0, 5)); + }); + input.addEventListener("blur", () => { setTimeout(() => { drop.hidden = true; }, 150); }); + drop.addEventListener("mousedown", (e) => { + const item = e.target.closest(".search-item"); + if (!item) return; + e.preventDefault(); + const key = item.dataset.key; + const m = lbModels.find((x) => x.key === key); + if (m) location.href = `/model.html?provider=${encodeURIComponent(m.provider)}&model=${encodeURIComponent(m.model)}`; + }); +} + let lbChartUrl = ""; async function genLbChart() { const status = $("#lb-chart-status"); @@ -209,7 +294,7 @@ async function genLbChart() { try { // 重新请求指定模型的排行数据(对比图只包含所选模型) const models = [...lbSelected].join(","); - const d = await api(`/api/leaderboard?sort=avg_decode_speed&order=desc&models=${encodeURIComponent(models)}`); + const d = await api(`/api/leaderboard?sort=heat&order=desc&models=${encodeURIComponent(models)}`); if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; } const n = d.rows.length; // 运行速度对比:预填充(左轴·空心柱) + 解码(右轴·实心柱) + 首字(虚线),三色 @@ -243,6 +328,14 @@ async function genLbChart() { } catch (e) { status.textContent = "❌ " + e.message; } } +function dlChartUrl() { + if (!lbChartUrl) { toast("请先生成图表"); return null; } + const a = document.createElement("a"); + a.href = lbChartUrl; + a.download = "运行速度对比图.png"; + document.body.appendChild(a); a.click(); a.remove(); +} + /* ───────────────────────── 模型详情页 ───────────────────────── */ async function loadModel() { const params = new URLSearchParams(location.search); @@ -673,6 +766,8 @@ async function checkAdmin() { loadSubmissions(true); loadAccounts(); loadCaps(); + loadSpeedItems(); + loadSettings(); return true; } } catch (e) { /* not logged in */ } @@ -686,6 +781,8 @@ function switchTab(tab) { $("#tab-subs").hidden = tab !== "subs"; $("#tab-accs").hidden = tab !== "accs"; $("#tab-caps").hidden = tab !== "caps"; + $("#tab-speed").hidden = tab !== "speed"; + $("#tab-settings").hidden = tab !== "settings"; } async function loadSubmissions(resetPage = false) { @@ -784,6 +881,54 @@ async function loadCaps() { `).join(""); } +async function loadSpeedItems() { + const tb = $("#si tbody"); + if (!tb) return; + let list; + try { list = await api("/api/admin/speed-items"); } + catch (e) { tb.innerHTML = `加载失败:${esc(e.message)}`; return; } + if (!list.length) { + tb.innerHTML = '暂无速度项,点击「🔄 从提交同步」自动生成。'; + return; + } + tb.innerHTML = list.map((s) => ` + ${s.id} + ${esc(providerLabel(s.provider))} + ${esc(s.model)} + 🔥 ${fmtInt(s.heat)} + ${fmtInt(s.count)} + ${esc((s.intro || "—").slice(0, 22))} + ${esc(s.remark || "—")} + ${fmtInt(s.real_cnt)} + + + + + `).join(""); +} + +async function loadSettings() { + const s = await api("/api/admin/settings"); + if ($("#set-watermark")) $("#set-watermark").value = s.watermark || ""; + if ($("#set-topn")) $("#set-topn").value = s.top_n || 10; +} + +function editSpeedItem(btn) { + const provider = prompt("提供商:", btn.dataset.provider || "") ?? btn.dataset.provider; + const model = prompt("模型名称:", btn.dataset.model || ""); + if (model === null) return; + const heat = prompt("热度(数字):", btn.dataset.heat || "0"); + const count = prompt("个数(展示提交数,0=自动):", btn.dataset.count || "0"); + const intro = prompt("配置简介:", btn.dataset.intro || ""); + const remark = prompt("备注:", btn.dataset.remark || ""); + const body = { + provider: provider || "", model: model || "", + heat: parseInt(heat) || 0, count: parseInt(count) || 0, + intro: intro || "", remark: remark || "", + }; + api(`/api/admin/speed-items/${btn.dataset.editSi}`, "PUT", body).then(() => { toast("已保存"); loadSpeedItems(); }); +} + function openCapEditor(c) { capEditId = c ? c.id : null; $("#cap-edit-title").textContent = c ? `编辑能力测试 #${c.id}` : "新增能力测试"; @@ -832,16 +977,19 @@ function bindIndex() { $$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort))); const gen = $("#lb-chart-gen"); if (gen) gen.addEventListener("click", genLbChart); + const dl = $("#lb-chart-dl"); + if (dl) dl.addEventListener("click", () => { if (!lbChartUrl) { toast("请先生成图表"); return; } dlChartUrl(); }); const all = $("#lb-chart-all"); 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); + if (btn) { e.preventDefault(); cartAdd(btn.dataset.cart); } }); + bindSearch(); bindCartBar(); } @@ -889,6 +1037,14 @@ function bindCompare() { if (run) run.addEventListener("click", runCompare); const gen = $("#cmp-chart-gen"); if (gen) gen.addEventListener("click", genCmpChart); + const dl = $("#cmp-chart-dl"); + if (dl) dl.addEventListener("click", () => { + if (!cmpChartUrl) { toast("请先生成图表"); return; } + const a = document.createElement("a"); + a.href = cmpChartUrl; + a.download = "模型运行速度对比图.png"; + document.body.appendChild(a); a.click(); a.remove(); + }); renderCmpColToggles(); renderSeriesCfg(); bindCartBar(); @@ -1023,6 +1179,57 @@ function bindAdmin() { subClose.addEventListener("click", () => { $("#sub-mask").hidden = true; }); $("#sub-mask").addEventListener("click", (e) => { if (e.target === $("#sub-mask")) $("#sub-mask").hidden = true; }); } + + // 速度项管理 + const siAdd = $("#btn-si-add"); + if (siAdd) siAdd.addEventListener("click", () => { + const provider = prompt("提供商:", "") || ""; + const model = prompt("模型名称:", ""); + if (!model) { toast("请填写模型名称"); return; } + const heat = prompt("热度:", "0") || 0; + const count = prompt("个数(展示提交数,0=自动):", "0") || 0; + const intro = prompt("配置简介:", "") || ""; + api("/api/admin/speed-items", "POST", { provider, model, heat: parseInt(heat) || 0, count: parseInt(count) || 0, intro }) + .then((r) => { if (!r.ok) throw new Error(r.error || "添加失败"); toast("已添加"); loadSpeedItems(); }) + .catch((e) => toast(e.message)); + }); + const siSync = $("#btn-si-sync"); + if (siSync) siSync.addEventListener("click", async () => { + siSync.disabled = true; siSync.textContent = "同步中…"; + try { + const r = await api("/api/admin/speed-items/sync", "POST", {}); + toast(`已同步 ${r.added} 个速度项`); + loadSpeedItems(); + } catch (e) { toast("同步失败:" + e.message); } + siSync.disabled = false; siSync.textContent = "🔄 从提交同步"; + }); + const siTb = $("#si tbody"); + if (siTb) siTb.addEventListener("click", (e) => { + const ed = e.target.closest("[data-edit-si]"); + const dl = e.target.closest("[data-del-si]"); + if (ed) editSpeedItem(ed); + if (dl) { + if (confirm(`确定删除速度项 #${dl.dataset.delSi}?`)) { + api(`/api/admin/speed-items/${dl.dataset.delSi}`, "DELETE").then(() => { toast("已删除"); loadSpeedItems(); }); + } + } + }); + + // 设置 + const save = $("#btn-settings-save"); + if (save) save.addEventListener("click", async () => { + const body = { + watermark_text: $("#set-watermark").value.trim(), + top_n: parseInt($("#set-topn").value) || 10, + }; + try { + await api("/api/admin/settings", "PUT", body); + const st = $("#set-status"); + st.textContent = "✅ 已保存,刷新页面生效"; + setTimeout(() => { st.textContent = ""; }, 3000); + toast("设置已保存"); + } catch (e) { toast("保存失败:" + e.message); } + }); } /* ───────────────────────── 初始化 ───────────────────────── */