From c6eef5e67fe0dafaba4c6d0b7b3d074596320bee Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Wed, 2 Sep 2026 13:38:07 +0800 Subject: [PATCH] =?UTF-8?q?v1.1.0=20=E5=90=8E=E5=8F=B0=E7=AE=A1=E7=90=86+?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E6=B5=8B=E8=AF=95+=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=AF=B9=E6=AF=94+=E7=A1=AC=E4=BB=B6=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=EF=BC=9A=E9=A2=9C=E8=89=B2=E5=AF=B9=E6=AF=94=E5=BA=A6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=EF=BC=9B=E6=8F=90=E4=BA=A4/=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=A7=BB=E5=88=B0=E5=90=8E=E5=8F=B0(admin/ad?= =?UTF-8?q?min123,=20session)=EF=BC=9B=E7=A7=BB=E9=99=A4=E5=89=8D=E5=8F=B0?= =?UTF-8?q?=E6=8F=90=E4=BA=A4/=E8=B4=A6=E5=8F=B7=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E4=B8=8E=E4=B8=80=E9=94=AE=E5=90=8C=E6=AD=A5=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=EF=BC=9B=E5=AF=B9=E6=AF=94=E5=9B=BE=E9=BB=98=E8=AE=A4=E5=85=A8?= =?UTF-8?q?=E9=83=A8+=E5=8F=AF=E6=8C=87=E5=AE=9A=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=EF=BC=9B=E6=96=B0=E5=A2=9E=E6=A8=A1=E5=9E=8B=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=A8=A1=E5=9D=97(=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=BC=96=E8=BE=91/=E5=89=8D=E5=8F=B0=E5=B1=95=E7=A4=BA)?= =?UTF-8?q?=EF=BC=9B=E6=A8=A1=E5=9E=8B=E5=AF=B9=E6=AF=94=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=A1=AC=E4=BB=B6=E6=98=BE=E7=A4=BA+=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E9=A1=B9=E5=8F=AF=E5=8E=BB=E7=95=99=EF=BC=9B=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=B8=A6=E7=A1=AC=E4=BB=B6=E5=AD=97=E6=AE=B5(admin=E5=8F=AF?= =?UTF-8?q?=E6=94=B9)=EF=BC=9Bllm-speed-tester=20=E6=96=B0=E5=A2=9E=20/api?= =?UTF-8?q?/gpu=20=E6=8E=A2=E6=B5=8B+=E5=8F=91=E9=80=81=E5=B8=A6=E7=A1=AC?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 88 +++++-- README.md | 57 +++-- app.py | 236 ++++++++++++++++-- config.py | 5 + database.py | 198 +++++++++++++-- static/accounts.html | 39 --- static/admin.html | 136 ++++++++++ static/capabilities.html | 41 +++ static/compare.html | 68 +++++ static/css/style.css | 136 +++++++--- static/index.html | 18 +- static/js/app.js | 526 ++++++++++++++++++++++++++++++++++----- static/model.html | 11 +- static/submissions.html | 57 ----- 14 files changed, 1331 insertions(+), 285 deletions(-) delete mode 100644 static/accounts.html create mode 100644 static/admin.html create mode 100644 static/capabilities.html create mode 100644 static/compare.html delete mode 100644 static/submissions.html diff --git a/API.md b/API.md index 164afa9..a4dff19 100644 --- a/API.md +++ b/API.md @@ -1,6 +1,6 @@ # 模型评测网站 API 文档 -Base: `http://:16066`(端口 16066) +Base: `http://:16066`(端口 16066)。后台管理接口需先登录(session cookie)。 ## 1. 接收提交(llm-speed-tester 一键发送) @@ -14,19 +14,17 @@ curl -X POST http://127.0.0.1:16066/api/submit \ -H "X-Token: meval16066" \ -d '{ "account": "性能评测组", # 账号,不存在自动创建 - "source_test_id": 25, # llm-speed-tester 测试ID + "source_test_id": 25, "source_site": "llm-speed-tester", "provider": "openai", "model": "nvidia/Qwen3.6-35B-A3B-NVFP4", + "hardware": "NVIDIA A100 80G x8", # 硬件/GPU 描述(随提交展示) "test_name": "我的测试", "summary": { "samples_ok":2, "samples_total":2, "avg_ttft_ms":180, "avg_prefill_speed":320.5, "avg_decode_speed":88.2, "avg_output_tokens":128, "avg_total_ms":2500, "concurrency_levels":[1], - "by_length": {"2048": {"avg_ttft_ms":150,"avg_prefill_speed":350, - "avg_decode_speed":92,"avg_prompt_tokens":1500, - "avg_output_tokens":128,"avg_total_ms":2000}}, - "by_concurrency": {} }, + "by_length": {...}, "by_concurrency": {} }, "gen": {"context_lengths":[2048,8192], "max_tokens":128, "samples":2, "concurrency_levels":[1]}, "runs": [] }' @@ -35,55 +33,91 @@ curl -X POST http://127.0.0.1:16066/api/submit \ 响应: ```json -{ "ok": true, "id": 21, "account_id": 2, "account": "性能评测组", +{ "ok": true, "id": 22, "account_id": 2, "account": "性能评测组", "model": "nvidia/Qwen3.6-35B-A3B-NVFP4", "url": "/model.html?provider=openai&model=nvidia/Qwen3.6-35B-A3B-NVFP4" } ``` -## 2. 排行 / 统计 +## 2. 后台登录 ```bash -# 模型速度排行(按 provider+model 聚合) -GET /api/leaderboard?sort=avg_decode_speed&order=desc&limit=200 -# sort 可选: avg_decode_speed / avg_prefill_speed / avg_ttft_ms / best_decode / cnt / accounts / last_tested - -GET /api/stats # 统计卡片 -GET /api/health # 健康检查 + stats +POST /api/admin/login {"username":"admin","password":"admin123"} # 成功后下发 session cookie +GET /api/admin/session # {"admin":true} +POST /api/admin/logout ``` -## 3. 模型详情 +## 3. 排行 / 统计 / 对比 + +```bash +# 模型速度排行(含硬件);models 可指定对比哪些(provider|model 逗号分隔) +GET /api/leaderboard?sort=avg_decode_speed&order=desc&limit=200 +GET /api/leaderboard?models=openai|qwen3.5-plus,autodl|glm-5.3-flash +# sort 可选: avg_decode_speed / avg_prefill_speed / avg_ttft_ms / best_decode / cnt / accounts / last_tested + +# 模型对比(默认全部模型);chart_metric: avg_decode_speed|avg_prefill_speed|avg_ttft_ms|best_decode;chart_type: bar|line +GET /api/compare?models=openai|qwen3.5-plus,google|gemini-2.0-flash&chart_metric=avg_ttft_ms&chart_type=bar + +GET /api/stats # 统计(模型/提交/账号/采样/能力测试数) +GET /api/health +``` + +## 4. 模型详情 ```bash GET /api/model?provider=openai&model=nvidia/Qwen3.6-35B-A3B-NVFP4 ``` -返回:该模型全部提交(submissions,含账号)+ 按上下文长度聚合(by_length)+ 折线图数据(line_csv/line_payload)。 +返回该模型全部提交(含账号、硬件)+ 按上下文长度聚合 + 折线图数据。 -## 4. 提交管理 +## 5. 提交(前台只读;增删改走后端) ```bash GET /api/submissions?page=1&page_size=20&q=关键词&account_id=2&model=xxx&provider=xxx -GET /api/submissions/ # 单条详情 -DELETE /api/submissions/ # 删除 -GET /api/submissions//chart # 上下文长度→速度折线图 PNG +GET /api/submissions/ +DELETE /api/submissions/ # 后台 +PUT /api/submissions//hardware {"hardware":"NVIDIA A100"} # 后台,改硬件 +GET /api/submissions//chart # 长度→速度折线图 PNG ``` -## 5. 账号管理 +## 6. 账号管理(后台) ```bash GET /api/accounts -POST /api/accounts {"name":"xxx","remark":"yyy"} # 新增(重名 400) -PUT /api/accounts/ {"name":"xxx","remark":"yyy"} # 改名/备注 -DELETE /api/accounts/ # 删除(连带其提交) +POST /api/accounts {"name":"xxx","remark":"yyy"} +PUT /api/accounts/ {"name":"xxx","remark":"yyy"} +DELETE /api/accounts/ ``` -## 6. 其他 +## 7. 模型能力测试(前台只读;内容后台编辑) + +```bash +# 前台:只返回 status=1 启用的能力测试 +GET /api/capabilities + +# 后台:全部(含停用)+ 增删改 +GET /api/admin/capabilities +POST /api/admin/capabilities +PUT /api/admin/capabilities/ +DELETE /api/admin/capabilities/ + +# 能力测试对象:{name, category, icon, description, sort, status, +# items: [ {title, prompt, expect}, ... ]} +``` + +## 8. 演示数据(后台) + +```bash +POST /api/seed-demo # 🎲 评测演示数据(含硬件) +POST /api/seed-capabilities # 🎲 能力测试演示数据 +``` + +## 9. 其他 ```bash -POST /api/seed-demo # 🎲 生成演示评测数据 POST /api/chart # 图表代理(转发 data-chart-tool 16016,返回 PNG) ``` ## 错误 -所有接口失败返回 `{"ok": false, "error": "..."}` + 对应 HTTP 状态码。 +所有接口失败返回 `{"ok": false, "error": "..."}` + 对应 HTTP 状态码; +后台接口未登录返回 401。 diff --git a/README.md b/README.md index 43d18b3..050268c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ⚡ 模型评测网站(model-eval-site) -聚合展示各种大模型的**运行速度评测**(解码 / 预填充 / 首字延迟),并支持从 +聚合展示各种大模型的**运行速度评测**(解码 / 预填充 / 首字延迟)+ **模型能力测试**模块,并支持从 [llm-speed-tester](../llm-speed-tester/) 一键把测试结果发送到**对应账号**下展示。 - 端口:**16066** @@ -10,38 +10,53 @@ | 页面 | 说明 | |------|------| -| `/`(index.html) | 🏆 模型速度排行:按模型+提供商聚合所有提交,表头可排序 + 解码速度 TOP20 对比图 | -| `/model.html` | 单模型详情:按上下文长度的速度折线图 + 各账号提交明细 | -| `/submissions.html` | 📥 提交管理:分页/搜索/详情弹窗/删除 + 「🎲 生成演示数据」 | -| `/accounts.html` | 👥 账号管理:账号列表(含提交数)/ 新增 / 编辑 / 删除 | +| `/`(index.html) | 🏆 模型速度排行:按模型+提供商聚合,表头可排序 + **硬件显示** + 解码速度对比图(**默认对比全部模型,可勾选指定模型**后重新生成) | +| `/capabilities.html` | 🧪 **模型能力测试模块**:按分类展示测试卡片,点击展开测试项(**内容由后台管理编辑**) | +| `/compare.html` | ⚖️ **模型对比**:默认对比全部模型,可勾选模型;**支持硬件显示**;**下方勾选可去留对比项**;可选指标/柱状/折线图 | +| `/model.html` | 单模型详情:按上下文长度的速度折线图 + 各账号提交明细(含硬件) | +| `/admin.html` | 🔐 **后台管理**(admin/admin123):提交管理 / 账号管理 / 能力测试管理 | + +## 后台管理(/admin.html) + +默认账号 `admin` / 密码 `admin123`(config.py 可改)。登录后可: + +- **📥 提交管理**:分页/搜索(模型/账号/硬件)/查看详情/删除/**点击硬件标签直接修改硬件**/🎲生成演示数据 +- **👥 账号管理**:新增/编辑/删除账号(删除连带其提交) +- **🧪 能力测试管理**:新增/编辑/删除能力测试模块(名称/分类/图标/说明/测试项JSON/排序/启停),前台只读展示 ## 与 llm-speed-tester 联动 -1. 在 llm-speed-tester 左侧「📤 评测站同步」填评测站地址(默认 `http://127.0.0.1:16066`)和账号名 -2. 测试完成后,在测试历史行点「📤 发送」,或在详情弹窗点「📤 发送到评测站」 -3. 结果以 **POST /api/submit** 提交,账号不存在自动创建,提交后排行榜实时更新 +1. 在 llm-speed-tester 左侧「📤 评测站同步」填评测站地址(默认 `http://127.0.0.1:16066`)+ 账号名 + **硬件/GPU**(可🔍自动探测) +2. 测试完成后,历史行「📤 发送」或详情弹窗「📤 发送到评测站」一键提交(含硬件信息) +3. 结果以 **POST /api/submit** 提交,账号不存在自动创建,排行榜/对比实时更新 ## API | 方法 | 路径 | 说明 | |------|------|------| -| POST | `/api/submit` | 接收 llm-speed-tester 提交(头 `X-Token: meval16066`) | -| GET | `/api/health` | 健康检查 + 统计 | -| GET | `/api/stats` | 统计卡片(模型数/提交数/账号数/采样数) | -| GET | `/api/leaderboard` | 模型速度排行(可 `sort`/`order`/`limit`) | -| GET | `/api/model` | 单模型详情(`provider` + `model` 参数) | -| GET | `/api/submissions` | 提交列表(`page`/`page_size`/`q`/`account_id`/`model`/`provider`) | +| POST | `/api/submit` | 接收 llm-speed-tester 提交(头 `X-Token: meval16066`,含 hardware) | +| POST | `/api/admin/login` | 后台登录(session) | +| GET | `/api/admin/session` | 检查后台登录态 | +| POST | `/api/admin/logout` | 退出后台 | +| GET | `/api/health` / `/api/stats` | 健康检查 + 统计(含能力测试数) | +| GET | `/api/leaderboard` | 模型速度排行(可 `sort`/`order`/`limit`/`models` 指定对比) | +| GET | `/api/compare` | 模型对比(`models`/`chart_metric`/`chart_type`) | +| GET | `/api/model` | 单模型详情(`provider`+`model`) | +| GET | `/api/submissions` | 提交列表(只读) | | GET | `/api/submissions/` | 单条提交详情 | -| DELETE | `/api/submissions/` | 删除提交 | -| GET | `/api/submissions//chart` | 单条提交:上下文长度→速度折线图 PNG | -| GET/POST | `/api/accounts` | 账号列表 / 新增 | -| PUT/DELETE | `/api/accounts/` | 改账号 / 删账号(连带删其提交) | -| POST | `/api/seed-demo` | 🎲 生成演示评测数据(便于看排行效果) | -| POST | `/api/chart` | 图表代理(转发 data-chart-tool 16016) | +| DELETE | `/api/submissions/` | 删除提交(后台) | +| PUT | `/api/submissions//hardware` | 修改提交硬件(后台) | +| GET | `/api/submissions//chart` | 单条提交:长度→速度折线图 PNG | +| GET/POST/PUT/DELETE | `/api/accounts...` | 账号管理(后台) | +| GET | `/api/capabilities` | 能力测试列表(前台只读,仅启用项) | +| GET/POST/PUT/DELETE | `/api/admin/capabilities...` | 能力测试管理(后台) | +| POST | `/api/seed-demo` | 🎲 生成演示评测数据(后台) | +| POST | `/api/seed-capabilities` | 🎲 生成演示能力测试(后台) | +| POST | `/api/chart` | 图表代理(data-chart-tool 16016) | ## 数据 -- `data/model_eval.db`:SQLite(accounts / submissions) +- `data/model_eval.db`:SQLite(accounts / submissions / capabilities) - 详细字段见 `database.py` ## 部署 diff --git a/app.py b/app.py index 7dba527..81521e1 100644 --- a/app.py +++ b/app.py @@ -2,19 +2,24 @@ """模型评测网站 - Flask 主应用 功能: 1. 聚合展示各种模型的运行速度(解码/预填充/首字延迟 排行 + 图表) - 2. 账号体系:每个提交挂到对应账号下 - 3. POST /api/submit 接收 llm-speed-tester 一键发送的测试结果 + 2. 模型对比(支持硬件显示 + 对比项可去留) + 3. 模型能力测试模块(内容由后台管理编辑) + 4. 账号体系:每个提交挂到对应账号下 + 5. POST /api/submit 接收 llm-speed-tester 一键发送的测试结果 + 6. 后台管理(/admin.html):提交/账号/能力测试/硬件 管理 """ import io +from functools import wraps import requests -from flask import Flask, jsonify, request, send_file, send_from_directory +from flask import Flask, jsonify, request, send_file, send_from_directory, session import config import database as db app = Flask(__name__, static_folder="static", static_url_path="") app.json.ensure_ascii = False +app.secret_key = config.SESSION_SECRET db.init_db() @@ -27,6 +32,15 @@ def _cors(resp): return resp +def admin_required(f): + @wraps(f) + def wrapper(*a, **kw): + if not session.get("admin"): + return jsonify({"ok": False, "error": "未登录或会话过期,请先登录后台"}), 401 + return f(*a, **kw) + return wrapper + + @app.route("/") def index(): return send_from_directory(app.static_folder, "index.html") @@ -39,6 +53,28 @@ def health(): "stats": db.get_stats()}) +# ───────────────────────── 后台登录 ───────────────────────── + +@app.route("/api/admin/login", methods=["POST"]) +def admin_login(): + body = request.get_json(force=True) or {} + if body.get("username") == config.ADMIN_USER and body.get("password") == config.ADMIN_PASSWORD: + session["admin"] = True + return jsonify({"ok": True}) + return jsonify({"ok": False, "error": "账号或密码错误"}), 401 + + +@app.route("/api/admin/logout", methods=["POST"]) +def admin_logout(): + session.clear() + return jsonify({"ok": True}) + + +@app.route("/api/admin/session") +def admin_session(): + return jsonify({"ok": True, "admin": bool(session.get("admin"))}) + + # ───────────────────────── 接收 llm-speed-tester 提交 ───────────────────────── @app.route("/api/submit", methods=["POST"]) @@ -83,10 +119,26 @@ def leaderboard(): order = request.args.get("order", "desc") limit = min(int(request.args.get("limit", 200) or 200), 500) rows = db.leaderboard(sort=sort, order=order, limit=limit) - # 按排行榜生成柱状图 CSV(前 20 名,解码速度) - top = rows[:20] + + # 可选:指定对比哪些模型(provider|model 或 仅 model,逗号分隔) + sel = request.args.get("models", "") + if sel: + keys = set() + for item in sel.split(","): + item = item.strip() + if not item: + continue + if "|" in item: + p, m = item.split("|", 1) + keys.add((p.strip(), m.strip())) + else: + 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)"] - for r in top: + 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"]), @@ -94,7 +146,7 @@ def leaderboard(): chart_csv = "\n".join(csv_lines) bar_payload = { "data": chart_csv, "chartType": "bar", - "title": "模型解码速度排行 TOP%s(tok/s)" % len(top), + "title": "模型解码速度对比(%d 个模型)" % len(chart_rows), "theme": "default", "showLegend": True, "showGrid": True, "showLabel": True, "seriesTypes": ["bar", "bar", "line"], "seriesAxis": [0, 0, 1], @@ -105,6 +157,56 @@ def leaderboard(): "bar_payload": bar_payload}) +# ───────────────────────── 模型对比(硬件 + 可去留对比项) ───────────────────────── + +@app.route("/api/compare") +def compare(): + """返回指定模型(默认全部)的聚合对比行 + 图表数据。 + models: 逗号分隔 provider|model;chart_metric: 解码/预填充/首字;chart_type: bar/line + """ + sel = request.args.get("models", "").strip() + keys = [] + if sel: + for item in sel.split(","): + item = item.strip() + if not item: + continue + if "|" in item: + p, m = item.split("|", 1) + keys.append((p.strip(), m.strip())) + else: + keys.append(("", item)) + 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) + + # 图表数据(按所选指标) + 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}) + + @app.route("/api/model") def model_detail(): provider = request.args.get("provider", "") @@ -114,7 +216,6 @@ def model_detail(): d = db.get_model_detail(provider, model) if not d["submissions"]: return jsonify({"ok": False, "error": "该模型暂无评测数据"}), 404 - # 按上下文长度聚合 → 折线图 CSV csv_lines = ["上下文长度(tok), 解码速度(tok/s), 预填充速度(tok/s)"] for r in d["by_length"]: csv_lines.append("%d, %s, %s" % (r["length"], r["avg_decode_speed"], r["avg_prefill_speed"])) @@ -134,7 +235,7 @@ def model_detail(): return jsonify({"ok": True, **d}) -# ───────────────────────── 提交管理 ───────────────────────── +# ───────────────────────── 提交(前台只读,管理走后端) ───────────────────────── @app.route("/api/submissions") def submissions(): @@ -158,11 +259,20 @@ def submission_detail(sid): @app.route("/api/submissions/", methods=["DELETE"]) +@admin_required def submission_delete(sid): db.delete_submission(sid) return jsonify({"ok": True}) +@app.route("/api/submissions//hardware", methods=["PUT"]) +@admin_required +def submission_hardware(sid): + body = request.get_json(force=True) or {} + db.update_submission_hardware(sid, body.get("hardware", "")) + return jsonify({"ok": True}) + + @app.route("/api/submissions//chart") def submission_chart(sid): """单条提交:上下文长度 → 解码/预填充速度 折线图""" @@ -193,14 +303,16 @@ def submission_chart(sid): return _chart_proxy(payload) -# ───────────────────────── 账号管理 ───────────────────────── +# ───────────────────────── 账号(后台管理) ───────────────────────── @app.route("/api/accounts") +@admin_required def accounts(): return jsonify(db.list_accounts()) @app.route("/api/accounts", methods=["POST"]) +@admin_required def account_add(): body = request.get_json(force=True) or {} try: @@ -211,6 +323,7 @@ def account_add(): @app.route("/api/accounts/", methods=["PUT"]) +@admin_required def account_update(aid): body = request.get_json(force=True) or {} db.rename_account(aid, body.get("name", ""), body.get("remark", "")) @@ -218,11 +331,52 @@ def account_update(aid): @app.route("/api/accounts/", methods=["DELETE"]) +@admin_required def account_delete(aid): db.delete_account(aid) return jsonify({"ok": True}) +# ───────────────────────── 模型能力测试模块(前台只读,内容后台编辑) ───────────────────────── + +@app.route("/api/capabilities") +def capabilities_list(): + return jsonify(db.list_capabilities(status_only=True)) + + +@app.route("/api/admin/capabilities") +@admin_required +def admin_capabilities_list(): + return jsonify(db.list_capabilities(status_only=False)) + + +@app.route("/api/admin/capabilities", methods=["POST"]) +@admin_required +def admin_capability_add(): + body = request.get_json(force=True) or {} + if not (body.get("name") or "").strip(): + return jsonify({"ok": False, "error": "请填写能力测试名称"}), 400 + cid = db.add_capability(body) + return jsonify({"ok": True, "id": cid}) + + +@app.route("/api/admin/capabilities/", methods=["PUT"]) +@admin_required +def admin_capability_update(cid): + body = request.get_json(force=True) or {} + if not (body.get("name") or "").strip(): + return jsonify({"ok": False, "error": "请填写能力测试名称"}), 400 + db.update_capability(cid, body) + return jsonify({"ok": True}) + + +@app.route("/api/admin/capabilities/", methods=["DELETE"]) +@admin_required +def admin_capability_delete(cid): + db.delete_capability(cid) + return jsonify({"ok": True}) + + # ───────────────────────── 图表代理(data-chart-tool) ───────────────────────── def _chart_proxy(payload): @@ -241,7 +395,7 @@ def chart_proxy(): return _chart_proxy(payload) -# ───────────────────────── 演示数据 ───────────────────────── +# ───────────────────────── 演示数据(后台) ───────────────────────── DEMO_MODELS = [ ("autodl", "qwen3.5-plus", [512, 2048, 4096, 8192, 16384]), @@ -256,24 +410,24 @@ DEMO_MODELS = [ @app.route("/api/seed-demo", methods=["POST"]) +@admin_required def seed_demo(): - """生成一批演示评测数据(便于查看排行榜效果),重复调用会追加""" + """生成一批演示评测数据(重复调用会追加)""" import random random.seed() accounts = ["测试小组A", "性能评测组", "模型研究所"] + gpus = ["NVIDIA A100 80G", "NVIDIA L20 48G", "NVIDIA RTX 4090 24G"] cnt = 0 for ai, acc_name in enumerate(accounts): aid = db.get_or_create_account(acc_name, remark="演示账号") for mi, (provider, model, lens) in enumerate(DEMO_MODELS): if (mi + ai) % 3 == 0 and ai != 0: continue # 让数据分布有点差异 - # 基础解码速度:给每个模型一个基准(越靠后越快一些随机) base_decode = 40 + mi * 12 + random.uniform(-5, 12) base_prefill = base_decode * random.uniform(0.55, 0.9) base_ttft = 200 + mi * 15 + random.uniform(-40, 80) by_length = {} for L in lens: - # 长度越长解码略降 k = 1 - (L / 65536) * 0.3 by_length[str(L)] = { "samples_ok": 2, "samples_total": 2, @@ -289,6 +443,7 @@ def seed_demo(): "account": acc_name, "source_test_id": 9000 + cnt, "source_site": "llm-speed-tester(演示)", "provider": provider, "model": model, + "hardware": gpus[(mi + ai) % len(gpus)], "test_name": "%s 基准评测" % model, "summary": { "samples_ok": 2, "samples_total": 2, @@ -309,6 +464,59 @@ def seed_demo(): return jsonify({"ok": True, "seeded": cnt, "stats": db.get_stats()}) +# ───────────────────────── 能力测试演示数据(后台) ───────────────────────── + +@app.route("/api/seed-capabilities", methods=["POST"]) +@admin_required +def seed_capabilities(): + """生成一批演示能力测试模块(便于展示)""" + demos = [ + {"name": "数学推理", "category": "推理", "icon": "🧮", + "description": "考察基础运算、代数、逻辑推导与数学应用题能力。", + "items": [ + {"title": "四则运算", "prompt": "计算 17 × 23 - 45 ÷ 5 的结果", "expect": "能正确按运算优先级给出结果"}, + {"title": "一元一次方程", "prompt": "解方程 3x + 7 = 28", "expect": "给出 x=7 并附简要过程"}, + {"title": "鸡兔同笼", "prompt": "笼中共35个头、94只脚,问鸡兔各几只", "expect": "正确列出方程并求解"}, + ]}, + {"name": "代码生成", "category": "编程", "icon": "💻", + "description": "考察代码生成、算法实现、Bug 修复与代码理解。", + "items": [ + {"title": "冒泡排序", "prompt": "用 Python 写一个冒泡排序", "expect": "代码正确、缩进规范"}, + {"title": "反转链表", "prompt": "实现单链表反转函数", "expect": "时间复杂度 O(n)"}, + {"title": "找 Bug", "prompt": "以下代码输出错误,请找出问题并修复:...", "expect": "准确定位并给出修复"}, + ]}, + {"name": "中文理解", "category": "语言", "icon": "📖", + "description": "考察中文阅读理解、概括、润色与改写能力。", + "items": [ + {"title": "段落概括", "prompt": "用一句话概括以下段落大意:...", "expect": "抓住主旨,表述通顺"}, + {"title": "润色改写", "prompt": "把这段口语化的介绍改写成正式书面语", "expect": "用词准确、逻辑清晰"}, + ]}, + {"name": "长文本处理", "category": "长文本", "icon": "📚", + "description": "考察超长上下文的记忆、定位与跨段归纳能力。", + "items": [ + {"title": "长文定位", "prompt": "在给定的 8K 文档中找出第 3 段提到的日期", "expect": "准确命中原文内容"}, + {"title": "跨段归纳", "prompt": "综合全文 5 个要点做总结", "expect": "要点无遗漏、顺序合理"}, + ]}, + {"name": "多模态理解", "category": "多模态", "icon": "🖼️", + "description": "考察图片识别、OCR 与图文关联能力。", + "items": [ + {"title": "OCR 识别", "prompt": "识别图片中的文字", "expect": "识别准确、无错漏"}, + {"title": "图文问答", "prompt": "根据图片回答其中的物品/场景", "expect": "描述准确、贴合图片"}, + ]}, + {"name": "对话与指令遵循", "category": "对话", "icon": "💬", + "description": "考察多轮对话、指令理解与约束遵循能力。", + "items": [ + {"title": "指令遵循", "prompt": "请只回答‘是’或‘否’:1+1=2 对吗?", "expect": "严格遵守格式约束"}, + {"title": "多轮记忆", "prompt": "记住我喜欢的颜色是蓝色,并在下一轮回答中引用", "expect": "跨轮记忆正确"}, + ]}, + ] + cnt = 0 + for i, c in enumerate(demos): + db.add_capability({**c, "sort": i}) + cnt += 1 + return jsonify({"ok": True, "seeded": cnt, "stats": db.get_stats()}) + + def _fmt(v): try: return "%.2f" % float(v) diff --git a/config.py b/config.py index 1fbe464..3051d35 100644 --- a/config.py +++ b/config.py @@ -13,5 +13,10 @@ DB_PATH = os.path.join(DATA_DIR, "model_eval.db") # 提交接口共享密钥(llm-speed-tester 发送时携带,防止被随意写入) SUBMIT_TOKEN = "meval16066" +# 后台管理登录(账号 / 密码) +ADMIN_USER = "admin" +ADMIN_PASSWORD = "admin123" +SESSION_SECRET = "model-eval-site-session-secret" + # data-chart-tool 图表服务地址(画速度对比图) CHART_API_BASE = "http://127.0.0.1:16016" diff --git a/database.py b/database.py index 41ad87e..807fbee 100644 --- a/database.py +++ b/database.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""SQLite 存储:账号 / 评测提交 / 提交明细""" +"""SQLite 存储:账号 / 评测提交 / 能力测试 / 统计""" import os import json import sqlite3 @@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS submissions( provider TEXT DEFAULT '', model TEXT DEFAULT '', test_name TEXT DEFAULT '', + hardware TEXT DEFAULT '', samples_ok INTEGER DEFAULT 0, samples_total INTEGER DEFAULT 0, avg_ttft_ms REAL, @@ -47,6 +48,19 @@ CREATE TABLE IF NOT EXISTS submissions( raw TEXT DEFAULT '{}' ); +CREATE TABLE IF NOT EXISTS capabilities( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category TEXT DEFAULT '', + icon TEXT DEFAULT '🧪', + description TEXT DEFAULT '', + items TEXT DEFAULT '[]', + status INTEGER DEFAULT 1, + sort INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) +); + 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); @@ -61,16 +75,31 @@ def _connect(): return conn +def _migrate(conn): + cur = conn.execute("PRAGMA table_info(submissions)") + cols = [r[1] for r in cur.fetchall()] + if "hardware" not in cols: + conn.execute("ALTER TABLE submissions ADD COLUMN hardware TEXT DEFAULT ''") + + def init_db(): with _lock: conn = _connect() try: conn.executescript(SCHEMA) + _migrate(conn) conn.commit() finally: conn.close() +def _jload(s, default): + try: + return json.loads(s or "") + except Exception: + return default + + # ───────────────────────── 账号 ───────────────────────── def get_or_create_account(name: str, remark: str = "") -> int: @@ -144,7 +173,7 @@ def delete_account(aid: int): # ───────────────────────── 提交 ───────────────────────── def add_submission(account_id: int, data: dict) -> int: - """data 是 llm-speed-tester 发来的整包(含 summary/gen/runs/config)""" + """data 是 llm-speed-tester 发来的整包(含 summary/gen/runs/config/hardware)""" summary = data.get("summary") or {} gen = data.get("gen") or {} runs = data.get("runs") or [] @@ -153,17 +182,18 @@ def add_submission(account_id: int, data: dict) -> int: try: cur = conn.execute( "INSERT INTO submissions(account_id,source_test_id,source_site,provider,model,test_name," - "samples_ok,samples_total,avg_ttft_ms,avg_prefill_speed,avg_decode_speed,avg_stream_decode," + "hardware,samples_ok,samples_total,avg_ttft_ms,avg_prefill_speed,avg_decode_speed,avg_stream_decode," "avg_prompt_tokens,avg_output_tokens,avg_total_ms,min_ttft_ms,max_ttft_ms,min_prefill_speed," "max_prefill_speed,min_decode_speed,max_decode_speed,min_total_ms,max_total_ms," "context_lengths,concurrency_levels,by_length,by_concurrency,runs,raw) VALUES(" - "?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (account_id, int(data.get("source_test_id") or 0), data.get("source_site") or "llm-speed-tester", data.get("provider") or "", data.get("model") or "", data.get("test_name") or data.get("name") or "", + (data.get("hardware") or "").strip(), int(summary.get("samples_ok") or 0), int(summary.get("samples_total") or 0), summary.get("avg_ttft_ms"), summary.get("avg_prefill_speed"), @@ -200,7 +230,7 @@ def get_submission(sid: int): (d["account_id"],)).fetchone()["name"] for k in ("context_lengths", "concurrency_levels", "by_length", "by_concurrency", "runs", "raw"): - d[k] = json.loads(d[k] or ("[]" if k in ("context_lengths", "concurrency_levels", "runs") else "{}")) + d[k] = _jload(d[k], "[]" if k in ("context_lengths", "concurrency_levels", "runs") else "{}") return d finally: conn.close() @@ -213,9 +243,9 @@ def list_submissions(page=1, page_size=20, q="", account_id=None, model="", prov where = ["1=1"] args = [] if q: - where.append("(s.model LIKE ? OR s.test_name LIKE ? OR a.name LIKE ? OR s.provider LIKE ?)") + where.append("(s.model LIKE ? OR s.test_name LIKE ? OR a.name LIKE ? OR s.provider LIKE ? OR s.hardware LIKE ?)") like = "%" + q + "%" - args += [like, like, like, like] + args += [like, like, like, like, like] if account_id: where.append("s.account_id=?") args.append(account_id) @@ -238,7 +268,7 @@ def list_submissions(page=1, page_size=20, q="", account_id=None, model="", prov for r in rows: d = dict(r) for k in ("context_lengths", "concurrency_levels"): - d[k] = json.loads(d[k] or "[]") + d[k] = _jload(d[k], "[]") out.append(d) return {"total": total, "items": out, "page": page, "page_size": page_size, "pages": max(1, -(-total // page_size))} @@ -246,6 +276,16 @@ def list_submissions(page=1, page_size=20, q="", account_id=None, model="", prov conn.close() +def update_submission_hardware(sid: int, hardware: str): + with _lock: + conn = _connect() + try: + conn.execute("UPDATE submissions SET hardware=? WHERE id=?", (hardware.strip(), sid)) + conn.commit() + finally: + conn.close() + + def delete_submission(sid: int): with _lock: conn = _connect() @@ -256,7 +296,7 @@ def delete_submission(sid: int): conn.close() -# ───────────────────────── 汇总统计 ───────────────────────── +# ───────────────────────── 汇总统计 / 排行 ───────────────────────── def get_stats(): with _lock: @@ -266,14 +306,15 @@ def get_stats(): sub_cnt = conn.execute("SELECT COUNT(*) c FROM submissions").fetchone()["c"] acc_cnt = conn.execute("SELECT COUNT(*) c FROM accounts").fetchone()["c"] ok_cnt = conn.execute("SELECT SUM(samples_ok) c FROM submissions").fetchone()["c"] or 0 + cap_cnt = conn.execute("SELECT COUNT(*) c FROM capabilities").fetchone()["c"] return {"models": model_cnt, "submissions": sub_cnt, - "accounts": acc_cnt, "samples_ok": ok_cnt} + "accounts": acc_cnt, "samples_ok": ok_cnt, "capabilities": cap_cnt} finally: conn.close() def leaderboard(sort="avg_decode_speed", order="desc", limit=200): - """按 (provider, model) 聚合所有提交 → 模型速度排行""" + """按 (provider, model) 聚合所有提交 → 模型速度排行(含硬件)""" sort_whitelist = { "avg_decode_speed": "avg_decode_speed", "avg_prefill_speed": "avg_prefill_speed", "avg_ttft_ms": "avg_ttft_ms", "best_decode": "best_decode", @@ -286,21 +327,51 @@ def leaderboard(sort="avg_decode_speed", order="desc", limit=200): conn = _connect() try: rows = conn.execute( - "SELECT provider, model, " + "SELECT s.provider, s.model, " "COUNT(*) AS cnt, " - "COUNT(DISTINCT account_id) AS accounts, " + "COUNT(DISTINCT s.account_id) AS accounts, " + "AVG(s.avg_decode_speed) AS avg_decode_speed, " + "AVG(s.avg_prefill_speed) AS avg_prefill_speed, " + "AVG(s.avg_stream_decode) AS avg_stream_decode, " + "AVG(s.avg_ttft_ms) AS avg_ttft_ms, " + "AVG(s.avg_output_tokens) AS avg_output_tokens, " + "AVG(s.avg_total_ms) AS avg_total_ms, " + "MAX(s.avg_decode_speed) AS best_decode, " + "MAX(s.created_at) AS last_tested, " + "(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<>'' " + "GROUP BY s.provider, s.model ORDER BY %s %s LIMIT ?" % (srt, od), + (limit,)).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def get_model_summary(provider, model): + """单个模型的聚合汇总(对比页用,含硬件)""" + with _lock: + conn = _connect() + try: + r = conn.execute( + "SELECT COUNT(*) AS cnt, COUNT(DISTINCT account_id) AS accounts, " "AVG(avg_decode_speed) AS avg_decode_speed, " "AVG(avg_prefill_speed) AS avg_prefill_speed, " "AVG(avg_stream_decode) AS avg_stream_decode, " "AVG(avg_ttft_ms) AS avg_ttft_ms, " "AVG(avg_output_tokens) AS avg_output_tokens, " "AVG(avg_total_ms) AS avg_total_ms, " + "SUM(samples_ok) AS samples_ok, " "MAX(avg_decode_speed) AS best_decode, " - "MAX(created_at) AS last_tested " - "FROM submissions WHERE model<>'' " - "GROUP BY provider, model ORDER BY %s %s LIMIT ?" % (srt, od), - (limit,)).fetchall() - return [dict(r) for r in rows] + "MIN(avg_decode_speed) AS min_decode_speed, " + "MAX(created_at) AS last_tested, " + "(SELECT s2.hardware FROM submissions s2 " + " WHERE s2.provider=submissions.provider AND s2.model=submissions.model AND s2.hardware<>'' " + " ORDER BY s2.id DESC LIMIT 1) AS hardware " + "FROM submissions WHERE provider=? AND model=?", + (provider, model)).fetchone() + return dict(r) if r else None finally: conn.close() @@ -319,10 +390,10 @@ def get_model_detail(provider, model): agg_length = {} for r in rows: d = dict(r) - d["by_length"] = json.loads(d.pop("by_length") or "{}") - d["by_concurrency"] = json.loads(d.pop("by_concurrency") or "{}") - d["context_lengths"] = json.loads(d.pop("context_lengths") or "[]") - d["concurrency_levels"] = json.loads(d.pop("concurrency_levels") or "[]") + d["by_length"] = _jload(d.pop("by_length"), "{}") + d["by_concurrency"] = _jload(d.pop("by_concurrency"), "{}") + d["context_lengths"] = _jload(d.pop("context_lengths"), "[]") + d["concurrency_levels"] = _jload(d.pop("concurrency_levels"), "[]") subs.append(d) for L, bl in d["by_length"].items(): a = agg_length.setdefault(str(L), {"count": 0, "sum_decode": 0.0, @@ -348,3 +419,86 @@ def get_model_detail(provider, model): "sub_cnt": len(subs)} finally: conn.close() + + +# ───────────────────────── 模型对比 ───────────────────────── + +def compare_models(model_keys): + """model_keys: [(provider, model), ...] → 每模型聚合汇总行""" + out = [] + for provider, model in model_keys: + r = get_model_summary(provider, model) + if r and r.get("cnt"): + out.append({"provider": provider, "model": model, **r}) + return out + + +# ───────────────────────── 能力测试模块 ───────────────────────── + +def list_capabilities(status_only=True): + with _lock: + conn = _connect() + try: + sql = "SELECT * FROM capabilities" + args = [] + if status_only: + sql += " WHERE status=1" + sql += " ORDER BY sort, id" + rows = conn.execute(sql, args).fetchall() + out = [] + for r in rows: + d = dict(r) + d["items"] = _jload(d["items"], "[]") + out.append(d) + return out + finally: + conn.close() + + +def add_capability(data: dict) -> int: + with _lock: + conn = _connect() + try: + cur = conn.execute( + "INSERT INTO capabilities(name,category,icon,description,items,status,sort) " + "VALUES(?,?,?,?,?,?,?)", + ((data.get("name") or "").strip(), + (data.get("category") or "").strip(), + (data.get("icon") or "🧪").strip(), + (data.get("description") or "").strip(), + json.dumps(data.get("items") or [], ensure_ascii=False), + 1 if data.get("status", 1) else 0, + int(data.get("sort") or 0))) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def update_capability(cid: int, data: dict): + with _lock: + conn = _connect() + try: + conn.execute( + "UPDATE capabilities SET name=?,category=?,icon=?,description=?,items=?,status=?,sort=?," + "updated_at=datetime('now','localtime') WHERE id=?", + ((data.get("name") or "").strip(), + (data.get("category") or "").strip(), + (data.get("icon") or "🧪").strip(), + (data.get("description") or "").strip(), + json.dumps(data.get("items") or [], ensure_ascii=False), + 1 if data.get("status", 1) else 0, + int(data.get("sort") or 0), cid)) + conn.commit() + finally: + conn.close() + + +def delete_capability(cid: int): + with _lock: + conn = _connect() + try: + conn.execute("DELETE FROM capabilities WHERE id=?", (cid,)) + conn.commit() + finally: + conn.close() diff --git a/static/accounts.html b/static/accounts.html deleted file mode 100644 index 85dc8eb..0000000 --- a/static/accounts.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - -账号管理 · 模型评测站 - - - -
-
模型评测站
- -
-
- -
-
-

