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.
@@ -1,14 +1,15 @@
1
1
  /**
2
- * Browser half of dsh-model-arena: the Model Arena page inside Settings.
2
+ * Browser half of dsh-model-arena: its page inside Settings.
3
3
  *
4
- * Services are named by their runtime identity, not by the package that provides them:
5
- * the settings shell is the `slots` service and the host bridge is `connection`. A
6
- * fiber that injects package names never resolves and the plugin stays pending, which
7
- * the loader reports as "did not activate".
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
- }, component: ComponentType): unknown;
20
+ locale: string;
21
+ }, component: ComponentType<{
22
+ t: (key: string, params?: Record<string, unknown>) => string;
23
+ }>): unknown;
20
24
  }
21
- /** The slots service is what the settings shell exposes. */
22
- export declare const inject: string[];
23
- export declare function apply(ctx: {
25
+ interface ClientContext {
24
26
  slots: SlotsService;
25
- }): void;
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 {};
@@ -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: the Model Arena page inside Settings.
2
+ * Browser half of dsh-model-arena: its page inside Settings.
4
3
  *
5
- * Services are named by their runtime identity, not by the package that provides them:
6
- * the settings shell is the `slots` service and the host bridge is `connection`. A
7
- * fiber that injects package names never resolves and the plugin stays pending, which
8
- * the loader reports as "did not activate".
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 { useCallback, useEffect, useState } from 'react';
13
- import { renderPanel } from './view.js';
14
- /** Path the host half registers on the web connection. */
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: () => 'Model Arena',
64
- }, ModelArenaPage));
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
+ };
@@ -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 declare function renderPanel(payload: PanelPayload): string;
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 {};
@@ -1,9 +1,117 @@
1
- export function renderPanel(payload) {
2
- const runRows = payload.recentRuns
3
- .map((r) => `<tr><td>${r.id}</td><td>${r.modelCount}</td><td>${new Date(r.timestamp).toLocaleString()}</td><td>${r.prompt.slice(0, 60)}...</td></tr>`)
4
- .join('');
5
- return `<div class="arena-panel">
6
- <h2>Model Arena</h2>
7
- ${runRows ? `<table class="arena-table"><thead><tr><th>Run</th><th>Models</th><th>Date</th><th>Prompt</th></tr></thead><tbody>${runRows}</tbody></table>` : '<p>No runs yet.</p>'}
8
- </div>`;
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
- // The panel view is written against `PanelPayload`; returning the bare run list
11
- // here made every array method on `payload.recentRuns` throw in the browser.
12
- fetch: () => Promise.resolve(Response.json({ recentRuns: arena.listRuns() }, {
13
- headers: { 'cache-control': 'no-store' },
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
@@ -9,5 +9,6 @@ export declare class ArenaStore {
9
9
  listRuns(): ArenaRun[];
10
10
  saveSuite(suite: Suite): void;
11
11
  getSuite(name: string): Suite | null;
12
+ deleteRun(id: string): boolean;
12
13
  static generateId(prompt: string, models: string[]): string;
13
14
  }