dsh-model-arena 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/arena-view.d.ts +4 -2
- package/lib/arena-view.js +4 -12
- package/lib/arena.d.ts +11 -21
- package/lib/arena.js +66 -10
- package/lib/arena.web.js +261 -35
- package/lib/arena.web.js.map +3 -3
- package/lib/client/index.d.ts +21 -10
- package/lib/client/index.js +13 -54
- package/lib/client/locales.d.ts +42 -0
- package/lib/client/locales.js +69 -0
- package/lib/client/view.d.ts +23 -1
- package/lib/client/view.js +116 -8
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- package/lib/routes.d.ts +2 -2
- package/lib/routes.js +51 -7
- package/lib/scoring.d.ts +6 -1
- package/lib/scoring.js +98 -0
- package/lib/store.d.ts +1 -0
- package/lib/store.js +8 -1
- package/lib/types.d.ts +50 -6
- package/package.json +4 -3
package/lib/client/index.d.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser half of dsh-model-arena:
|
|
2
|
+
* Browser half of dsh-model-arena: its page inside Settings.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the settings shell is
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
4
|
+
* Two services are injected by their runtime identity, not by the packages that provide
|
|
5
|
+
* them: the settings shell is `slots`, the dictionary registry is `locale`. The label and
|
|
6
|
+
* every string inside the component are translated through this plugin's own namespace,
|
|
7
|
+
* which is why the page follows the UI language instead of the plugin author's.
|
|
8
8
|
*
|
|
9
9
|
* @module client
|
|
10
10
|
*/
|
|
11
11
|
import type { ComponentType } from 'react';
|
|
12
|
+
export declare const inject: string[];
|
|
12
13
|
interface SlotsService {
|
|
13
14
|
inject(name: string, register: () => void): void;
|
|
14
15
|
register(options: {
|
|
@@ -16,11 +17,21 @@ interface SlotsService {
|
|
|
16
17
|
id: string;
|
|
17
18
|
order: number;
|
|
18
19
|
label: () => string;
|
|
19
|
-
|
|
20
|
+
locale: string;
|
|
21
|
+
}, component: ComponentType<{
|
|
22
|
+
t: (key: string, params?: Record<string, unknown>) => string;
|
|
23
|
+
}>): unknown;
|
|
20
24
|
}
|
|
21
|
-
|
|
22
|
-
export declare const inject: string[];
|
|
23
|
-
export declare function apply(ctx: {
|
|
25
|
+
interface ClientContext {
|
|
24
26
|
slots: SlotsService;
|
|
25
|
-
|
|
27
|
+
locale: {
|
|
28
|
+
register(ns: string, dicts: {
|
|
29
|
+
zh: unknown;
|
|
30
|
+
en: unknown;
|
|
31
|
+
}): () => void;
|
|
32
|
+
bind(ns: string): (key: string, params?: Record<string, unknown>) => string;
|
|
33
|
+
};
|
|
34
|
+
effect(callback: () => unknown, label?: string): unknown;
|
|
35
|
+
}
|
|
36
|
+
export declare function apply(ctx: ClientContext): void;
|
|
26
37
|
export {};
|
package/lib/client/index.js
CHANGED
|
@@ -1,65 +1,24 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
1
|
/**
|
|
3
|
-
* Browser half of dsh-model-arena:
|
|
2
|
+
* Browser half of dsh-model-arena: its page inside Settings.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
* the settings shell is
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
4
|
+
* Two services are injected by their runtime identity, not by the packages that provide
|
|
5
|
+
* them: the settings shell is `slots`, the dictionary registry is `locale`. The label and
|
|
6
|
+
* every string inside the component are translated through this plugin's own namespace,
|
|
7
|
+
* which is why the page follows the UI language instead of the plugin author's.
|
|
9
8
|
*
|
|
10
9
|
* @module client
|
|
11
10
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
const PANEL_PATH = '/api/arena.panel';
|
|
16
|
-
/** Fetch the panel payload; `reload` re-runs the request. */
|
|
17
|
-
function usePanel() {
|
|
18
|
-
const [html, setHtml] = useState(null);
|
|
19
|
-
const [error, setError] = useState(null);
|
|
20
|
-
const [tick, setTick] = useState(0);
|
|
21
|
-
const reload = useCallback(() => setTick((t) => t + 1), []);
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
const controller = new AbortController();
|
|
24
|
-
setError(null);
|
|
25
|
-
fetch(PANEL_PATH, { signal: controller.signal })
|
|
26
|
-
.then(async (response) => {
|
|
27
|
-
if (!response.ok)
|
|
28
|
-
throw new Error(`panel request failed with ${response.status}`);
|
|
29
|
-
return response.json();
|
|
30
|
-
})
|
|
31
|
-
.then((payload) => {
|
|
32
|
-
if (!controller.signal.aborted)
|
|
33
|
-
setHtml(renderPanel(payload));
|
|
34
|
-
})
|
|
35
|
-
.catch((cause) => {
|
|
36
|
-
if (controller.signal.aborted)
|
|
37
|
-
return;
|
|
38
|
-
// Say what failed rather than rendering an empty panel, which would read as
|
|
39
|
-
// "nothing recorded" — a different and wrong answer.
|
|
40
|
-
setError(cause instanceof Error ? cause.message : String(cause));
|
|
41
|
-
});
|
|
42
|
-
return () => controller.abort();
|
|
43
|
-
}, [tick]);
|
|
44
|
-
return { html, error, reload };
|
|
45
|
-
}
|
|
46
|
-
function ModelArenaPage() {
|
|
47
|
-
const { html, error, reload } = usePanel();
|
|
48
|
-
if (error !== null) {
|
|
49
|
-
return (_jsxs("section", { children: [_jsxs("p", { role: "alert", children: ["Model Arena panel failed to load: ", error] }), _jsx("button", { type: "button", onClick: reload, children: "Retry" })] }));
|
|
50
|
-
}
|
|
51
|
-
if (html === null)
|
|
52
|
-
return _jsx("p", { "aria-live": "polite", children: "Loading the Model Arena panel\u2026" });
|
|
53
|
-
// The markup is produced by `renderPanel`, which escapes every interpolated value.
|
|
54
|
-
return _jsx("div", { dangerouslySetInnerHTML: { __html: html } });
|
|
55
|
-
}
|
|
56
|
-
/** The slots service is what the settings shell exposes. */
|
|
57
|
-
export const inject = ['slots'];
|
|
11
|
+
import { NS, en, zh } from './locales.js';
|
|
12
|
+
import { ArenaPanel } from './view.js';
|
|
13
|
+
export const inject = ['slots', 'locale'];
|
|
58
14
|
export function apply(ctx) {
|
|
15
|
+
// `zh` is the key-set source of truth, matching the official client plugins.
|
|
16
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-model-arena: dictionaries');
|
|
59
17
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
60
18
|
name: 'settings.section',
|
|
61
19
|
id: 'model-arena',
|
|
62
20
|
order: 42,
|
|
63
|
-
label: () => '
|
|
64
|
-
|
|
21
|
+
label: () => ctx.locale.bind(NS)('nav'),
|
|
22
|
+
locale: NS,
|
|
23
|
+
}, ArenaPanel));
|
|
65
24
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dictionaries for the Model Arena page.
|
|
3
|
+
*
|
|
4
|
+
* `zh` is the key-set source of truth, as in the official client plugins, and `en` is
|
|
5
|
+
* typed against it: a key translated in one language but not the other fails the build
|
|
6
|
+
* instead of silently rendering the raw key.
|
|
7
|
+
*
|
|
8
|
+
* @module client/locales
|
|
9
|
+
*/
|
|
10
|
+
/** Dictionary namespace owned by this plugin. */
|
|
11
|
+
export declare const NS = "modelArena";
|
|
12
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
13
|
+
export declare const zh: {
|
|
14
|
+
nav: string;
|
|
15
|
+
title: string;
|
|
16
|
+
run: string;
|
|
17
|
+
models: string;
|
|
18
|
+
model: string;
|
|
19
|
+
date: string;
|
|
20
|
+
prompt: string;
|
|
21
|
+
empty: string;
|
|
22
|
+
refresh: string;
|
|
23
|
+
loading: string;
|
|
24
|
+
failed: string;
|
|
25
|
+
retry: string;
|
|
26
|
+
leaderboard: string;
|
|
27
|
+
wins: string;
|
|
28
|
+
avgRating: string;
|
|
29
|
+
runs: string;
|
|
30
|
+
totalRuns: string;
|
|
31
|
+
uniqueModels: string;
|
|
32
|
+
totalRatings: string;
|
|
33
|
+
recentRuns: string;
|
|
34
|
+
winner: string;
|
|
35
|
+
reference: string;
|
|
36
|
+
error: string;
|
|
37
|
+
delete: string;
|
|
38
|
+
confirmDelete: string;
|
|
39
|
+
notFound: string;
|
|
40
|
+
};
|
|
41
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
42
|
+
export declare const en: typeof zh;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dictionaries for the Model Arena page.
|
|
3
|
+
*
|
|
4
|
+
* `zh` is the key-set source of truth, as in the official client plugins, and `en` is
|
|
5
|
+
* typed against it: a key translated in one language but not the other fails the build
|
|
6
|
+
* instead of silently rendering the raw key.
|
|
7
|
+
*
|
|
8
|
+
* @module client/locales
|
|
9
|
+
*/
|
|
10
|
+
/** Dictionary namespace owned by this plugin. */
|
|
11
|
+
export const NS = 'modelArena';
|
|
12
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
13
|
+
export const zh = {
|
|
14
|
+
'nav': '模型竞技场',
|
|
15
|
+
'title': '模型竞技场',
|
|
16
|
+
'run': '运行',
|
|
17
|
+
'models': '模型数',
|
|
18
|
+
'model': '模型',
|
|
19
|
+
'date': '时间',
|
|
20
|
+
'prompt': '提示词',
|
|
21
|
+
'empty': '还没有运行记录。用同一个提示词跑多个模型后,结果会显示在这里。',
|
|
22
|
+
'refresh': '刷新',
|
|
23
|
+
'loading': '正在加载…',
|
|
24
|
+
'failed': '加载失败',
|
|
25
|
+
'retry': '重试',
|
|
26
|
+
'leaderboard': 'Elo 排行榜',
|
|
27
|
+
'wins': '胜',
|
|
28
|
+
'avgRating': '平均评分',
|
|
29
|
+
'runs': '次数',
|
|
30
|
+
'totalRuns': '总运行数',
|
|
31
|
+
'uniqueModels': '模型种类',
|
|
32
|
+
'totalRatings': '总评分数',
|
|
33
|
+
'recentRuns': '最近运行',
|
|
34
|
+
'winner': '胜出',
|
|
35
|
+
'reference': '参考答案',
|
|
36
|
+
'error': '错误',
|
|
37
|
+
'delete': '删除',
|
|
38
|
+
'confirmDelete': '确定删除这条运行记录?',
|
|
39
|
+
'notFound': '未找到运行记录',
|
|
40
|
+
};
|
|
41
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
42
|
+
export const en = {
|
|
43
|
+
'nav': 'Model Arena',
|
|
44
|
+
'title': 'Model Arena',
|
|
45
|
+
'run': 'Run',
|
|
46
|
+
'models': 'Models',
|
|
47
|
+
'model': 'Model',
|
|
48
|
+
'date': 'Date',
|
|
49
|
+
'prompt': 'Prompt',
|
|
50
|
+
'empty': 'No runs yet. Run one prompt through several models and the comparison appears here.',
|
|
51
|
+
'refresh': 'Refresh',
|
|
52
|
+
'loading': 'Loading…',
|
|
53
|
+
'failed': 'Failed to load',
|
|
54
|
+
'retry': 'Retry',
|
|
55
|
+
'leaderboard': 'Elo Leaderboard',
|
|
56
|
+
'wins': 'Wins',
|
|
57
|
+
'avgRating': 'Avg Rating',
|
|
58
|
+
'runs': 'Runs',
|
|
59
|
+
'totalRuns': 'Total Runs',
|
|
60
|
+
'uniqueModels': 'Models',
|
|
61
|
+
'totalRatings': 'Ratings',
|
|
62
|
+
'recentRuns': 'Recent Runs',
|
|
63
|
+
'winner': 'Winner',
|
|
64
|
+
'reference': 'Reference',
|
|
65
|
+
'error': 'Error',
|
|
66
|
+
'delete': 'Delete',
|
|
67
|
+
'confirmDelete': 'Delete this run?',
|
|
68
|
+
'notFound': 'Run not found',
|
|
69
|
+
};
|
package/lib/client/view.d.ts
CHANGED
|
@@ -1,2 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rendering half of the modelArena page.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `index.tsx` so a static render can assert in Node what the page draws —
|
|
5
|
+
* the shipped bundle is a loader factory only a browser can run. Every user-visible
|
|
6
|
+
* string comes from the `t` seat the renderer binds from this plugin's namespace, so the
|
|
7
|
+
* page follows the UI language; no copy is hardcoded here.
|
|
8
|
+
*
|
|
9
|
+
* @module client/view
|
|
10
|
+
*/
|
|
1
11
|
import type { PanelPayload } from '../types.js';
|
|
2
|
-
export
|
|
12
|
+
export type Translate = (key: string, params?: Record<string, unknown>) => string;
|
|
13
|
+
export interface PanelProps {
|
|
14
|
+
t: Translate;
|
|
15
|
+
}
|
|
16
|
+
interface PanelState {
|
|
17
|
+
payload: PanelPayload | null;
|
|
18
|
+
error: string | null;
|
|
19
|
+
}
|
|
20
|
+
export declare function usePanel(): PanelState & {
|
|
21
|
+
reload: () => void;
|
|
22
|
+
};
|
|
23
|
+
export declare function ArenaPanel({ t }: PanelProps): import("react").JSX.Element;
|
|
24
|
+
export {};
|
package/lib/client/view.js
CHANGED
|
@@ -1,9 +1,117 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Pure rendering half of the modelArena page.
|
|
4
|
+
*
|
|
5
|
+
* Separate from `index.tsx` so a static render can assert in Node what the page draws —
|
|
6
|
+
* the shipped bundle is a loader factory only a browser can run. Every user-visible
|
|
7
|
+
* string comes from the `t` seat the renderer binds from this plugin's namespace, so the
|
|
8
|
+
* page follows the UI language; no copy is hardcoded here.
|
|
9
|
+
*
|
|
10
|
+
* @module client/view
|
|
11
|
+
*/
|
|
12
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
13
|
+
const PANEL_PATH = '/api/arena.panel';
|
|
14
|
+
const RUN_PATH = '/api/arena.run';
|
|
15
|
+
const RATE_PATH = '/api/arena.rate';
|
|
16
|
+
const DELETE_PATH = '/api/arena.delete';
|
|
17
|
+
// --- Styles ---
|
|
18
|
+
const wrap = { display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 820, fontFamily: 'inherit' };
|
|
19
|
+
const head = { display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' };
|
|
20
|
+
const section = { marginTop: 8 };
|
|
21
|
+
const sectionTitle = { fontSize: 13, fontWeight: 600, marginBottom: 6 };
|
|
22
|
+
const muted = { fontSize: 12, opacity: 0.75 };
|
|
23
|
+
const table = { borderCollapse: 'collapse', width: '100%' };
|
|
24
|
+
const th = { textAlign: 'left', padding: '4px 10px 4px 0', fontWeight: 600, fontSize: 12, opacity: 0.8, borderBottom: '0.5px solid rgba(128,128,128,0.4)' };
|
|
25
|
+
const td = { padding: '6px 10px 6px 0', fontSize: 13, borderBottom: '0.5px solid rgba(128,128,128,0.18)' };
|
|
26
|
+
const card = { padding: '8px 12px', borderRadius: 6, border: '1px solid rgba(128,128,128,0.2)', background: 'rgba(128,128,128,0.04)', fontSize: 13 };
|
|
27
|
+
const badge = { display: 'inline-block', padding: '1px 6px', borderRadius: 9, fontSize: 11, fontWeight: 600 };
|
|
28
|
+
const statRow = { display: 'flex', gap: 16, flexWrap: 'wrap' };
|
|
29
|
+
const statBox = { ...card, flex: 1, minWidth: 100, textAlign: 'center' };
|
|
30
|
+
const starBtn = { cursor: 'pointer', background: 'none', border: 'none', fontSize: 16, padding: '0 1px' };
|
|
31
|
+
const clickRow = { cursor: 'pointer' };
|
|
32
|
+
const preBlock = { whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, maxHeight: 200, overflow: 'auto', padding: 8, borderRadius: 4, background: 'rgba(128,128,128,0.06)', border: '1px solid rgba(128,128,128,0.15)' };
|
|
33
|
+
const dangerBtn = { fontSize: 11, color: '#e55', background: 'none', border: '1px solid #e55', borderRadius: 4, padding: '2px 8px', cursor: 'pointer' };
|
|
34
|
+
export function usePanel() {
|
|
35
|
+
const [state, setState] = useState({ payload: null, error: null });
|
|
36
|
+
const [tick, setTick] = useState(0);
|
|
37
|
+
const reload = useCallback(() => setTick((v) => v + 1), []);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const c = new AbortController();
|
|
40
|
+
setState((p) => ({ ...p, error: null }));
|
|
41
|
+
fetch(PANEL_PATH, { signal: c.signal })
|
|
42
|
+
.then(async (r) => {
|
|
43
|
+
if (!r.ok)
|
|
44
|
+
throw new Error(String(r.status));
|
|
45
|
+
return r.json();
|
|
46
|
+
})
|
|
47
|
+
.then((payload) => { if (!c.signal.aborted)
|
|
48
|
+
setState({ payload, error: null }); })
|
|
49
|
+
.catch((e) => { if (!c.signal.aborted)
|
|
50
|
+
setState({ payload: null, error: e instanceof Error ? e.message : String(e) }); });
|
|
51
|
+
return () => c.abort();
|
|
52
|
+
}, [tick]);
|
|
53
|
+
return { ...state, reload };
|
|
54
|
+
}
|
|
55
|
+
// --- Stars ---
|
|
56
|
+
function Stars({ rating, onRate }) {
|
|
57
|
+
return (_jsx("span", { children: [1, 2, 3, 4, 5].map((n) => (_jsx("button", { type: "button", style: starBtn, onClick: () => onRate(n), title: `${n}/5`, children: n <= rating ? '★' : '☆' }, n))) }));
|
|
58
|
+
}
|
|
59
|
+
// --- Leaderboard ---
|
|
60
|
+
function Leaderboard({ entries, t }) {
|
|
61
|
+
if (entries.length === 0)
|
|
62
|
+
return null;
|
|
63
|
+
return (_jsxs("div", { style: section, children: [_jsxs("div", { style: sectionTitle, children: ["\uD83C\uDFC6 ", t('leaderboard')] }), _jsxs("table", { style: table, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: th, children: "#" }), _jsx("th", { style: th, children: t('model') }), _jsx("th", { style: th, children: "Elo" }), _jsx("th", { style: th, children: t('wins') }), _jsx("th", { style: th, children: t('avgRating') }), _jsx("th", { style: th, children: t('runs') })] }) }), _jsx("tbody", { children: entries.map((e, i) => (_jsxs("tr", { children: [_jsx("td", { style: td, children: i + 1 }), _jsx("td", { style: td, children: _jsx("strong", { children: e.model }) }), _jsx("td", { style: td, children: e.elo }), _jsx("td", { style: td, children: e.wins }), _jsx("td", { style: td, children: e.avgRating > 0 ? e.avgRating.toFixed(1) : '—' }), _jsx("td", { style: td, children: e.totalRuns })] }, e.model))) })] })] }));
|
|
64
|
+
}
|
|
65
|
+
// --- Stats summary ---
|
|
66
|
+
function StatsSummary({ stats, t }) {
|
|
67
|
+
return (_jsxs("div", { style: statRow, children: [_jsxs("div", { style: statBox, children: [_jsx("div", { style: { fontSize: 20, fontWeight: 700 }, children: stats.totalRuns }), _jsx("div", { style: muted, children: t('totalRuns') })] }), _jsxs("div", { style: statBox, children: [_jsx("div", { style: { fontSize: 20, fontWeight: 700 }, children: stats.uniqueModels }), _jsx("div", { style: muted, children: t('uniqueModels') })] }), _jsxs("div", { style: statBox, children: [_jsx("div", { style: { fontSize: 20, fontWeight: 700 }, children: stats.totalRatings }), _jsx("div", { style: muted, children: t('totalRatings') })] })] }));
|
|
68
|
+
}
|
|
69
|
+
// --- Run detail expansion ---
|
|
70
|
+
function RunDetail({ runId, t, onDelete }) {
|
|
71
|
+
const [run, setRun] = useState(null);
|
|
72
|
+
const [loading, setLoading] = useState(true);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
setLoading(true);
|
|
75
|
+
fetch(`${RUN_PATH}?id=${runId}`)
|
|
76
|
+
.then((r) => r.json())
|
|
77
|
+
.then(setRun)
|
|
78
|
+
.catch(() => setRun(null))
|
|
79
|
+
.finally(() => setLoading(false));
|
|
80
|
+
}, [runId]);
|
|
81
|
+
const handleRate = useCallback((model, rating) => {
|
|
82
|
+
fetch(RATE_PATH, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ runId, model, rating }),
|
|
86
|
+
})
|
|
87
|
+
.then((r) => r.json())
|
|
88
|
+
.then(setRun)
|
|
89
|
+
.catch(() => { });
|
|
90
|
+
}, [runId]);
|
|
91
|
+
const handleDelete = useCallback(() => {
|
|
92
|
+
if (!confirm(t('confirmDelete')))
|
|
93
|
+
return;
|
|
94
|
+
fetch(DELETE_PATH, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ runId }),
|
|
98
|
+
}).then(() => onDelete()).catch(() => { });
|
|
99
|
+
}, [runId, t, onDelete]);
|
|
100
|
+
if (loading)
|
|
101
|
+
return _jsx("p", { style: muted, children: t('loading') });
|
|
102
|
+
if (!run)
|
|
103
|
+
return _jsx("p", { style: muted, children: t('notFound') });
|
|
104
|
+
return (_jsxs("div", { style: { ...card, marginTop: 4 }, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, children: [_jsxs("strong", { children: [t('prompt'), ": "] }), _jsx("button", { type: "button", style: dangerBtn, onClick: handleDelete, children: t('delete') })] }), _jsx("div", { style: preBlock, children: run.prompt }), run.reference && (_jsxs("div", { style: { marginTop: 6 }, children: [_jsxs("div", { style: { fontSize: 12, fontWeight: 600, marginBottom: 2 }, children: [t('reference'), ":"] }), _jsx("div", { style: preBlock, children: run.reference })] })), _jsx("div", { style: { marginTop: 8 }, children: run.results.map((r) => (_jsxs("div", { style: { ...card, marginTop: 6 }, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, children: [_jsx("strong", { children: r.model }), _jsx("span", { children: r.error ? (_jsx("span", { style: { ...badge, background: 'rgba(255,80,80,0.15)', color: '#e55' }, children: t('error') })) : (_jsxs("span", { style: { ...badge, background: 'rgba(80,200,120,0.15)', color: '#4a4' }, children: [r.latencyMs, "ms"] })) })] }), _jsx("div", { style: preBlock, children: r.error ?? r.output }), _jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 4 }, children: [_jsxs("span", { style: muted, children: [r.promptTokens + r.completionTokens, " tokens (", r.promptTokens, "\u2191 ", r.completionTokens, "\u2193)"] }), _jsx(Stars, { rating: r.rating ?? 0, onRate: (n) => handleRate(r.model, n) })] })] }, r.model))) })] }));
|
|
105
|
+
}
|
|
106
|
+
// --- Main Panel ---
|
|
107
|
+
export function ArenaPanel({ t }) {
|
|
108
|
+
const { payload, error, reload } = usePanel();
|
|
109
|
+
const [expandedRun, setExpandedRun] = useState(null);
|
|
110
|
+
const header = (_jsxs("header", { style: head, children: [_jsxs("strong", { style: { fontSize: 14 }, children: ["\u2694\uFE0F ", t('title')] }), _jsx("span", { style: { flex: 1 } }), _jsx("button", { type: "button", onClick: reload, style: { fontSize: 12 }, children: t('refresh') })] }));
|
|
111
|
+
if (error !== null) {
|
|
112
|
+
return (_jsxs("div", { style: wrap, children: [header, _jsxs("p", { role: "alert", style: { margin: 0, fontSize: 13 }, children: [t('failed'), ": ", error] }), _jsx("button", { type: "button", onClick: reload, style: { alignSelf: 'flex-start', fontSize: 12 }, children: t('retry') })] }));
|
|
113
|
+
}
|
|
114
|
+
if (payload === null)
|
|
115
|
+
return _jsx("p", { style: muted, "aria-live": "polite", children: t('loading') });
|
|
116
|
+
return (_jsxs("div", { style: wrap, children: [header, _jsx(StatsSummary, { stats: payload.stats, t: t }), _jsx(Leaderboard, { entries: payload.leaderboard, t: t }), _jsxs("div", { style: section, children: [_jsxs("div", { style: sectionTitle, children: ["\uD83D\uDCCB ", t('recentRuns')] }), payload.recentRuns.length === 0 ? (_jsx("p", { style: { margin: 0, fontSize: 13, opacity: 0.8 }, children: t('empty') })) : (_jsxs("table", { style: table, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: th, children: t('run') }), _jsx("th", { style: th, children: t('models') }), _jsx("th", { style: th, children: t('winner') }), _jsx("th", { style: th, children: t('date') })] }) }), _jsx("tbody", { children: payload.recentRuns.map((row) => (_jsxs(_Fragment, { children: [_jsxs("tr", { style: clickRow, onClick: () => setExpandedRun(expandedRun === row.id ? null : row.id), children: [_jsxs("td", { style: td, children: [_jsx("code", { style: { fontSize: 11 }, children: row.id }), _jsx("span", { style: { marginLeft: 6, fontSize: 10 }, children: expandedRun === row.id ? '▼' : '▶' })] }), _jsx("td", { style: td, children: row.modelCount }), _jsx("td", { style: td, children: row.winner ?? '—' }), _jsx("td", { style: td, children: new Date(row.timestamp).toLocaleString() })] }, row.id), expandedRun === row.id && (_jsx("tr", { children: _jsx("td", { colSpan: 4, style: { padding: '4px 0' }, children: _jsx(RunDetail, { runId: row.id, t: t, onDelete: () => { setExpandedRun(null); reload(); } }) }) }, `${row.id}-detail`))] }))) })] }))] })] }));
|
|
9
117
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -14,11 +14,14 @@ export interface ArenaService {
|
|
|
14
14
|
listRuns(): ReturnType<Arena['listRuns']>;
|
|
15
15
|
compare(runId: string, modelA: string, modelB: string): ReturnType<Arena['compare']>;
|
|
16
16
|
rate(runId: string, model: string, rating: number): ReturnType<Arena['rate']>;
|
|
17
|
+
deleteRun(runId: string): boolean;
|
|
17
18
|
saveSuite(suite: {
|
|
18
19
|
name: string;
|
|
19
20
|
prompts: string[];
|
|
20
21
|
models: string[];
|
|
21
22
|
}): void;
|
|
22
23
|
regression(suiteName: string, baselineRunId: string, currentRunId: string): ReturnType<Arena['regression']>;
|
|
24
|
+
leaderboard(): ReturnType<Arena['leaderboard']>;
|
|
25
|
+
stats(): ReturnType<Arena['stats']>;
|
|
23
26
|
}
|
|
24
27
|
export declare function apply(ctx: Context): void;
|
package/lib/index.js
CHANGED
|
@@ -11,8 +11,11 @@ export function apply(ctx) {
|
|
|
11
11
|
listRuns: () => arena.listRuns(),
|
|
12
12
|
compare: (runId, modelA, modelB) => arena.compare(runId, modelA, modelB),
|
|
13
13
|
rate: (runId, model, rating) => arena.rate(runId, model, rating),
|
|
14
|
+
deleteRun: (runId) => arena.deleteRun(runId),
|
|
14
15
|
saveSuite: (suite) => arena.saveSuite(suite),
|
|
15
16
|
regression: (suiteName, baselineRunId, currentRunId) => arena.regression(suiteName, baselineRunId, currentRunId),
|
|
17
|
+
leaderboard: () => arena.leaderboard(),
|
|
18
|
+
stats: () => arena.stats(),
|
|
16
19
|
};
|
|
17
20
|
ctx.provide('arena', service);
|
|
18
21
|
registerArenaRoutes(ctx, service);
|
package/lib/routes.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import type { ArenaService } from './index.js';
|
|
3
|
-
import { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH } from './arena-view.js';
|
|
4
|
-
export { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH };
|
|
3
|
+
import { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH, ARENA_COMPARE_PATH, ARENA_DELETE_PATH } from './arena-view.js';
|
|
4
|
+
export { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH, ARENA_COMPARE_PATH, ARENA_DELETE_PATH };
|
|
5
5
|
export declare function registerArenaRoutes(ctx: Context, arena: ArenaService): void;
|
package/lib/routes.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH } from './arena-view.js';
|
|
2
|
-
export { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH };
|
|
1
|
+
import { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH, ARENA_COMPARE_PATH, ARENA_DELETE_PATH } from './arena-view.js';
|
|
2
|
+
export { ARENA_PANEL_PATH, ARENA_RUN_PATH, ARENA_RATE_PATH, ARENA_COMPARE_PATH, ARENA_DELETE_PATH };
|
|
3
3
|
export function registerArenaRoutes(ctx, arena) {
|
|
4
4
|
ctx.inject(['connection'], (connectionCtx) => {
|
|
5
5
|
const connection = connectionCtx.connection;
|
|
6
|
+
// Panel payload (with leaderboard + stats)
|
|
6
7
|
connection.fetch.register({
|
|
7
8
|
path: ARENA_PANEL_PATH,
|
|
8
9
|
methods: ['GET'],
|
|
9
10
|
requestBody: 'buffered',
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
})),
|
|
11
|
+
fetch: () => Promise.resolve(Response.json({
|
|
12
|
+
recentRuns: arena.listRuns(),
|
|
13
|
+
leaderboard: arena.leaderboard(),
|
|
14
|
+
stats: arena.stats(),
|
|
15
|
+
}, { headers: { 'cache-control': 'no-store' } })),
|
|
15
16
|
});
|
|
17
|
+
// Run detail
|
|
16
18
|
connection.fetch.register({
|
|
17
19
|
path: ARENA_RUN_PATH,
|
|
18
20
|
methods: ['GET'],
|
|
@@ -28,6 +30,7 @@ export function registerArenaRoutes(ctx, arena) {
|
|
|
28
30
|
return Response.json(run, { headers: { 'cache-control': 'no-store' } });
|
|
29
31
|
},
|
|
30
32
|
});
|
|
33
|
+
// Rate a model in a run
|
|
31
34
|
connection.fetch.register({
|
|
32
35
|
path: ARENA_RATE_PATH,
|
|
33
36
|
methods: ['POST'],
|
|
@@ -49,5 +52,46 @@ export function registerArenaRoutes(ctx, arena) {
|
|
|
49
52
|
return Response.json(run, { headers: { 'cache-control': 'no-store' } });
|
|
50
53
|
},
|
|
51
54
|
});
|
|
55
|
+
// Compare two models in a run
|
|
56
|
+
connection.fetch.register({
|
|
57
|
+
path: ARENA_COMPARE_PATH,
|
|
58
|
+
methods: ['GET'],
|
|
59
|
+
requestBody: 'buffered',
|
|
60
|
+
fetch: async (request) => {
|
|
61
|
+
const url = new URL(request.url);
|
|
62
|
+
const runId = url.searchParams.get('runId');
|
|
63
|
+
const modelA = url.searchParams.get('modelA');
|
|
64
|
+
const modelB = url.searchParams.get('modelB');
|
|
65
|
+
if (!runId || !modelA || !modelB) {
|
|
66
|
+
return Response.json({ error: 'missing runId, modelA, or modelB' }, { status: 400 });
|
|
67
|
+
}
|
|
68
|
+
const result = arena.compare(runId, modelA, modelB);
|
|
69
|
+
if (!result)
|
|
70
|
+
return Response.json({ error: 'not found' }, { status: 404 });
|
|
71
|
+
return Response.json(result, { headers: { 'cache-control': 'no-store' } });
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
// Delete a run
|
|
75
|
+
connection.fetch.register({
|
|
76
|
+
path: ARENA_DELETE_PATH,
|
|
77
|
+
methods: ['POST'],
|
|
78
|
+
requestBody: 'buffered',
|
|
79
|
+
fetch: async (request) => {
|
|
80
|
+
let body;
|
|
81
|
+
try {
|
|
82
|
+
body = await request.json();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return Response.json({ error: 'request body must be JSON' }, { status: 400 });
|
|
86
|
+
}
|
|
87
|
+
if (!body.runId) {
|
|
88
|
+
return Response.json({ error: 'missing runId' }, { status: 400 });
|
|
89
|
+
}
|
|
90
|
+
const ok = arena.deleteRun(body.runId);
|
|
91
|
+
if (!ok)
|
|
92
|
+
return Response.json({ error: 'run not found' }, { status: 404 });
|
|
93
|
+
return Response.json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
|
|
94
|
+
},
|
|
95
|
+
});
|
|
52
96
|
});
|
|
53
97
|
}
|
package/lib/scoring.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import type { ModelResult, Score } from './types.js';
|
|
1
|
+
import type { ModelResult, Score, LeaderboardEntry } from './types.js';
|
|
2
2
|
/** Score outputs automatically based on simple metrics. */
|
|
3
3
|
export declare function autoScore(results: ModelResult[], reference?: string): Record<string, Score>;
|
|
4
4
|
/** Simple character-level similarity (Jaccard on character bigrams). */
|
|
5
5
|
export declare function similarity(a: string, b: string): number;
|
|
6
|
+
/** Build a leaderboard from all runs using Elo rating. */
|
|
7
|
+
export declare function buildLeaderboard(runs: {
|
|
8
|
+
results: ModelResult[];
|
|
9
|
+
scores: Record<string, Score>;
|
|
10
|
+
}[]): LeaderboardEntry[];
|
package/lib/scoring.js
CHANGED
|
@@ -33,3 +33,101 @@ export function similarity(a, b) {
|
|
|
33
33
|
intersection++;
|
|
34
34
|
return intersection / (bigramsA.size + bigramsB.size - intersection);
|
|
35
35
|
}
|
|
36
|
+
// ── Elo rating system ────────────────────────────────────────
|
|
37
|
+
const INITIAL_ELO = 1200;
|
|
38
|
+
const K_FACTOR = 32;
|
|
39
|
+
/** Compute expected score (probability of winning). */
|
|
40
|
+
function expectedScore(ratingA, ratingB) {
|
|
41
|
+
return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
|
|
42
|
+
}
|
|
43
|
+
/** Build a leaderboard from all runs using Elo rating. */
|
|
44
|
+
export function buildLeaderboard(runs) {
|
|
45
|
+
const records = new Map();
|
|
46
|
+
const getOrInit = (model) => {
|
|
47
|
+
if (!records.has(model)) {
|
|
48
|
+
records.set(model, { elo: INITIAL_ELO, wins: 0, losses: 0, draws: 0, games: 0, totalLatencyMs: 0, totalTokens: 0, totalRuns: 0, totalRating: 0, ratingCount: 0 });
|
|
49
|
+
}
|
|
50
|
+
return records.get(model);
|
|
51
|
+
};
|
|
52
|
+
for (const run of runs) {
|
|
53
|
+
// accumulate raw stats
|
|
54
|
+
for (const r of run.results) {
|
|
55
|
+
const rec = getOrInit(r.model);
|
|
56
|
+
rec.totalLatencyMs += r.latencyMs;
|
|
57
|
+
rec.totalTokens += r.promptTokens + r.completionTokens;
|
|
58
|
+
rec.totalRuns++;
|
|
59
|
+
if (r.rating !== undefined) {
|
|
60
|
+
rec.totalRating += r.rating;
|
|
61
|
+
rec.ratingCount++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// pairwise Elo updates based on user rating (if any) or composite auto-score
|
|
65
|
+
const models = run.results.map((r) => r.model);
|
|
66
|
+
for (let i = 0; i < models.length; i++) {
|
|
67
|
+
for (let j = i + 1; j < models.length; j++) {
|
|
68
|
+
const a = models[i];
|
|
69
|
+
const b = models[j];
|
|
70
|
+
const recA = getOrInit(a);
|
|
71
|
+
const recB = getOrInit(b);
|
|
72
|
+
const sA = compositeScore(run.scores[a]);
|
|
73
|
+
const sB = compositeScore(run.scores[b]);
|
|
74
|
+
const eA = expectedScore(recA.elo, recB.elo);
|
|
75
|
+
let actualA;
|
|
76
|
+
if (Math.abs(sA - sB) < 0.01) {
|
|
77
|
+
// draw
|
|
78
|
+
actualA = 0.5;
|
|
79
|
+
recA.draws++;
|
|
80
|
+
recB.draws++;
|
|
81
|
+
}
|
|
82
|
+
else if (sA > sB) {
|
|
83
|
+
actualA = 1;
|
|
84
|
+
recA.wins++;
|
|
85
|
+
recB.losses++;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
actualA = 0;
|
|
89
|
+
recA.losses++;
|
|
90
|
+
recB.wins++;
|
|
91
|
+
}
|
|
92
|
+
recA.elo += K_FACTOR * (actualA - eA);
|
|
93
|
+
recB.elo += K_FACTOR * ((1 - actualA) - (1 - eA));
|
|
94
|
+
recA.games++;
|
|
95
|
+
recB.games++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const entries = [];
|
|
100
|
+
for (const [model, rec] of records) {
|
|
101
|
+
entries.push({
|
|
102
|
+
model,
|
|
103
|
+
elo: Math.round(rec.elo),
|
|
104
|
+
wins: rec.wins,
|
|
105
|
+
losses: rec.losses,
|
|
106
|
+
draws: rec.draws,
|
|
107
|
+
games: rec.games,
|
|
108
|
+
totalRuns: rec.totalRuns,
|
|
109
|
+
avgRating: rec.ratingCount > 0 ? Math.round((rec.totalRating / rec.ratingCount) * 10) / 10 : 0,
|
|
110
|
+
avgLatencyMs: rec.games > 0 ? Math.round(rec.totalLatencyMs / rec.games) : 0,
|
|
111
|
+
avgTokens: rec.games > 0 ? Math.round(rec.totalTokens / rec.games) : 0,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
entries.sort((a, b) => b.elo - a.elo);
|
|
115
|
+
return entries;
|
|
116
|
+
}
|
|
117
|
+
/** Combine score dimensions into a single number for ranking. */
|
|
118
|
+
function compositeScore(s) {
|
|
119
|
+
if (!s)
|
|
120
|
+
return 0;
|
|
121
|
+
// User rating dominates if present (1-5 scale → 0-1)
|
|
122
|
+
if (s.userRating !== undefined)
|
|
123
|
+
return s.userRating / 5;
|
|
124
|
+
// Otherwise average available auto-scores
|
|
125
|
+
const parts = [];
|
|
126
|
+
if (s.referenceMatch !== undefined)
|
|
127
|
+
parts.push(s.referenceMatch);
|
|
128
|
+
if (s.lengthScore !== undefined)
|
|
129
|
+
parts.push(s.lengthScore);
|
|
130
|
+
if (s.latencyScore !== undefined)
|
|
131
|
+
parts.push(s.latencyScore);
|
|
132
|
+
return parts.length > 0 ? parts.reduce((a, b) => a + b, 0) / parts.length : 0;
|
|
133
|
+
}
|
package/lib/store.d.ts
CHANGED