From 3a6a1e02a21ac55a52b9d2ad81d1b5bddb89084a Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Thu, 3 Sep 2026 00:50:05 +0800 Subject: [PATCH] =?UTF-8?q?v1.4.0=20=E5=85=A8=E5=B1=80=E6=90=9C=E7=B4=A2+?= =?UTF-8?q?=E6=B0=B4=E5=8D=B05=E9=A3=8E=E6=A0=BC+=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=AE=A1=E7=90=86+=E6=8E=92=E8=A1=8C?= =?UTF-8?q?=E5=88=97=E5=8F=AF=E9=85=8D=EF=BC=9A=E6=90=9C=E7=B4=A2=E6=A1=86?= =?UTF-8?q?=E6=8B=86=E5=88=86(=E7=BD=91=E9=A1=B5=E9=A1=B6=E9=83=A8?= =?UTF-8?q?=E5=B1=85=E4=B8=AD=3D=E9=80=89=E4=B8=AD=E8=B7=B3=E8=AF=A6?= =?UTF-8?q?=E6=83=85;=20=E6=8E=92=E8=A1=8C=E5=86=85=E6=90=9C=E7=B4=A2=3D?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=80=9F=E5=BA=A6=E9=A1=B9=E5=88=B0=E6=9C=AC?= =?UTF-8?q?=E5=8C=BA=E5=9F=9F=E6=9F=A5=E7=9C=8B,=E5=8F=AF=E2=9C=95?= =?UTF-8?q?=E7=A7=BB=E9=99=A4);=20=E6=B0=B4=E5=8D=B0=E9=A2=84=E7=BD=AE5?= =?UTF-8?q?=E7=A7=8D=E9=A3=8E=E6=A0=BC(=E5=8F=B3=E4=B8=8B=E8=A7=92/?= =?UTF-8?q?=E5=85=A8=E5=9B=BE=E6=96=9C=E7=BA=B9/=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E6=A8=AA=E6=9D=A1/=E5=9B=9B=E8=A7=92/=E5=AF=B9=E8=A7=92?= =?UTF-8?q?=E5=A4=A7=E5=AD=97)=E5=90=8E=E5=8F=B0=E5=8F=AF=E9=80=89;=20?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=80=9F=E5=BA=A6=E5=AF=B9=E6=AF=94=E5=9B=BE?= =?UTF-8?q?=E8=A6=81=E5=AF=B9=E6=AF=94=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=8F=AF=E5=8F=89=E6=8E=89/=E6=B8=85=E7=A9=BA=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=8E=BB=E9=99=A4/+=E5=AF=B9=E6=AF=94=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0;=20=E5=9B=BE=E8=A1=A8=E6=94=B9=E6=B7=B1=E8=89=B2?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E9=80=82=E9=85=8D=E6=B7=B1=E8=89=B2=E8=83=8C?= =?UTF-8?q?=E6=99=AF;=20=E6=8E=92=E8=A1=8C=E6=A6=9C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E6=A1=86=E6=9E=B6=E5=88=97,=E9=A6=96?= =?UTF-8?q?=E5=AD=97ms=E9=BB=98=E8=AE=A4=E9=9A=90=E8=97=8F,=E5=B9=B3?= =?UTF-8?q?=E5=9D=87=E8=A7=A3=E7=A0=81+=E6=9C=80=E4=BD=B3=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=E5=90=88=E5=B9=B6,=E7=A1=AC=E4=BB=B6+=E6=8E=A8?= =?UTF-8?q?=E7=90=86=E6=A1=86=E6=9E=B6=E5=90=88=E5=B9=B6,=E5=88=97?= =?UTF-8?q?=E6=98=BE=E7=A4=BA/=E5=90=88=E5=B9=B6=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E2=9A=99=EF=B8=8F=E8=AE=BE=E7=BD=AE=E5=8F=AF=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 95 ++++++++--- config.py | 14 +- database.py | 22 ++- static/admin.html | 28 +++- static/css/style.css | 21 +++ static/index.html | 35 ++-- static/js/app.js | 376 ++++++++++++++++++++++++++++--------------- 7 files changed, 407 insertions(+), 184 deletions(-) diff --git a/app.py b/app.py index 7e8e4e2..7a703cd 100644 --- a/app.py +++ b/app.py @@ -31,6 +31,11 @@ def _watermark_text(): return db.get_setting("watermark_text", config.DEFAULT_WATERMARK) +def _watermark_style(): + st = db.get_setting("watermark_style", config.DEFAULT_WATERMARK_STYLE) + return st if st in config.WATERMARK_STYLES else config.DEFAULT_WATERMARK_STYLE + + def _top_n(): try: return max(1, int(db.get_setting("top_n", str(config.DEFAULT_TOP_N)))) @@ -38,10 +43,22 @@ def _top_n(): return config.DEFAULT_TOP_N -def apply_watermark(img_bytes: bytes, text: str) -> bytes: - """给图片叠加简单水印:全图浅淡斜纹 + 右下角标注。颜色自适应当前背景(浅底深字/深底浅字)""" +def _column_config(): + out = {} + for k, default in config.COLUMN_DEFAULTS.items(): + v = db.get_setting(k, default) + out[k] = 1 if str(v).strip() in ("1", "true", "on") else 0 + return out + + +def apply_watermark(img_bytes: bytes, text: str, style: str = "") -> bytes: + """给图片叠加水印,5 种风格可选:corner右下角标注 / diagonal全图斜纹 / bottom底部横条 / corners四角标注 / diag-big对角大字 + 颜色自适应:浅底深字 / 深底浅字""" if not text: return img_bytes + style = style or config.DEFAULT_WATERMARK_STYLE + if style not in config.WATERMARK_STYLES: + style = config.DEFAULT_WATERMARK_STYLE try: from PIL import Image, ImageDraw, ImageFont except Exception: @@ -51,34 +68,59 @@ def apply_watermark(img_bytes: bytes, text: str) -> bytes: 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 + a_diag = 30 if not dark_bg else 26 + a_main = 130 if not dark_bg else 135 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) + font_big = ImageFont.truetype(_WATERMARK_FONT, 24) + font_huge = ImageFont.truetype(_WATERMARK_FONT, 60) except Exception: font = ImageFont.load_default() font_big = ImageFont.load_default() + font_huge = 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,)) + def _corner_label(): + 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 + (a_main,)) - # 右下角较清晰标注 - 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,)) + if style == "corner": + _corner_label() + elif style == "diagonal": + 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 + (a_diag,)) + elif style == "bottom": + # 底部半透明横条 + box = d.textbbox((0, 0), text, font=font_big) + tw = box[2] - box[0]; th = box[3] - box[1] + bh = th + 24 + d.rectangle([0, h - bh, w, h], fill=(0, 0, 0, 90) if not dark_bg else (255, 255, 255, 70)) + d.text((16, h - bh + 10), text, font=font_big, fill=fill + (a_main,)) + # 底部中央小字 + cx = (w - tw) // 2 + d.text((cx, h - 20), text, font=font_big, fill=fill + (110,)) + elif style == "corners": + box = d.textbbox((0, 0), text, font=font_big) + tw = box[2] - box[0]; th = box[3] - box[1] + for px, py in [(12, 12), (w - tw - 12, 12), (12, h - th - 12), (w - tw - 12, h - th - 12)]: + d.text((px, py), text, font=font_big, fill=fill + (a_main,)) + elif style == "diag-big": + box = d.textbbox((0, 0), text, font=font_huge) + tw = box[2] - box[0]; th = box[3] - box[1] + for i in range(-1, 2): + d.text(((w - tw) // 2 + i * 14, (h - th) // 2 + i * 28), text, font=font_huge, fill=fill + (38,)) + d.text(((w - tw) // 2, (h - th) // 2), text, font=font_huge, fill=fill + (80,)) + else: + _corner_label() out = Image.alpha_composite(img, overlay) buf = io.BytesIO() @@ -209,8 +251,10 @@ def leaderboard(): chart_rows = rows[: int(request.args.get("chart_top", 30) or 30)] chart_csv = _speed_csv(chart_rows) + cols = _column_config() return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv, "top_n": _top_n(), - "watermark": _watermark_text()}) + "watermark": _watermark_text(), "watermark_style": _watermark_style(), + "columns": cols}) def _speed_csv(chart_rows): @@ -271,7 +315,7 @@ def model_detail(): line_payload = { "data": line_csv, "chartType": "line", "title": "%s %s · 解码速度随上下文长度变化" % (provider or "", model), - "theme": "default", "showLegend": True, "showGrid": True, "showLabel": False, + "theme": "dark", "showLegend": True, "showGrid": True, "showLabel": False, "smoothLine": True, "dualYAxis": True, "leftAxisName": "预填充速度(tok/s)", "rightAxisName": "解码速度(tok/s)", "seriesTypes": ["line", "line"], "seriesAxis": [0, 1], @@ -341,7 +385,7 @@ def submission_chart(sid): payload = { "data": "\n".join(csv_lines), "chartType": "line", "title": "%s %s · 速度随上下文长度(提交#%d)" % (s["provider"], s["model"], sid), - "theme": "default", "showLegend": True, "showGrid": True, "showLabel": False, + "theme": "dark", "showLegend": True, "showGrid": True, "showLabel": False, "smoothLine": True, "dualYAxis": True, "leftAxisName": "预填充速度(tok/s)", "rightAxisName": "解码速度(tok/s)", "seriesTypes": ["line", "line"], "seriesAxis": [0, 1], @@ -389,7 +433,9 @@ def account_delete(aid): @app.route("/api/settings") def public_settings(): - return jsonify({"ok": True, "watermark": _watermark_text(), "top_n": _top_n()}) + return jsonify({"ok": True, "watermark": _watermark_text(), + "watermark_style": _watermark_style(), + "top_n": _top_n(), "columns": _column_config()}) @app.route("/api/admin/speed-items") @@ -437,14 +483,15 @@ def admin_speed_item_sync(): @admin_required def admin_settings_get(): return jsonify({"ok": True, "settings": db.get_all_settings(), - "watermark": _watermark_text(), "top_n": _top_n()}) + "watermark": _watermark_text(), "watermark_style": _watermark_style(), + "top_n": _top_n(), "columns": _column_config()}) @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"} + allow = {"watermark_text", "watermark_style", "top_n"} | set(config.COLUMN_DEFAULTS.keys()) for k, v in body.items(): if k in allow: db.set_setting(k, v) @@ -500,8 +547,8 @@ 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 - # 叠加水印(默认首页网址,后台可配置) - data = apply_watermark(resp.content, _watermark_text()) + # 叠加水印(默认首页网址,后台可配置文字+5种风格) + data = apply_watermark(resp.content, _watermark_text(), _watermark_style()) return send_file(io.BytesIO(data), mimetype="image/png") diff --git a/config.py b/config.py index d11327d..4eed49b 100644 --- a/config.py +++ b/config.py @@ -18,9 +18,21 @@ ADMIN_USER = "admin" ADMIN_PASSWORD = "admin123" SESSION_SECRET = "model-eval-site-session-secret" -# 图片水印(默认=首页网址,后台可改) +# 图片水印(默认=首页网址,后台可改;样式后台可选) DEFAULT_WATERMARK = "http://121.40.164.32:16066" +DEFAULT_WATERMARK_STYLE = "corner" # corner/diagonal/bottom/corners/diag-big DEFAULT_TOP_N = 10 # 排行榜默认显示热度最高的前 N 个 +# 排行榜列配置默认值(后台⚙️设置可改) +COLUMN_DEFAULTS = { + "lb_show_ttft": "0", # 是否显示首字ms + "lb_show_hardware": "1", # 是否显示硬件 + "lb_show_framework": "1", # 是否显示推理框架 + "lb_merge_decode": "1", # 平均解码+最佳解码合并为一列 + "lb_merge_hwfw": "1", # 硬件+推理框架合并在同一列 +} + +WATERMARK_STYLES = ["corner", "diagonal", "bottom", "corners", "diag-big"] + # data-chart-tool 图表服务地址(画速度对比图) CHART_API_BASE = "http://127.0.0.1:16016" diff --git a/database.py b/database.py index 5a54a27..05d76e3 100644 --- a/database.py +++ b/database.py @@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS speed_items( model TEXT DEFAULT '', heat INTEGER DEFAULT 0, intro TEXT DEFAULT '', + framework TEXT DEFAULT '', count INTEGER DEFAULT 0, remark TEXT DEFAULT '', updated_at TEXT DEFAULT (datetime('now','localtime')) @@ -97,6 +98,10 @@ def _migrate(conn): cols = [r[1] for r in cur.fetchall()] if "hardware" not in cols: conn.execute("ALTER TABLE submissions ADD COLUMN hardware TEXT DEFAULT ''") + cur = conn.execute("PRAGMA table_info(speed_items)") + scols = [r[1] for r in cur.fetchall()] + if "framework" not in scols: + conn.execute("ALTER TABLE speed_items ADD COLUMN framework TEXT DEFAULT ''") def init_db(): @@ -359,6 +364,7 @@ def leaderboard(sort="heat", order="desc", limit=500): "MAX(s.created_at) AS last_tested, " "COALESCE(MAX(si.heat), 0) AS heat, " "MAX(si.intro) AS intro, " + "MAX(si.framework) AS framework, " "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<>'' " @@ -546,8 +552,8 @@ def touch_speed_item(provider, model): 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)) + "INSERT INTO speed_items(provider,model,heat,intro,framework,count) VALUES(?,?,?,?,?,?)", + (provider, model, cnt, "", "", cnt)) conn.commit() return cur.lastrowid finally: @@ -580,8 +586,9 @@ def add_speed_item(provider, model, heat=0, intro="", count=0, remark=""): 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 "")) + "INSERT INTO speed_items(provider,model,heat,intro,framework,count,remark) VALUES(?,?,?,?,?,?,?)", + (provider, model, int(heat or 0), intro or "", (data.get("framework") or "").strip(), + int(count or 0), remark or "")) conn.commit() return cur.lastrowid finally: @@ -593,12 +600,13 @@ def update_speed_item(sid: int, data: dict): conn = _connect() try: conn.execute( - "UPDATE speed_items SET provider=?,model=?,heat=?,intro=?,count=?,remark=?," + "UPDATE speed_items SET provider=?,model=?,heat=?,intro=?,framework=?,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(), + (data.get("framework") or "").strip(), int(data.get("count") or 0), (data.get("remark") or "").strip(), sid)) @@ -632,8 +640,8 @@ def sync_speed_items(): if r0: continue conn.execute( - "INSERT INTO speed_items(provider,model,heat,intro,count) VALUES(?,?,?,?,?)", - (r["provider"], r["model"], r["c"], "", r["c"])) + "INSERT INTO speed_items(provider,model,heat,intro,framework,count) VALUES(?,?,?,?,?,?)", + (r["provider"], r["model"], r["c"], "", "", r["c"])) added += 1 conn.commit() return added diff --git a/static/admin.html b/static/admin.html index e0b5820..731ea9e 100644 --- a/static/admin.html +++ b/static/admin.html @@ -103,25 +103,43 @@
- -
#提供商模型🔥热度个数配置简介备注实际提交操作
加载中…
+ 配置简介推理框架备注实际提交操作 + 加载中…
diff --git a/static/css/style.css b/static/css/style.css index 4b9648b..5841709 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -266,3 +266,24 @@ textarea.items-editor { min-height: 160px; font-family: ui-monospace, monospace; .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; } +.search-item .si-act { float: right; font-size: 12px; color: var(--accent); } + +/* 网页顶部居中搜索 */ +.top-search { + position: relative; max-width: 640px; margin: 4px auto 18px; +} +.top-search input { + width: 100%; padding: 11px 16px; font-size: 14px; border-radius: 22px; + background: var(--panel2); border: 1px solid var(--border); color: var(--text); +} +.top-search input:focus { border-color: var(--accent); outline: none; } +.top-search .search-drop { max-width: 640px; } + +/* 已添加(钉住)的速度项 */ +.pinned-chip { + display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 14px; + background: rgba(255,180,60,.12); border: 1px solid rgba(255,180,60,.4); font-size: 12.5px; +} +.pinned-chip .x { color: var(--muted); cursor: pointer; font-weight: 700; } +.pinned-chip .x:hover { color: var(--danger); } +.pinned-empty { color: var(--muted); font-size: 12px; } diff --git a/static/index.html b/static/index.html index f5cd89b..4674b5d 100644 --- a/static/index.html +++ b/static/index.html @@ -18,6 +18,12 @@
+ + +
评测模型数
评测提交数
@@ -27,40 +33,27 @@
-

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

