Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fe82328b6 | ||
|
|
35589b1e9b | ||
|
|
05aa4ad1a4 | ||
|
|
0f55483eaf | ||
|
|
faabf3b7e1 |
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- **访问地址:** `http://<IP>:16097/`
|
- **访问地址:** `http://<IP>:16097/`
|
||||||
- **技术栈:** Python 3 + Flask + SQLite(纯 REST,无额外依赖)
|
- **技术栈:** Python 3 + Flask + SQLite(纯 REST,无额外依赖)
|
||||||
- **版本:** v2.3.0
|
- **版本:** v2.5.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
### 🔌 大模型接口配置
|
### 🔌 大模型接口配置
|
||||||
- 支持 **OpenAI 兼容**(OpenAI / DeepSeek / 火山方舟 / 任意兼容网关)、**Anthropic(Claude)**、**Google Gemini** 三类提供商
|
- 支持 **OpenAI 兼容**(OpenAI / DeepSeek / 火山方舟 / 任意兼容网关)、**Anthropic(Claude)**、**Google Gemini** 三类提供商
|
||||||
- 配置项:配置名称、Base URL(留空自动使用官方默认)、API Key(可显示/隐藏)、模型名称、温度
|
- 配置项:配置名称、Base URL(留空自动使用官方默认)、API Key(可显示/隐藏)、模型名称、温度
|
||||||
|
- **📋 查看模型按钮**:填好 Base URL + API Key 后点击,实时调用该接口的 `/models` 获取全部模型(OpenAI 兼容 `GET /models`、Anthropic `GET /v1/models`、Gemini `GET /v1beta/models`),弹出下拉面板可**搜索**(输入关键词实时过滤)/ 点击直接**填入模型名称**;仍支持手动直接输入模型名(搜索框里直接输入回车也能确认填入)
|
||||||
- 配置可**保存/加载/删除**,方便多模型对比
|
- 配置可**保存/加载/删除**,方便多模型对比
|
||||||
|
|
||||||
### 🚀 速度测试配置
|
### 🚀 速度测试配置
|
||||||
@@ -177,4 +178,4 @@ llm-speed-tester/
|
|||||||
## Git
|
## Git
|
||||||
|
|
||||||
- **仓库:** `hz4th_coder/llm-speed-tester`
|
- **仓库:** `hz4th_coder/llm-speed-tester`
|
||||||
- **版本:** v2.3.0(多并发测试(默认单流,预设2/4可自定义并发档,整批吞吐聚合+按并发分组)+ 多测试结果对比(历史勾选→对比表/柱状图/并发折线图))
|
- **版本:** v2.5.0(模型实时获取:接口配置区「📋 查看模型」按钮,Base URL+API Key 下实时拉取模型列表(GET /models),搜索/点击填入,仍支持手动输入)
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
"""LLM 速度测试台 - Flask 主应用"""
|
"""LLM 速度测试台 - Flask 主应用"""
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import Flask, jsonify, request, send_file, send_from_directory
|
from flask import Flask, jsonify, request, send_file, send_from_directory
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import database as db
|
import database as db
|
||||||
from llm_providers import DEFAULT_URLS, ProviderError, call_stream
|
from llm_providers import DEFAULT_URLS, ProviderError, call_stream, list_models
|
||||||
from tester import TestRunner
|
from tester import TestRunner
|
||||||
|
|
||||||
app = Flask(__name__, static_folder="static", static_url_path="")
|
app = Flask(__name__, static_folder="static", static_url_path="")
|
||||||
@@ -24,6 +25,19 @@ def index():
|
|||||||
return send_from_directory(app.static_folder, "index.html")
|
return send_from_directory(app.static_folder, "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/gpu")
|
||||||
|
def gpu_info():
|
||||||
|
"""自动探测本机 GPU(nvidia-smi),供评测站同步时标注硬件"""
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
|
||||||
|
text=True, timeout=5, stderr=subprocess.DEVNULL)
|
||||||
|
gpus = [g.strip() for g in out.strip().splitlines() if g.strip()]
|
||||||
|
return jsonify({"ok": True, "gpus": gpus, "label": " / ".join(gpus)})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": True, "gpus": [], "label": "", "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/health")
|
@app.route("/api/health")
|
||||||
def health():
|
def health():
|
||||||
running = [tid for tid, r in RUNNERS.items() if r.is_alive()]
|
running = [tid for tid, r in RUNNERS.items() if r.is_alive()]
|
||||||
@@ -79,6 +93,22 @@ def del_config(cid):
|
|||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/models", methods=["POST"])
|
||||||
|
def list_models_api():
|
||||||
|
"""实时获取某接口(Base URL + API Key)下的所有模型列表,供前端选择填入"""
|
||||||
|
cfg = _fill_defaults(request.get_json(force=True) or {})
|
||||||
|
try:
|
||||||
|
models = list_models(cfg)
|
||||||
|
if not models:
|
||||||
|
return jsonify({"ok": True, "models": [], "count": 0,
|
||||||
|
"note": "接口可用,但未返回任何模型"})
|
||||||
|
return jsonify({"ok": True, "models": models, "count": len(models)})
|
||||||
|
except ProviderError as e:
|
||||||
|
return jsonify({"ok": False, "error": str(e)})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/configs/test", methods=["POST"])
|
@app.route("/api/configs/test", methods=["POST"])
|
||||||
def test_config():
|
def test_config():
|
||||||
cfg = _fill_defaults(request.get_json(force=True) or {})
|
cfg = _fill_defaults(request.get_json(force=True) or {})
|
||||||
|
|||||||
@@ -307,6 +307,56 @@ def stream_google(cfg, prompt, gen, log, should_stop=None):
|
|||||||
cached_tokens, output_chars, len(prompt))
|
cached_tokens, output_chars, len(prompt))
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── 模型列表获取 ─────────────────────────
|
||||||
|
|
||||||
|
def list_models(cfg):
|
||||||
|
"""实时获取该接口(Base URL + API Key)下的所有模型 ID 列表。
|
||||||
|
支持 OpenAI 兼容 / Anthropic / Google Gemini 三种提供商。"""
|
||||||
|
provider = cfg.get("provider", "openai")
|
||||||
|
api_key = cfg.get("api_key") or ""
|
||||||
|
if not api_key:
|
||||||
|
raise ProviderError("请填写 API Key")
|
||||||
|
base = (cfg.get("base_url") or DEFAULT_URLS.get(provider, "")).rstrip("/")
|
||||||
|
|
||||||
|
if provider == "openai":
|
||||||
|
# GET {base}/models Authorization: Bearer <key> → data[].id
|
||||||
|
headers = {"Authorization": "Bearer " + api_key}
|
||||||
|
resp = requests.get(base + "/models", headers=headers,
|
||||||
|
timeout=config.CONNECT_TIMEOUT)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ProviderError("HTTP %s: %s" % (resp.status_code, resp.text[:400]))
|
||||||
|
data = resp.json()
|
||||||
|
return [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
|
||||||
|
if provider == "anthropic":
|
||||||
|
# GET {base}/v1/models x-api-key + anthropic-version → data[].id
|
||||||
|
headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
|
||||||
|
resp = requests.get(base + "/v1/models", headers=headers,
|
||||||
|
timeout=config.CONNECT_TIMEOUT)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ProviderError("HTTP %s: %s" % (resp.status_code, resp.text[:400]))
|
||||||
|
data = resp.json()
|
||||||
|
return [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
|
||||||
|
if provider == "google":
|
||||||
|
# GET {base}/v1beta/models?key=<key> → models[].name(形如 models/gemini-1.5-pro)
|
||||||
|
resp = requests.get(base + "/v1beta/models", params={"key": api_key},
|
||||||
|
timeout=config.CONNECT_TIMEOUT)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ProviderError("HTTP %s: %s" % (resp.status_code, resp.text[:400]))
|
||||||
|
data = resp.json()
|
||||||
|
out = []
|
||||||
|
for m in (data.get("models") or []):
|
||||||
|
name = m.get("name") or ""
|
||||||
|
if name.startswith("models/"):
|
||||||
|
name = name[len("models/"):]
|
||||||
|
if name and name not in out:
|
||||||
|
out.append(name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
raise ProviderError("不支持的提供商类型: %s" % provider)
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────────── 统一入口 ─────────────────────────
|
# ───────────────────────── 统一入口 ─────────────────────────
|
||||||
|
|
||||||
def call_stream(cfg, prompt, gen, log=None, should_stop=None):
|
def call_stream(cfg, prompt, gen, log=None, should_stop=None):
|
||||||
|
|||||||
@@ -129,6 +129,34 @@ body {
|
|||||||
.conn-result.ok { background: rgba(34,197,139,.12); color: var(--accent2); border: 1px solid rgba(34,197,139,.3); }
|
.conn-result.ok { background: rgba(34,197,139,.12); color: var(--accent2); border: 1px solid rgba(34,197,139,.3); }
|
||||||
.conn-result.fail { background: rgba(255,92,108,.1); color: var(--danger); border: 1px solid rgba(255,92,108,.3); }
|
.conn-result.fail { background: rgba(255,92,108,.1); color: var(--danger); border: 1px solid rgba(255,92,108,.3); }
|
||||||
|
|
||||||
|
/* ── 查看模型(实时获取模型列表下拉面板) ── */
|
||||||
|
.model-pick {
|
||||||
|
margin-top: 8px; background: var(--panel2); border: 1px solid var(--accent);
|
||||||
|
border-radius: 10px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.model-pick[hidden] { display: none; }
|
||||||
|
.model-pick-head {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.model-pick-head input {
|
||||||
|
flex: 1; background: var(--bg); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 6px 8px; font-size: 12px; outline: none;
|
||||||
|
}
|
||||||
|
.model-pick-head input:focus { border-color: var(--accent); }
|
||||||
|
.model-pick-list { max-height: 240px; overflow-y: auto; }
|
||||||
|
.model-pick-item {
|
||||||
|
padding: 7px 10px; font-size: 12.5px; cursor: pointer;
|
||||||
|
font-family: var(--mono); word-break: break-all;
|
||||||
|
border-bottom: 1px solid rgba(42,53,80,.5);
|
||||||
|
}
|
||||||
|
.model-pick-item:hover { background: rgba(79,140,255,.16); color: var(--accent); }
|
||||||
|
.model-pick-item.active { background: rgba(34,197,139,.14); color: var(--accent2); }
|
||||||
|
.model-pick-empty {
|
||||||
|
padding: 14px 10px; color: var(--muted); font-size: 12px; text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 指标卡 ── */
|
/* ── 指标卡 ── */
|
||||||
.metrics { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; }
|
.metrics { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; }
|
||||||
.metric-card {
|
.metric-card {
|
||||||
|
|||||||
+35
-2
@@ -55,8 +55,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>模型名称</label>
|
<label>模型名称 <span class="hint-inline">可直接输入,也可点击「查看模型」实时获取</span></label>
|
||||||
<div class="icon-input"><span class="icon">🤖</span><input id="cfg-model" placeholder="如 gpt-4o / deepseek-chat / claude-sonnet-4"></div>
|
<div class="row">
|
||||||
|
<div class="icon-input" style="flex:1"><span class="icon">🤖</span><input id="cfg-model" placeholder="如 gpt-4o / deepseek-chat / claude-sonnet-4"></div>
|
||||||
|
<button class="btn small" id="btn-list-models" title="实时获取此接口(Base URL + API Key)下的所有模型并选择填入">📋 查看模型</button>
|
||||||
|
</div>
|
||||||
|
<div class="model-pick" id="model-pick" hidden>
|
||||||
|
<div class="model-pick-head">
|
||||||
|
<input id="model-pick-search" placeholder="🔍 搜索模型(可直接输入模型名回车确认)" autocomplete="off">
|
||||||
|
<button class="btn small" id="model-pick-close" title="关闭">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="model-pick-list" id="model-pick-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="conn-result" id="model-result" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>温度 Temperature <b id="tmp-val">0.7</b></label>
|
<label>温度 Temperature <b id="tmp-val">0.7</b></label>
|
||||||
@@ -116,6 +127,27 @@
|
|||||||
<button class="btn danger block" id="btn-cancel" disabled>■ 停止</button>
|
<button class="btn danger block" id="btn-cancel" disabled>■ 停止</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>📤 评测站同步</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>评测站地址</label>
|
||||||
|
<div class="icon-input"><span class="icon">🌐</span><input id="eval-url" placeholder="http://127.0.0.1:16066"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>评测站账号(结果挂到该账号下)</label>
|
||||||
|
<div class="icon-input"><span class="icon">👥</span><input id="eval-account" placeholder="如:性能评测组"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>硬件 / GPU(自动探测可修改,随结果一并发送)</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="icon-input"><span class="icon">🖥</span><input id="eval-hardware" placeholder="如:NVIDIA A100 80G"></div>
|
||||||
|
<button class="btn small" id="btn-detect-gpu" title="自动探测本机GPU">🔍 探测</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn block" id="btn-eval-test">🔗 测试连接</button>
|
||||||
|
<div class="conn-result" id="eval-result" hidden></div>
|
||||||
|
<div class="hint" style="margin-top:6px">测试完成后,在历史列表或详情里点「📤 发送到评测站」,一键把结果发布到模型评测网站(端口 16066)对应账号下。</div>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 右侧:指标 + 日志 + 历史 -->
|
<!-- 右侧:指标 + 日志 + 历史 -->
|
||||||
@@ -170,6 +202,7 @@
|
|||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
<h2>测试详情 #<span id="dt-id"></span></h2>
|
<h2>测试详情 #<span id="dt-id"></span></h2>
|
||||||
<div>
|
<div>
|
||||||
|
<button class="btn small primary" id="dt-send-eval">📤 发送到评测站</button>
|
||||||
<button class="btn small primary" id="dt-export-xlsx">⬇ 导出 Excel</button>
|
<button class="btn small primary" id="dt-export-xlsx">⬇ 导出 Excel</button>
|
||||||
<button class="btn small primary" id="dt-export">导出 JSON</button>
|
<button class="btn small primary" id="dt-export">导出 JSON</button>
|
||||||
<button class="btn small" id="dt-close">✕</button>
|
<button class="btn small" id="dt-close">✕</button>
|
||||||
|
|||||||
+198
-2
@@ -191,6 +191,7 @@ function loadSelectedConfig() {
|
|||||||
$("#cfg-temp").value = c.temperature ?? 0.7;
|
$("#cfg-temp").value = c.temperature ?? 0.7;
|
||||||
$("#tmp-val").textContent = (c.temperature ?? 0.7).toFixed(1);
|
$("#tmp-val").textContent = (c.temperature ?? 0.7).toFixed(1);
|
||||||
updateDefaultUrlHint();
|
updateDefaultUrlHint();
|
||||||
|
hideModelPick();
|
||||||
flash("已加载配置:" + c.name);
|
flash("已加载配置:" + c.name);
|
||||||
}).catch(() => toast("加载失败"));
|
}).catch(() => toast("加载失败"));
|
||||||
}
|
}
|
||||||
@@ -223,6 +224,81 @@ function showConn(ok, text) {
|
|||||||
box.textContent = text;
|
box.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────────────────────── 查看模型(实时获取接口下所有模型) ───────────────────────── */
|
||||||
|
|
||||||
|
function hideModelPick() {
|
||||||
|
$("#model-pick").hidden = true;
|
||||||
|
$("#model-result").hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listModels() {
|
||||||
|
const cfg = currentConfig();
|
||||||
|
hideModelPick();
|
||||||
|
if (!cfg.base_url) { toast("请先填写 Base URL"); return; }
|
||||||
|
if (!cfg.api_key) { toast("请先填写 API Key"); return; }
|
||||||
|
const panel = $("#model-pick");
|
||||||
|
panel.hidden = false;
|
||||||
|
const listEl = $("#model-pick-list");
|
||||||
|
listEl.innerHTML = '<div class="model-pick-empty">⏳ 正在获取模型列表(GET /models)...</div>';
|
||||||
|
try {
|
||||||
|
const r = await api("/api/models", "POST", cfg);
|
||||||
|
if (!r.ok) throw new Error(r.error || "获取失败");
|
||||||
|
const models = r.models || [];
|
||||||
|
if (!models.length) {
|
||||||
|
listEl.innerHTML = '<div class="model-pick-empty">⚠️ ' + esc(r.note || "接口可用,但未返回任何模型") + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderModelList(models);
|
||||||
|
} catch (e) {
|
||||||
|
panel.hidden = true;
|
||||||
|
const box = $("#model-result");
|
||||||
|
box.hidden = false;
|
||||||
|
box.className = "conn-result fail";
|
||||||
|
box.textContent = "❌ 获取模型失败:" + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModelList(models) {
|
||||||
|
const listEl = $("#model-pick-list");
|
||||||
|
const search = $("#model-pick-search");
|
||||||
|
search.value = "";
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
const fill = (m) => {
|
||||||
|
$("#cfg-model").value = m;
|
||||||
|
hideModelPick();
|
||||||
|
toast("已填入模型:" + m);
|
||||||
|
};
|
||||||
|
const render = () => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
const cur = $("#cfg-model").value.trim();
|
||||||
|
const filtered = q ? models.filter((m) => m.toLowerCase().includes(q)) : models;
|
||||||
|
if (!filtered.length) {
|
||||||
|
listEl.innerHTML = '<div class="model-pick-empty">无匹配模型,可直接在上方输入模型名</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
for (const m of filtered) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "model-pick-item" + (m === cur ? " active" : "");
|
||||||
|
item.textContent = m;
|
||||||
|
item.title = "点击填入模型名称";
|
||||||
|
item.addEventListener("click", () => fill(m));
|
||||||
|
listEl.appendChild(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
search.addEventListener("input", render);
|
||||||
|
search.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const v = search.value.trim();
|
||||||
|
if (v) fill(v); // 手动输入回车直接确认填入
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
hideModelPick();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
search.focus();
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────────────────────── 测试运行 ───────────────────────── */
|
/* ───────────────────────── 测试运行 ───────────────────────── */
|
||||||
|
|
||||||
function setStatus(state, label) {
|
function setStatus(state, label) {
|
||||||
@@ -383,6 +459,7 @@ async function loadHistory() {
|
|||||||
<td><span class="status-pill ${esc(t.status)}">${STATUS_LABEL[t.status] || t.status}</span></td>
|
<td><span class="status-pill ${esc(t.status)}">${STATUS_LABEL[t.status] || t.status}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn small" data-view="${t.id}">查看</button>
|
<button class="btn small" data-view="${t.id}">查看</button>
|
||||||
|
<button class="btn small" data-send="${t.id}" title="发送到评测站对应账号下">📤 发送</button>
|
||||||
<button class="btn small" data-xlsx="${t.id}">Excel</button>
|
<button class="btn small" data-xlsx="${t.id}">Excel</button>
|
||||||
<button class="btn small danger" data-del="${t.id}">删除</button>
|
<button class="btn small danger" data-del="${t.id}">删除</button>
|
||||||
</td>`;
|
</td>`;
|
||||||
@@ -824,6 +901,115 @@ function exportCurrentLog() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────────────────────── 评测站同步(一键发送) ───────────────────────── */
|
||||||
|
|
||||||
|
const EVAL_TOKEN = "meval16066"; // 与评测站 config.SUBMIT_TOKEN 一致
|
||||||
|
|
||||||
|
function evalSettings() {
|
||||||
|
return {
|
||||||
|
url: (localStorage.getItem("evalUrl") || "http://127.0.0.1:16066").replace(/\/+$/, ""),
|
||||||
|
account: localStorage.getItem("evalAccount") || "默认账号",
|
||||||
|
hardware: localStorage.getItem("evalHardware") || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEvalSettings() {
|
||||||
|
const s = evalSettings();
|
||||||
|
const u = $("#eval-url"), a = $("#eval-account"), h = $("#eval-hardware");
|
||||||
|
if (u) u.value = s.url;
|
||||||
|
if (a) a.value = s.account;
|
||||||
|
if (h) h.value = s.hardware;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEvalSettings() {
|
||||||
|
localStorage.setItem("evalUrl", $("#eval-url").value.trim() || "http://127.0.0.1:16066");
|
||||||
|
localStorage.setItem("evalAccount", $("#eval-account").value.trim() || "默认账号");
|
||||||
|
localStorage.setItem("evalHardware", $("#eval-hardware").value.trim() || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectGpu() {
|
||||||
|
const box = $("#eval-result");
|
||||||
|
try {
|
||||||
|
const r = await api("/api/gpu");
|
||||||
|
if (r.label) {
|
||||||
|
$("#eval-hardware").value = r.label;
|
||||||
|
saveEvalSettings();
|
||||||
|
box.hidden = false; box.className = "conn-result ok";
|
||||||
|
box.textContent = `✅ 已探测到 GPU:${r.label}`;
|
||||||
|
} else {
|
||||||
|
box.hidden = false; box.className = "conn-result fail";
|
||||||
|
box.textContent = "未探测到 GPU(nvidia-smi 不可用),可手动填写硬件描述";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
box.hidden = false; box.className = "conn-result fail";
|
||||||
|
box.textContent = "❌ 探测失败:" + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testEvalConn() {
|
||||||
|
saveEvalSettings();
|
||||||
|
const s = evalSettings();
|
||||||
|
const box = $("#eval-result");
|
||||||
|
box.hidden = false; box.className = "conn-result";
|
||||||
|
box.textContent = "⏳ 正在连接评测站...";
|
||||||
|
try {
|
||||||
|
const resp = await fetch(s.url + "/api/health");
|
||||||
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
|
const j = await resp.json();
|
||||||
|
const st = j.stats || {};
|
||||||
|
box.className = "conn-result ok";
|
||||||
|
box.textContent = `✅ 连接成功(端口 ${j.port})| 模型 ${st.models} · 提交 ${st.submissions} · 账号 ${st.accounts}`;
|
||||||
|
} catch (e) {
|
||||||
|
box.className = "conn-result fail";
|
||||||
|
box.textContent = "❌ 连接失败:" + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendToEval(id) {
|
||||||
|
saveEvalSettings();
|
||||||
|
const s = evalSettings();
|
||||||
|
if (!confirm(`将测试 #${id} 发送到评测站(${s.url})的账号「${s.account}」下?`)) return;
|
||||||
|
let t;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/tests/${id}/export.json`);
|
||||||
|
if (!resp.ok) throw new Error("读取测试数据失败");
|
||||||
|
t = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
toast("读取失败:" + e.message); return;
|
||||||
|
}
|
||||||
|
if (!(t.summary && t.summary.samples_ok > 0)) {
|
||||||
|
toast("该测试没有成功采样数据,无法发送"); return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
account: s.account,
|
||||||
|
source_test_id: id,
|
||||||
|
source_site: "llm-speed-tester",
|
||||||
|
provider: t.provider || "",
|
||||||
|
model: t.model || "",
|
||||||
|
hardware: s.hardware,
|
||||||
|
test_name: t.name || "",
|
||||||
|
summary: t.summary || {},
|
||||||
|
gen: t.gen || {},
|
||||||
|
config: t.config || {},
|
||||||
|
runs: t.runs || [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const resp = await fetch(s.url + "/api/submit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "X-Token": EVAL_TOKEN },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const j = await resp.json().catch(() => ({}));
|
||||||
|
if (!j.ok) throw new Error(j.error || "发送失败(HTTP " + resp.status + ")");
|
||||||
|
toast(`✅ 已发送!评测站提交 #${j.id}(账号 ${j.account} / ${j.model})`);
|
||||||
|
setTimeout(() => {
|
||||||
|
toast(`📤 查看详情:${s.url}${j.url}`);
|
||||||
|
}, 3600);
|
||||||
|
} catch (e) {
|
||||||
|
toast("❌ 发送失败:" + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────────────────────── 提示 ───────────────────────── */
|
/* ───────────────────────── 提示 ───────────────────────── */
|
||||||
|
|
||||||
function toast(msg) {
|
function toast(msg) {
|
||||||
@@ -841,13 +1027,17 @@ function flash(msg) {
|
|||||||
/* ───────────────────────── 事件绑定 ───────────────────────── */
|
/* ───────────────────────── 事件绑定 ───────────────────────── */
|
||||||
|
|
||||||
function bind() {
|
function bind() {
|
||||||
$("#cfg-provider").addEventListener("change", updateDefaultUrlHint);
|
$("#cfg-provider").addEventListener("change", () => { updateDefaultUrlHint(); hideModelPick(); });
|
||||||
|
$("#cfg-baseurl").addEventListener("input", hideModelPick);
|
||||||
|
$("#cfg-apikey").addEventListener("input", hideModelPick);
|
||||||
$("#cfg-temp").addEventListener("input", () => $("#tmp-val").textContent = parseFloat($("#cfg-temp").value).toFixed(1));
|
$("#cfg-temp").addEventListener("input", () => $("#tmp-val").textContent = parseFloat($("#cfg-temp").value).toFixed(1));
|
||||||
|
|
||||||
$("#btn-toggle-key").addEventListener("click", () => {
|
$("#btn-toggle-key").addEventListener("click", () => {
|
||||||
const inp = $("#cfg-apikey");
|
const inp = $("#cfg-apikey");
|
||||||
inp.type = inp.type === "password" ? "text" : "password";
|
inp.type = inp.type === "password" ? "text" : "password";
|
||||||
});
|
});
|
||||||
|
$("#btn-list-models").addEventListener("click", listModels);
|
||||||
|
$("#model-pick-close").addEventListener("click", hideModelPick);
|
||||||
$("#btn-save-config").addEventListener("click", async () => {
|
$("#btn-save-config").addEventListener("click", async () => {
|
||||||
const r = await api("/api/configs", "POST", currentConfig());
|
const r = await api("/api/configs", "POST", currentConfig());
|
||||||
if (r.ok) { toast("配置已保存"); loadConfigs(); }
|
if (r.ok) { toast("配置已保存"); loadConfigs(); }
|
||||||
@@ -863,6 +1053,8 @@ function bind() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("#btn-test-conn").addEventListener("click", testConnection);
|
$("#btn-test-conn").addEventListener("click", testConnection);
|
||||||
|
$("#btn-eval-test").addEventListener("click", testEvalConn);
|
||||||
|
$("#btn-detect-gpu").addEventListener("click", detectGpu);
|
||||||
$("#btn-start").addEventListener("click", startTest);
|
$("#btn-start").addEventListener("click", startTest);
|
||||||
$("#btn-cancel").addEventListener("click", stopTest);
|
$("#btn-cancel").addEventListener("click", stopTest);
|
||||||
$("#btn-context-add").addEventListener("click", addContextLength);
|
$("#btn-context-add").addEventListener("click", addContextLength);
|
||||||
@@ -877,9 +1069,11 @@ function bind() {
|
|||||||
|
|
||||||
$("#history tbody").addEventListener("click", (e) => {
|
$("#history tbody").addEventListener("click", (e) => {
|
||||||
const v = e.target.closest("[data-view]");
|
const v = e.target.closest("[data-view]");
|
||||||
|
const s = e.target.closest("[data-send]");
|
||||||
const x = e.target.closest("[data-xlsx]");
|
const x = e.target.closest("[data-xlsx]");
|
||||||
const d = e.target.closest("[data-del]");
|
const d = e.target.closest("[data-del]");
|
||||||
if (v) viewDetail(Number(v.dataset.view));
|
if (v) viewDetail(Number(v.dataset.view));
|
||||||
|
if (s) sendToEval(Number(s.dataset.send));
|
||||||
if (x) exportXlsx(Number(x.dataset.xlsx));
|
if (x) exportXlsx(Number(x.dataset.xlsx));
|
||||||
if (d) {
|
if (d) {
|
||||||
const id = Number(d.dataset.del);
|
const id = Number(d.dataset.del);
|
||||||
@@ -911,8 +1105,9 @@ function bind() {
|
|||||||
$("#cmp-mask").addEventListener("click", (e) => { if (e.target === $("#cmp-mask")) closeCompare(); });
|
$("#cmp-mask").addEventListener("click", (e) => { if (e.target === $("#cmp-mask")) closeCompare(); });
|
||||||
$("#dt-export").addEventListener("click", exportDetail);
|
$("#dt-export").addEventListener("click", exportDetail);
|
||||||
$("#dt-export-xlsx").addEventListener("click", () => { if (window.__detail) exportXlsx(window.__detail.id); });
|
$("#dt-export-xlsx").addEventListener("click", () => { if (window.__detail) exportXlsx(window.__detail.id); });
|
||||||
|
$("#dt-send-eval").addEventListener("click", () => { if (window.__detail) sendToEval(window.__detail.id); });
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeDetail(); closeCompare(); } });
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideModelPick(); closeDetail(); closeCompare(); } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────────────────────── 初始化 ───────────────────────── */
|
/* ───────────────────────── 初始化 ───────────────────────── */
|
||||||
@@ -924,5 +1119,6 @@ function bind() {
|
|||||||
updateDefaultUrlHint();
|
updateDefaultUrlHint();
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
|
loadEvalSettings();
|
||||||
setInterval(() => { if (!currentTestId) loadHistory(); }, 30000); // 空闲时定期刷新历史
|
setInterval(() => { if (!currentTestId) loadHistory(); }, 30000); // 空闲时定期刷新历史
|
||||||
})();
|
})();
|
||||||
Reference in New Issue
Block a user