Compare commits

4 Commits
Author SHA1 Message Date
hz4th_coder ccfd7eb689 v2.8.0 清空历史真正物理删除 + 请求超时可配修复慢接口被掐断
1. 清空历史=真正清空(不再只是删行留痕):
   - 清空前先停止所有运行中测试线程,避免其继续写入孤儿数据
   - DELETE 后重置自增序列(sqlite_sequence),新测试 ID 从 1 开始
   - WAL checkpoint(TRUNCATE) + VACUUM 物理回收文件空间,数据不可恢复
   - 单条删除删光时同样重置自增序列

2. 慢接口被掐断修复(预处理/首字耗时过长 Read timed out):
   - 读取超时默认 300s→1800s(30分钟),连接 60s
   - 「速度测试配置」新增 请求超时(秒) 输入框(默认1800),测试连接+速度采样共用,按次可调(如3600)
   - 超时错误改为友好提示:等待响应超时(超过N秒未收到数据),引导调大请求超时,不再裸抛 Read timed out
   - 测试启动日志打印当前超时配置
   - 修复案例: 18008 Qwen3.8-FP8 131072 长上下文预填充被 300s 掐断
2026-09-13 12:38:40 +08:00
hz4th_coder 31e5bbb873 v2.7.2 查看模型接口不通时不显示原始错误,优雅提示接口不通 2026-09-09 11:06:39 +08:00
hz4th_coder 8c413cc997 v2.7.1 左侧配置面板独立滚动:鼠标在左侧滚动只翻左侧几个块,右侧滚右侧 2026-09-06 10:56:50 +08:00
hz4th_coder f499144465 v2.7.0 一键导出全部历史(GET /api/tests/export JSON, Key打码);左侧三块配置卡片可折叠展开(状态localStorage持久化);置顶/置底按钮高亮蓝渐变高对比配色 2026-09-06 02:54:16 +08:00
10 changed files with 233 additions and 29 deletions
+5
View File
@@ -163,6 +163,9 @@
### `DELETE /api/tests`
一键清空全部测试历史(测试记录 + 采样指标 + 日志),不可恢复。响应 `{ "ok": true }`
### `GET /api/tests/export`
一键导出全部历史记录为 JSON 文件下载(`Content-Disposition: attachment`,文件名带时间戳)。每条含基本字段 + 配置(**API Key 打码为 `******`**)+ 生成参数 + 汇总指标,不含逐条采样与完整日志。
### `POST /api/tests/<id>/cancel`
停止正在运行的测试。响应 `{ "ok": true, "msg": "正在停止..." }`
@@ -369,6 +372,8 @@ curl "$BASE/api/tests?page=1&page_size=20"
curl "$BASE/api/tests?q=Qwen&status=done,error&sort=decode&order=desc&page=1&page_size=10"
# 一键清空全部历史(不可恢复)
curl -X DELETE $BASE/api/tests
# 一键导出全部历史(JSON 文件下载)
curl -OJ $BASE/api/tests/export
curl $BASE/api/tests/9
# 画图数据(CSV
+6 -3
View File
@@ -4,7 +4,7 @@
- **访问地址:** `http://<IP>:16097/`
- **技术栈:** Python 3 + Flask + SQLite(纯 REST,无额外依赖)
- **版本:** v2.6.0
- **版本:** v2.7.0
---
@@ -40,9 +40,11 @@
- **文件下载 Excelxlsx)**:历史记录「Excel」按钮或详情弹窗「导出 Excel」,包含 汇总 / 采样明细 / 日志 三个 Sheet
- **文件下载 JSON**:详情弹窗「导出 JSON」
- 测试历史留存(含测试名称),可随时刷新、查看、导出、删除
- **📥 一键导出全部历史**:历史卡片「导出全部」按钮,把全部测试记录(配置 Key 打码 + 生成参数 + 汇总指标)导出为一个 JSON 文件下载(`GET /api/tests/export`
- **📄 历史分页 + 筛选 + 搜索 + 排序**:历史列表支持分页(10/20/50/100 条每页)、多条件筛选(状态多选、提供商)、关键词搜索(编号/名称/模型/提供商/状态/时间)、点击表头升降排序(#/时间/名称/提供商/模型/采样/首字/预填充/解码/状态,数值列空值恒排最后);提供「✕ 重置」一键恢复默认
- **🗑 一键清空历史**:历史卡片右上角「清空历史」二次确认后,删除全部测试记录/采样/日志(不可恢复)
- **⬆⬇ 一键置顶/置底**:右下角固定悬浮按钮,平滑滚动到页面顶部/底部
- **🗜 左侧配置块可折叠**:大模型接口配置 / 速度测试配置 / 评测站同步 三个卡片点击标题即可折叠/展开(箭头指示),状态本地持久化
- **⬆⬇ 一键置顶/置底**:右下角固定悬浮按钮(亮蓝渐变高对比配色,易于发现),平滑滚动到页面顶部/底部
- **⚖️ 多测试结果对比**:测试历史表格勾选多个测试(可全选),点「⚖️ 对比所选」弹出对比面板:
- **指标对比表**:时间 / 名称 / 模型 / 并发档 / 采样 / 首字 / 预填充 / 解码 / 单流均解码 / 输出 / 总耗时 并排展示(点击测试名可跳详情)
- **柱状图**:各测试预填充(空心柱)vs 解码(实心柱)速度对比
@@ -106,6 +108,7 @@ pip install -r requirements.txt
| POST | `/api/configs/test` | 测试连接 |
| GET/POST | `/api/tests?page=&page_size=&q=&status=&provider=&sort=&order=` | 测试历史(分页/搜索/筛选/排序)/ 启动测试(异步) |
| DELETE | `/api/tests` | 一键清空全部测试历史(含采样与日志) |
| GET | `/api/tests/export` | 一键导出全部历史记录(JSON 文件下载,API Key 打码) |
| GET | `/api/tests/<id>` | 测试详情(含 runs / logs / summary / 按长度分组) |
| GET | `/api/tests/<id>/logs?after=<id>` | 增量日志(前端轮询用) |
| POST | `/api/tests/<id>/cancel` | 停止测试 |
@@ -183,4 +186,4 @@ llm-speed-tester/
## Git
- **仓库:** `hz4th_coder/llm-speed-tester`
- **版本:** v2.6.0历史分页+筛选+搜索+表头排序;一键清空历史;右下角置顶/置底悬浮按钮
- **版本:** v2.7.0一键导出全部历史(JSON);左侧三块配置卡片可折叠展开(状态持久化);置顶/置底按钮高亮蓝渐变配色
+40 -3
View File
@@ -3,6 +3,8 @@
import io
import json
import subprocess
import time
from datetime import datetime
import requests
from flask import Flask, jsonify, request, send_file, send_from_directory
@@ -111,13 +113,17 @@ def list_models_api():
@app.route("/api/configs/test", methods=["POST"])
def test_config():
cfg = _fill_defaults(request.get_json(force=True) or {})
body = request.get_json(force=True) or {}
cfg = _fill_defaults(body)
if not cfg.get("api_key"):
return jsonify({"ok": False, "error": "请填写 API Key"}), 400
try:
# 连接测试:只要流式请求成功返回(哪怕正文为空/只有思维链)都算连通
# 慢接口长预处理可能长时间等不到首字,这里直接用请求里带的超时(默认 30 分钟),避免误判接口不通
m = call_stream(cfg, "你好,请简要回答:1+1=",
{"max_tokens": 32, "avoid_cache": False})
{"max_tokens": 32, "avoid_cache": False,
"read_timeout": body.get("read_timeout"),
"connect_timeout": body.get("connect_timeout")})
note = ""
if not (m.get("output_tokens") or m.get("output_chars")):
note = "(连接正常,但本次未返回正文内容,可能为推理型模型)"
@@ -146,6 +152,24 @@ def start_test():
return jsonify({"ok": True, "id": tid})
@app.route("/api/tests/export")
def export_tests():
"""一键导出全部历史记录(JSON 文件下载,API Key 打码)"""
data = db.export_tests()
payload = {
"ok": True,
"count": len(data),
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"tests": data,
}
resp = app.response_class(json.dumps(payload, ensure_ascii=False),
mimetype="application/json")
resp.headers["Content-Disposition"] = (
'attachment; filename="llm_speed_tests_%s.json"'
% datetime.now().strftime("%Y%m%d_%H%M%S"))
return resp
@app.route("/api/tests", methods=["GET"])
def list_tests():
"""历史记录列表:支持关键词搜索 q、状态 status(逗号多选)、提供商 provider、
@@ -169,7 +193,20 @@ def list_tests():
@app.route("/api/tests", methods=["DELETE"])
def clear_tests():
"""一键清空全部测试历史(含采样指标与日志)"""
"""一键清空全部测试历史(含采样指标与日志)
先停止所有运行中的测试线程,避免清空后它们继续写入孤儿数据;
再由 db.clear_tests 做物理删除(重置自增 + VACUUM 瘦身),真正清空、不可恢复。"""
# 1) 停止所有运行中的测试,并等待其退出
alive = [r for r in RUNNERS.values() if r.is_alive()]
for r in alive:
r.request_cancel()
if alive:
deadline = time.time() + 5
for r in alive:
if r.is_alive():
r.join(timeout=max(0.1, deadline - time.time()))
RUNNERS.clear()
# 2) 物理清空数据库
db.clear_tests()
return jsonify({"ok": True})
+4 -2
View File
@@ -10,9 +10,11 @@ DATA_DIR = os.path.join(BASE_DIR, "data")
LOG_DIR = os.path.join(BASE_DIR, "logs")
DB_PATH = os.path.join(DATA_DIR, "llm_speed_tester.db")
# 流式请求超时:连接 60s,两次数据包间隔最长 300s推理型/长上文模型也够用
# 流式请求超时:连接 60s,两次数据包间隔最长 1800s30 分钟
# 注意:慢接口长上下文预处理(预填充)期间服务端可能长时间不返回任何字节,
# 等待首字受 STREAM_READ_TIMEOUT 限制;如接口更慢可在「速度测试配置」里按次调大请求超时。
CONNECT_TIMEOUT = 60
STREAM_READ_TIMEOUT = 300
STREAM_READ_TIMEOUT = 1800
# data-chart-tool 图表服务地址(用它的 /api/chart 画折线图:预填充左轴虚线 / 解码右轴实线)
CHART_API_BASE = "http://127.0.0.1:16016"
+36 -1
View File
@@ -277,14 +277,21 @@ def query_tests(q="", status="", provider="", sort="id", order="desc",
def clear_tests():
"""一键清空全部历史(测试记录 + 采样指标 + 日志)"""
"""一键清空全部历史(测试记录 + 采样指标 + 日志)
真正清空:删除全部行 + 重置自增序列 + WAL 检查点 + VACUUM 物理回收文件空间,
数据不可恢复(不是只从列表里移除,文件里也不再残留)。"""
with _lock:
conn = _connect()
try:
conn.execute("DELETE FROM test_runs")
conn.execute("DELETE FROM logs")
conn.execute("DELETE FROM tests")
# 重置自增序列:清空后新测试 ID 从 1 重新开始
conn.execute("DELETE FROM sqlite_sequence WHERE name IN ('tests','test_runs','logs')")
conn.commit()
# WAL 检查点截断 + VACUUM:把文件里已删除的数据页物理抹掉、文件瘦身
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.execute("VACUUM")
finally:
conn.close()
@@ -296,11 +303,39 @@ def delete_test(tid: int):
conn.execute("DELETE FROM tests WHERE id=?", (tid,))
conn.execute("DELETE FROM test_runs WHERE test_id=?", (tid,))
conn.execute("DELETE FROM logs WHERE test_id=?", (tid,))
# 全部删光时顺带重置自增序列
n = conn.execute("SELECT COUNT(*) FROM tests").fetchone()[0]
if n == 0:
conn.execute("DELETE FROM sqlite_sequence WHERE name IN ('tests','test_runs','logs')")
conn.commit()
finally:
conn.close()
def export_tests():
"""导出全部历史:每条含 基本字段 + 配置(Key打码) + 生成参数 + 摘要指标,
不含逐条采样与完整日志(避免文件过大)"""
with _lock:
conn = _connect()
try:
rows = conn.execute(
"SELECT id,created_at,status,provider,model,name,config_json,gen_cfg_json,summary_json,error "
"FROM tests ORDER BY id").fetchall()
out = []
for r in rows:
d = dict(r)
cfg = json.loads(d.pop("config_json") or "{}")
if cfg.get("api_key"):
cfg["api_key"] = "******"
d["config"] = cfg
d["gen"] = json.loads(d.pop("gen_cfg_json") or "{}")
d["summary"] = json.loads(d.pop("summary_json") or "{}")
out.append(d)
return out
finally:
conn.close()
# ───────────────────────── 采样指标 ─────────────────────────
def add_run(tid: int, run_index: int, metrics: dict, error: str = "", context_length: int = 0):
+39 -3
View File
@@ -116,7 +116,7 @@ def stream_openai(cfg, prompt, gen, log, should_stop=None):
if should_stop and should_stop():
raise StopRequested()
resp = requests.post(url, json=build_payload(), headers=headers, stream=True,
timeout=(config.CONNECT_TIMEOUT, config.STREAM_READ_TIMEOUT))
timeout=_timeouts(gen))
if resp.status_code == 200:
break
err = resp.text[:400]
@@ -152,6 +152,10 @@ def stream_openai(cfg, prompt, gen, log, should_stop=None):
cached_tokens = details.get("cached_tokens") or 0
except StopRequested:
raise
except requests.exceptions.ReadTimeout:
raise ProviderError(_read_timeout_err(gen))
except requests.exceptions.ConnectTimeout:
raise ProviderError(_connect_timeout_err(gen))
except Exception as e:
raise ProviderError("流式请求异常: %s" % e)
finally:
@@ -171,6 +175,30 @@ def _bad_stream_options(err: str):
or "additional properties" in err)
def _timeouts(gen):
"""从测试参数 gen 里取超时配置(秒),未配置则用全局默认。
返回 (connect, read) 元组,供 requests timeout 使用。"""
try:
connect = float(gen.get("connect_timeout") or config.CONNECT_TIMEOUT)
except (TypeError, ValueError):
connect = config.CONNECT_TIMEOUT
try:
read = float(gen.get("read_timeout") or config.STREAM_READ_TIMEOUT)
except (TypeError, ValueError):
read = config.STREAM_READ_TIMEOUT
return (max(connect, 5), max(read, 10))
def _read_timeout_err(gen):
"""读超时的友好报错:提示这是等待数据超时,可调大请求超时"""
return ("等待响应超时:超过 %.0f 秒未收到数据(接口预处理/首字耗时过长或网络过慢),"
"请在「速度测试配置」中调大“请求超时(秒)”" % _timeouts(gen)[1])
def _connect_timeout_err(gen):
return "连接超时:超过 %.0f 秒未建立连接(请检查地址/网络)" % _timeouts(gen)[0]
def _iter_json(resp):
"""解析 SSE data: 行,逐个返回 JSON 对象"""
for raw in resp.iter_lines(decode_unicode=True):
@@ -206,7 +234,7 @@ def stream_anthropic(cfg, prompt, gen, log, should_stop=None):
if should_stop and should_stop():
raise StopRequested()
resp = requests.post(url, json=payload, headers=headers, stream=True,
timeout=(config.CONNECT_TIMEOUT, config.STREAM_READ_TIMEOUT))
timeout=_timeouts(gen))
if resp.status_code != 200:
err = resp.text[:400]
resp.close()
@@ -233,6 +261,10 @@ def stream_anthropic(cfg, prompt, gen, log, should_stop=None):
output_tokens = usage.get("output_tokens") or output_tokens
except StopRequested:
raise
except requests.exceptions.ReadTimeout:
raise ProviderError(_read_timeout_err(gen))
except requests.exceptions.ConnectTimeout:
raise ProviderError(_connect_timeout_err(gen))
except Exception as e:
raise ProviderError("流式请求异常: %s" % e)
finally:
@@ -272,7 +304,7 @@ def stream_google(cfg, prompt, gen, log, should_stop=None):
if should_stop and should_stop():
raise StopRequested()
resp = requests.post(url, params=params, json=payload, headers=headers, stream=True,
timeout=(config.CONNECT_TIMEOUT, config.STREAM_READ_TIMEOUT))
timeout=_timeouts(gen))
if resp.status_code != 200:
err = resp.text[:400]
resp.close()
@@ -299,6 +331,10 @@ def stream_google(cfg, prompt, gen, log, should_stop=None):
cached_tokens = um.get("cachedContentTokenCount") or 0
except StopRequested:
raise
except requests.exceptions.ReadTimeout:
raise ProviderError(_read_timeout_err(gen))
except requests.exceptions.ConnectTimeout:
raise ProviderError(_connect_timeout_err(gen))
except Exception as e:
raise ProviderError("流式请求异常: %s" % e)
finally:
+36 -7
View File
@@ -43,17 +43,44 @@ body {
/* ── 布局 ── */
.layout { display: grid; grid-template-columns: 340px 1fr; gap: 16px; padding: 16px 24px; align-items: start; }
.config-panel { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 72px; }
.config-panel {
display: flex; flex-direction: column; gap: 16px;
position: sticky; top: 72px;
/* 左侧独立滚动:面板固定视口高度,鼠标在左侧滚动时只翻左侧几个块,不带动右侧 */
max-height: calc(100vh - 88px);
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 6px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.config-panel::-webkit-scrollbar { width: 8px; }
.config-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.config-panel::-webkit-scrollbar-thumb:hover { background: var(--accent); }
.main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 12px; padding: 16px;
}
/* ── 可折叠卡片(左侧三块) ── */
.card h2 { font-size: 15px; margin-bottom: 14px; }
.collapse-head {
display: flex; align-items: center; justify-content: space-between;
cursor: pointer; user-select: none;
}
.collapse-head:hover { color: var(--accent); }
.collapse-arrow {
font-size: 12px; color: var(--muted); transition: transform .15s;
}
.card.collapsed .collapse-arrow { transform: rotate(-90deg); }
.card.collapsed .collapse-body { display: none; }
/* ── 卡片头部 ── */
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.card-head h2 { margin-bottom: 0; }
/* ── 表单 ── */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; }
@@ -279,20 +306,22 @@ pre.json-box {
padding: 10px; font: 11.5px/1.6 var(--mono); overflow: auto; max-height: 260px; color: #a8f0d0;
}
/* ── 右下角一键置顶/置底 ── */
/* ── 右下角一键置顶/置底(高对比配色,易于发现) ── */
.scroll-fab {
position: fixed; right: 20px; bottom: 20px;
display: flex; flex-direction: column; gap: 8px; z-index: 60;
}
.fab {
width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--border);
background: var(--panel2); color: var(--text); font-size: 16px; cursor: pointer;
box-shadow: 0 4px 14px rgba(0,0,0,.45); transition: .15s; line-height: 1;
width: 44px; height: 44px; border-radius: 50%; border: none;
background: linear-gradient(135deg, #5a92ff, #2f6bff);
color: #fff; font-size: 18px; cursor: pointer;
box-shadow: 0 6px 18px rgba(47,107,255,.55); transition: .15s; line-height: 1;
}
.fab:hover { border-color: var(--accent); color: var(--accent); }
.fab:hover { filter: brightness(1.15); transform: translateY(-1px); }
.fab:active { transform: translateY(0); }
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; }
.config-panel { position: static; }
.config-panel { position: static; max-height: none; overflow: visible; }
.metrics { grid-template-columns: repeat(3, 1fr); }
}
+18 -6
View File
@@ -19,8 +19,9 @@
<main class="layout">
<!-- 左侧:配置 -->
<aside class="config-panel">
<section class="card">
<h2>🔌 大模型接口配置</h2>
<section class="card collapsible" id="sec-interface">
<h2 class="collapse-head">🔌 大模型接口配置<span class="collapse-arrow"></span></h2>
<div class="collapse-body">
<div class="field">
<label>提供商类型</label>
<select id="cfg-provider">
@@ -75,10 +76,12 @@
</div>
<button class="btn block" id="btn-test-conn">🔍 测试连接</button>
<div class="conn-result" id="conn-result" hidden></div>
</div>
</section>
<section class="card">
<h2>🚀 速度测试配置</h2>
<section class="card collapsible" id="sec-test">
<h2 class="collapse-head">🚀 速度测试配置<span class="collapse-arrow"></span></h2>
<div class="collapse-body">
<div class="field">
<label>测试名称(主题)</label>
<input id="gen-name" type="text" placeholder="如:DeepSeek-V4 不同上下文长度速度对比">
@@ -122,13 +125,20 @@
<span class="slider"></span>
</label>
</div>
<div class="field">
<label>请求超时(秒)<span class="hint-inline">等待首字/预处理上限,接口慢就调大</span></label>
<div class="icon-input"><span class="icon"></span><input id="gen-timeout" type="number" min="30" step="30" value="1800"></div>
<div class="hint">连接测试与速度采样共用:超过此时长仍未收到数据才判定超时(默认 1800 秒 = 30 分钟)。测慢接口/超长上下文时建议调大,如 3600。</div>
</div>
<div class="btn-group">
<button class="btn primary block" id="btn-start">▶ 开始测试</button>
<button class="btn danger block" id="btn-cancel" disabled>■ 停止</button>
</div>
</div>
</section>
<section class="card">
<h2>📤 评测站同步</h2>
<section class="card collapsible" id="sec-eval">
<h2 class="collapse-head">📤 评测站同步<span class="collapse-arrow"></span></h2>
<div class="collapse-body">
<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>
@@ -147,6 +157,7 @@
<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>
</div>
</section>
</aside>
@@ -179,6 +190,7 @@
<div class="log-actions">
<span class="hint" id="hist-selected"></span>
<button class="btn small primary" id="btn-compare">⚖️ 对比所选</button>
<button class="btn small" id="btn-export-history" title="一键导出全部历史记录(JSON">📥 导出全部</button>
<button class="btn small danger" id="btn-clear-history" title="一键清空全部历史记录(不可恢复)">🗑 清空历史</button>
<button class="btn small" id="btn-refresh-history">刷新</button>
</div>
+40 -2
View File
@@ -69,6 +69,7 @@ function currentGen() {
concurrency_levels: [...concurrencyLevelsActive].sort((a, b) => a - b),
avoid_cache: $("#gen-avoid-cache").checked,
warmup: $("#gen-warmup").checked,
read_timeout: parseInt($("#gen-timeout").value) || 1800,
};
}
@@ -201,9 +202,11 @@ function loadSelectedConfig() {
async function testConnection() {
const cfg = currentConfig();
if (!cfg.api_key) { showConn(false, "请先填写 API Key"); return; }
// 慢接口长预处理可能等首字很久,连接测试也带上请求超时,避免误判接口不通
cfg.read_timeout = parseInt($("#gen-timeout").value) || 1800;
const box = $("#conn-result");
box.hidden = false; box.className = "conn-result";
box.textContent = "⏳ 正在测试连接...";
box.textContent = "⏳ 正在测试连接(等待首字,最长约 " + cfg.read_timeout + " 秒)...";
try {
const r = await api("/api/configs/test", "POST", cfg);
if (r.ok) {
@@ -254,7 +257,8 @@ async function listModels() {
const box = $("#model-result");
box.hidden = false;
box.className = "conn-result fail";
box.textContent = "❌ 获取模型失败:" + e.message;
box.textContent = "❌ 接口不通,请检查 Base URL / API Key 配置后重试";
console.warn("[listModels] 获取模型失败(已隐藏原始错误):", e);
}
}
@@ -1098,8 +1102,23 @@ function bind() {
$("#btn-export-log").addEventListener("click", exportCurrentLog);
$("#btn-refresh-history").addEventListener("click", () => { histState.page = 1; loadHistory(); });
$("#btn-compare").addEventListener("click", openCompare);
$("#btn-export-history").addEventListener("click", exportAllHistory);
$("#btn-clear-history").addEventListener("click", clearAllHistory);
// 左侧三块:点击标题折叠/展开(状态存 localStorage
const COLLAPSE_KEY = "llmst.collapsed";
let collapsedSections = new Set();
try { collapsedSections = new Set(JSON.parse(localStorage.getItem(COLLAPSE_KEY) || "[]")); } catch (e) {}
document.querySelectorAll(".card.collapsible").forEach((sec) => {
if (collapsedSections.has(sec.id)) sec.classList.add("collapsed");
const head = sec.querySelector(".collapse-head");
if (head) head.addEventListener("click", () => {
const on = sec.classList.toggle("collapsed");
if (on) collapsedSections.add(sec.id); else collapsedSections.delete(sec.id);
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify([...collapsedSections])); } catch (e) {}
});
});
// 历史:筛选 / 搜索 / 每页条数 / 分页 / 重置
let histQTimer = null;
$("#hist-q").addEventListener("input", () => {
@@ -1223,6 +1242,25 @@ async function clearAllHistory() {
}
}
async function exportAllHistory() {
try {
const resp = await fetch("/api/tests/export");
if (!resp.ok) throw new Error("HTTP " + resp.status);
const blob = await resp.blob();
const cd = resp.headers.get("Content-Disposition") || "";
const m = cd.match(/filename\"?=?\"?([^";]+)\"?/);
const name = m ? m[1] : "llm_speed_tests.json";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
toast("✅ 已导出全部历史(共 " + (blob.size / 1024).toFixed(1) + " KB");
} catch (e) {
toast("❌ 导出失败:" + e.message);
}
}
/* ───────────────────────── 初始化 ───────────────────────── */
(async function init() {
+9 -2
View File
@@ -77,6 +77,9 @@ class TestRunner(threading.Thread):
% (" / ".join(str(x) for x in lengths), max_tokens,
" / ".join(str(x) for x in concurrency_levels), n,
"" if warmup else "", "" if avoid_cache else ""))
to = self.gen.get("read_timeout") or "默认(1800)"
self.log("INFO", "请求超时: 连接 %s s | 等待首字/预处理 %s s(接口慢可在左侧调大)"
% (self.gen.get("connect_timeout") or 60, to))
ratio = self._calibrate()
self.ratio = ratio
@@ -157,7 +160,9 @@ class TestRunner(threading.Thread):
"""
from concurrent.futures import ThreadPoolExecutor
gen_opt = {"max_tokens": max_tokens, "avoid_cache": avoid_cache}
gen_opt = {"max_tokens": max_tokens, "avoid_cache": avoid_cache,
"connect_timeout": self.gen.get("connect_timeout"),
"read_timeout": self.gen.get("read_timeout")}
def worker(idx):
prompt = self._finalize_prompt(base_prompt) # 每流独立随机前缀,避免共享缓存
@@ -240,7 +245,9 @@ class TestRunner(threading.Thread):
self.log("INFO", "正在校准 token/字符 比例(发送小探测请求)...")
try:
m = lp.call_stream(self.cfg, probe,
{"max_tokens": 8, "avoid_cache": False},
{"max_tokens": 8, "avoid_cache": False,
"connect_timeout": self.gen.get("connect_timeout"),
"read_timeout": self.gen.get("read_timeout")},
log=lambda lv, msg: self.log(lv, msg),
should_stop=self.should_stop)
pt = m.get("prompt_tokens") or 0