👥 测试账号 (llm-speed-tester 发送时选择账号归属,不存在会自动创建)

-
-
-
- -
-
- - -
账号备注创建时间提交数操作
加载中…
-
-
-
- -
- - - diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 0000000..c51cb1b --- /dev/null +++ b/static/admin.html @@ -0,0 +1,136 @@ + + + + + +后台管理 · 模型评测站 + + + +
+
模型评测站 · 后台管理
+ +
+ +
+ +
+ + + + + +
+ + + + + + + +
+ + + diff --git a/static/capabilities.html b/static/capabilities.html new file mode 100644 index 0000000..6d6633e --- /dev/null +++ b/static/capabilities.html @@ -0,0 +1,41 @@ + + + + + +模型能力测试 · 模型评测站 + + + + + +
+
+

🧪 模型能力测试模块 (测试内容由后台管理维护,点击卡片展开测试项)

+
+
+
+
+ + + + +
+ + + diff --git a/static/compare.html b/static/compare.html new file mode 100644 index 0000000..acea4a2 --- /dev/null +++ b/static/compare.html @@ -0,0 +1,68 @@ + + + + + +模型对比 · 模型评测站 + + + + + +
+
+

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

+ +
① 选择要对比的模型(默认全部):
+
+
+ + +
+ +
② 勾选需要展示的对比项(可去留):
+
+ +
+ + +
+ +
+
+
+
+ +
+

