Compare commits

..

No commits in common. "541b0b477d76b714bd7391f79b12619da179c53d" and "8a4c6b279e9c481b587e9f1daead7bd6a91c589d" have entirely different histories.

11 changed files with 0 additions and 3079 deletions

5
webui/.gitignore vendored
View File

@ -1,5 +0,0 @@
# Runtime job state / logs — do not commit
data/
__pycache__/
*.pyc
.pytest_cache/

View File

@ -1,3 +0,0 @@
fastapi>=0.110.0
uvicorn>=0.27.0
pydantic>=2.0.0

View File

@ -1,384 +0,0 @@
#!/usr/bin/env python3
"""Scan EvalScope output directories and aggregate per-model benchmark scores."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# Display order for comparison charts
CATEGORY_ORDER = [
('math', '数学推理', [
'aime24', 'aime25', 'aime26', 'hmmt26',
'imo_answerbench', 'competition_math', 'gsm8k',
]),
('code', '代码', ['humaneval', 'live_code_bench', 'bigcodebench']),
('science', '科学 / 高难推理', ['gpqa_diamond', 'super_gpqa', 'hle']),
('knowledge', '知识与通用能力', [
'mmlu', 'mmlu_pro', 'cmmlu', 'bbh', 'arc',
'drop', 'hellaswag', 'winogrande', 'simple_qa', 'trivia_qa',
]),
('long_context', '长文本', ['longbench_v2', 'openai_mrcr']),
('tool_agent', '工具调用 / 智能体', ['bfcl_v3', 'general_fc', 'tau2_bench']),
]
BENCHMARK_ALIAS = {
'hle_low': 'hle',
}
SKIP_DIR_NAMES = {
'active_time', 'perf_stats_backup', 'predictions_archive',
'logs', 'configs', 'reports', 'predictions', 'reviews',
}
def _is_seed_dir(path: Path) -> bool:
return path.is_dir() and path.name.startswith('seed_')
def _is_benchmark_dir(path: Path) -> bool:
if not path.is_dir() or path.name in SKIP_DIR_NAMES:
return False
try:
return any(_is_seed_dir(child) for child in path.iterdir())
except OSError:
return False
def _is_model_folder(path: Path) -> bool:
if not path.is_dir() or path.name.startswith('.'):
return False
try:
return any(_is_benchmark_dir(child) for child in path.iterdir())
except OSError:
return False
def extract_score(report_data: dict) -> Optional[float]:
score = report_data.get('score')
if score is not None:
try:
return float(score)
except (TypeError, ValueError):
pass
for metric in report_data.get('metrics') or []:
if metric.get('name') in ('mean_acc', 'acc', 'accuracy', 'pass@1', 'Score'):
for key in ('score', 'macro_score'):
if metric.get(key) is not None:
try:
return float(metric[key])
except (TypeError, ValueError):
continue
# fallback: first metric with a score
for metric in report_data.get('metrics') or []:
for key in ('score', 'macro_score'):
if metric.get(key) is not None:
try:
return float(metric[key])
except (TypeError, ValueError):
continue
return None
def _find_report_files(seed_dir: Path, benchmark: str) -> List[Path]:
reports_dir = seed_dir / 'reports'
if not reports_dir.is_dir():
return []
preferred = [
reports_dir / f'{benchmark}.json',
reports_dir / f'{BENCHMARK_ALIAS.get(benchmark, benchmark)}.json',
]
found = [p for p in preferred if p.is_file()]
if found:
return found
# nested: reports/<model>/<benchmark>.json (legacy)
nested = sorted(reports_dir.glob(f'*/*.json')) + sorted(reports_dir.glob('*.json'))
return [p for p in nested if p.is_file()]
def _read_report(path: Path) -> Optional[dict]:
try:
return json.loads(path.read_text(encoding='utf-8'))
except Exception:
return None
def collect_benchmark_scores(model_dir: Path, benchmark: str) -> Optional[dict]:
bench_dir = model_dir / benchmark
if not bench_dir.is_dir():
# alias: hle stored as hle_low
for alias_src, alias_dst in BENCHMARK_ALIAS.items():
if benchmark == alias_dst:
alt = model_dir / alias_src
if alt.is_dir():
bench_dir = alt
break
else:
return None
scores: List[float] = []
runs: List[dict] = []
model_name = None
num_samples = None
for seed_dir in sorted(p for p in bench_dir.iterdir() if _is_seed_dir(p)):
for report_path in _find_report_files(seed_dir, benchmark):
data = _read_report(report_path)
if not data:
continue
score = extract_score(data)
if score is None:
continue
scores.append(score)
if model_name is None:
model_name = data.get('model_name')
if num_samples is None:
num_samples = data.get('num')
if num_samples is None:
metrics = data.get('metrics') or []
if metrics:
num_samples = metrics[0].get('num')
try:
rel = str(report_path.relative_to(model_dir))
except ValueError:
rel = str(report_path)
runs.append({
'seed_dir': seed_dir.name,
'score': score,
'report': rel,
})
break # one report per seed dir
if not scores:
return None
avg = sum(scores) / len(scores)
return {
'benchmark': BENCHMARK_ALIAS.get(benchmark, benchmark),
'score': round(avg, 6),
'scores': scores,
'n_runs': len(scores),
'num_samples': num_samples,
'model_name': model_name,
'runs': runs,
}
def list_benchmarks_in_model(model_dir: Path) -> List[str]:
names = []
for child in sorted(model_dir.iterdir()):
if not _is_benchmark_dir(child):
continue
names.append(BENCHMARK_ALIAS.get(child.name, child.name))
# unique preserve order
seen = set()
out = []
for n in names:
if n not in seen:
seen.add(n)
out.append(n)
return out
def scan_output_dir(output_dir: Path) -> dict:
output_dir = Path(output_dir)
if not output_dir.is_dir():
return {
'output_dir': str(output_dir),
'models': [],
'benchmarks': [],
'categories': [],
'matrix': {},
}
models = []
all_benchmarks = set()
matrix: Dict[str, Dict[str, dict]] = {}
for child in sorted(output_dir.iterdir()):
if not _is_model_folder(child):
continue
folder = child.name
bench_names = list_benchmarks_in_model(child)
model_scores: Dict[str, dict] = {}
served_model = None
for bench in bench_names:
info = collect_benchmark_scores(child, bench)
if not info:
continue
model_scores[bench] = info
all_benchmarks.add(bench)
if served_model is None and info.get('model_name'):
served_model = info['model_name']
if not model_scores:
continue
models.append({
'folder': folder,
'model_name': served_model or folder,
'benchmarks': sorted(model_scores.keys()),
'n_benchmarks': len(model_scores),
'path': str(child),
})
matrix[folder] = model_scores
# Ordered benchmark list by category, then leftovers
ordered = []
seen = set()
categories = []
for cat_id, cat_name, items in CATEGORY_ORDER:
present = [b for b in items if b in all_benchmarks]
if present:
categories.append({'id': cat_id, 'name': cat_name, 'items': present})
for b in present:
if b not in seen:
seen.add(b)
ordered.append(b)
others = sorted(all_benchmarks - seen)
if others:
categories.append({'id': 'other', 'name': '其他', 'items': others})
ordered.extend(others)
# Compact matrix for API: folder -> benchmark -> score summary
compact = {}
for folder, benches in matrix.items():
compact[folder] = {
b: {
'score': info['score'],
'n_runs': info['n_runs'],
'num_samples': info.get('num_samples'),
'model_name': info.get('model_name'),
}
for b, info in benches.items()
}
return {
'output_dir': str(output_dir),
'models': models,
'benchmarks': ordered,
'categories': categories,
'matrix': compact,
}
def compare_models(
output_dir: Path,
folders: Optional[List[str]] = None,
benchmarks: Optional[List[str]] = None,
) -> dict:
overview = scan_output_dir(output_dir)
available = {m['folder'] for m in overview['models']}
if folders:
selected = [f for f in folders if f in available]
else:
selected = [m['folder'] for m in overview['models']]
if benchmarks:
bench_list = [b for b in benchmarks if b in overview['benchmarks']]
else:
bench_list = list(overview['benchmarks'])
series = []
for folder in selected:
scores = []
for b in bench_list:
cell = overview['matrix'].get(folder, {}).get(b)
scores.append(cell['score'] if cell else None)
model_meta = next((m for m in overview['models'] if m['folder'] == folder), None)
series.append({
'folder': folder,
'label': model_meta['model_name'] if model_meta else folder,
'display': folder,
'scores': scores,
})
# Per-benchmark ranking
ranking = []
for i, b in enumerate(bench_list):
rows = []
for s in series:
if s['scores'][i] is not None:
rows.append({'folder': s['folder'], 'label': s['label'], 'score': s['scores'][i]})
rows.sort(key=lambda x: x['score'], reverse=True)
ranking.append({'benchmark': b, 'rows': rows})
# Capability-domain aggregation: mean score over selected benches in each category
bench_set = set(bench_list)
active_categories = []
for cat in overview['categories']:
items = [b for b in cat['items'] if b in bench_set]
if items:
active_categories.append({
'id': cat['id'],
'name': cat['name'],
'items': items,
})
category_labels = [c['name'] for c in active_categories]
category_series = []
for folder in selected:
scores = []
details = []
for cat in active_categories:
vals = []
for b in cat['items']:
cell = overview['matrix'].get(folder, {}).get(b)
if cell and cell.get('score') is not None:
vals.append(float(cell['score']))
if vals:
avg = sum(vals) / len(vals)
scores.append(round(avg, 6))
else:
avg = None
scores.append(None)
details.append({
'category': cat['name'],
'score': avg,
'n_benchmarks': len(vals),
'benchmarks': cat['items'],
})
model_meta = next((m for m in overview['models'] if m['folder'] == folder), None)
category_series.append({
'folder': folder,
'label': model_meta['model_name'] if model_meta else folder,
'display': folder,
'scores': scores,
'details': details,
})
category_ranking = []
for i, cat in enumerate(active_categories):
rows = []
for s in category_series:
if s['scores'][i] is not None:
rows.append({
'folder': s['folder'],
'label': s['label'],
'score': s['scores'][i],
'n_benchmarks': s['details'][i]['n_benchmarks'],
})
rows.sort(key=lambda x: x['score'], reverse=True)
category_ranking.append({
'category': cat['name'],
'id': cat['id'],
'items': cat['items'],
'rows': rows,
})
return {
'output_dir': overview['output_dir'],
'benchmarks': bench_list,
'categories': overview['categories'],
'models': [m for m in overview['models'] if m['folder'] in selected],
'series': series,
'ranking': ranking,
'category_labels': category_labels,
'category_series': category_series,
'category_ranking': category_ranking,
'matrix': {
f: {b: overview['matrix'].get(f, {}).get(b) for b in bench_list}
for f in selected
},
}

View File

@ -1,642 +0,0 @@
#!/usr/bin/env python3
"""Local EvalScope launch panel — wraps bash/run.py via FastAPI."""
from __future__ import annotations
import asyncio
import json
import os
import signal
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
WEBUI_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = WEBUI_DIR.parent
BASH_DIR = PROJECT_ROOT / 'bash'
RUN_SCRIPT = BASH_DIR / 'run.py'
STATIC_DIR = WEBUI_DIR / 'static'
DATA_DIR = WEBUI_DIR / 'data'
JOBS_DIR = DATA_DIR / 'jobs'
LOGS_DIR = DATA_DIR / 'logs'
sys.path.insert(0, str(BASH_DIR))
import run as run_module # noqa: E402
import results_scan # noqa: E402
JOBS_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_OUTPUT_DIR = Path(run_module.DEFAULT_OUTPUT_DIR)
CUSTOM_SUITES_PATH = DATA_DIR / 'custom_suites.json'
app = FastAPI(title='EvalStone Launch Panel', version='1.0.0')
def _load_custom_suites() -> Dict[str, List[str]]:
if not CUSTOM_SUITES_PATH.exists():
return {}
try:
data = json.loads(CUSTOM_SUITES_PATH.read_text(encoding='utf-8'))
if not isinstance(data, dict):
return {}
out: Dict[str, List[str]] = {}
for name, items in data.items():
if not isinstance(name, str) or not name.strip():
continue
if not isinstance(items, list):
continue
cleaned = [str(x).strip() for x in items if str(x).strip()]
if cleaned:
out[name.strip()] = cleaned
return out
except Exception:
return {}
def _save_custom_suites(suites: Dict[str, List[str]]) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
CUSTOM_SUITES_PATH.write_text(
json.dumps(suites, ensure_ascii=False, indent=2),
encoding='utf-8',
)
class CustomSuiteRequest(BaseModel):
name: str = Field(..., min_length=1)
datasets: List[str] = Field(..., min_length=1)
# ---------------------------------------------------------------------------
# Job store
# ---------------------------------------------------------------------------
class JobRecord:
def __init__(self, job_id: str, payload: dict, command: List[str]):
self.id = job_id
self.payload = payload
self.command = command
self.status = 'queued' # queued | running | completed | failed | stopped
self.created_at = datetime.now(timezone.utc).isoformat()
self.started_at: Optional[str] = None
self.finished_at: Optional[str] = None
self.return_code: Optional[int] = None
self.pid: Optional[int] = None
self.log_path = LOGS_DIR / f'{job_id}.log'
self.error: Optional[str] = None
self._proc: Optional[asyncio.subprocess.Process] = None
self._log_fp = None
def to_dict(self) -> dict:
return {
'id': self.id,
'status': self.status,
'created_at': self.created_at,
'started_at': self.started_at,
'finished_at': self.finished_at,
'return_code': self.return_code,
'pid': self.pid,
'command': self.command,
'payload': self.payload,
'log_path': str(self.log_path),
'error': self.error,
}
def save(self) -> None:
path = JOBS_DIR / f'{self.id}.json'
path.write_text(json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding='utf-8')
JOBS: Dict[str, JobRecord] = {}
_ACTIVE_JOB_ID: Optional[str] = None
_LOCK = asyncio.Lock()
def _load_existing_jobs() -> None:
for path in sorted(JOBS_DIR.glob('*.json'), key=lambda p: p.stat().st_mtime, reverse=True):
try:
data = json.loads(path.read_text(encoding='utf-8'))
job = JobRecord(data['id'], data.get('payload', {}), data.get('command', []))
job.status = data.get('status', 'unknown')
job.created_at = data.get('created_at', job.created_at)
job.started_at = data.get('started_at')
job.finished_at = data.get('finished_at')
job.return_code = data.get('return_code')
job.pid = data.get('pid')
job.error = data.get('error')
if job.status == 'running':
# Process cannot be resumed after server restart
job.status = 'failed'
job.error = 'Server restarted while job was running'
job.finished_at = datetime.now(timezone.utc).isoformat()
job.save()
JOBS[job.id] = job
except Exception:
continue
_load_existing_jobs()
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class LaunchRequest(BaseModel):
model: str = Field(..., min_length=1)
api_url: str = Field(..., min_length=1)
api_key: str = 'EMPTY'
thinking: bool = False
selection_mode: str = 'suite' # suite | datasets
suite: str = 'official'
datasets: List[str] = Field(default_factory=list)
exclude: List[str] = Field(default_factory=list)
folder_name: Optional[str] = None
limit: Optional[str] = None
seed: int = 42
batch_size: int = 4
thinking_max_tokens_scale: float = 1.0
max_tokens_add: int = 0
dataset_dir: Optional[str] = None
output_dir: Optional[str] = None
config: Optional[str] = None
tokenizer_path: Optional[str] = None
judge_model: Optional[str] = None
judge_api_url: Optional[str] = None
judge_api_key: Optional[str] = None
judge_max_tokens: Optional[int] = None
write_summary: bool = True
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Capability-domain categories for the custom benchmark picker.
# Order here is the display order in the UI.
BENCHMARK_CATEGORIES = [
{
'id': 'math',
'name': '数学推理',
'items': [
'aime24', 'aime25', 'aime26', 'hmmt26',
'imo_answerbench', 'competition_math', 'gsm8k',
],
},
{
'id': 'code',
'name': '代码',
'items': ['humaneval', 'live_code_bench', 'bigcodebench'],
},
{
'id': 'science',
'name': '科学 / 高难推理',
'items': ['gpqa_diamond', 'super_gpqa', 'hle'],
},
{
'id': 'knowledge',
'name': '知识与通用能力',
'items': [
'mmlu', 'mmlu_pro', 'cmmlu', 'bbh', 'arc',
'drop', 'hellaswag', 'winogrande', 'simple_qa', 'trivia_qa',
],
},
{
'id': 'long_context',
'name': '长文本',
'items': ['longbench_v2', 'openai_mrcr'],
},
{
'id': 'tool_agent',
'name': '工具调用 / 智能体',
'items': ['bfcl_v3', 'general_fc', 'tau2_bench'],
},
]
def _meta() -> dict:
all_benchmarks = sorted(
set(run_module.ALL_MULTI_RUN)
| set(run_module.ALL_SINGLE_RUN)
| set(run_module.ALL_AGENT)
)
categorized = {b for cat in BENCHMARK_CATEGORIES for b in cat['items']}
other = sorted(set(all_benchmarks) - categorized)
categories = [dict(cat) for cat in BENCHMARK_CATEGORIES]
if other:
categories.append({'id': 'other', 'name': '其他', 'items': other})
suites = {}
for name, cfg in run_module.SUITES.items():
suites[name] = {
'multi': list(cfg['multi']),
'single': list(cfg['single']),
'agent': list(cfg['agent']),
'all': list(cfg['multi']) + list(cfg['single']) + list(cfg['agent']),
}
return {
'suites': suites,
'custom_suites': _load_custom_suites(),
'benchmarks': all_benchmarks,
'categories': categories,
'multi_run': run_module.MULTI_RUN_CONFIG,
'defaults': {
'model': run_module.DEFAULT_MODEL,
'api_url': run_module.DEFAULT_API_URL,
'api_key': 'EMPTY',
'dataset_dir': run_module.DEFAULT_DATASET_DIR,
'output_dir': run_module.DEFAULT_OUTPUT_DIR,
'config': run_module.DEFAULT_CONFIG,
'tokenizer_path': run_module.DEFAULT_TOKENIZER_PATH,
'seed': run_module.DEFAULT_SEED,
'batch_size': run_module.DEFAULT_BATCH_SIZE,
'thinking': run_module.DEFAULT_ENABLE_THINKING,
'judge_model': run_module.DEFAULT_JUDGE_MODEL,
'judge_api_url': run_module.DEFAULT_JUDGE_API_URL,
'judge_max_tokens': run_module.DEFAULT_JUDGE_MAX_TOKENS,
'suite': 'official',
},
'project_root': str(PROJECT_ROOT),
'run_script': str(RUN_SCRIPT),
}
def build_command(req: LaunchRequest) -> List[str]:
cmd = [
sys.executable,
str(RUN_SCRIPT),
'--model', req.model,
'--api-url', req.api_url,
'--seed', str(req.seed),
'--batch-size', str(req.batch_size),
]
# Keep API key in env only; avoid requiring a custom --api-key CLI flag in run.py.
if req.thinking:
cmd.append('--thinking')
else:
cmd.append('--no-thinking')
if req.selection_mode == 'datasets':
if not req.datasets:
raise HTTPException(status_code=400, detail='请至少选择一个 benchmark')
cmd.extend(['--datasets', ','.join(req.datasets)])
else:
if req.suite not in run_module.SUITES:
raise HTTPException(status_code=400, detail=f'未知 suite: {req.suite}')
cmd.extend(['--suite', req.suite])
if req.exclude:
cmd.extend(['--exclude', ','.join(req.exclude)])
if req.folder_name:
cmd.extend(['--folder-name', req.folder_name])
if req.limit is not None and str(req.limit).strip() != '':
cmd.extend(['--limit', str(req.limit)])
if req.thinking_max_tokens_scale != 1.0:
cmd.extend(['--thinking-max-tokens-scale', str(req.thinking_max_tokens_scale)])
if req.max_tokens_add:
cmd.extend(['--max-tokens-add', str(req.max_tokens_add)])
if req.dataset_dir:
cmd.extend(['--dataset-dir', req.dataset_dir])
if req.output_dir:
cmd.extend(['--output-dir', req.output_dir])
if req.config:
cmd.extend(['--config', req.config])
if req.tokenizer_path:
cmd.extend(['--tokenizer-path', req.tokenizer_path])
if req.judge_model:
cmd.extend(['--judge-model', req.judge_model])
if req.judge_api_url:
cmd.extend(['--judge-api-url', req.judge_api_url])
if req.judge_api_key:
cmd.extend(['--judge-api-key', req.judge_api_key])
if req.judge_max_tokens is not None:
cmd.extend(['--judge-max-tokens', str(req.judge_max_tokens)])
if not req.write_summary:
cmd.append('--no-summary')
return cmd
async def _pump_stdout(job: JobRecord) -> None:
assert job._proc is not None and job._log_fp is not None
assert job._proc.stdout is not None
try:
while True:
line = await job._proc.stdout.readline()
if not line:
break
text = line.decode('utf-8', errors='replace')
if job._log_fp and not job._log_fp.closed:
job._log_fp.write(text)
job._log_fp.flush()
return_code = await job._proc.wait()
job.return_code = return_code
job.finished_at = datetime.now(timezone.utc).isoformat()
if job.status in ('stopping', 'stopped'):
job.status = 'stopped'
job.error = job.error or 'Stopped by user'
elif job.status == 'running':
if return_code == 0:
job.status = 'completed'
else:
job.status = 'failed'
job.error = f'Process exited with code {return_code}'
finally:
job.pid = None
job._proc = None
if job._log_fp and not job._log_fp.closed:
try:
job._log_fp.close()
except Exception:
pass
job._log_fp = None
job.save()
global _ACTIVE_JOB_ID
if _ACTIVE_JOB_ID == job.id:
_ACTIVE_JOB_ID = None
async def start_job(req: LaunchRequest) -> JobRecord:
global _ACTIVE_JOB_ID
async with _LOCK:
if _ACTIVE_JOB_ID and _ACTIVE_JOB_ID in JOBS and JOBS[_ACTIVE_JOB_ID].status == 'running':
raise HTTPException(status_code=409, detail=f'已有任务在运行: {_ACTIVE_JOB_ID}')
cmd = build_command(req)
job_id = datetime.now().strftime('%Y%m%d_%H%M%S') + '_' + uuid.uuid4().hex[:8]
job = JobRecord(job_id, req.model_dump(), cmd)
job.log_path.write_text('', encoding='utf-8')
env = os.environ.copy()
env['PYTHONUNBUFFERED'] = '1'
if req.api_key and req.api_key != 'EMPTY':
env['OPENAI_API_KEY'] = req.api_key
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(PROJECT_ROOT),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=env,
start_new_session=True,
)
except Exception as e:
job.status = 'failed'
job.error = str(e)
job.finished_at = datetime.now(timezone.utc).isoformat()
job.save()
JOBS[job.id] = job
raise HTTPException(status_code=500, detail=f'启动失败: {e}') from e
job._proc = proc
job.pid = proc.pid
job.status = 'running'
job.started_at = datetime.now(timezone.utc).isoformat()
job._log_fp = open(job.log_path, 'a', encoding='utf-8')
header = (
f'# job {job.id}\n'
f'# cwd: {PROJECT_ROOT}\n'
f'# cmd: {" ".join(cmd)}\n'
f'# started: {job.started_at}\n'
f'{"=" * 60}\n'
)
job._log_fp.write(header)
job._log_fp.flush()
job.save()
JOBS[job.id] = job
_ACTIVE_JOB_ID = job.id
asyncio.create_task(_pump_stdout(job))
return job
async def stop_job(job_id: str) -> JobRecord:
job = JOBS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail='任务不存在')
if job.status != 'running' or job._proc is None:
raise HTTPException(status_code=400, detail='任务未在运行')
proc = job._proc
job.status = 'stopping'
job.error = 'Stopped by user'
if job._log_fp and not job._log_fp.closed:
try:
job._log_fp.write('\n# stop requested by user\n')
job._log_fp.flush()
except Exception:
pass
job.save()
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
except Exception:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=15)
except asyncio.TimeoutError:
try:
os.killpg(proc.pid, signal.SIGKILL)
except Exception:
try:
proc.kill()
except Exception:
pass
# Final status is finalized by _pump_stdout; wait briefly for it.
for _ in range(20):
if job.status in ('stopped', 'failed', 'completed'):
break
await asyncio.sleep(0.1)
if job.status == 'stopping':
job.status = 'stopped'
job.finished_at = datetime.now(timezone.utc).isoformat()
job.save()
return job
# ---------------------------------------------------------------------------
# API routes
# ---------------------------------------------------------------------------
@app.get('/api/health')
async def health():
return {'ok': True, 'project_root': str(PROJECT_ROOT)}
@app.get('/api/meta')
async def meta():
return _meta()
@app.get('/api/jobs')
async def list_jobs(limit: int = 50):
items = sorted(JOBS.values(), key=lambda j: j.created_at, reverse=True)[:limit]
return {'jobs': [j.to_dict() for j in items], 'active_job_id': _ACTIVE_JOB_ID}
@app.get('/api/jobs/{job_id}')
async def get_job(job_id: str):
job = JOBS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail='任务不存在')
return job.to_dict()
@app.post('/api/jobs')
async def create_job(req: LaunchRequest):
job = await start_job(req)
return job.to_dict()
@app.post('/api/jobs/{job_id}/stop')
async def api_stop_job(job_id: str):
job = await stop_job(job_id)
return job.to_dict()
@app.get('/api/jobs/{job_id}/logs')
async def get_logs(job_id: str, offset: int = 0):
job = JOBS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail='任务不存在')
if not job.log_path.exists():
return {'content': '', 'offset': 0, 'next_offset': 0, 'done': job.status not in ('queued', 'running')}
data = job.log_path.read_bytes()
if offset < 0:
offset = 0
if offset > len(data):
offset = len(data)
chunk = data[offset:].decode('utf-8', errors='replace')
return {
'content': chunk,
'offset': offset,
'next_offset': len(data),
'done': job.status not in ('queued', 'running'),
'status': job.status,
}
@app.get('/api/jobs/{job_id}/stream')
async def stream_logs(job_id: str, offset: int = 0):
job = JOBS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail='任务不存在')
async def event_gen():
pos = max(0, offset)
while True:
if job.log_path.exists():
data = job.log_path.read_bytes()
if pos < len(data):
chunk = data[pos:].decode('utf-8', errors='replace')
pos = len(data)
payload = json.dumps({'type': 'log', 'content': chunk, 'offset': pos}, ensure_ascii=False)
yield f'data: {payload}\n\n'
status_payload = json.dumps({
'type': 'status',
'status': job.status,
'return_code': job.return_code,
'offset': pos,
}, ensure_ascii=False)
yield f'data: {status_payload}\n\n'
if job.status not in ('queued', 'running'):
done_payload = json.dumps({'type': 'done', 'status': job.status, 'offset': pos}, ensure_ascii=False)
yield f'data: {done_payload}\n\n'
break
await asyncio.sleep(0.8)
return StreamingResponse(event_gen(), media_type='text/event-stream')
@app.get('/api/custom-suites')
async def list_custom_suites():
return {'suites': _load_custom_suites()}
@app.post('/api/custom-suites')
async def upsert_custom_suite(req: CustomSuiteRequest):
name = req.name.strip()
if not name:
raise HTTPException(status_code=400, detail='组合名称不能为空')
if name in run_module.SUITES:
raise HTTPException(status_code=400, detail=f'名称与内置 suite 冲突: {name}')
datasets = []
seen = set()
for item in req.datasets:
d = str(item).strip()
if not d or d in seen:
continue
seen.add(d)
datasets.append(d)
if not datasets:
raise HTTPException(status_code=400, detail='请至少选择一个 benchmark')
suites = _load_custom_suites()
suites[name] = datasets
_save_custom_suites(suites)
return {'ok': True, 'name': name, 'datasets': datasets, 'suites': suites}
@app.delete('/api/custom-suites/{name}')
async def delete_custom_suite(name: str):
suites = _load_custom_suites()
if name not in suites:
raise HTTPException(status_code=404, detail='自定义组合不存在')
suites.pop(name, None)
_save_custom_suites(suites)
return {'ok': True, 'suites': suites}
@app.get('/api/results/overview')
async def results_overview(output_dir: Optional[str] = None):
root = Path(output_dir) if output_dir else DEFAULT_OUTPUT_DIR
return results_scan.scan_output_dir(root)
@app.get('/api/results/compare')
async def results_compare(
models: Optional[str] = None,
benchmarks: Optional[str] = None,
output_dir: Optional[str] = None,
):
root = Path(output_dir) if output_dir else DEFAULT_OUTPUT_DIR
folder_list = [x.strip() for x in (models or '').split(',') if x.strip()] or None
bench_list = [x.strip() for x in (benchmarks or '').split(',') if x.strip()] or None
return results_scan.compare_models(root, folders=folder_list, benchmarks=bench_list)
@app.get('/')
async def index():
return FileResponse(STATIC_DIR / 'index.html')
@app.get('/results')
async def results_page():
return FileResponse(STATIC_DIR / 'results.html')
app.mount('/static', StaticFiles(directory=str(STATIC_DIR)), name='static')
if __name__ == '__main__':
import uvicorn
host = os.environ.get('WEBUI_HOST', '0.0.0.0')
port = int(os.environ.get('WEBUI_PORT', '7860'))
uvicorn.run('server:app', host=host, port=port, reload=False)

View File

@ -1,15 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
HOST="${WEBUI_HOST:-0.0.0.0}"
PORT="${WEBUI_PORT:-7860}"
echo "EvalStone Launch Panel"
echo " project : $(dirname "$DIR")"
echo " listen : http://${HOST}:${PORT}"
echo
exec python3 -m uvicorn server:app --host "$HOST" --port "$PORT"

View File

@ -1,551 +0,0 @@
(() => {
const state = {
meta: null,
selectionMode: 'suite',
suiteKind: 'builtin', // builtin | custom
suite: 'official',
customSuites: {},
activeJobId: null,
pollTimer: null,
logOffset: 0,
};
const $ = (id) => document.getElementById(id);
const form = $('launchForm');
const formMsg = $('formMsg');
const logView = $('logView');
const jobList = $('jobList');
function setMsg(text, type = '') {
formMsg.textContent = text || '';
formMsg.className = `msg ${type}`.trim();
}
async function api(path, options) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail || res.statusText || 'request failed';
throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));
}
return data;
}
function fillDefaults(meta) {
const d = meta.defaults;
$('model').value = d.model || '';
$('api_url').value = d.api_url || '';
$('api_key').value = d.api_key || 'EMPTY';
$('seed').value = d.seed;
$('batch_size').value = d.batch_size;
$('thinking').checked = !!d.thinking;
$('dataset_dir').value = d.dataset_dir || '';
$('output_dir').value = d.output_dir || '';
$('config').value = d.config || '';
$('tokenizer_path').value = d.tokenizer_path || '';
$('judge_model').value = d.judge_model || '';
$('judge_api_url').value = d.judge_api_url || '';
$('judge_max_tokens').value = d.judge_max_tokens || '';
state.suite = d.suite || 'official';
state.suiteKind = 'builtin';
state.customSuites = meta.custom_suites || {};
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSuites() {
const box = $('suiteList');
box.innerHTML = '';
const suites = state.meta.suites;
Object.keys(suites).forEach((name) => {
const info = suites[name];
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `suite-card${state.suiteKind === 'builtin' && state.suite === name ? ' active' : ''}`;
const names = info.all.map(escapeHtml).join(', ');
btn.innerHTML = `
<strong>${escapeHtml(name)}</strong>
<span class="suite-count">${info.all.length} benchmarks</span>
<div class="suite-names">${names}</div>
`;
btn.addEventListener('click', () => {
state.suiteKind = 'builtin';
state.suite = name;
renderSuites();
renderCustomSuites();
});
box.appendChild(btn);
});
}
function renderCustomSuites() {
const box = $('customSuiteList');
const empty = $('customSuiteEmpty');
box.innerHTML = '';
const names = Object.keys(state.customSuites || {});
empty.classList.toggle('hidden', names.length > 0);
names.forEach((name) => {
const items = state.customSuites[name] || [];
const wrap = document.createElement('div');
wrap.className = `suite-card custom${state.suiteKind === 'custom' && state.suite === name ? ' active' : ''}`;
const main = document.createElement('button');
main.type = 'button';
main.className = 'suite-card-main';
main.innerHTML = `
<strong>${escapeHtml(name)}</strong>
<span class="suite-count">${items.length} benchmarks · 自定义</span>
<div class="suite-names">${items.map(escapeHtml).join(', ')}</div>
`;
main.addEventListener('click', () => {
state.suiteKind = 'custom';
state.suite = name;
renderSuites();
renderCustomSuites();
// sync editor checkboxes
const set = new Set(items);
document.querySelectorAll('#customPickList input').forEach((el) => {
el.checked = set.has(el.value);
});
$('customSuiteName').value = name;
updateCustomPickCount();
});
const del = document.createElement('button');
del.type = 'button';
del.className = 'ghost danger-text suite-del';
del.textContent = '删除';
del.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(`删除自定义组合「${name}」?`)) return;
try {
const data = await api(`/api/custom-suites/${encodeURIComponent(name)}`, { method: 'DELETE' });
state.customSuites = data.suites || {};
if (state.suiteKind === 'custom' && state.suite === name) {
state.suiteKind = 'builtin';
state.suite = state.meta.defaults?.suite || 'official';
}
renderSuites();
renderCustomSuites();
setMsg(`已删除组合: ${name}`, 'ok');
} catch (err) {
setMsg(err.message, 'error');
}
});
wrap.appendChild(main);
wrap.appendChild(del);
box.appendChild(wrap);
});
}
function renderBenchmarkPicker(containerId, withCountCb) {
const box = $(containerId);
box.innerHTML = '';
const multi = new Set(Object.keys(state.meta.multi_run || {}));
const categories = state.meta.categories || [
{ id: 'all', name: '全部', items: state.meta.benchmarks || [] },
];
categories.forEach((cat) => {
const section = document.createElement('section');
section.className = `bench-category cat-${cat.id}`;
const head = document.createElement('div');
head.className = 'bench-cat-head';
head.innerHTML = `
<div class="bench-cat-title">
<strong>${escapeHtml(cat.name)}</strong>
<span class="muted">${cat.items.length}</span>
</div>
<div class="bench-cat-actions">
<button type="button" class="ghost cat-select">全选本组</button>
<button type="button" class="ghost cat-clear">清空</button>
</div>
`;
head.querySelector('.cat-select').addEventListener('click', () => {
section.querySelectorAll('input[type="checkbox"]').forEach((el) => { el.checked = true; });
withCountCb();
});
head.querySelector('.cat-clear').addEventListener('click', () => {
section.querySelectorAll('input[type="checkbox"]').forEach((el) => { el.checked = false; });
withCountCb();
});
section.appendChild(head);
const grid = document.createElement('div');
grid.className = 'bench-cat-grid';
cat.items.forEach((name) => {
const label = document.createElement('label');
const isMulti = multi.has(name);
label.className = `bench-item${isMulti ? ' multi' : ''}`;
label.title = isMulti ? `multi-run x${state.meta.multi_run[name]}` : cat.name;
label.innerHTML = `<input type="checkbox" value="${escapeHtml(name)}" /><span>${escapeHtml(name)}</span>`;
if (isMulti) {
const tag = document.createElement('em');
tag.className = 'run-tag';
tag.textContent = `×${state.meta.multi_run[name]}`;
label.appendChild(tag);
}
label.querySelector('input').addEventListener('change', withCountCb);
grid.appendChild(label);
});
section.appendChild(grid);
box.appendChild(section);
});
withCountCb();
}
function renderBenchmarks() {
renderBenchmarkPicker('benchmarkList', updateSelectedCount);
}
function renderCustomPicker() {
renderBenchmarkPicker('customPickList', updateCustomPickCount);
}
function updateSelectedCount() {
const n = [...document.querySelectorAll('#benchmarkList input:checked')].length;
$('selectedCount').textContent = `已选 ${n}`;
}
function updateCustomPickCount() {
const n = [...document.querySelectorAll('#customPickList input:checked')].length;
$('customPickCount').textContent = `已勾选 ${n}`;
}
function setMode(mode) {
state.selectionMode = mode;
document.querySelectorAll('.mode-tabs .tab').forEach((el) => {
el.classList.toggle('active', el.dataset.mode === mode);
});
$('suitePane').classList.toggle('hidden', mode !== 'suite');
$('datasetsPane').classList.toggle('hidden', mode !== 'datasets');
}
function collectPayload() {
const datasets = [...document.querySelectorAll('#benchmarkList input:checked')].map((el) => el.value);
const excludeRaw = $('exclude').value.trim();
const exclude = excludeRaw
? excludeRaw.split(',').map((s) => s.trim()).filter(Boolean)
: [];
let selectionMode = state.selectionMode;
let suite = state.suite;
let finalDatasets = datasets;
if (selectionMode === 'suite' && state.suiteKind === 'custom') {
selectionMode = 'datasets';
finalDatasets = [...(state.customSuites[state.suite] || [])];
suite = state.suite;
}
const payload = {
model: $('model').value.trim(),
api_url: $('api_url').value.trim(),
api_key: $('api_key').value.trim() || 'EMPTY',
thinking: $('thinking').checked,
selection_mode: selectionMode,
suite,
datasets: finalDatasets,
exclude: selectionMode === 'suite' ? exclude : [],
folder_name: $('folder_name').value.trim() || null,
limit: $('limit').value.trim() || null,
seed: Number($('seed').value || 42),
batch_size: Number($('batch_size').value || 4),
thinking_max_tokens_scale: Number($('thinking_max_tokens_scale').value || 1),
max_tokens_add: Number($('max_tokens_add').value || 0),
dataset_dir: $('dataset_dir').value.trim() || null,
output_dir: $('output_dir').value.trim() || null,
config: $('config').value.trim() || null,
tokenizer_path: $('tokenizer_path').value.trim() || null,
judge_model: $('judge_model').value.trim() || null,
judge_api_url: $('judge_api_url').value.trim() || null,
judge_api_key: $('judge_api_key').value.trim() || null,
judge_max_tokens: $('judge_max_tokens').value
? Number($('judge_max_tokens').value)
: null,
write_summary: $('write_summary').value === 'true',
};
return payload;
}
async function saveCustomSuite(name, datasets) {
const data = await api('/api/custom-suites', {
method: 'POST',
body: JSON.stringify({ name, datasets }),
});
state.customSuites = data.suites || {};
state.suiteKind = 'custom';
state.suite = name;
renderSuites();
renderCustomSuites();
setMsg(`已永久保存组合: ${name}${datasets.length} 个)`, 'ok');
}
function statusClass(status) {
return status || 'idle';
}
function renderActive(job) {
const card = $('activeCard');
if (!job) {
card.className = 'active-card idle';
$('activeStatus').className = 'badge';
$('activeStatus').textContent = 'idle';
$('activeJobId').textContent = '—';
$('activeCmd').textContent = '尚未启动任务';
$('stopBtn').disabled = true;
return;
}
card.className = `active-card ${statusClass(job.status)}`;
$('activeStatus').className = `badge ${statusClass(job.status)}`;
$('activeStatus').textContent = job.status;
$('activeJobId').textContent = job.id;
$('activeCmd').textContent = (job.command || []).join(' ');
$('stopBtn').disabled = job.status !== 'running';
state.activeJobId = job.id;
}
function renderJobs(jobs, activeId) {
jobList.innerHTML = '';
if (!jobs.length) {
jobList.innerHTML = '<div class="muted">暂无历史任务</div>';
return;
}
jobs.forEach((job) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'job-item';
const model = job.payload?.model || '-';
const thinking = job.payload?.thinking ? 'thinking' : 'no-thinking';
btn.innerHTML = `
<div class="row">
<strong>${escapeHtml(job.id)}</strong>
<span class="badge ${statusClass(job.status)}">${escapeHtml(job.status)}</span>
</div>
<small>${escapeHtml(model)} · ${thinking}</small>
`;
btn.addEventListener('click', () => followJob(job.id, true));
if (job.id === activeId) btn.style.borderColor = 'rgba(214,162,74,0.75)';
jobList.appendChild(btn);
});
}
async function refreshJobs() {
const data = await api('/api/jobs');
renderJobs(data.jobs || [], data.active_job_id);
if (data.active_job_id) {
const active = (data.jobs || []).find((j) => j.id === data.active_job_id);
if (active) renderActive(active);
} else if (state.activeJobId) {
const current = (data.jobs || []).find((j) => j.id === state.activeJobId);
if (current) renderActive(current);
}
return data;
}
async function pullLogs(reset = false) {
if (!state.activeJobId) return;
if (reset) {
state.logOffset = 0;
logView.textContent = '';
}
const data = await api(`/api/jobs/${state.activeJobId}/logs?offset=${state.logOffset}`);
if (data.content) {
logView.textContent += data.content;
state.logOffset = data.next_offset;
if ($('autoScroll').checked) logView.scrollTop = logView.scrollHeight;
}
if (data.status) {
$('activeStatus').className = `badge ${statusClass(data.status)}`;
$('activeStatus').textContent = data.status;
$('activeCard').className = `active-card ${statusClass(data.status)}`;
$('stopBtn').disabled = data.status !== 'running';
}
if (data.done) stopPolling();
}
function stopPolling() {
if (state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
}
}
function startPolling() {
stopPolling();
state.pollTimer = setInterval(async () => {
try {
await pullLogs(false);
await refreshJobs();
} catch (e) {
console.error(e);
}
}, 1200);
}
async function followJob(jobId, resetLog = false) {
const job = await api(`/api/jobs/${jobId}`);
renderActive(job);
await pullLogs(resetLog);
if (job.status === 'running' || job.status === 'queued') startPolling();
else stopPolling();
}
async function init() {
try {
await api('/api/health');
$('healthDot').className = 'dot ok';
$('healthText').textContent = 'server online';
} catch (e) {
$('healthDot').className = 'dot bad';
$('healthText').textContent = 'server offline';
setMsg(e.message, 'error');
return;
}
state.meta = await api('/api/meta');
fillDefaults(state.meta);
renderSuites();
renderCustomSuites();
renderBenchmarks();
renderCustomPicker();
setMode('suite');
const jobs = await refreshJobs();
if (jobs.active_job_id) {
await followJob(jobs.active_job_id, true);
} else if (jobs.jobs?.[0]) {
await followJob(jobs.jobs[0].id, true);
}
}
document.querySelectorAll('.mode-tabs .tab').forEach((el) => {
el.addEventListener('click', () => setMode(el.dataset.mode));
});
$('selectAllBtn').addEventListener('click', () => {
document.querySelectorAll('#benchmarkList input').forEach((el) => { el.checked = true; });
updateSelectedCount();
});
$('clearAllBtn').addEventListener('click', () => {
document.querySelectorAll('#benchmarkList input').forEach((el) => { el.checked = false; });
updateSelectedCount();
});
$('refreshJobsBtn').addEventListener('click', () => refreshJobs().catch((e) => setMsg(e.message, 'error')));
$('saveCustomSuiteBtn').addEventListener('click', async () => {
const name = $('customSuiteName').value.trim();
const datasets = [...document.querySelectorAll('#customPickList input:checked')].map((el) => el.value);
if (!name) {
setMsg('请填写组合名称', 'error');
return;
}
if (!datasets.length) {
setMsg('请至少勾选一个 benchmark', 'error');
return;
}
try {
await saveCustomSuite(name, datasets);
} catch (err) {
setMsg(err.message, 'error');
}
});
$('loadCheckedToCustomBtn').addEventListener('click', () => {
// no-op helper text: already editing in place; just focus name
$('customSuiteName').focus();
setMsg('请在下方勾选数据集并填写组合名称后保存', 'ok');
});
$('saveFromDatasetsBtn').addEventListener('click', async () => {
const datasets = [...document.querySelectorAll('#benchmarkList input:checked')].map((el) => el.value);
if (!datasets.length) {
setMsg('请先勾选 benchmark', 'error');
return;
}
const name = prompt('输入要永久保存的组合名称:');
if (!name || !name.trim()) return;
try {
// sync into custom picker too
const set = new Set(datasets);
document.querySelectorAll('#customPickList input').forEach((el) => {
el.checked = set.has(el.value);
});
$('customSuiteName').value = name.trim();
updateCustomPickCount();
await saveCustomSuite(name.trim(), datasets);
setMode('suite');
} catch (err) {
setMsg(err.message, 'error');
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
setMsg('');
const requiredPaths = [
['dataset_dir', 'Dataset Dir'],
['output_dir', 'Output Dir'],
['config', 'Config YAML'],
['tokenizer_path', 'Tokenizer Path'],
];
for (const [id, label] of requiredPaths) {
if (!$(id).value.trim()) {
setMsg(`请填写必填路径: ${label}`, 'error');
$(id).focus();
return;
}
}
const payload = collectPayload();
if (payload.selection_mode === 'datasets' && !payload.datasets.length) {
setMsg('请至少选择一个 benchmark / 自定义组合', 'error');
return;
}
$('launchBtn').disabled = true;
try {
const job = await api('/api/jobs', {
method: 'POST',
body: JSON.stringify(payload),
});
setMsg(`任务已启动: ${job.id}`, 'ok');
renderActive(job);
state.logOffset = 0;
logView.textContent = '';
await refreshJobs();
startPolling();
await pullLogs(true);
} catch (err) {
setMsg(err.message, 'error');
} finally {
$('launchBtn').disabled = false;
}
});
$('stopBtn').addEventListener('click', async () => {
if (!state.activeJobId) return;
if (!confirm(`确认停止任务 ${state.activeJobId}`)) return;
try {
const job = await api(`/api/jobs/${state.activeJobId}/stop`, { method: 'POST' });
renderActive(job);
setMsg('任务已停止', 'ok');
await refreshJobs();
await pullLogs(false);
} catch (err) {
setMsg(err.message, 'error');
}
});
init();
})();

View File

@ -1,229 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EvalStone Launch</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="page">
<header class="top">
<div class="brand">
<span class="brand-mark"></span>
<div>
<h1>EvalStone Launch</h1>
<p>本地评测启动台 · 基于 <code>bash/run.py</code></p>
</div>
</div>
<div class="top-right">
<nav class="nav-tabs">
<a href="/" class="active">启动台</a>
<a href="/results">结果分析</a>
</nav>
<div class="top-meta">
<span id="healthDot" class="dot"></span>
<span id="healthText">connecting…</span>
</div>
</div>
</header>
<main class="layout">
<section class="panel form-panel">
<form id="launchForm">
<div class="block">
<h2>路径配置(必填)</h2>
<div class="grid-2">
<label class="full">
<span>Dataset Dir</span>
<input id="dataset_dir" name="dataset_dir" required />
</label>
<label class="full">
<span>Output Dir</span>
<input id="output_dir" name="output_dir" required />
</label>
<label class="full">
<span>Config YAML</span>
<input id="config" name="config" required />
</label>
<label class="full">
<span>Tokenizer Path</span>
<input id="tokenizer_path" name="tokenizer_path" required />
</label>
</div>
</div>
<div class="block">
<h2>模型接入</h2>
<div class="grid-2">
<label>
<span>Model Name</span>
<input id="model" name="model" required placeholder="DeepSeek-V4-Flash-Int8" />
</label>
<label>
<span>API URL</span>
<input id="api_url" name="api_url" required placeholder="http://localhost:30000/v1" />
</label>
<label>
<span>API Key</span>
<input id="api_key" name="api_key" placeholder="EMPTY" />
</label>
<label>
<span>Folder Name可选</span>
<input id="folder_name" name="folder_name" placeholder="默认取 model / model_THINKING" />
</label>
</div>
</div>
<div class="block">
<h2>Thinking</h2>
<div class="thinking-row">
<label class="switch">
<input type="checkbox" id="thinking" name="thinking" />
<span class="slider"></span>
<span class="switch-text">启用 thinking 模式</span>
</label>
<label class="inline">
<span>max_tokens_add</span>
<input type="number" id="max_tokens_add" name="max_tokens_add" value="0" min="0" step="1024" />
</label>
<label class="inline">
<span>thinking scale</span>
<input type="number" id="thinking_max_tokens_scale" name="thinking_max_tokens_scale" value="1.0" min="0.1" step="0.1" />
</label>
</div>
</div>
<div class="block">
<h2>评测范围</h2>
<div class="mode-tabs" role="tablist">
<button type="button" class="tab active" data-mode="suite">按 Suite / 自定义组合</button>
<button type="button" class="tab" data-mode="datasets">自选 Benchmark</button>
</div>
<div id="suitePane" class="pane">
<h3 class="subhead">内置 Suite</h3>
<div id="suiteList" class="suite-list"></div>
<h3 class="subhead mt">已保存的自定义组合</h3>
<div id="customSuiteList" class="suite-list"></div>
<p id="customSuiteEmpty" class="muted">暂无自定义组合,可在下方勾选并保存</p>
<div class="custom-editor mt">
<h3 class="subhead">新建 / 更新自定义组合</h3>
<div class="custom-editor-row">
<label>
<span>组合名称</span>
<input id="customSuiteName" placeholder="例如: my_math_code" />
</label>
<div class="custom-editor-actions">
<button type="button" id="saveCustomSuiteBtn" class="primary">永久保存组合</button>
<button type="button" id="loadCheckedToCustomBtn" class="ghost">从下方勾选填入</button>
</div>
</div>
<div id="customPickList" class="benchmark-list compact"></div>
<span id="customPickCount" class="muted">已勾选 0</span>
</div>
<label class="mt">
<span>Exclude逗号分隔可选仅对内置 Suite 生效)</span>
<input id="exclude" name="exclude" placeholder="例如: tau2_bench,hle" />
</label>
</div>
<div id="datasetsPane" class="pane hidden">
<div class="bench-actions">
<button type="button" id="selectAllBtn" class="ghost">全选</button>
<button type="button" id="clearAllBtn" class="ghost">清空</button>
<button type="button" id="saveFromDatasetsBtn" class="ghost">保存为自定义组合</button>
<span id="selectedCount" class="muted">已选 0</span>
</div>
<div id="benchmarkList" class="benchmark-list"></div>
</div>
</div>
<details class="block advanced">
<summary>其他参数</summary>
<div class="grid-2 mt">
<label>
<span>Limitnone = 全量)</span>
<input id="limit" name="limit" placeholder="none" />
</label>
<label>
<span>Seed</span>
<input type="number" id="seed" name="seed" value="42" />
</label>
<label>
<span>Batch Size</span>
<input type="number" id="batch_size" name="batch_size" value="4" min="1" />
</label>
<label>
<span>Write Summary</span>
<select id="write_summary" name="write_summary">
<option value="true" selected></option>
<option value="false"></option>
</select>
</label>
<label>
<span>Judge Model</span>
<input id="judge_model" name="judge_model" />
</label>
<label>
<span>Judge API URL</span>
<input id="judge_api_url" name="judge_api_url" />
</label>
<label>
<span>Judge API Key</span>
<input id="judge_api_key" name="judge_api_key" type="password" />
</label>
<label>
<span>Judge Max Tokens</span>
<input type="number" id="judge_max_tokens" name="judge_max_tokens" />
</label>
</div>
</details>
<div class="actions">
<button type="submit" id="launchBtn" class="primary">启动评测</button>
<button type="button" id="stopBtn" class="danger" disabled>停止任务</button>
<span id="formMsg" class="msg"></span>
</div>
</form>
</section>
<section class="panel side-panel">
<div class="side-head">
<h2>运行状态</h2>
<button type="button" id="refreshJobsBtn" class="ghost">刷新</button>
</div>
<div id="activeCard" class="active-card idle">
<div class="active-title">
<span id="activeStatus" class="badge">idle</span>
<code id="activeJobId"></code>
</div>
<pre id="activeCmd" class="cmd">尚未启动任务</pre>
</div>
<div class="log-head">
<h3>实时日志</h3>
<label class="check-inline">
<input type="checkbox" id="autoScroll" checked />
自动滚动
</label>
</div>
<pre id="logView" class="log-view"></pre>
<div class="jobs-head">
<h3>历史任务</h3>
</div>
<div id="jobList" class="job-list"></div>
</section>
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -1,142 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EvalStone Results</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/styles.css" />
<script src="/static/vendor/chart.umd.min.js"></script>
</head>
<body>
<div class="page">
<header class="top">
<div class="brand">
<span class="brand-mark"></span>
<div>
<h1>EvalStone Results</h1>
<p>多模型同基准对比 · 扫描 <code>output/</code></p>
</div>
</div>
<nav class="nav-tabs">
<a href="/">启动台</a>
<a href="/results" class="active">结果分析</a>
</nav>
</header>
<main class="results-layout">
<section class="panel filters-panel">
<div class="block">
<h2>数据源</h2>
<label>
<span>Output Dir</span>
<input id="outputDir" />
</label>
<div class="actions mt">
<button type="button" id="reloadBtn" class="primary">重新扫描</button>
<span id="scanMsg" class="msg"></span>
</div>
<p id="scanMeta" class="muted mt"></p>
</div>
<div class="block">
<h2>选择模型folder</h2>
<div class="bench-actions">
<button type="button" id="modelAllBtn" class="ghost">全选</button>
<button type="button" id="modelClearBtn" class="ghost">清空</button>
</div>
<div id="modelList" class="pick-list"></div>
</div>
<div class="block">
<h2>选择 Benchmark</h2>
<div class="bench-actions">
<button type="button" id="benchAllBtn" class="ghost">全选</button>
<button type="button" id="benchClearBtn" class="ghost">清空</button>
</div>
<div id="benchList" class="pick-list bench-pick"></div>
</div>
<div class="actions">
<button type="button" id="compareBtn" class="primary">更新对比</button>
</div>
</section>
<section class="panel charts-panel">
<div class="chart-block">
<div class="side-head">
<h2>能力分类走势</h2>
<span class="muted">横轴 = 能力域(数学/代码/智能体等),纵轴 = 该域平均分</span>
</div>
<div class="chart-wrap">
<canvas id="categoryLineChart"></canvas>
</div>
</div>
<div class="chart-block">
<div class="side-head">
<h2>能力分类柱状对比</h2>
<span class="muted">各模型在同一能力域的平均得分</span>
</div>
<div class="chart-wrap">
<canvas id="categoryBarChart"></canvas>
</div>
</div>
<div class="chart-block">
<div class="side-head">
<h2>得分走势</h2>
<span class="muted">横轴 = benchmark纵轴 = score</span>
</div>
<div class="chart-wrap">
<canvas id="lineChart"></canvas>
</div>
</div>
<div class="chart-block">
<div class="side-head">
<h2>柱状对比</h2>
<span class="muted">同 benchmark 下各模型得分</span>
</div>
<div class="chart-wrap">
<canvas id="barChart"></canvas>
</div>
</div>
<div class="chart-block">
<div class="side-head">
<h2>雷达图</h2>
<span class="muted">能力轮廓(需 ≥3 个共同 benchmark</span>
</div>
<div class="chart-wrap radar-wrap">
<canvas id="radarChart"></canvas>
</div>
</div>
<div class="chart-block">
<div class="side-head">
<h2>得分表</h2>
<span class="muted">平均值(跨 seed / multi-run</span>
</div>
<div class="table-wrap">
<table id="scoreTable">
<thead></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="chart-block">
<div class="side-head">
<h2>各 Benchmark 排名</h2>
</div>
<div id="rankList" class="rank-list"></div>
</div>
</section>
</main>
</div>
<script src="/static/results.js?v=20260729"></script>
</body>
</html>

View File

@ -1,395 +0,0 @@
(() => {
const state = {
overview: null,
compare: null,
charts: { line: null, bar: null, radar: null, catLine: null, catBar: null },
};
const $ = (id) => document.getElementById(id);
const COLORS = [
'#d6a24a', '#6fbf8a', '#6aa8d6', '#d86a5b',
'#8b7ec8', '#e0c36a', '#5ec4b0', '#c98a6a',
];
function setMsg(text, type = '') {
const el = $('scanMsg');
el.textContent = text || '';
el.className = `msg ${type}`.trim();
}
async function api(path) {
const res = await fetch(path);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || res.statusText || 'request failed');
}
return data;
}
function selectedValues(containerId) {
return [...document.querySelectorAll(`#${containerId} input:checked`)].map((el) => el.value);
}
function renderModelList(models) {
const box = $('modelList');
box.innerHTML = '';
if (!models.length) {
box.innerHTML = '<div class="muted">未发现模型结果目录</div>';
return;
}
models.forEach((m, i) => {
const label = document.createElement('label');
label.className = 'pick-item';
label.innerHTML = `
<input type="checkbox" value="${m.folder}" checked />
<span class="pick-main">
<strong>${m.folder}</strong>
<small>${m.model_name} · ${m.n_benchmarks} benches</small>
</span>
<i class="swatch" style="background:${COLORS[i % COLORS.length]}"></i>
`;
box.appendChild(label);
});
}
function renderBenchList(categories, benchmarks) {
const box = $('benchList');
box.innerHTML = '';
if (!benchmarks.length) {
box.innerHTML = '<div class="muted">暂无 benchmark 结果</div>';
return;
}
const cats = categories && categories.length
? categories
: [{ id: 'all', name: '全部', items: benchmarks }];
cats.forEach((cat) => {
const section = document.createElement('div');
section.className = `pick-cat cat-${cat.id}`;
section.innerHTML = `
<div class="pick-cat-head">
<strong>${cat.name}</strong>
<span class="muted">${cat.items.length}</span>
<button type="button" class="ghost cat-all">全选</button>
<button type="button" class="ghost cat-none">清空</button>
</div>
`;
const grid = document.createElement('div');
grid.className = 'pick-grid';
cat.items.forEach((name) => {
const label = document.createElement('label');
label.className = 'pick-chip';
label.innerHTML = `<input type="checkbox" value="${name}" checked /><span>${name}</span>`;
grid.appendChild(label);
});
section.querySelector('.cat-all').addEventListener('click', () => {
grid.querySelectorAll('input').forEach((el) => { el.checked = true; });
});
section.querySelector('.cat-none').addEventListener('click', () => {
grid.querySelectorAll('input').forEach((el) => { el.checked = false; });
});
section.appendChild(grid);
box.appendChild(section);
});
}
function destroyCharts() {
Object.keys(state.charts).forEach((k) => {
if (state.charts[k]) {
state.charts[k].destroy();
state.charts[k] = null;
}
});
}
function chartDatasets(series, type) {
return series.map((s, i) => {
const color = COLORS[i % COLORS.length];
const base = {
label: s.display,
data: s.scores.map((v) => (v == null ? null : Number((v * 100).toFixed(2)))),
borderColor: color,
backgroundColor: type === 'radar' ? color + '33' : color + 'cc',
tension: 0.25,
spanGaps: false,
pointRadius: 4,
pointHoverRadius: 6,
};
if (type === 'bar') {
return { ...base, borderWidth: 0, borderRadius: 4 };
}
if (type === 'radar') {
return { ...base, fill: true, borderWidth: 2 };
}
return { ...base, fill: false, borderWidth: 2 };
});
}
function axisOptions(maxRotation = 45) {
return {
x: {
ticks: { color: '#92a197', maxRotation, minRotation: 0, font: { size: 12 } },
grid: { color: 'rgba(51,64,56,0.6)' },
},
y: {
min: 0,
max: 100,
ticks: {
color: '#92a197',
callback: (v) => v + '%',
},
grid: { color: 'rgba(51,64,56,0.6)' },
title: { display: true, text: 'Score (%)', color: '#92a197' },
},
};
}
function markEmpty(el, msg) {
if (!el) return;
const wrap = el.parentElement || el;
wrap.classList.add('empty');
wrap.dataset.empty = msg;
}
function clearEmpty(el) {
if (!el) return;
const wrap = el.parentElement || el;
wrap.classList.remove('empty');
delete wrap.dataset.empty;
}
function makeChart(key, canvas, config) {
if (typeof Chart === 'undefined') {
markEmpty(canvas, 'Chart.js 未加载,请强制刷新页面 (Ctrl+F5)');
return null;
}
if (!canvas) return null;
try {
clearEmpty(canvas);
const chart = new Chart(canvas, config);
state.charts[key] = chart;
return chart;
} catch (err) {
console.error('chart error', key, err);
markEmpty(canvas, `图表渲染失败: ${err.message || err}`);
return null;
}
}
function renderCharts(compare) {
destroyCharts();
const labels = compare.benchmarks || [];
const common = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#c9d4cb', boxWidth: 12, font: { family: 'IBM Plex Sans', size: 13 } },
},
tooltip: {
callbacks: {
label(ctx) {
const v = ctx.parsed.y ?? ctx.parsed.r;
return `${ctx.dataset.label}: ${v == null ? '—' : v.toFixed(2) + '%'}`;
},
},
},
},
scales: {},
};
// Capability-domain charts
const catLabels = compare.category_labels || [];
const catSeries = compare.category_series || [];
const catLineCanvas = $('categoryLineChart');
const catBarCanvas = $('categoryBarChart');
if (!catLabels.length || !catSeries.length) {
markEmpty(catLineCanvas, '当前选择下无可用能力分类数据');
markEmpty(catBarCanvas, '当前选择下无可用能力分类数据');
} else {
makeChart('catLine', catLineCanvas, {
type: 'line',
data: { labels: catLabels, datasets: chartDatasets(catSeries, 'line') },
options: { ...common, scales: axisOptions(0) },
});
makeChart('catBar', catBarCanvas, {
type: 'bar',
data: { labels: catLabels, datasets: chartDatasets(catSeries, 'bar') },
options: {
...common,
scales: {
x: { ...axisOptions(0).x, grid: { display: false } },
y: axisOptions(0).y,
},
},
});
}
if (!labels.length) {
markEmpty($('lineChart'), '暂无 benchmark 得分');
markEmpty($('barChart'), '暂无 benchmark 得分');
} else {
makeChart('line', $('lineChart'), {
type: 'line',
data: { labels, datasets: chartDatasets(compare.series, 'line') },
options: { ...common, scales: axisOptions(45) },
});
makeChart('bar', $('barChart'), {
type: 'bar',
data: { labels, datasets: chartDatasets(compare.series, 'bar') },
options: {
...common,
scales: {
x: { ...axisOptions(45).x, grid: { display: false } },
y: axisOptions(45).y,
},
},
});
}
const radarLabels = labels;
const canRadar = radarLabels.length >= 3 && (compare.series || []).length > 0;
const radarCanvas = $('radarChart');
if (!canRadar) {
markEmpty(radarCanvas, '共同 benchmark 不足 3 个,暂不绘制雷达图');
} else {
makeChart('radar', radarCanvas, {
type: 'radar',
data: { labels: radarLabels, datasets: chartDatasets(compare.series, 'radar') },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#c9d4cb', boxWidth: 12 } },
},
scales: {
r: {
min: 0,
max: 100,
ticks: { color: '#92a197', backdropColor: 'transparent', stepSize: 20 },
grid: { color: 'rgba(51,64,56,0.7)' },
angleLines: { color: 'rgba(51,64,56,0.7)' },
pointLabels: { color: '#c9d4cb', font: { size: 12 } },
},
},
},
});
}
}
function renderTable(compare) {
const thead = $('scoreTable').querySelector('thead');
const tbody = $('scoreTable').querySelector('tbody');
thead.innerHTML = '';
tbody.innerHTML = '';
const head = document.createElement('tr');
head.innerHTML = `<th>Benchmark</th>${compare.series.map((s) => `<th>${s.display}</th>`).join('')}<th>最佳</th>`;
thead.appendChild(head);
compare.benchmarks.forEach((b, i) => {
const tr = document.createElement('tr');
let best = -1;
let bestFolder = '—';
compare.series.forEach((s) => {
const v = s.scores[i];
if (v != null && v > best) {
best = v;
bestFolder = s.display;
}
});
const cells = compare.series.map((s) => {
const v = s.scores[i];
if (v == null) return '<td class="na">—</td>';
const pct = (v * 100).toFixed(2);
const isBest = v === best && best >= 0;
return `<td class="${isBest ? 'best' : ''}">${pct}%</td>`;
}).join('');
tr.innerHTML = `<td class="bench-col">${b}</td>${cells}<td class="best-name">${bestFolder}</td>`;
tbody.appendChild(tr);
});
}
function renderRanking(compare) {
const box = $('rankList');
box.innerHTML = '';
compare.ranking.forEach((item) => {
const card = document.createElement('div');
card.className = 'rank-card';
const rows = item.rows.length
? item.rows.map((r, idx) => `
<div class="rank-row">
<span class="rank-idx">#${idx + 1}</span>
<span class="rank-name">${r.folder}</span>
<span class="rank-score">${(r.score * 100).toFixed(2)}%</span>
</div>`).join('')
: '<div class="muted">无数据</div>';
card.innerHTML = `<strong>${item.benchmark}</strong>${rows}`;
box.appendChild(card);
});
}
async function loadOverview() {
const dir = $('outputDir').value.trim();
const qs = dir ? `?output_dir=${encodeURIComponent(dir)}` : '';
setMsg('扫描中…');
try {
const data = await api(`/api/results/overview${qs}`);
state.overview = data;
if (!$('outputDir').value) $('outputDir').value = data.output_dir || '';
$('scanMeta').textContent = `${data.models.length} 个模型 · ${data.benchmarks.length} 个 benchmark · ${data.output_dir}`;
renderModelList(data.models);
renderBenchList(data.categories, data.benchmarks);
setMsg('扫描完成', 'ok');
await runCompare();
} catch (e) {
setMsg(e.message, 'error');
}
}
async function runCompare() {
const models = selectedValues('modelList');
const benchmarks = selectedValues('benchList');
if (!models.length) {
setMsg('请至少选择一个模型', 'error');
return;
}
if (!benchmarks.length) {
setMsg('请至少选择一个 benchmark', 'error');
return;
}
const dir = $('outputDir').value.trim();
const params = new URLSearchParams({
models: models.join(','),
benchmarks: benchmarks.join(','),
});
if (dir) params.set('output_dir', dir);
const data = await api(`/api/results/compare?${params}`);
state.compare = data;
renderCharts(data);
renderTable(data);
renderRanking(data);
setMsg(`已对比 ${data.series.length} 模型 × ${data.benchmarks.length} benches`, 'ok');
}
$('reloadBtn').addEventListener('click', () => loadOverview());
$('compareBtn').addEventListener('click', () => runCompare().catch((e) => setMsg(e.message, 'error')));
$('modelAllBtn').addEventListener('click', () => {
document.querySelectorAll('#modelList input').forEach((el) => { el.checked = true; });
});
$('modelClearBtn').addEventListener('click', () => {
document.querySelectorAll('#modelList input').forEach((el) => { el.checked = false; });
});
$('benchAllBtn').addEventListener('click', () => {
document.querySelectorAll('#benchList input').forEach((el) => { el.checked = true; });
});
$('benchClearBtn').addEventListener('click', () => {
document.querySelectorAll('#benchList input').forEach((el) => { el.checked = false; });
});
// bootstrap defaults from meta
api('/api/meta').then((meta) => {
$('outputDir').value = meta.defaults?.output_dir || '';
return loadOverview();
}).catch(() => loadOverview());
})();

View File

@ -1,693 +0,0 @@
:root {
--bg0: #121714;
--bg1: #1a211c;
--bg2: #232c26;
--line: #334038;
--text: #e7eee8;
--muted: #92a197;
--accent: #d6a24a;
--accent-2: #6fbf8a;
--danger: #d86a5b;
--shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
--radius: 14px;
--font: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: var(--font);
color: var(--text);
background:
radial-gradient(1200px 600px at 10% -10%, rgba(214, 162, 74, 0.16), transparent 55%),
radial-gradient(900px 500px at 100% 0%, rgba(111, 191, 138, 0.12), transparent 50%),
linear-gradient(180deg, #0f1411 0%, var(--bg0) 40%, #101612 100%);
}
code, pre, .mono { font-family: var(--mono); }
.page {
max-width: 1440px;
margin: 0 auto;
padding: 28px 24px 40px;
}
.top {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
margin-bottom: 22px;
}
.brand { display: flex; gap: 14px; align-items: center; }
.brand-mark {
width: 14px; height: 42px; border-radius: 999px;
background: linear-gradient(180deg, var(--accent), var(--accent-2));
box-shadow: 0 0 24px rgba(214, 162, 74, 0.35);
}
.brand h1 {
margin: 0;
font-size: 28px;
letter-spacing: 0.02em;
font-weight: 700;
}
.brand p {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
}
.brand code {
color: var(--accent);
background: rgba(214, 162, 74, 0.1);
padding: 1px 6px;
border-radius: 6px;
}
.top-meta {
display: flex; align-items: center; gap: 8px;
color: var(--muted); font-size: 13px;
}
.dot {
width: 9px; height: 9px; border-radius: 50%;
background: #667;
box-shadow: 0 0 0 3px rgba(102, 102, 119, 0.2);
}
.dot.ok { background: var(--accent-2); box-shadow: 0 0 0 3px rgba(111, 191, 138, 0.2); }
.dot.bad { background: var(--danger); box-shadow: 0 0 0 3px rgba(216, 106, 91, 0.2); }
.layout {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(340px, 0.85fr);
gap: 18px;
align-items: start;
}
.panel {
background: linear-gradient(180deg, rgba(26, 33, 28, 0.96), rgba(18, 23, 20, 0.96));
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
}
.block + .block { margin-top: 18px; }
.block h2, .side-head h2, .log-head h3, .jobs-head h3 {
margin: 0 0 10px;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #c7d2c9;
}
.subhead {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: #c7d2c9;
}
label {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 14px;
color: var(--muted);
}
label.full { grid-column: 1 / -1; }
label span { letter-spacing: 0.02em; color: #b7c4ba; }
input, select, button, summary {
font: inherit;
}
input, select {
width: 100%;
background: var(--bg0);
color: var(--text);
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px 10px;
font-size: 15px;
line-height: 1.35;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus {
border-color: rgba(214, 162, 74, 0.7);
box-shadow: 0 0 0 3px rgba(214, 162, 74, 0.15);
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.thinking-row {
display: flex;
flex-wrap: wrap;
gap: 14px 18px;
align-items: end;
}
.thinking-row .inline { min-width: 140px; }
.switch {
flex-direction: row;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.switch input { display: none; }
.slider {
width: 42px; height: 24px; border-radius: 999px;
background: #2c3630; position: relative; transition: .2s;
border: 1px solid var(--line);
}
.slider::after {
content: "";
position: absolute; top: 2px; left: 2px;
width: 18px; height: 18px; border-radius: 50%;
background: #c5d0c7; transition: .2s;
}
.switch input:checked + .slider {
background: rgba(214, 162, 74, 0.35);
border-color: rgba(214, 162, 74, 0.7);
}
.switch input:checked + .slider::after {
transform: translateX(18px);
background: var(--accent);
}
.switch-text { color: var(--text); font-size: 15px; }
.mode-tabs {
display: inline-flex;
background: var(--bg0);
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px;
margin-bottom: 12px;
}
.tab {
border: 0;
background: transparent;
color: var(--muted);
padding: 7px 12px;
border-radius: 999px;
cursor: pointer;
font-size: 14px;
}
.tab.active {
background: rgba(214, 162, 74, 0.18);
color: var(--text);
}
.suite-list {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.suite-card {
text-align: left;
border: 1px solid var(--line);
background: var(--bg0);
color: var(--text);
border-radius: 10px;
padding: 10px 12px;
cursor: pointer;
transition: border-color .15s, transform .15s;
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
}
.suite-card:hover { transform: translateY(-1px); }
.suite-card.active {
border-color: rgba(214, 162, 74, 0.75);
background: rgba(214, 162, 74, 0.08);
}
.suite-card.custom {
padding-right: 64px;
}
.suite-card-main {
all: unset;
cursor: pointer;
display: block;
width: 100%;
}
.suite-card strong {
display: block;
margin-bottom: 2px;
font-size: 15px;
}
.suite-count {
display: block;
color: var(--muted);
font-size: 13px;
margin-bottom: 4px;
}
.suite-names {
color: #c9d4cb;
font-size: 13px;
line-height: 1.55;
white-space: normal;
word-break: break-word;
overflow: visible;
text-overflow: unset;
}
.suite-del {
position: absolute;
top: 8px;
right: 8px;
color: #ffb3a8 !important;
}
.danger-text { color: #ffb3a8; }
.custom-editor {
border: 1px dashed var(--line);
border-radius: 10px;
padding: 12px;
background: rgba(12, 16, 14, 0.35);
}
.custom-editor-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: end;
margin-bottom: 10px;
}
.custom-editor-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.benchmark-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
.benchmark-list.compact { max-height: 280px; }
.bench-category {
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(12, 16, 14, 0.55);
padding: 8px;
}
.bench-category.cat-math { border-left: 3px solid #6fbf8a; }
.bench-category.cat-code { border-left: 3px solid #6aa8d6; }
.bench-category.cat-science { border-left: 3px solid #c79a5a; }
.bench-category.cat-knowledge { border-left: 3px solid #9aa4b2; }
.bench-category.cat-long_context { border-left: 3px solid #8b7ec8; }
.bench-category.cat-tool_agent { border-left: 3px solid #d6a24a; }
.bench-category.cat-other { border-left: 3px solid #667; }
.bench-cat-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.bench-cat-title {
display: flex;
align-items: baseline;
gap: 8px;
}
.bench-cat-title strong {
font-size: 14px;
letter-spacing: 0.02em;
color: var(--text);
}
.bench-cat-actions { display: flex; gap: 4px; }
.bench-cat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.bench-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--bg0);
cursor: pointer;
font-size: 14px;
color: var(--text);
min-height: 0;
}
.bench-item input { width: auto; }
.bench-item.multi { border-color: rgba(111, 191, 138, 0.35); }
.bench-item .run-tag {
margin-left: auto;
font-style: normal;
font-size: 12px;
color: #9ee0b2;
opacity: 0.85;
}
.bench-actions {
display: flex; gap: 8px; align-items: center; margin-bottom: 10px;
}
.muted { color: var(--muted); font-size: 13px; }
.mt { margin-top: 12px; }
.hidden { display: none !important; }
.advanced {
border: 1px dashed var(--line);
border-radius: 12px;
padding: 10px 14px;
}
.advanced summary {
cursor: pointer;
color: var(--muted);
font-size: 13px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-top: 22px;
}
button {
border-radius: 8px;
border: 1px solid var(--line);
padding: 8px 14px;
cursor: pointer;
background: var(--bg2);
color: var(--text);
font-size: 14px;
}
button:disabled { opacity: 0.45; cursor: not-allowed; }
button.primary {
background: linear-gradient(180deg, #e0b05a, #c48c2f);
border-color: #b9852d;
color: #1a1408;
font-weight: 600;
}
button.danger {
background: rgba(216, 106, 91, 0.15);
border-color: rgba(216, 106, 91, 0.45);
color: #ffc2ba;
}
button.ghost {
background: transparent;
padding: 5px 9px;
font-size: 13px;
}
.msg { color: var(--muted); font-size: 14px; }
.msg.error { color: var(--danger); }
.msg.ok { color: var(--accent-2); }
.side-head, .log-head, .jobs-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.active-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: var(--bg0);
margin-bottom: 14px;
}
.active-card.running { border-color: rgba(111, 191, 138, 0.55); }
.active-card.failed, .active-card.stopped { border-color: rgba(216, 106, 91, 0.45); }
.active-card.completed { border-color: rgba(214, 162, 74, 0.45); }
.active-title {
display: flex; gap: 10px; align-items: center; margin-bottom: 8px;
}
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
background: #2a332d;
color: var(--muted);
}
.badge.running { background: rgba(111, 191, 138, 0.18); color: #9ee0b2; }
.badge.completed { background: rgba(214, 162, 74, 0.18); color: #f0d08a; }
.badge.failed, .badge.stopped { background: rgba(216, 106, 91, 0.18); color: #ffb3a8; }
.cmd, .log-view {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.5;
color: #c9d4cb;
}
.cmd { color: var(--muted); max-height: 72px; overflow: auto; }
.log-view {
height: 360px;
overflow: auto;
background: #0c100e;
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
margin: 8px 0 16px;
}
.check-inline {
flex-direction: row;
align-items: center;
gap: 6px;
font-size: 12px;
}
.check-inline input { width: auto; }
.job-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 220px;
overflow: auto;
}
.job-item {
text-align: left;
width: 100%;
border: 1px solid var(--line);
background: var(--bg0);
border-radius: 10px;
padding: 10px 12px;
cursor: pointer;
}
.job-item:hover { border-color: rgba(214, 162, 74, 0.45); }
.job-item .row {
display: flex; justify-content: space-between; gap: 8px; margin-bottom: 4px;
}
.job-item small { color: var(--muted); }
.top-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
.nav-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(18, 23, 20, 0.7);
}
.nav-tabs a {
color: var(--muted);
text-decoration: none;
padding: 7px 14px;
border-radius: 999px;
font-size: 13px;
}
.nav-tabs a.active,
.nav-tabs a:hover {
color: var(--text);
background: rgba(214, 162, 74, 0.18);
}
.results-layout {
display: grid;
grid-template-columns: minmax(280px, 0.85fr) minmax(0, 1.4fr);
gap: 18px;
align-items: start;
}
.filters-panel { position: sticky; top: 16px; }
.pick-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 240px;
overflow: auto;
}
.pick-list.bench-pick { max-height: 320px; }
.pick-item {
flex-direction: row;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg0);
cursor: pointer;
}
.pick-item input { width: auto; }
.pick-main { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.pick-main strong { color: var(--text); font-size: 13px; }
.pick-main small { color: var(--muted); font-size: 11px; }
.swatch {
width: 10px; height: 28px; border-radius: 999px; display: inline-block;
}
.pick-cat {
border: 1px solid var(--line);
border-radius: 10px;
padding: 8px;
background: rgba(12, 16, 14, 0.45);
}
.pick-cat + .pick-cat { margin-top: 8px; }
.pick-cat-head {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
}
.pick-cat-head strong { color: var(--text); font-size: 12px; }
.pick-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.pick-chip {
flex-direction: row;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--bg0);
cursor: pointer;
font-size: 12px;
color: var(--text);
}
.pick-chip input { width: auto; }
.chart-block + .chart-block { margin-top: 22px; }
.chart-wrap {
height: 340px;
background: #0c100e;
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
position: relative;
}
.chart-wrap.radar-wrap { height: 380px; }
.chart-wrap.empty::after {
content: attr(data-empty);
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 13px;
background: rgba(12, 16, 14, 0.85);
border-radius: 12px;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 12px;
background: #0c100e;
}
#scoreTable {
width: 100%;
border-collapse: collapse;
font-size: 13px;
font-family: var(--mono);
}
#scoreTable th, #scoreTable td {
padding: 10px 12px;
border-bottom: 1px solid rgba(51, 64, 56, 0.8);
text-align: right;
white-space: nowrap;
}
#scoreTable th:first-child,
#scoreTable td:first-child,
#scoreTable .bench-col {
text-align: left;
position: sticky;
left: 0;
background: #0c100e;
}
#scoreTable th {
color: var(--muted);
font-weight: 500;
background: #121714;
}
#scoreTable td.best { color: #f0d08a; font-weight: 600; }
#scoreTable td.na { color: #5a665e; }
#scoreTable .best-name { color: var(--accent-2); }
.rank-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.rank-card {
border: 1px solid var(--line);
border-radius: 12px;
background: var(--bg0);
padding: 12px;
}
.rank-card strong {
display: block;
margin-bottom: 8px;
font-size: 13px;
}
.rank-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
font-size: 13px;
padding: 6px 0;
color: var(--muted);
}
.rank-idx { color: var(--accent); font-family: var(--mono); }
.rank-name {
color: var(--text);
white-space: normal;
overflow: visible;
text-overflow: unset;
word-break: break-all;
line-height: 1.4;
font-size: 13px;
}
.rank-score {
font-family: var(--mono);
color: #c9d4cb;
white-space: nowrap;
padding-top: 1px;
}
@media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; }
.results-layout { grid-template-columns: 1fr; }
.filters-panel { position: static; }
.bench-cat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.grid-2, .suite-list, .bench-cat-grid, .pick-grid { grid-template-columns: 1fr; }
.page { padding: 18px 14px 28px; }
.brand h1 { font-size: 22px; }
.bench-cat-head { flex-wrap: wrap; }
.top { flex-direction: column; align-items: flex-start; }
.top-right { align-items: flex-start; }
}

File diff suppressed because one or more lines are too long