-
- +

🏆 模型运行速度排行 (默认显示热度最高的前10个;下方搜索框可把指定速度项添加进本区域查看)

+
+
+
- - - - - - - - - - - - - - - +
#提供商模型 / 配置简介🔥热度提交账号平均解码 tok/s平均预填充 tok/s首字 ms最佳解码 tok/s硬件最后测试操作
加载中…
-

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

-
选择要对比的模型(默认全部;也可点排行榜每行「+对比」加入对比车后到「⚖️ 模型对比」页对比):
+

📊 运行速度对比图 (要对比的模型列表:每项可✕掉,点排行榜「+对比」可添加,清空一键去除)

+
要对比的模型列表(默认对比全部):
- + diff --git a/static/js/app.js b/static/js/app.js index 6fd2f08..bf0006f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -47,6 +47,11 @@ function cartAdd(key) { const c = getCart(); if (!c.includes(key)) { c.push(key); saveCart(c); toast("+ 已加入对比"); } else { toast("已在对比中"); } + // 同步到首页运行速度对比图的要对比列表 + if (typeof lbChartSel !== "undefined" && lbChartSel) { + lbChartSel.add(key); + if (typeof renderLbChips === "function") renderLbChips(); + } renderCartBar(); } function cartRemove(key) { saveCart(getCart().filter((k) => k !== key)); renderCartBar(); } @@ -87,7 +92,7 @@ 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, + theme: opts.theme || "dark", showLegend: series.length > 1, showGrid: true, showLabel: opts.showLabel ?? false, smoothLine: true, dualYAxis: series.some((s) => s.axis === 1), leftAxisName: opts.leftAxisName || "预填充速度(tok/s)", @@ -112,13 +117,15 @@ function navActive() { }); } -/* ───────────────────────── 首页:排行(热度前10 + 搜索) ───────────────────────── */ +/* ───────────────────────── 首页:排行(热度前N + 搜索添加 + 动态列) ───────────────────────── */ let sortKey = "heat"; let sortOrder = "desc"; -let lbModels = []; // 全部模型(含 heat/intro/count_override),已按热度排序 -let lbAll = []; -let lbSelected = new Set(); // selected keys(对比图用) +let lbAll = []; // 全部模型(含 heat/intro/framework/count_override),按热度 +let lbModels = []; let lbTopN = 10; +let lbPinned = []; // 搜索框“添加”到本区域的模型 key(按添加顺序) +let lbChartSel = new Set(); // 运行速度对比图要对比的模型(默认全部) +let lbCols = { lb_show_ttft: 0, lb_show_hardware: 1, lb_show_framework: 1, lb_merge_decode: 1, lb_merge_hwfw: 1 }; let lbQuery = ""; const modelKey = (p, m) => `${p}|${m}`; @@ -130,6 +137,8 @@ const LB_SORT_GETTER = { last_tested: (r) => r.last_tested || "", }; +const fwTag = (fw) => (fw ? `⚙ ${esc(fw)}` : ""); + async function loadStats() { try { const s = await api("/api/stats"); @@ -156,125 +165,184 @@ function cmpLb(a, b) { return sortOrder === "desc" ? -c : c; } +/* 排行榜列定义(后台可配) */ +function lbColDefs() { + const cols = lbCols, d = []; + d.push({ type: "rank", label: "#", cls: "num" }); + d.push({ type: "provider", label: "提供商" }); + d.push({ type: "model", label: "模型 / 配置简介" }); + d.push({ type: "heat", label: "🔥热度", cls: "num", sort: "heat" }); + d.push({ type: "cnt", label: "提交", cls: "num", sort: "cnt" }); + d.push({ type: "accounts", label: "账号", cls: "num", sort: "accounts" }); + if (cols.lb_merge_decode) d.push({ type: "decode_merged", label: "解码 tok/s(均 / 最佳)", cls: "num", sort: "avg_decode_speed" }); + else { + d.push({ type: "avg_decode", label: "平均解码 tok/s", cls: "num", sort: "avg_decode_speed" }); + d.push({ type: "best_decode", label: "最佳解码 tok/s", cls: "num", sort: "best_decode" }); + } + d.push({ type: "prefill", label: "平均预填充 tok/s", cls: "num", sort: "avg_prefill_speed" }); + if (cols.lb_show_ttft) d.push({ type: "ttft", label: "首字 ms", cls: "num", sort: "avg_ttft_ms" }); + if (cols.lb_merge_hwfw) d.push({ type: "hwfw", label: "硬件 / 推理框架" }); + else { + if (cols.lb_show_hardware) d.push({ type: "hw", label: "硬件" }); + if (cols.lb_show_framework) d.push({ type: "fw", label: "推理框架" }); + } + d.push({ type: "last", label: "最后测试", cls: "num", sort: "last_tested" }); + d.push({ type: "ops", label: "操作" }); + return d; +} + +function lbCell(r, c, idx, maxDecode) { + const key = modelKey(r.provider, r.model); + switch (c.type) { + case "rank": return `${idx + 1}`; + case "provider": return `${esc(providerLabel(r.provider))}`; + case "model": { + const intro = r.intro ? `
${esc(r.intro)}
` : ""; + return `${esc(r.model)}${intro}`; + } + case "heat": return `🔥 ${fmtInt(r.heat || 0)}`; + case "cnt": return `${fmtInt(r.count_override > 0 ? r.count_override : r.cnt)}`; + case "accounts": return `${fmtInt(r.accounts)}`; + case "decode_merged": { + const meter = r.avg_decode_speed ? `
` : ""; + return `${fmt(r.avg_decode_speed)} / ${fmt(r.best_decode)}${meter}`; + } + case "avg_decode": { + const meter = r.avg_decode_speed ? `
` : ""; + return `${fmt(r.avg_decode_speed)}${meter}`; + } + case "best_decode": return `${fmt(r.best_decode)}`; + case "prefill": return `${fmt(r.avg_prefill_speed)}`; + case "ttft": return `${fmt(r.avg_ttft_ms)}`; + case "hwfw": return `${hwTag(r.hardware)} ${fwTag(r.framework)}`; + case "hw": return `${hwTag(r.hardware)}`; + case "fw": return `${fwTag(r.framework)}`; + case "last": return `${esc((r.last_tested || "").slice(5, 16))}`; + case "ops": return ` + 详情 + + `; + } + return ""; +} + +function renderLbTable(rows, pinnedSet) { + const thead = $("#lb thead"); + const tbody = $("#lb tbody"); + if (!thead || !tbody) return; + const defs = lbColDefs(); + thead.innerHTML = "" + defs.map((c) => { + const arrow = c.sort ? (c.sort === sortKey ? (sortOrder === "desc" ? " ▼" : " ▲") : "") : ""; + return `${esc(c.label)}${arrow}`; + }).join("") + ""; + if (!rows.length) { + tbody.innerHTML = `${pinnedSet ? "请在下方搜索框中搜索并添加速度项查看" : "暂无评测数据"}`; + return; + } + const maxDecode = Math.max(...rows.map((r) => r.avg_decode_speed || 0)); + tbody.innerHTML = rows.map((r, i) => { + const pinned = pinnedSet && pinnedSet.has(modelKey(r.provider, r.model)); + const mark = pinned ? ` 📌 已添加` : ""; + const cells = defs.map((c, ci) => { + if (ci === 0) return `${i + 1}${mark}`; + return lbCell(r, c, i, maxDecode); + }).join(""); + return `${cells}`; + }).join(""); + $$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort))); +} + +async function loadLeaderboard() { + const tbody = $("#lb tbody"); + if (!tbody) return; + tbody.innerHTML = '加载中…'; + let d; + try { + d = await api(`/api/leaderboard?sort=heat&order=desc&limit=500`); + } catch (e) { + tbody.innerHTML = `加载失败:${esc(e.message)}`; + return; + } + if (d.top_n) lbTopN = d.top_n; + if (d.columns) lbCols = d.columns; + 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 (!lbChartSel.size) lbModels.forEach((m) => lbChartSel.add(m.key)); // 默认对比全部 + renderLbChips(); + renderPinned(); + applyView(); +} + +/* 视图 = 已添加(pinned) + 热度前N */ +function applyView() { + const rows = []; + const seen = new Set(); + const pinSet = new Set(lbPinned); + for (const key of lbPinned) { + const m = lbAll.find((r) => modelKey(r.provider, r.model) === key); + if (m) { rows.push(m); seen.add(key); } + } + let top = [...lbAll].sort(cmpLb); + if (pinSet.size) top = top.filter((r) => !pinSet.has(modelKey(r.provider, r.model))); + rows.push(...top.slice(0, lbTopN)); + renderLbTable(rows, pinSet); +} + +function renderPinned() { + const bar = $("#lb-pinned"); + if (!bar) return; + if (!lbPinned.length) { bar.innerHTML = '尚未添加速度项 —— 用下方搜索框搜索并点「+ 添加」可把指定速度项固定到本区域查看'; return; } + bar.innerHTML = lbPinned.map((key) => { + const m = lbAll.find((r) => modelKey(r.provider, r.model) === key); + return `📌 ${esc(m ? m.model : cartModelName(key))} `; + }).join(""); +} + +/* 运行速度对比图:要对比的模型 chips(每项可✕,清空一键去除) */ function renderLbChips() { const box = $("#lb-chart-models"); if (!box) return; box.innerHTML = ""; if (!lbModels.length) { box.innerHTML = '暂无模型'; return; } for (const m of lbModels) { - const on = lbSelected.has(m.key); + const on = lbChartSel.has(m.key); const c = document.createElement("span"); c.className = "chip" + (on ? " on" : ""); - c.title = "点击选择/取消该模型"; - c.innerHTML = `${esc(m.model)}`; - c.addEventListener("click", () => { - if (lbSelected.has(m.key)) lbSelected.delete(m.key); else lbSelected.add(m.key); + c.title = "✕ 移出对比列表;在排行榜点「+对比」可再加回"; + c.innerHTML = `${esc(m.model)}`; + c.addEventListener("click", (e) => { + if (e.target.closest("[data-chipx]")) { + lbChartSel.delete(m.key); + c.classList.remove("on"); + return; + } + if (lbChartSel.has(m.key)) lbChartSel.delete(m.key); else lbChartSel.add(m.key); c.classList.toggle("on"); }); box.appendChild(c); } } -async function loadLeaderboard() { - const tb = $("#lb tbody"); - if (!tb) return; - tb.innerHTML = '加载中…'; - let d; - try { - d = await api(`/api/leaderboard?sort=heat&order=desc&limit=500`); - } catch (e) { - 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 (!rows.length) { - tb.innerHTML = '' + (q ? `未找到匹配「${esc(lbQuery)}」的模型` : "暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。") + ''; - return; - } - 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)}${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)} - ${fmt(r.avg_ttft_ms)} - ${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"); +/* 顶部居中搜索(选中跳详情) */ +function bindTopSearch() { + const input = $("#top-search"); + const drop = $("#top-search-drop"); if (!input || !drop) return; - input.addEventListener("focus", () => { - // 点击输入框 → 显示最常见的5个速度项(按热度) - renderSearchDrop(lbAll.slice(0, 5)); - }); + const show = (items) => { + if (!items || !items.length) { drop.hidden = true; return; } + drop.hidden = false; + drop.innerHTML = items.map((r) => `
+
${esc(r.model)} 🔥${fmtInt(r.heat || 0)}
+
${esc(r.intro || providerLabel(r.provider))} · 提交 ${fmtInt(r.cnt)} · 解码 ${fmt(r.avg_decode_speed)} tok/s · ${r.hardware ? esc(r.hardware) : ""} ${r.framework ? esc(r.framework) : ""}
+
`).join(""); + }; + input.addEventListener("focus", () => show(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)); + const q = input.value.trim().toLowerCase(); + const list = q ? lbAll.filter((r) => (r.model + " " + r.provider + " " + (r.intro || "")).toLowerCase().includes(q)).slice(0, 5) : lbAll.slice(0, 5); + show(list); }); - input.addEventListener("blur", () => { setTimeout(() => { drop.hidden = true; }, 150); }); + input.addEventListener("blur", () => setTimeout(() => { drop.hidden = true; }, 150)); drop.addEventListener("mousedown", (e) => { const item = e.target.closest(".search-item"); if (!item) return; @@ -285,19 +353,62 @@ function bindSearch() { }); } +/* 排行内搜索(点击“添加”把速度项固定到本区域) */ +function bindLbSearch() { + const input = $("#lb-search"); + const drop = $("#lb-search-drop"); + if (!input || !drop) return; + const show = (items) => { + if (!items || !items.length) { drop.hidden = true; return; } + drop.hidden = false; + drop.innerHTML = items.map((r) => { + const key = modelKey(r.provider, r.model); + const pinned = lbPinned.includes(key); + return `
+
${esc(r.model)} 🔥${fmtInt(r.heat || 0)}
+
${esc(r.intro || providerLabel(r.provider))} · 解码 ${fmt(r.avg_decode_speed)} tok/s
+ ${pinned ? "✓ 已添加" : "+ 添加"} +
`; + }).join(""); + }; + input.addEventListener("focus", () => show(lbAll.slice(0, 5))); + input.addEventListener("input", () => { + const q = input.value.trim().toLowerCase(); + const list = q ? lbAll.filter((r) => (r.model + " " + r.provider + " " + (r.intro || "")).toLowerCase().includes(q)).slice(0, 6) : lbAll.slice(0, 5); + show(list); + }); + input.addEventListener("blur", () => setTimeout(() => { drop.hidden = true; }, 150)); + drop.addEventListener("mousedown", (e) => { + const item = e.target.closest("[data-add]"); + if (!item) return; + e.preventDefault(); + const key = item.dataset.add; + if (!lbPinned.includes(key)) { + lbPinned.push(key); + toast("📌 已添加到本区域"); + renderPinned(); + applyView(); + } + }); + // 钉住项的 ✕ + const bar = $("#lb-pinned"); + if (bar) bar.addEventListener("click", (e) => { + const x = e.target.closest("[data-pinx]"); + if (x) { lbPinned = lbPinned.filter((k) => k !== x.dataset.pinx); renderPinned(); applyView(); } + }); +} + let lbChartUrl = ""; async function genLbChart() { const status = $("#lb-chart-status"); const imgWrap = $("#lb-chart-img"); - if (!lbSelected.size) { status.textContent = "请至少选择一个模型"; return; } + if (!lbChartSel.size) { status.textContent = "请先添加要对比的模型(点排行榜「+对比」或点「✅ 全选」)"; return; } status.textContent = "⏳ 正在生成..."; try { - // 重新请求指定模型的排行数据(对比图只包含所选模型) - const models = [...lbSelected].join(","); + const models = [...lbChartSel].join(","); 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; - // 运行速度对比:预填充(左轴·空心柱) + 解码(右轴·实心柱) + 首字(虚线),三色 const series = RUN_SERIES.map((s, i) => ({ ...s, show: true, type: ["bar", "bar", "line"][i], @@ -888,7 +999,7 @@ async function loadSpeedItems() { try { list = await api("/api/admin/speed-items"); } catch (e) { tb.innerHTML = `加载失败:${esc(e.message)}`; return; } if (!list.length) { - tb.innerHTML = '暂无速度项,点击「🔄 从提交同步」自动生成。'; + tb.innerHTML = '暂无速度项,点击「🔄 从提交同步」自动生成。'; return; } tb.innerHTML = list.map((s) => ` @@ -898,10 +1009,11 @@ async function loadSpeedItems() { 🔥 ${fmtInt(s.heat)} ${fmtInt(s.count)} ${esc((s.intro || "—").slice(0, 22))} + ${s.framework ? `⚙ ${esc(s.framework)}` : "—"} ${esc(s.remark || "—")} ${fmtInt(s.real_cnt)} - + `).join(""); @@ -910,7 +1022,12 @@ async function loadSpeedItems() { async function loadSettings() { const s = await api("/api/admin/settings"); if ($("#set-watermark")) $("#set-watermark").value = s.watermark || ""; + if ($("#set-wmstyle")) $("#set-wmstyle").value = s.watermark_style || "corner"; if ($("#set-topn")) $("#set-topn").value = s.top_n || 10; + const cols = s.columns || {}; + $$("#set-cols input[data-col]").forEach((cb) => { + cb.checked = !!cols[cb.dataset.col]; + }); } function editSpeedItem(btn) { @@ -920,11 +1037,12 @@ function editSpeedItem(btn) { const heat = prompt("热度(数字):", btn.dataset.heat || "0"); const count = prompt("个数(展示提交数,0=自动):", btn.dataset.count || "0"); const intro = prompt("配置简介:", btn.dataset.intro || ""); + const framework = prompt("推理框架(如 vLLM / llama.cpp / TensorRT-LLM / SGLang):", btn.dataset.framework || ""); const remark = prompt("备注:", btn.dataset.remark || ""); const body = { provider: provider || "", model: model || "", heat: parseInt(heat) || 0, count: parseInt(count) || 0, - intro: intro || "", remark: remark || "", + intro: intro || "", framework: framework || "", remark: remark || "", }; api(`/api/admin/speed-items/${btn.dataset.editSi}`, "PUT", body).then(() => { toast("已保存"); loadSpeedItems(); }); } @@ -974,22 +1092,25 @@ async function saveCapEditor() { /* ───────────────────────── 事件绑定 ───────────────────────── */ 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(); }); - // +对比按钮 / 详情链接 + // 顶部居中搜索(跳详情) + bindTopSearch(); + // 排行内搜索(添加速度项到本区域) + bindLbSearch(); + // 排行榜行:+对比 / 详情 const tb = $("#lb tbody"); if (tb) tb.addEventListener("click", (e) => { const btn = e.target.closest("[data-cart]"); if (btn) { e.preventDefault(); cartAdd(btn.dataset.cart); } }); - bindSearch(); + // 运行速度对比图:生成 / 下载 / 全选 / 清空 + 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) => lbChartSel.add(m.key)); renderLbChips(); }); + const clear = $("#lb-chart-clear"); + if (clear) clear.addEventListener("click", () => { lbChartSel.clear(); renderLbChips(); }); bindCartBar(); } @@ -1189,7 +1310,8 @@ function bindAdmin() { 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 }) + const framework = prompt("推理框架(如 vLLM / llama.cpp):", "") || ""; + api("/api/admin/speed-items", "POST", { provider, model, heat: parseInt(heat) || 0, count: parseInt(count) || 0, intro, framework }) .then((r) => { if (!r.ok) throw new Error(r.error || "添加失败"); toast("已添加"); loadSpeedItems(); }) .catch((e) => toast(e.message)); }); @@ -1220,8 +1342,10 @@ function bindAdmin() { if (save) save.addEventListener("click", async () => { const body = { watermark_text: $("#set-watermark").value.trim(), + watermark_style: $("#set-wmstyle").value, top_n: parseInt($("#set-topn").value) || 10, }; + $$("#set-cols input[data-col]").forEach((cb) => { body[cb.dataset.col] = cb.checked ? "1" : "0"; }); try { await api("/api/admin/settings", "PUT", body); const st = $("#set-status");