📊 对比图表

+
+ + + + +
+
选择指标后点击生成(默认对比全部选中模型)
+
+
+ +
+ + + diff --git a/static/css/style.css b/static/css/style.css index 5ee3784..bda1954 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,15 +1,16 @@ -/* 模型评测网站 - 深色仪表盘风格 */ +/* 模型评测网站 - 深色仪表盘风格(高对比可读性优化) */ :root { --bg: #0f1420; - --panel: #171e2e; - --panel2: #1c2436; - --border: #26314a; - --text: #e6ebf5; - --muted: #8b97b3; - --accent: #4f8cff; - --accent2: #22c55e; - --warn: #f59e0b; - --danger: #ef4444; + --panel: #1a2233; + --panel2: #232d43; + --border: #303d5c; + --text: #f0f4fc; /* 主文字提亮 */ + --text-strong: #ffffff; + --muted: #aab6cf; /* 弱化文字提亮,保证可读性 */ + --accent: #5b94ff; + --accent2: #2ee67a; + --warn: #ffb43c; + --danger: #ff5d5d; --radius: 12px; } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -30,13 +31,13 @@ a:hover { text-decoration: underline; } padding: 14px 24px; background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 20; } -.brand { font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 8px; } +.brand { font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 8px; color: var(--text-strong); } .brand .logo { font-size: 20px; } -.nav { display: flex; gap: 4px; } +.nav { display: flex; gap: 4px; flex-wrap: wrap; } .nav a { color: var(--muted); padding: 6px 12px; border-radius: 8px; font-size: 13px; } -.nav a:hover { color: var(--text); text-decoration: none; background: var(--panel2); } +.nav a:hover { color: var(--text-strong); text-decoration: none; background: var(--panel2); } .nav a.active { color: #fff; background: var(--accent); } .topbar .spacer { flex: 1; } @@ -49,55 +50,64 @@ a:hover { text-decoration: underline; } padding: 16px 18px; } .stat-card .k { color: var(--muted); font-size: 12px; } -.stat-card .v { font-size: 26px; font-weight: 700; margin-top: 4px; } +.stat-card .v { font-size: 26px; font-weight: 700; margin-top: 4px; color: var(--text-strong); } .stat-card .v small { font-size: 13px; color: var(--muted); font-weight: 400; } .panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; margin-bottom: 18px; } -.panel h2 { font-size: 15px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; } -.panel h3 { font-size: 14px; margin: 14px 0 10px; } +.panel h2 { font-size: 15px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; color: var(--text-strong); } +.panel h3 { font-size: 14px; margin: 14px 0 10px; color: var(--text-strong); } /* 表格 */ table { width: 100%; border-collapse: collapse; } th, td { padding: 9px 10px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; } th { color: var(--muted); font-weight: 600; white-space: nowrap; cursor: pointer; user-select: none; } -th.sortable:hover { color: var(--text); } +th.sortable:hover { color: var(--text-strong); } th .arrow { font-size: 10px; color: var(--accent); } td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } -tr:hover td { background: rgba(79, 140, 255, 0.05); } +td a { font-weight: 600; } +tr:hover td { background: rgba(91, 148, 255, 0.06); } .tbl-wrap { overflow-x: auto; } /* 徽标 / pill */ .pill { display: inline-block; padding: 2px 9px; border-radius: 20px; font-size: 11px; - background: rgba(79, 140, 255, 0.15); color: var(--accent); white-space: nowrap; + background: rgba(91, 148, 255, 0.18); color: #b9d2ff; white-space: nowrap; } -.pill.green { background: rgba(34, 197, 94, 0.15); color: var(--accent2); } -.pill.amber { background: rgba(245, 158, 11, 0.15); color: var(--warn); } -.pill.red { background: rgba(239, 68, 68, 0.15); color: var(--danger); } -.pill.gray { background: rgba(139, 151, 179, 0.15); color: var(--muted); } +.pill.green { background: rgba(46, 230, 122, 0.15); color: #8ef5b8; } +.pill.amber { background: rgba(255, 180, 60, 0.15); color: #ffd893; } +.pill.red { background: rgba(255, 93, 93, 0.15); color: #ffb3b3; } +.pill.gray { background: rgba(170, 182, 207, 0.15); color: #c3cde2; } .badge-decode { font-weight: 700; color: var(--accent2); } +/* 硬件标签 */ +.hw-tag { + display: inline-block; padding: 1px 8px; border-radius: 6px; font-size: 11px; + background: rgba(155, 120, 255, 0.16); color: #c9b8ff; border: 1px solid rgba(155, 120, 255, 0.35); + white-space: nowrap; +} + /* 按钮 */ .btn { display: inline-block; padding: 6px 13px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel2); color: var(--text); font-size: 12.5px; cursor: pointer; transition: all .15s; white-space: nowrap; } -.btn:hover { border-color: var(--accent); color: #fff; } +.btn:hover { border-color: var(--accent); color: var(--text-strong); } .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } .btn.primary:hover { opacity: .88; } -.btn.danger { color: var(--danger); border-color: rgba(239,68,68,.4); } -.btn.danger:hover { background: rgba(239,68,68,.12); } +.btn.danger { color: var(--danger); border-color: rgba(255,93,93,.45); } +.btn.danger:hover { background: rgba(255,93,93,.14); } .btn.small { padding: 3px 9px; font-size: 12px; } .btn.link { background: none; border: none; color: var(--accent); padding: 0; } .btn.link:hover { text-decoration: underline; } .btn:disabled { opacity: .5; cursor: not-allowed; } +.btn[hidden] { display: none !important; } /* 表单 */ -input[type=text], input[type=search], input[type=number], select, textarea { +input[type=text], input[type=search], input[type=number], input[type=password], select, textarea { background: var(--panel2); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 7px 10px; font-size: 13px; outline: none; } @@ -110,7 +120,7 @@ label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px /* 弹窗 */ .mask { - position: fixed; inset: 0; background: rgba(6, 9, 16, .72); z-index: 100; + position: fixed; inset: 0; background: rgba(6, 9, 16, .78); z-index: 100; display: flex; align-items: center; justify-content: center; padding: 24px; } .modal { @@ -122,7 +132,7 @@ label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--border); } -.modal-head h3 { margin: 0; font-size: 15px; } +.modal-head h3 { margin: 0; font-size: 15px; color: var(--text-strong); } .modal-body { padding: 16px 18px; overflow-y: auto; } .modal-foot { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 10px; justify-content: flex-end; } .mask[hidden] { display: none; } @@ -134,6 +144,7 @@ label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px .kv-v { font-size: 14px; font-weight: 600; } /* 图表 */ +.chart-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; } .chart-img-wrap { text-align: center; padding: 8px 0; } .chart-img-wrap img { max-width: 100%; border-radius: 8px; } .chart-csv { @@ -144,17 +155,17 @@ label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px /* 空态 / 提示 */ .empty { color: var(--muted); text-align: center; padding: 30px 0; } .notice { - background: rgba(79,140,255,.1); border: 1px solid rgba(79,140,255,.3); - border-radius: 8px; padding: 10px 14px; font-size: 12.5px; color: #cfe0ff; + background: rgba(91,148,255,.12); border: 1px solid rgba(91,148,255,.35); + border-radius: 8px; padding: 10px 14px; font-size: 12.5px; color: #d3e3ff; margin-bottom: 12px; } -.notice.green { background: rgba(34,197,94,.1); border-color: rgba(34,197,94,.3); color: #d4f7e2; } -.notice.red { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.3); color: #ffd7d7; } +.notice.green { background: rgba(46,230,122,.1); border-color: rgba(46,230,122,.32); color: #daf8e8; } +.notice.red { background: rgba(255,93,93,.1); border-color: rgba(255,93,93,.32); color: #ffd9d9; } /* toast */ #toast { position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%); - background: #1d2538; border: 1px solid var(--accent); color: #fff; + background: #202a42; border: 1px solid var(--accent); color: #fff; padding: 10px 18px; border-radius: 10px; z-index: 999; font-size: 13px; box-shadow: 0 6px 20px rgba(0,0,0,.4); display: none; max-width: 80vw; } @@ -170,3 +181,60 @@ label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px /* 进度条 */ .meter { background: var(--panel2); border-radius: 6px; height: 8px; overflow: hidden; margin-top: 6px; } .meter > div { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 6px; } + +/* 模型选择 chips */ +.chips { display: flex; flex-wrap: wrap; gap: 8px; } +.chip { + display: inline-flex; align-items: center; gap: 5px; padding: 4px 11px; border-radius: 16px; + background: var(--panel2); border: 1px solid var(--border); font-size: 12.5px; cursor: pointer; + user-select: none; color: var(--muted); +} +.chip:hover { border-color: var(--accent); } +.chip.on { background: rgba(91,148,255,.16); border-color: var(--accent); color: var(--text-strong); } +.chip .x { color: var(--muted); font-weight: 700; } +.chip .x:hover { color: var(--danger); } + +/* 能力测试卡片 */ +.cap-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; } +.cap-card { + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); + padding: 16px 18px; cursor: pointer; transition: border-color .15s; +} +.cap-card:hover { border-color: var(--accent); } +.cap-card .cap-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.cap-card .cap-icon { font-size: 26px; } +.cap-card .cap-name { font-size: 15px; font-weight: 700; color: var(--text-strong); } +.cap-card .cap-cat { margin-left: auto; } +.cap-card .cap-desc { color: var(--muted); font-size: 12.5px; margin-bottom: 8px; } +.cap-card .cap-count { color: var(--muted); font-size: 12px; } +.cap-item { background: var(--panel2); border-radius: 8px; padding: 10px 12px; margin: 6px 0; } +.cap-item .it-title { font-weight: 600; color: var(--text-strong); font-size: 13px; margin-bottom: 4px; } +.cap-item .it-prompt { color: var(--text); font-size: 12.5px; margin-bottom: 4px; } +.cap-item .it-expect { color: var(--muted); font-size: 12px; } + +/* 对比项勾选 */ +.col-toggles { display: flex; flex-wrap: wrap; gap: 10px; padding: 8px 0; } +.col-toggles label { display: inline-flex; align-items: center; gap: 5px; margin: 0; color: var(--text); font-size: 12.5px; cursor: pointer; } +.col-toggles input[type=checkbox] { accent-color: var(--accent); width: 14px; height: 14px; } + +/* 后台管理 */ +.admin-login { + max-width: 360px; margin: 12vh auto; background: var(--panel); + border: 1px solid var(--border); border-radius: 14px; padding: 26px 28px; +} +.admin-login h2 { text-align: center; margin-bottom: 18px; color: var(--text-strong); } +.admin-login .field { margin-bottom: 14px; } +.admin-login input { width: 100%; } +.tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; } +.tabs button { + padding: 7px 16px; border-radius: 8px; border: 1px solid var(--border); + background: var(--panel2); color: var(--muted); cursor: pointer; font-size: 13px; +} +.tabs button.active { background: var(--accent); color: #fff; border-color: var(--accent); } +.admin-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 10px; } +.admin-head h2 { margin: 0; color: var(--text-strong); } + +/* 能力测试编辑行 */ +.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%; } diff --git a/static/index.html b/static/index.html index 2490a40..cdb88c5 100644 --- a/static/index.html +++ b/static/index.html @@ -11,11 +11,11 @@
模型评测站
- llm-speed-tester 一键同步 + ⚙️ 后台管理
@@ -24,6 +24,7 @@
评测提交数
测试账号数
成功采样
+
能力测试项
@@ -40,16 +41,21 @@ 平均预填充 tok/s 首字 ms 最佳解码 tok/s + 硬件 最后测试 - 加载中… + 加载中…
-

📊 解码速度 TOP20 对比图

-
+

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

+
选择要对比的模型(默认全部):
+
+
+ +
diff --git a/static/js/app.js b/static/js/app.js index 2a73be6..dfd66c3 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -8,10 +8,9 @@ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const fmt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : Number(v).toFixed(2).replace(/\.?0+$/, "")); const fmtInt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : Math.round(v)); -const toastEl = () => $("#toast"); let toastTimer = null; function toast(msg) { - const el = toastEl(); + const el = $("#toast"); el.textContent = msg; el.style.display = "block"; clearTimeout(toastTimer); @@ -22,7 +21,8 @@ async function api(path, method = "GET", body) { const opt = { method, headers: {} }; if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); } const resp = await fetch(path, opt); - const j = await resp.json().catch(() => ({})); + let j = {}; + try { j = await resp.json(); } catch (e) { j = {}; } if (!resp.ok && !j.ok) throw new Error(j.error || `请求失败(${resp.status})`); return j; } @@ -33,7 +33,8 @@ function providerLabel(p) { return m[p] || p || "—"; } -/* 顶部导航高亮 */ +const hwTag = (hw) => (hw ? `🖥 ${esc(hw)}` : "—"); + function navActive() { const path = location.pathname.split("/").pop() || "index.html"; $$(".nav a").forEach((a) => { @@ -44,14 +45,19 @@ function navActive() { /* ───────────────────────── 首页:排行 ───────────────────────── */ let sortKey = "avg_decode_speed"; let sortOrder = "desc"; +let lbModels = []; // [{provider, model, key}] +let lbSelected = new Set(); // selected keys + +const modelKey = (p, m) => `${p}|${m}`; async function loadStats() { try { const s = await api("/api/stats"); - $("#st-models").textContent = s.models; - $("#st-subs").textContent = s.submissions; - $("#st-accounts").textContent = s.accounts; - $("#st-samples").innerHTML = fmtInt(s.samples_ok) + " 次成功采样"; + if ($("#st-models")) $("#st-models").textContent = s.models; + if ($("#st-subs")) $("#st-subs").textContent = s.submissions; + if ($("#st-accounts")) $("#st-accounts").textContent = s.accounts; + if ($("#st-samples")) $("#st-samples").innerHTML = fmtInt(s.samples_ok) + " 次成功采样"; + if ($("#st-caps")) $("#st-caps").textContent = s.capabilities; } catch (e) { /* ignore */ } } @@ -61,24 +67,49 @@ function sortClick(key) { loadLeaderboard(); } +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 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.classList.toggle("on"); + }); + box.appendChild(c); + } +} + 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; } + // 记录模型列表,用于对比图选择 + 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 meter = r.avg_decode_speed ? `
` : ""; @@ -92,23 +123,26 @@ async function loadLeaderboard() { ${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(""); - window.__lbRows = d.rows; - window.__lbChart = d.bar_payload; } let lbChartUrl = ""; async function genLbChart() { - if (!window.__lbChart) { toast("暂无数据可画图"); return; } const status = $("#lb-chart-status"); const imgWrap = $("#lb-chart-img"); + if (!lbSelected.size) { status.textContent = "请至少选择一个模型"; return; } status.textContent = "⏳ 正在生成..."; try { + // 重新请求指定模型的排行数据(对比图只包含所选模型) + 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 resp = await fetch("/api/chart", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(window.__lbChart), + body: JSON.stringify(d.bar_payload), }); if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } const blob = await resp.blob(); @@ -117,8 +151,8 @@ async function genLbChart() { imgWrap.innerHTML = ""; const img = document.createElement("img"); img.src = lbChartUrl; - img.alt = "模型速度排行图"; - img.onload = () => { status.textContent = "✅ 生成完成"; }; + img.alt = "模型解码速度对比图"; + img.onload = () => { status.textContent = `✅ 已生成(${d.rows.length} 个模型)`; }; imgWrap.appendChild(img); } catch (e) { status.textContent = "❌ " + e.message; } } @@ -133,18 +167,25 @@ async function loadModel() { try { d = await api(`/api/model?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}`); } catch (e) { - $("#model-body").innerHTML = `
${esc(e.message)}
`; + $("#m-sub").textContent = e.message; return; } document.title = `${d.model} · 模型评测`; $("#m-title").textContent = `${d.model} 详情`; $("#m-sub").textContent = `${providerLabel(d.provider)} · 共 ${d.sub_cnt} 条提交`; + // 硬件汇总 + const hws = {}; + d.submissions.forEach((s) => { if (s.hardware) hws[s.hardware] = (hws[s.hardware] || 0) + 1; }); + const hwList = Object.entries(hws).map(([hw, n]) => `${esc(hw)} ×${n}`).join(";"); + $("#m-hw").textContent = hwList ? `🖥 硬件:${hwList}` : ""; + $("#md-submissions").innerHTML = d.submissions.map((s) => { const conc = (s.concurrency_levels || [1]).join("/"); return ` #${s.id} ${esc(s.account)} ${esc(s.created_at)} + ${hwTag(s.hardware)} ${fmt(s.samples_ok)}/${fmt(s.samples_total)} ${fmt(s.avg_ttft_ms)} ${fmt(s.avg_prefill_speed)} @@ -156,14 +197,12 @@ async function loadModel() { `; }).join(""); - // 折线图:解码速度随上下文长度 window.__modelChart = d.line_payload; window.__modelLineCsv = d.line_csv; $("#m-chart-csv").value = d.line_csv; $("#m-chart-img").innerHTML = '
点击「🎨 生成折线图」绘制
'; $("#m-chart-status").textContent = ""; - // 数据统计(按长度聚合表) $("#md-lengths").innerHTML = d.by_length.map((r) => ` ${fmtInt(r.length)} ${fmtInt(r.count)} @@ -197,6 +236,184 @@ async function genModelChart() { } catch (e) { status.textContent = "❌ " + e.message; } } +/* ───────────────────────── 能力测试页 ───────────────────────── */ +async function loadCapabilities() { + const grid = $("#cap-grid"); + if (!grid) return; + grid.innerHTML = '
加载中…
'; + let list; + try { list = await api("/api/capabilities"); } + catch (e) { grid.innerHTML = `
加载失败:${esc(e.message)}
`; return; } + if ($("#cap-sub")) $("#cap-sub").textContent = `共 ${list.length} 项能力测试`; + if (!list.length) { + grid.innerHTML = '
暂无能力测试内容,请到「⚙️ 后台管理 → 能力测试管理」添加。
'; + return; + } + grid.innerHTML = list.map((c) => ` +
+
+ ${esc(c.icon || "🧪")} + ${esc(c.name)} + ${esc(c.category || "未分类")} +
+
${esc(c.description || "暂无描述")}
+
📝 含 ${(c.items || []).length} 个测试项,点击查看详情
+
`).join(""); + grid.querySelectorAll(".cap-card").forEach((card) => { + card.addEventListener("click", () => openCapDetail(list.find((c) => c.id === Number(card.dataset.cap)))); + }); +} + +function openCapDetail(c) { + const mask = $("#cap-mask"); + if (!mask) return; + mask.hidden = false; + $("#cap-modal-title").textContent = `${c.icon || "🧪"} ${c.name}`; + const items = (c.items || []).map((it, i) => ` +
+
${i + 1}. ${esc(it.title || "未命名")}
+
📝 ${esc(it.prompt || "")}
+ ${it.expect ? `
🎯 期望:${esc(it.expect)}
` : ""} +
`).join(""); + $("#cap-modal-body").innerHTML = ` +
+
分类
${esc(c.category || "未分类")}
+
测试项数
${(c.items || []).length}
+
更新时间
${esc(c.updated_at || c.created_at || "")}
+
+
${esc(c.description || "")}
+

📝 测试项

+ ${items || '
暂无测试项
'} + `; +} + +/* ───────────────────────── 模型对比页 ───────────────────────── */ +let cmpModels = []; +let cmpSelected = new Set(); +let cmpCols = new Set(["provider", "model", "hardware", "avg_decode_speed", "avg_prefill_speed", "avg_ttft_ms"]); +let cmpRows = []; +let cmpChartUrl = ""; + +const CMP_COL_DEFS = [ + { key: "provider", label: "提供商", type: "text" }, + { key: "model", label: "模型", type: "text" }, + { key: "hardware", label: "硬件", type: "hw" }, + { key: "cnt", label: "提交数", type: "num" }, + { key: "accounts", label: "账号数", type: "num" }, + { key: "samples_ok", label: "采样数", type: "num" }, + { key: "avg_decode_speed", label: "平均解码 tok/s", type: "num" }, + { key: "avg_prefill_speed", label: "平均预填充 tok/s", type: "num" }, + { key: "avg_stream_decode", label: "单流均解码 tok/s", type: "num" }, + { key: "avg_ttft_ms", label: "首字 ms", type: "num" }, + { key: "best_decode", label: "最佳解码 tok/s", type: "num" }, + { key: "min_decode_speed", label: "最低解码 tok/s", type: "num" }, + { key: "avg_output_tokens", label: "输出 tok", type: "num" }, + { key: "avg_total_ms", label: "总耗时 ms", type: "num" }, + { key: "last_tested", label: "最后测试", type: "time" }, +]; + +function renderCmpColToggles() { + const box = $("#cmp-cols"); + if (!box) return; + box.innerHTML = CMP_COL_DEFS.map((c) => ` + `).join(""); + box.querySelectorAll("input[data-cmpcol]").forEach((inp) => { + inp.addEventListener("change", () => { + if (inp.checked) cmpCols.add(inp.value); else cmpCols.delete(inp.value); + renderCmpTable(); + }); + }); +} + +async function loadCmpModels() { + 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)); + renderCmpChips(); +} + +function renderCmpChips() { + const box = $("#cmp-models"); + box.innerHTML = ""; + if (!cmpModels.length) { box.innerHTML = '暂无模型'; return; } + for (const m of cmpModels) { + const on = cmpSelected.has(m.key); + const c = document.createElement("span"); + 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); + c.classList.toggle("on"); + }); + box.appendChild(c); + } +} + +async function runCompare() { + const status = $("#cmp-status"); + const tbody = $("#cmp-table tbody"); + if (!cmpSelected.size) { status.textContent = "请至少选择一个模型"; return; } + status.textContent = "⏳ 正在对比..."; + const models = [...cmpSelected].join(","); + try { + const d = await api(`/api/compare?models=${encodeURIComponent(models)}`); + cmpRows = d.rows; + renderCmpTable(); + status.textContent = `✅ 已对比 ${cmpRows.length} 个模型`; + } catch (e) { + status.textContent = "❌ " + e.message; + } +} + +function renderCmpTable() { + const table = $("#cmp-table"); + const thead = table.querySelector("thead"); + const tbody = table.querySelector("tbody"); + const cols = CMP_COL_DEFS.filter((c) => cmpCols.has(c.key)); + thead.innerHTML = "" + cols.map((c) => `${esc(c.label)}`).join("") + ""; + if (!cmpRows.length) { tbody.innerHTML = '请先选择模型并点击「🔄 生成对比」'; return; } + tbody.innerHTML = cmpRows.map((r) => "" + cols.map((c) => { + const v = r[c.key]; + if (c.type === "hw") return `${hwTag(v)}`; + if (c.type === "num") return `${fmt(v)}`; + if (c.type === "time") return `${esc((v || "").slice(5, 16))}`; + if (c.key === "model") return `${esc(v)}`; + if (c.key === "provider") return `${esc(providerLabel(v))}`; + return `${esc(v)}`; + }).join("") + "").join(""); +} + +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; + status.textContent = "⏳ 正在生成..."; + 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; } + const resp = await fetch("/api/chart", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(d.chart_payload), + }); + if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; } + const blob = await resp.blob(); + if (cmpChartUrl) URL.revokeObjectURL(cmpChartUrl); + cmpChartUrl = URL.createObjectURL(blob); + imgWrap.innerHTML = ""; + const img = document.createElement("img"); + img.src = cmpChartUrl; + img.onload = () => { status.textContent = `✅ 已生成(${d.rows.length} 个模型)`; }; + imgWrap.appendChild(img); + } catch (e) { status.textContent = "❌ " + e.message; } +} + /* ───────────────────────── 提交详情弹窗 ───────────────────────── */ async function openSubmission(sid) { const mask = $("#sub-mask"); @@ -239,6 +456,7 @@ async function openSubmission(sid) {
账号
${esc(s.account)}
模型
${esc(s.model)}
提供商
${esc(providerLabel(s.provider))}
+
硬件
${hwTag(s.hardware)}
测试名称
${esc(s.test_name || "—")}
提交时间
${esc(s.created_at)}
来源
${esc(s.source_site)} #${s.source_test_id}
@@ -267,7 +485,6 @@ async function openSubmission(sid) {

📋 采样明细

${runsHtml} `; - window.__subId = sid; let chartUrl = ""; $("#sub-chart-gen").addEventListener("click", async () => { const status = $("#sub-chart-status"); @@ -288,20 +505,46 @@ async function openSubmission(sid) { }); } -/* ───────────────────────── 提交管理页 ───────────────────────── */ +/* ───────────────────────── 后台管理 ───────────────────────── */ let subPage = 1; let subQuery = ""; +let capEditId = null; + +async function checkAdmin() { + try { + const d = await api("/api/admin/session"); + if (d.admin) { + $("#login-box").hidden = true; + $("#admin-box").hidden = false; + $("#btn-logout").hidden = false; + loadSubmissions(true); + loadAccounts(); + loadCaps(); + return true; + } + } catch (e) { /* not logged in */ } + $("#login-box").hidden = false; + $("#admin-box").hidden = true; + return false; +} + +function switchTab(tab) { + $$("#admin-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab)); + $("#tab-subs").hidden = tab !== "subs"; + $("#tab-accs").hidden = tab !== "accs"; + $("#tab-caps").hidden = tab !== "caps"; +} async function loadSubmissions(resetPage = false) { const tb = $("#subs tbody"); if (!tb) return; if (resetPage) subPage = 1; - tb.innerHTML = '加载中…'; + tb.innerHTML = '加载中…'; let d; try { d = await api(`/api/submissions?page=${subPage}&page_size=20&q=${encodeURIComponent(subQuery)}`); } catch (e) { - tb.innerHTML = `加载失败:${esc(e.message)}`; + tb.innerHTML = `加载失败:${esc(e.message)}`; return; } $("#sub-total").textContent = `共 ${d.total} 条`; @@ -309,7 +552,7 @@ async function loadSubmissions(resetPage = false) { $("#sub-prev").disabled = d.page <= 1; $("#sub-next").disabled = d.page >= d.pages; if (!d.items.length) { - tb.innerHTML = '暂无提交记录。可点击右上角「🎲 生成演示数据」或从 llm-speed-tester 发送。'; + tb.innerHTML = '暂无提交记录。可点击「🎲 生成演示数据」或从 llm-speed-tester 发送。'; return; } tb.innerHTML = d.items.map((s) => ` @@ -318,11 +561,11 @@ async function loadSubmissions(resetPage = false) { ${esc(s.created_at)} ${esc(providerLabel(s.provider))} ${esc(s.model)} + ${esc(s.hardware || "未标注 ✎")} ${fmt(s.samples_ok)}/${fmt(s.samples_total)} ${fmt(s.avg_prefill_speed)} ${fmt(s.avg_decode_speed)} ${fmt(s.avg_ttft_ms)} - ${s.test_name ? esc(s.test_name.slice(0, 14)) : "—"} @@ -330,7 +573,16 @@ async function loadSubmissions(resetPage = false) { `).join(""); } -/* ───────────────────────── 账号管理页 ───────────────────────── */ +async function editHardware(sid) { + const v = prompt(`为提交 #${sid} 设置硬件/GPU(留空清除):`, ""); + if (v === null) return; + try { + await api(`/api/submissions/${sid}/hardware`, "PUT", { hardware: v }); + toast("硬件已更新"); + loadSubmissions(); + } catch (e) { toast("更新失败:" + e.message); } +} + async function loadAccounts() { const tb = $("#accs tbody"); if (!tb) return; @@ -338,7 +590,7 @@ async function loadAccounts() { try { list = await api("/api/accounts"); } catch (e) { tb.innerHTML = `加载失败`; return; } if (!list.length) { - tb.innerHTML = '暂无账号。当 llm-speed-tester 一键发送时账号会自动创建。'; + tb.innerHTML = '暂无账号。llm-speed-tester 一键发送时账号会自动创建。'; return; } tb.innerHTML = list.map((a) => ` @@ -353,11 +605,84 @@ async function loadAccounts() { `).join(""); } +async function loadCaps() { + const tb = $("#caps tbody"); + if (!tb) return; + let list; + try { list = await api("/api/admin/capabilities"); } + catch (e) { tb.innerHTML = `加载失败:${esc(e.message)}`; return; } + if (!list.length) { + tb.innerHTML = '暂无能力测试,点击「➕ 新增能力测试」或「🎲 生成演示能力测试」。'; + return; + } + tb.innerHTML = list.map((c) => ` + ${c.id} + ${esc(c.icon || "🧪")} + ${esc(c.name)} + ${esc(c.category || "未分类")} + ${(c.items || []).length} + ${esc((c.description || "").slice(0, 20))} + ${c.sort} + ${c.status ? '启用' : '停用'} + + + + + `).join(""); +} + +function openCapEditor(c) { + capEditId = c ? c.id : null; + $("#cap-edit-title").textContent = c ? `编辑能力测试 #${c.id}` : "新增能力测试"; + $("#ce-name").value = c ? c.name : ""; + $("#ce-category").value = c ? c.category : ""; + $("#ce-icon").value = c ? c.icon : "🧪"; + $("#ce-sort").value = c ? c.sort : 0; + $("#ce-desc").value = c ? c.description : ""; + $("#ce-items").value = c ? JSON.stringify(c.items || [], null, 2) : '[{"title":"题目1","prompt":"提示词内容","expect":"期望结果"}]'; + $("#ce-status").checked = c ? !!c.status : true; + $("#cap-mask").hidden = false; +} + +function parseItems(text) { + const t = (text || "").trim(); + if (!t) return []; + let arr; + try { arr = JSON.parse(t); } + catch (e) { throw new Error("测试项 JSON 格式错误:" + e.message); } + if (!Array.isArray(arr)) throw new Error("测试项必须是 JSON 数组"); + return arr; +} + +async function saveCapEditor() { + const name = $("#ce-name").value.trim(); + if (!name) { toast("请填写名称"); return; } + let items; + try { items = parseItems($("#ce-items").value); } + catch (e) { toast(e.message); return; } + const body = { + name, category: $("#ce-category").value.trim(), icon: $("#ce-icon").value.trim() || "🧪", + sort: parseInt($("#ce-sort").value) || 0, description: $("#ce-desc").value.trim(), + items, status: $("#ce-status").checked ? 1 : 0, + }; + try { + if (capEditId) await api(`/api/admin/capabilities/${capEditId}`, "PUT", body); + else await api("/api/admin/capabilities", "POST", body); + toast("已保存"); + $("#cap-mask").hidden = true; + loadCaps(); + } catch (e) { toast("保存失败:" + e.message); } +} + /* ───────────────────────── 事件绑定 ───────────────────────── */ 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 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(); }); } function bindModel() { @@ -375,7 +700,7 @@ function bindModel() { if (!modelChartUrl) { toast("请先生成图表"); return; } const a = document.createElement("a"); a.href = modelChartUrl; - a.download = `model_chart.png`; + a.download = "model_chart.png"; document.body.appendChild(a); a.click(); a.remove(); }); const body = $("#md-submissions"); @@ -385,19 +710,48 @@ function bindModel() { }); } -function bindSubmissions() { - const seed = $("#btn-seed"); - if (seed) seed.addEventListener("click", async () => { - seed.disabled = true; - seed.textContent = "生成中…"; +function bindCapabilities() { + const close = $("#cap-close"); + if (close) { + close.addEventListener("click", () => { $("#cap-mask").hidden = true; }); + $("#cap-mask").addEventListener("click", (e) => { if (e.target === $("#cap-mask")) $("#cap-mask").hidden = true; }); + } +} + +function bindCompare() { + const all = $("#cmp-all"); + if (all) all.addEventListener("click", () => { cmpModels.forEach((m) => cmpSelected.add(m.key)); renderCmpChips(); }); + const none = $("#cmp-none"); + if (none) none.addEventListener("click", () => { cmpSelected.clear(); renderCmpChips(); }); + const run = $("#cmp-run"); + if (run) run.addEventListener("click", runCompare); + const gen = $("#cmp-chart-gen"); + if (gen) gen.addEventListener("click", genCmpChart); + renderCmpColToggles(); +} + +function bindAdmin() { + const login = $("#btn-login"); + if (login) login.addEventListener("click", async () => { + const u = $("#login-user").value.trim(); + const p = $("#login-pass").value; try { - const r = await api("/api/seed-demo", "POST", {}); - toast(`已生成 ${r.seeded} 条演示数据`); - loadSubmissions(true); - } catch (e) { toast("生成失败:" + e.message); } - seed.disabled = false; - seed.textContent = "🎲 生成演示数据"; + await api("/api/admin/login", "POST", { username: u, password: p }); + $("#login-err").hidden = true; + checkAdmin(); + } catch (e) { + $("#login-err").hidden = false; + $("#login-err").textContent = e.message; + } }); + const logout = $("#btn-logout"); + if (logout) logout.addEventListener("click", async () => { + await api("/api/admin/logout", "POST"); + location.reload(); + }); + $$("#admin-tabs button").forEach((b) => b.addEventListener("click", () => switchTab(b.dataset.tab))); + + // 提交管理 const search = $("#sub-search"); if (search) { search.addEventListener("keydown", (e) => { if (e.key === "Enter") { subQuery = search.value.trim(); loadSubmissions(true); } }); @@ -405,13 +759,25 @@ function bindSubmissions() { } $("#sub-prev").addEventListener("click", () => { if (subPage > 1) { subPage--; loadSubmissions(); } }); $("#sub-next").addEventListener("click", () => { subPage++; loadSubmissions(); }); - const tb = $("#subs tbody"); - tb.addEventListener("click", (e) => { + const seed = $("#btn-seed"); + if (seed) seed.addEventListener("click", async () => { + seed.disabled = true; seed.textContent = "生成中…"; + try { + const r = await api("/api/seed-demo", "POST", {}); + toast(`已生成 ${r.seeded} 条演示数据`); + loadSubmissions(true); + } catch (e) { toast("生成失败:" + e.message); } + seed.disabled = false; seed.textContent = "🎲 生成演示数据"; + }); + const subsTb = $("#subs tbody"); + subsTb.addEventListener("click", (e) => { const v = e.target.closest("[data-view]"); const d = e.target.closest("[data-del]"); const l = e.target.closest(".s-link"); + const h = e.target.closest("[data-hw]"); if (v) { e.preventDefault(); openSubmission(v.dataset.view); } if (l) { e.preventDefault(); openSubmission(l.dataset.sid); } + if (h) { editHardware(Number(h.dataset.hw)); } if (d) { const id = d.dataset.del; if (confirm(`确定删除提交 #${id}?`)) { @@ -419,16 +785,10 @@ function bindSubmissions() { } } }); - const close = $("#sub-close"); - if (close) { - close.addEventListener("click", () => { $("#sub-mask").hidden = true; }); - $("#sub-mask").addEventListener("click", (e) => { if (e.target === $("#sub-mask")) $("#sub-mask").hidden = true; }); - } -} -function bindAccounts() { - const add = $("#btn-add-acc"); - if (add) add.addEventListener("click", async () => { + // 账号管理 + const addAcc = $("#btn-add-acc"); + if (addAcc) addAcc.addEventListener("click", async () => { const name = $("#acc-name").value.trim(); const remark = $("#acc-remark").value.trim(); if (!name) { toast("请输入账号名称"); return; } @@ -439,8 +799,8 @@ function bindAccounts() { loadAccounts(); } catch (e) { toast(e.message); } }); - const tb = $("#accs tbody"); - tb.addEventListener("click", (e) => { + const accsTb = $("#accs tbody"); + accsTb.addEventListener("click", (e) => { const ed = e.target.closest("[data-edit]"); const dl = e.target.closest("[data-del]"); if (ed) { @@ -456,6 +816,49 @@ function bindAccounts() { } } }); + + // 能力测试管理 + const capAdd = $("#btn-cap-add"); + if (capAdd) capAdd.addEventListener("click", () => openCapEditor(null)); + const capSeed = $("#btn-cap-seed"); + if (capSeed) capSeed.addEventListener("click", async () => { + capSeed.disabled = true; capSeed.textContent = "生成中…"; + try { + const r = await api("/api/seed-capabilities", "POST", {}); + toast(`已生成 ${r.seeded} 项能力测试`); + loadCaps(); + } catch (e) { toast("生成失败:" + e.message); } + capSeed.disabled = false; capSeed.textContent = "🎲 生成演示能力测试"; + }); + const capsTb = $("#caps tbody"); + capsTb.addEventListener("click", async (e) => { + const ed = e.target.closest("[data-edit-cap]"); + const dl = e.target.closest("[data-del-cap]"); + if (ed) { + const list = await api("/api/admin/capabilities"); + const c = list.find((x) => x.id === Number(ed.dataset.editCap)); + if (c) openCapEditor(c); + } + if (dl) { + const id = dl.dataset.delCap; + if (confirm(`确定删除能力测试 #${id}?`)) { + api(`/api/admin/capabilities/${id}`, "DELETE").then(() => { toast("已删除"); loadCaps(); }); + } + } + }); + const ceSave = $("#ce-save"); + if (ceSave) ceSave.addEventListener("click", saveCapEditor); + const ceCancel = $("#ce-cancel"); + if (ceCancel) ceCancel.addEventListener("click", () => { $("#cap-mask").hidden = true; }); + const ceClose = $("#cap-edit-close"); + if (ceClose) ceClose.addEventListener("click", () => { $("#cap-mask").hidden = true; }); + + // 提交详情弹窗关闭 + const subClose = $("#sub-close"); + if (subClose) { + subClose.addEventListener("click", () => { $("#sub-mask").hidden = true; }); + $("#sub-mask").addEventListener("click", (e) => { if (e.target === $("#sub-mask")) $("#sub-mask").hidden = true; }); + } } /* ───────────────────────── 初始化 ───────────────────────── */ @@ -469,14 +872,17 @@ document.addEventListener("DOMContentLoaded", () => { } else if (page === "model.html") { loadModel(); bindModel(); - } else if (page === "submissions.html") { - loadSubmissions(true); - bindSubmissions(); - } else if (page === "accounts.html") { - loadAccounts(); - bindAccounts(); + } else if (page === "capabilities.html") { + loadCapabilities(); + bindCapabilities(); + } else if (page === "compare.html") { + loadCmpModels(); + bindCompare(); + } else if (page === "admin.html") { + checkAdmin(); + bindAdmin(); } document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { const m = $("#sub-mask"); if (m) m.hidden = true; } + if (e.key === "Escape") { $("#sub-mask").hidden = true; $("#cap-mask").hidden = true; } }); }); diff --git a/static/model.html b/static/model.html index 78ed663..fa32b65 100644 --- a/static/model.html +++ b/static/model.html @@ -11,17 +11,18 @@
模型评测站
- ← 返回排行 + ⚙️ 后台管理

模型详情

+
@@ -55,10 +56,10 @@

📥 该模型的所有评测提交(按账号)

- + -
提交账号时间采样首字 ms提交账号时间硬件采样首字 ms 预填充 tok/s解码 tok/s单流均解码 并发输出 tok测试名称
加载中…
+ 加载中…
diff --git a/static/submissions.html b/static/submissions.html deleted file mode 100644 index 717c037..0000000 --- a/static/submissions.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - -提交管理 · 模型评测站 - - - -
-
模型评测站
- -
- -
- -
-
-

📥 评测提交

-
💡 这些数据来自 llm-speed-tester 的「📤 发送到评测站」按钮(按账号归属)。
-
- - - 支持分页查看、详情、删除 -
-
- - - - -
提交账号时间提供商模型采样预填充 tok/s解码 tok/s首字 ms测试名称操作
加载中…
-
- -
-
- - - - -
- - -