dsh-model-arena 0.2.1 → 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,6 +1,8 @@
1
- import type { PanelPayload, ArenaRun } from './types.js';
1
+ import type { PanelPayload, RunSummary, LeaderboardEntry, ArenaStats } from './types.js';
2
2
  /** Route paths shared between host and client. */
3
3
  export declare const ARENA_PANEL_PATH = "/api/arena.panel";
4
4
  export declare const ARENA_RUN_PATH = "/api/arena.run";
5
5
  export declare const ARENA_RATE_PATH = "/api/arena.rate";
6
- export declare function panelState(runs: ArenaRun[]): PanelPayload;
6
+ export declare const ARENA_COMPARE_PATH = "/api/arena.compare";
7
+ export declare const ARENA_DELETE_PATH = "/api/arena.delete";
8
+ export declare function panelState(runs: RunSummary[], leaderboard: LeaderboardEntry[], stats: ArenaStats): PanelPayload;
package/lib/arena-view.js CHANGED
@@ -2,16 +2,8 @@
2
2
  export const ARENA_PANEL_PATH = '/api/arena.panel';
3
3
  export const ARENA_RUN_PATH = '/api/arena.run';
4
4
  export const ARENA_RATE_PATH = '/api/arena.rate';
5
- export function panelState(runs) {
6
- return {
7
- recentRuns: runs
8
- .sort((a, b) => b.timestamp - a.timestamp)
9
- .slice(0, 20)
10
- .map((r) => ({
11
- id: r.id,
12
- prompt: r.prompt,
13
- timestamp: r.timestamp,
14
- modelCount: r.results.length,
15
- })),
16
- };
5
+ export const ARENA_COMPARE_PATH = '/api/arena.compare';
6
+ export const ARENA_DELETE_PATH = '/api/arena.delete';
7
+ export function panelState(runs, leaderboard, stats) {
8
+ return { recentRuns: runs.slice(0, 20), leaderboard, stats };
17
9
  }
package/lib/arena.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ArenaRun, ModelResult, Suite, RegressionReport, Score } from './types.js';
1
+ import type { ArenaRun, ModelResult, Suite, RegressionReport, LeaderboardEntry, ArenaStats, RunSummary, ComparePayload } from './types.js';
2
2
  export declare class Arena {
3
3
  private projectDir;
4
4
  private store;
@@ -6,31 +6,21 @@ export declare class Arena {
6
6
  /** Save a completed run (results already collected from model calls). */
7
7
  saveRun(prompt: string, results: ModelResult[], reference?: string): ArenaRun;
8
8
  getRun(id: string): ArenaRun | null;
9
- listRuns(): {
10
- id: string;
11
- prompt: string;
12
- timestamp: number;
13
- modelCount: number;
14
- }[];
9
+ listRuns(): RunSummary[];
15
10
  /** Compare two models' outputs within a run. */
16
- compare(runId: string, modelA: string, modelB: string): {
17
- modelA: string;
18
- modelB: string;
19
- outputA: string;
20
- outputB: string;
21
- diff: import("./diff.js").DiffLine[];
22
- summary: {
23
- added: number;
24
- removed: number;
25
- changed: number;
26
- };
27
- scoreA: Score;
28
- scoreB: Score;
29
- } | null;
11
+ compare(runId: string, modelA: string, modelB: string): ComparePayload | null;
30
12
  /** Set a user rating for a model in a run. */
31
13
  rate(runId: string, model: string, rating: number): ArenaRun | null;
14
+ /** Delete a run. */
15
+ deleteRun(runId: string): boolean;
32
16
  /** Save a benchmark suite. */
33
17
  saveSuite(suite: Suite): void;
34
18
  /** Generate a regression report between two runs of the same suite. */
35
19
  regression(suiteName: string, baselineRunId: string, currentRunId: string): RegressionReport | null;
20
+ /** Elo leaderboard from all runs. */
21
+ leaderboard(): LeaderboardEntry[];
22
+ /** Aggregate stats. */
23
+ stats(): ArenaStats;
24
+ /** Pick the best model in a run by composite score. */
25
+ private pickWinner;
36
26
  }
package/lib/arena.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ArenaStore } from './store.js';
2
- import { autoScore } from './scoring.js';
2
+ import { autoScore, buildLeaderboard } from './scoring.js';
3
3
  import { lineDiff, diffSummary } from './diff.js';
4
4
  export class Arena {
5
5
  projectDir;
@@ -13,7 +13,7 @@ export class Arena {
13
13
  const models = results.map((r) => r.model);
14
14
  const id = ArenaStore.generateId(prompt, models);
15
15
  const scores = autoScore(results, reference);
16
- const run = { id, prompt, timestamp: Date.now(), results, scores };
16
+ const run = { id, prompt, timestamp: Date.now(), results, scores, reference };
17
17
  this.store.saveRun(run);
18
18
  return run;
19
19
  }
@@ -21,12 +21,16 @@ export class Arena {
21
21
  return this.store.getRun(id);
22
22
  }
23
23
  listRuns() {
24
- return this.store.listRuns().map((r) => ({
25
- id: r.id,
26
- prompt: r.prompt,
27
- timestamp: r.timestamp,
28
- modelCount: r.results.length,
29
- }));
24
+ return this.store.listRuns().map((r) => {
25
+ const winner = this.pickWinner(r);
26
+ return {
27
+ id: r.id,
28
+ prompt: r.prompt,
29
+ timestamp: r.timestamp,
30
+ modelCount: r.results.length,
31
+ winner,
32
+ };
33
+ });
30
34
  }
31
35
  /** Compare two models' outputs within a run. */
32
36
  compare(runId, modelA, modelB) {
@@ -38,14 +42,15 @@ export class Arena {
38
42
  if (!a || !b)
39
43
  return null;
40
44
  return {
45
+ runId,
41
46
  modelA: a.model,
42
47
  modelB: b.model,
43
48
  outputA: a.output,
44
49
  outputB: b.output,
45
50
  diff: lineDiff(a.output, b.output),
46
51
  summary: diffSummary(a.output, b.output),
47
- scoreA: run.scores[modelA],
48
- scoreB: run.scores[modelB],
52
+ scoreA: run.scores[modelA] ?? {},
53
+ scoreB: run.scores[modelB] ?? {},
49
54
  };
50
55
  }
51
56
  /** Set a user rating for a model in a run. */
@@ -59,6 +64,10 @@ export class Arena {
59
64
  this.store.saveRun(run);
60
65
  return run;
61
66
  }
67
+ /** Delete a run. */
68
+ deleteRun(runId) {
69
+ return this.store.deleteRun(runId);
70
+ }
62
71
  /** Save a benchmark suite. */
63
72
  saveSuite(suite) {
64
73
  this.store.saveSuite(suite);
@@ -84,4 +93,51 @@ export class Arena {
84
93
  }
85
94
  return { suiteName, baselineRunId, currentRunId, changes };
86
95
  }
96
+ /** Elo leaderboard from all runs. */
97
+ leaderboard() {
98
+ const allRuns = this.store.listRuns();
99
+ return buildLeaderboard(allRuns);
100
+ }
101
+ /** Aggregate stats. */
102
+ stats() {
103
+ const allRuns = this.store.listRuns();
104
+ const allModels = new Set();
105
+ const allPrompts = new Set();
106
+ for (const run of allRuns) {
107
+ allPrompts.add(run.prompt);
108
+ for (const r of run.results)
109
+ allModels.add(r.model);
110
+ }
111
+ let totalRatings = 0;
112
+ for (const run of allRuns) {
113
+ for (const [, score] of Object.entries(run.scores)) {
114
+ if (score.userRating !== undefined)
115
+ totalRatings++;
116
+ }
117
+ }
118
+ return {
119
+ totalRuns: allRuns.length,
120
+ totalModels: allModels.size,
121
+ uniqueModels: allModels.size,
122
+ uniquePrompts: allPrompts.size,
123
+ totalRatings,
124
+ avgModelsPerRun: allRuns.length > 0
125
+ ? Math.round((allRuns.reduce((s, r) => s + r.results.length, 0) / allRuns.length) * 10) / 10
126
+ : 0,
127
+ };
128
+ }
129
+ /** Pick the best model in a run by composite score. */
130
+ pickWinner(run) {
131
+ let best;
132
+ let bestScore = -Infinity;
133
+ for (const [model, score] of Object.entries(run.scores)) {
134
+ const s = (score.userRating !== undefined ? score.userRating / 5 : 0)
135
+ + (score.referenceMatch ?? 0) + (score.latencyScore ?? 0);
136
+ if (s > bestScore) {
137
+ bestScore = s;
138
+ best = model;
139
+ }
140
+ }
141
+ return best;
142
+ }
87
143
  }
package/lib/arena.web.js CHANGED
@@ -34,62 +34,219 @@ var zh = {
34
34
  "title": "\u6A21\u578B\u7ADE\u6280\u573A",
35
35
  "run": "\u8FD0\u884C",
36
36
  "models": "\u6A21\u578B\u6570",
37
+ "model": "\u6A21\u578B",
37
38
  "date": "\u65F6\u95F4",
38
39
  "prompt": "\u63D0\u793A\u8BCD",
39
40
  "empty": "\u8FD8\u6CA1\u6709\u8FD0\u884C\u8BB0\u5F55\u3002\u7528\u540C\u4E00\u4E2A\u63D0\u793A\u8BCD\u8DD1\u591A\u4E2A\u6A21\u578B\u540E\uFF0C\u7ED3\u679C\u4F1A\u663E\u793A\u5728\u8FD9\u91CC\u3002",
40
41
  "refresh": "\u5237\u65B0",
41
42
  "loading": "\u6B63\u5728\u52A0\u8F7D\u2026",
42
43
  "failed": "\u52A0\u8F7D\u5931\u8D25",
43
- "retry": "\u91CD\u8BD5"
44
+ "retry": "\u91CD\u8BD5",
45
+ "leaderboard": "Elo \u6392\u884C\u699C",
46
+ "wins": "\u80DC",
47
+ "avgRating": "\u5E73\u5747\u8BC4\u5206",
48
+ "runs": "\u6B21\u6570",
49
+ "totalRuns": "\u603B\u8FD0\u884C\u6570",
50
+ "uniqueModels": "\u6A21\u578B\u79CD\u7C7B",
51
+ "totalRatings": "\u603B\u8BC4\u5206\u6570",
52
+ "recentRuns": "\u6700\u8FD1\u8FD0\u884C",
53
+ "winner": "\u80DC\u51FA",
54
+ "reference": "\u53C2\u8003\u7B54\u6848",
55
+ "error": "\u9519\u8BEF",
56
+ "delete": "\u5220\u9664",
57
+ "confirmDelete": "\u786E\u5B9A\u5220\u9664\u8FD9\u6761\u8FD0\u884C\u8BB0\u5F55\uFF1F",
58
+ "notFound": "\u672A\u627E\u5230\u8FD0\u884C\u8BB0\u5F55"
44
59
  };
45
60
  var en = {
46
61
  "nav": "Model Arena",
47
62
  "title": "Model Arena",
48
63
  "run": "Run",
49
64
  "models": "Models",
65
+ "model": "Model",
50
66
  "date": "Date",
51
67
  "prompt": "Prompt",
52
68
  "empty": "No runs yet. Run one prompt through several models and the comparison appears here.",
53
69
  "refresh": "Refresh",
54
70
  "loading": "Loading\u2026",
55
71
  "failed": "Failed to load",
56
- "retry": "Retry"
72
+ "retry": "Retry",
73
+ "leaderboard": "Elo Leaderboard",
74
+ "wins": "Wins",
75
+ "avgRating": "Avg Rating",
76
+ "runs": "Runs",
77
+ "totalRuns": "Total Runs",
78
+ "uniqueModels": "Models",
79
+ "totalRatings": "Ratings",
80
+ "recentRuns": "Recent Runs",
81
+ "winner": "Winner",
82
+ "reference": "Reference",
83
+ "error": "Error",
84
+ "delete": "Delete",
85
+ "confirmDelete": "Delete this run?",
86
+ "notFound": "Run not found"
57
87
  };
58
88
 
59
89
  // src/client/view.tsx
60
90
  var import_react = require("react");
61
91
  var import_jsx_runtime = require("react/jsx-runtime");
62
92
  var PANEL_PATH = "/api/arena.panel";
63
- var wrap = { display: "flex", flexDirection: "column", gap: 12, maxWidth: 760, fontFamily: "inherit" };
93
+ var RUN_PATH = "/api/arena.run";
94
+ var RATE_PATH = "/api/arena.rate";
95
+ var DELETE_PATH = "/api/arena.delete";
96
+ var wrap = { display: "flex", flexDirection: "column", gap: 14, maxWidth: 820, fontFamily: "inherit" };
64
97
  var head = { display: "flex", alignItems: "baseline", gap: 12, flexWrap: "wrap" };
98
+ var section = { marginTop: 8 };
99
+ var sectionTitle = { fontSize: 13, fontWeight: 600, marginBottom: 6 };
65
100
  var muted = { fontSize: 12, opacity: 0.75 };
66
101
  var table = { borderCollapse: "collapse", width: "100%" };
67
102
  var 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)" };
68
103
  var td = { padding: "6px 10px 6px 0", fontSize: 13, borderBottom: "0.5px solid rgba(128,128,128,0.18)" };
104
+ var card = { padding: "8px 12px", borderRadius: 6, border: "1px solid rgba(128,128,128,0.2)", background: "rgba(128,128,128,0.04)", fontSize: 13 };
105
+ var badge = { display: "inline-block", padding: "1px 6px", borderRadius: 9, fontSize: 11, fontWeight: 600 };
106
+ var statRow = { display: "flex", gap: 16, flexWrap: "wrap" };
107
+ var statBox = { ...card, flex: 1, minWidth: 100, textAlign: "center" };
108
+ var starBtn = { cursor: "pointer", background: "none", border: "none", fontSize: 16, padding: "0 1px" };
109
+ var clickRow = { cursor: "pointer" };
110
+ var 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)" };
111
+ var dangerBtn = { fontSize: 11, color: "#e55", background: "none", border: "1px solid #e55", borderRadius: 4, padding: "2px 8px", cursor: "pointer" };
69
112
  function usePanel() {
70
113
  const [state, setState] = (0, import_react.useState)({ payload: null, error: null });
71
114
  const [tick, setTick] = (0, import_react.useState)(0);
72
- const reload = (0, import_react.useCallback)(() => setTick((value) => value + 1), []);
115
+ const reload = (0, import_react.useCallback)(() => setTick((v) => v + 1), []);
73
116
  (0, import_react.useEffect)(() => {
74
- const controller = new AbortController();
75
- setState((previous) => ({ ...previous, error: null }));
76
- fetch(PANEL_PATH, { signal: controller.signal }).then(async (response) => {
77
- if (!response.ok) throw new Error(String(response.status));
78
- return response.json();
117
+ const c = new AbortController();
118
+ setState((p) => ({ ...p, error: null }));
119
+ fetch(PANEL_PATH, { signal: c.signal }).then(async (r) => {
120
+ if (!r.ok) throw new Error(String(r.status));
121
+ return r.json();
79
122
  }).then((payload) => {
80
- if (!controller.signal.aborted) setState({ payload, error: null });
81
- }).catch((cause) => {
82
- if (controller.signal.aborted) return;
83
- setState({ payload: null, error: cause instanceof Error ? cause.message : String(cause) });
123
+ if (!c.signal.aborted) setState({ payload, error: null });
124
+ }).catch((e) => {
125
+ if (!c.signal.aborted) setState({ payload: null, error: e instanceof Error ? e.message : String(e) });
84
126
  });
85
- return () => controller.abort();
127
+ return () => c.abort();
86
128
  }, [tick]);
87
129
  return { ...state, reload };
88
130
  }
131
+ function Stars({ rating, onRate }) {
132
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: [1, 2, 3, 4, 5].map((n) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: starBtn, onClick: () => onRate(n), title: `${n}/5`, children: n <= rating ? "\u2605" : "\u2606" }, n)) });
133
+ }
134
+ function Leaderboard({ entries, t }) {
135
+ if (entries.length === 0) return null;
136
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: section, children: [
137
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: sectionTitle, children: [
138
+ "\u{1F3C6} ",
139
+ t("leaderboard")
140
+ ] }),
141
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("table", { style: table, children: [
142
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", { children: [
143
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: "#" }),
144
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("model") }),
145
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: "Elo" }),
146
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("wins") }),
147
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("avgRating") }),
148
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("runs") })
149
+ ] }) }),
150
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tbody", { children: entries.map((e, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", { children: [
151
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: i + 1 }),
152
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: e.model }) }),
153
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: e.elo }),
154
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: e.wins }),
155
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: e.avgRating > 0 ? e.avgRating.toFixed(1) : "\u2014" }),
156
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: e.totalRuns })
157
+ ] }, e.model)) })
158
+ ] })
159
+ ] });
160
+ }
161
+ function StatsSummary({ stats, t }) {
162
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: statRow, children: [
163
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: statBox, children: [
164
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 20, fontWeight: 700 }, children: stats.totalRuns }),
165
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: muted, children: t("totalRuns") })
166
+ ] }),
167
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: statBox, children: [
168
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 20, fontWeight: 700 }, children: stats.uniqueModels }),
169
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: muted, children: t("uniqueModels") })
170
+ ] }),
171
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: statBox, children: [
172
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 20, fontWeight: 700 }, children: stats.totalRatings }),
173
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: muted, children: t("totalRatings") })
174
+ ] })
175
+ ] });
176
+ }
177
+ function RunDetail({ runId, t, onDelete }) {
178
+ const [run, setRun] = (0, import_react.useState)(null);
179
+ const [loading, setLoading] = (0, import_react.useState)(true);
180
+ (0, import_react.useEffect)(() => {
181
+ setLoading(true);
182
+ fetch(`${RUN_PATH}?id=${runId}`).then((r) => r.json()).then(setRun).catch(() => setRun(null)).finally(() => setLoading(false));
183
+ }, [runId]);
184
+ const handleRate = (0, import_react.useCallback)((model, rating) => {
185
+ fetch(RATE_PATH, {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json" },
188
+ body: JSON.stringify({ runId, model, rating })
189
+ }).then((r) => r.json()).then(setRun).catch(() => {
190
+ });
191
+ }, [runId]);
192
+ const handleDelete = (0, import_react.useCallback)(() => {
193
+ if (!confirm(t("confirmDelete"))) return;
194
+ fetch(DELETE_PATH, {
195
+ method: "POST",
196
+ headers: { "Content-Type": "application/json" },
197
+ body: JSON.stringify({ runId })
198
+ }).then(() => onDelete()).catch(() => {
199
+ });
200
+ }, [runId, t, onDelete]);
201
+ if (loading) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: muted, children: t("loading") });
202
+ if (!run) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: muted, children: t("notFound") });
203
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { ...card, marginTop: 4 }, children: [
204
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }, children: [
205
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("strong", { children: [
206
+ t("prompt"),
207
+ ": "
208
+ ] }),
209
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: dangerBtn, onClick: handleDelete, children: t("delete") })
210
+ ] }),
211
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: preBlock, children: run.prompt }),
212
+ run.reference && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 6 }, children: [
213
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { fontSize: 12, fontWeight: 600, marginBottom: 2 }, children: [
214
+ t("reference"),
215
+ ":"
216
+ ] }),
217
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: preBlock, children: run.reference })
218
+ ] }),
219
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { marginTop: 8 }, children: run.results.map((r) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { ...card, marginTop: 6 }, children: [
220
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [
221
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: r.model }),
222
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: r.error ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...badge, background: "rgba(255,80,80,0.15)", color: "#e55" }, children: t("error") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { ...badge, background: "rgba(80,200,120,0.15)", color: "#4a4" }, children: [
223
+ r.latencyMs,
224
+ "ms"
225
+ ] }) })
226
+ ] }),
227
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: preBlock, children: r.error ?? r.output }),
228
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 4 }, children: [
229
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: muted, children: [
230
+ r.promptTokens + r.completionTokens,
231
+ " tokens (",
232
+ r.promptTokens,
233
+ "\u2191 ",
234
+ r.completionTokens,
235
+ "\u2193)"
236
+ ] }),
237
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Stars, { rating: r.rating ?? 0, onRate: (n) => handleRate(r.model, n) })
238
+ ] })
239
+ ] }, r.model)) })
240
+ ] });
241
+ }
89
242
  function ArenaPanel({ t }) {
90
243
  const { payload, error, reload } = usePanel();
244
+ const [expandedRun, setExpandedRun] = (0, import_react.useState)(null);
91
245
  const header = /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("header", { style: head, children: [
92
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { style: { fontSize: 13 }, children: t("title") }),
246
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("strong", { style: { fontSize: 14 }, children: [
247
+ "\u2694\uFE0F ",
248
+ t("title")
249
+ ] }),
93
250
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
94
251
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: reload, style: { fontSize: 12 }, children: t("refresh") })
95
252
  ] });
@@ -107,19 +264,36 @@ function ArenaPanel({ t }) {
107
264
  if (payload === null) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: muted, "aria-live": "polite", children: t("loading") });
108
265
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: wrap, children: [
109
266
  header,
110
- payload.recentRuns.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { margin: 0, fontSize: 13, opacity: 0.8 }, children: t("empty") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("table", { style: table, children: [
111
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", { children: [
112
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("run") }),
113
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("models") }),
114
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("date") }),
115
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("prompt") })
116
- ] }) }),
117
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tbody", { children: payload.recentRuns.map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", { children: [
118
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { style: { fontSize: 11 }, children: row.id }) }),
119
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: row.modelCount }),
120
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: new Date(row.timestamp).toLocaleString() }),
121
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: row.prompt.length > 60 ? `${row.prompt.slice(0, 60)}\u2026` : row.prompt })
122
- ] }, row.id)) })
267
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatsSummary, { stats: payload.stats, t }),
268
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Leaderboard, { entries: payload.leaderboard, t }),
269
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: section, children: [
270
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: sectionTitle, children: [
271
+ "\u{1F4CB} ",
272
+ t("recentRuns")
273
+ ] }),
274
+ payload.recentRuns.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { margin: 0, fontSize: 13, opacity: 0.8 }, children: t("empty") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("table", { style: table, children: [
275
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", { children: [
276
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("run") }),
277
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("models") }),
278
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("winner") }),
279
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { style: th, children: t("date") })
280
+ ] }) }),
281
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tbody", { children: payload.recentRuns.map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
282
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", { style: clickRow, onClick: () => setExpandedRun(expandedRun === row.id ? null : row.id), children: [
283
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("td", { style: td, children: [
284
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { style: { fontSize: 11 }, children: row.id }),
285
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { marginLeft: 6, fontSize: 10 }, children: expandedRun === row.id ? "\u25BC" : "\u25B6" })
286
+ ] }),
287
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: row.modelCount }),
288
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: row.winner ?? "\u2014" }),
289
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: td, children: new Date(row.timestamp).toLocaleString() })
290
+ ] }, row.id),
291
+ expandedRun === row.id && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tr", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { colSpan: 4, style: { padding: "4px 0" }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RunDetail, { runId: row.id, t, onDelete: () => {
292
+ setExpandedRun(null);
293
+ reload();
294
+ } }) }) }, `${row.id}-detail`)
295
+ ] })) })
296
+ ] })
123
297
  ] })
124
298
  ] });
125
299
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/client/index.tsx", "../src/client/locales.ts", "../src/client/view.tsx"],
4
- "sourcesContent": ["/**\n * Browser half of dsh-model-arena: its page inside Settings.\n *\n * Two services are injected by their runtime identity, not by the packages that provide\n * them: the settings shell is `slots`, the dictionary registry is `locale`. The label and\n * every string inside the component are translated through this plugin's own namespace,\n * which is why the page follows the UI language instead of the plugin author's.\n *\n * @module client\n */\n\nimport type { ComponentType } from 'react'\n\nimport { NS, en, zh } from './locales.js'\nimport { ArenaPanel } from './view.js'\n\nexport const inject = ['slots', 'locale']\n\ninterface SlotsService {\n inject(name: string, register: () => void): void\n register(\n options: { name: string; id: string; order: number; label: () => string; locale: string },\n component: ComponentType<{ t: (key: string, params?: Record<string, unknown>) => string }>,\n ): unknown\n}\n\ninterface ClientContext {\n slots: SlotsService\n locale: {\n register(ns: string, dicts: { zh: unknown; en: unknown }): () => void\n bind(ns: string): (key: string, params?: Record<string, unknown>) => string\n }\n effect(callback: () => unknown, label?: string): unknown\n}\n\nexport function apply(ctx: ClientContext): void {\n // `zh` is the key-set source of truth, matching the official client plugins.\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-model-arena: dictionaries')\n\n ctx.slots.inject('settings.section', () => ctx.slots.register(\n {\n name: 'settings.section',\n id: 'model-arena',\n order: 42,\n label: () => ctx.locale.bind(NS)('nav'),\n locale: NS,\n },\n ArenaPanel,\n ))\n}\n", "/**\n * Dictionaries for the Model Arena page.\n *\n * `zh` is the key-set source of truth, as in the official client plugins, and `en` is\n * typed against it: a key translated in one language but not the other fails the build\n * instead of silently rendering the raw key.\n *\n * @module client/locales\n */\n\n/** Dictionary namespace owned by this plugin. */\nexport const NS = 'modelArena'\n\n/** Simplified Chinese dictionary (the key-set source of truth). */\nexport const zh = {\n 'nav': '\u6A21\u578B\u7ADE\u6280\u573A',\n 'title': '\u6A21\u578B\u7ADE\u6280\u573A',\n 'run': '\u8FD0\u884C',\n 'models': '\u6A21\u578B\u6570',\n 'date': '\u65F6\u95F4',\n 'prompt': '\u63D0\u793A\u8BCD',\n 'empty': '\u8FD8\u6CA1\u6709\u8FD0\u884C\u8BB0\u5F55\u3002\u7528\u540C\u4E00\u4E2A\u63D0\u793A\u8BCD\u8DD1\u591A\u4E2A\u6A21\u578B\u540E\uFF0C\u7ED3\u679C\u4F1A\u663E\u793A\u5728\u8FD9\u91CC\u3002',\n 'refresh': '\u5237\u65B0',\n 'loading': '\u6B63\u5728\u52A0\u8F7D\u2026',\n 'failed': '\u52A0\u8F7D\u5931\u8D25',\n 'retry': '\u91CD\u8BD5',\n}\n\n/** English dictionary, checked complete against the zh key set. */\nexport const en: typeof zh = {\n 'nav': 'Model Arena',\n 'title': 'Model Arena',\n 'run': 'Run',\n 'models': 'Models',\n 'date': 'Date',\n 'prompt': 'Prompt',\n 'empty': 'No runs yet. Run one prompt through several models and the comparison appears here.',\n 'refresh': 'Refresh',\n 'loading': 'Loading\u2026',\n 'failed': 'Failed to load',\n 'retry': 'Retry',\n}\n", "/**\n * Pure rendering half of the modelArena page.\n *\n * Separate from `index.tsx` so a static render can assert in Node what the page draws \u2014\n * the shipped bundle is a loader factory only a browser can run. Every user-visible\n * string comes from the `t` seat the renderer binds from this plugin's namespace, so the\n * page follows the UI language; no copy is hardcoded here.\n *\n * @module client/view\n */\n\nimport { useCallback, useEffect, useState } from 'react'\nimport type { CSSProperties } from 'react'\n\nimport type { PanelPayload } from '../types.js'\n\n/** The translate seat the renderer binds from this plugin's locale namespace. */\nexport type Translate = (key: string, params?: Record<string, unknown>) => string\n\nexport interface PanelProps {\n /** Bound translate function for this plugin's namespace. */\n t: Translate\n}\n\n/** Panel route registered by the host half on the web connection. */\nconst PANEL_PATH = \"/api/arena.panel\"\n\nconst wrap: CSSProperties = { display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 760, fontFamily: 'inherit' }\nconst head: CSSProperties = { display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }\nconst muted: CSSProperties = { fontSize: 12, opacity: 0.75 }\nconst table: CSSProperties = { borderCollapse: 'collapse', width: '100%' }\nconst th: CSSProperties = { textAlign: 'left', padding: '4px 10px 4px 0', fontWeight: 600, fontSize: 12, opacity: 0.8, borderBottom: '0.5px solid rgba(128,128,128,0.4)' }\nconst td: CSSProperties = { padding: '6px 10px 6px 0', fontSize: 13, borderBottom: '0.5px solid rgba(128,128,128,0.18)' }\n\ninterface PanelState {\n payload: PanelPayload | null\n error: string | null\n}\n\n/** Fetch the host panel payload; `reload` re-runs the request. */\nexport function usePanel(): PanelState & { reload: () => void } {\n const [state, setState] = useState<PanelState>({ payload: null, error: null })\n const [tick, setTick] = useState(0)\n const reload = useCallback(() => setTick((value) => value + 1), [])\n\n useEffect(() => {\n const controller = new AbortController()\n setState((previous) => ({ ...previous, error: null }))\n fetch(PANEL_PATH, { signal: controller.signal })\n .then(async (response) => {\n if (!response.ok) throw new Error(String(response.status))\n return response.json() as Promise<PanelPayload>\n })\n .then((payload) => {\n if (!controller.signal.aborted) setState({ payload, error: null })\n })\n .catch((cause: unknown) => {\n if (controller.signal.aborted) return\n setState({ payload: null, error: cause instanceof Error ? cause.message : String(cause) })\n })\n return () => controller.abort()\n }, [tick])\n\n return { ...state, reload }\n}\n\nexport function ArenaPanel({ t }: PanelProps) {\n const { payload, error, reload } = usePanel()\n const header = (\n <header style={head}>\n <strong style={{ fontSize: 13 }}>{t('title')}</strong>\n <span style={{ flex: 1 }} />\n <button type=\"button\" onClick={reload} style={{ fontSize: 12 }}>{t('refresh')}</button>\n </header>\n )\n if (error !== null) {\n return (\n <div style={wrap}>\n {header}\n <p role=\"alert\" style={{ margin: 0, fontSize: 13 }}>{t('failed')}: {error}</p>\n <button type=\"button\" onClick={reload} style={{ alignSelf: 'flex-start', fontSize: 12 }}>{t('retry')}</button>\n </div>\n )\n }\n if (payload === null) return <p style={muted} aria-live=\"polite\">{t('loading')}</p>\n return (\n <div style={wrap}>\n {header}\n {payload.recentRuns.length === 0 ? (\n <p style={{ margin: 0, fontSize: 13, opacity: 0.8 }}>{t('empty')}</p>\n ) : (\n <table style={table}>\n <thead>\n <tr><th style={th}>{t('run')}</th><th style={th}>{t('models')}</th><th style={th}>{t('date')}</th><th style={th}>{t('prompt')}</th></tr>\n </thead>\n <tbody>\n {payload.recentRuns.map((row) => (\n <tr key={row.id}>\n <td style={td}><code style={{ fontSize: 11 }}>{row.id}</code></td>\n <td style={td}>{row.modelCount}</td>\n <td style={td}>{new Date(row.timestamp).toLocaleString()}</td>\n <td style={td}>{row.prompt.length > 60 ? `${row.prompt.slice(0, 60)}\u2026` : row.prompt}</td>\n </tr>\n ))}\n </tbody>\n </table>\n )}\n </div>\n )\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACWO,IAAM,KAAK;AAGX,IAAM,KAAK;AAAA,EAChB,OAAO;AAAA,EACP,SAAS;AAAA,EACT,OAAO;AAAA,EACP,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,SAAS;AAAA,EACT,WAAW;AAAA,EACX,WAAW;AAAA,EACX,UAAU;AAAA,EACV,SAAS;AACX;AAGO,IAAM,KAAgB;AAAA,EAC3B,OAAO;AAAA,EACP,SAAS;AAAA,EACT,OAAO;AAAA,EACP,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,SAAS;AAAA,EACT,WAAW;AAAA,EACX,WAAW;AAAA,EACX,UAAU;AAAA,EACV,SAAS;AACX;;;AC9BA,mBAAiD;AA0D7C;AA5CJ,IAAM,aAAa;AAEnB,IAAM,OAAsB,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,IAAI,UAAU,KAAK,YAAY,UAAU;AACtH,IAAM,OAAsB,EAAE,SAAS,QAAQ,YAAY,YAAY,KAAK,IAAI,UAAU,OAAO;AACjG,IAAM,QAAuB,EAAE,UAAU,IAAI,SAAS,KAAK;AAC3D,IAAM,QAAuB,EAAE,gBAAgB,YAAY,OAAO,OAAO;AACzE,IAAM,KAAoB,EAAE,WAAW,QAAQ,SAAS,kBAAkB,YAAY,KAAK,UAAU,IAAI,SAAS,KAAK,cAAc,oCAAoC;AACzK,IAAM,KAAoB,EAAE,SAAS,kBAAkB,UAAU,IAAI,cAAc,qCAAqC;AAQjH,SAAS,WAAgD;AAC9D,QAAM,CAAC,OAAO,QAAQ,QAAI,uBAAqB,EAAE,SAAS,MAAM,OAAO,KAAK,CAAC;AAC7E,QAAM,CAAC,MAAM,OAAO,QAAI,uBAAS,CAAC;AAClC,QAAM,aAAS,0BAAY,MAAM,QAAQ,CAAC,UAAU,QAAQ,CAAC,GAAG,CAAC,CAAC;AAElE,8BAAU,MAAM;AACd,UAAM,aAAa,IAAI,gBAAgB;AACvC,aAAS,CAAC,cAAc,EAAE,GAAG,UAAU,OAAO,KAAK,EAAE;AACrD,UAAM,YAAY,EAAE,QAAQ,WAAW,OAAO,CAAC,EAC5C,KAAK,OAAO,aAAa;AACxB,UAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,OAAO,SAAS,MAAM,CAAC;AACzD,aAAO,SAAS,KAAK;AAAA,IACvB,CAAC,EACA,KAAK,CAAC,YAAY;AACjB,UAAI,CAAC,WAAW,OAAO,QAAS,UAAS,EAAE,SAAS,OAAO,KAAK,CAAC;AAAA,IACnE,CAAC,EACA,MAAM,CAAC,UAAmB;AACzB,UAAI,WAAW,OAAO,QAAS;AAC/B,eAAS,EAAE,SAAS,MAAM,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,CAAC;AAAA,IAC3F,CAAC;AACH,WAAO,MAAM,WAAW,MAAM;AAAA,EAChC,GAAG,CAAC,IAAI,CAAC;AAET,SAAO,EAAE,GAAG,OAAO,OAAO;AAC5B;AAEO,SAAS,WAAW,EAAE,EAAE,GAAe;AAC5C,QAAM,EAAE,SAAS,OAAO,OAAO,IAAI,SAAS;AAC5C,QAAM,SACJ,6CAAC,YAAO,OAAO,MACb;AAAA,gDAAC,YAAO,OAAO,EAAE,UAAU,GAAG,GAAI,YAAE,OAAO,GAAE;AAAA,IAC7C,4CAAC,UAAK,OAAO,EAAE,MAAM,EAAE,GAAG;AAAA,IAC1B,4CAAC,YAAO,MAAK,UAAS,SAAS,QAAQ,OAAO,EAAE,UAAU,GAAG,GAAI,YAAE,SAAS,GAAE;AAAA,KAChF;AAEF,MAAI,UAAU,MAAM;AAClB,WACE,6CAAC,SAAI,OAAO,MACT;AAAA;AAAA,MACD,6CAAC,OAAE,MAAK,SAAQ,OAAO,EAAE,QAAQ,GAAG,UAAU,GAAG,GAAI;AAAA,UAAE,QAAQ;AAAA,QAAE;AAAA,QAAG;AAAA,SAAM;AAAA,MAC1E,4CAAC,YAAO,MAAK,UAAS,SAAS,QAAQ,OAAO,EAAE,WAAW,cAAc,UAAU,GAAG,GAAI,YAAE,OAAO,GAAE;AAAA,OACvG;AAAA,EAEJ;AACA,MAAI,YAAY,KAAM,QAAO,4CAAC,OAAE,OAAO,OAAO,aAAU,UAAU,YAAE,SAAS,GAAE;AAC/E,SACE,6CAAC,SAAI,OAAO,MACT;AAAA;AAAA,IACA,QAAQ,WAAW,WAAW,IAC7B,4CAAC,OAAE,OAAO,EAAE,QAAQ,GAAG,UAAU,IAAI,SAAS,IAAI,GAAI,YAAE,OAAO,GAAE,IAEjE,6CAAC,WAAM,OAAO,OACZ;AAAA,kDAAC,WACC,uDAAC,QAAG;AAAA,oDAAC,QAAG,OAAO,IAAK,YAAE,KAAK,GAAE;AAAA,QAAK,4CAAC,QAAG,OAAO,IAAK,YAAE,QAAQ,GAAE;AAAA,QAAK,4CAAC,QAAG,OAAO,IAAK,YAAE,MAAM,GAAE;AAAA,QAAK,4CAAC,QAAG,OAAO,IAAK,YAAE,QAAQ,GAAE;AAAA,SAAK,GACrI;AAAA,MACA,4CAAC,WACE,kBAAQ,WAAW,IAAI,CAAC,QACvB,6CAAC,QACC;AAAA,oDAAC,QAAG,OAAO,IAAI,sDAAC,UAAK,OAAO,EAAE,UAAU,GAAG,GAAI,cAAI,IAAG,GAAO;AAAA,QAC7D,4CAAC,QAAG,OAAO,IAAK,cAAI,YAAW;AAAA,QAC/B,4CAAC,QAAG,OAAO,IAAK,cAAI,KAAK,IAAI,SAAS,EAAE,eAAe,GAAE;AAAA,QACzD,4CAAC,QAAG,OAAO,IAAK,cAAI,OAAO,SAAS,KAAK,GAAG,IAAI,OAAO,MAAM,GAAG,EAAE,CAAC,WAAM,IAAI,QAAO;AAAA,WAJ7E,IAAI,EAKb,CACD,GACH;AAAA,OACF;AAAA,KAEJ;AAEJ;;;AF7FO,IAAM,SAAS,CAAC,SAAS,QAAQ;AAmBjC,SAAS,MAAM,KAA0B;AAE9C,MAAI,OAAO,MAAM,IAAI,OAAO,SAAS,IAAI,EAAE,IAAI,GAAG,CAAC,GAAG,+BAA+B;AAErF,MAAI,MAAM,OAAO,oBAAoB,MAAM,IAAI,MAAM;AAAA,IACnD;AAAA,MACE,MAAM;AAAA,MACN,IAAI;AAAA,MACJ,OAAO;AAAA,MACP,OAAO,MAAM,IAAI,OAAO,KAAK,EAAE,EAAE,KAAK;AAAA,MACtC,QAAQ;AAAA,IACV;AAAA,IACA;AAAA,EACF,CAAC;AACH;",
4
+ "sourcesContent": ["/**\n * Browser half of dsh-model-arena: its page inside Settings.\n *\n * Two services are injected by their runtime identity, not by the packages that provide\n * them: the settings shell is `slots`, the dictionary registry is `locale`. The label and\n * every string inside the component are translated through this plugin's own namespace,\n * which is why the page follows the UI language instead of the plugin author's.\n *\n * @module client\n */\n\nimport type { ComponentType } from 'react'\n\nimport { NS, en, zh } from './locales.js'\nimport { ArenaPanel } from './view.js'\n\nexport const inject = ['slots', 'locale']\n\ninterface SlotsService {\n inject(name: string, register: () => void): void\n register(\n options: { name: string; id: string; order: number; label: () => string; locale: string },\n component: ComponentType<{ t: (key: string, params?: Record<string, unknown>) => string }>,\n ): unknown\n}\n\ninterface ClientContext {\n slots: SlotsService\n locale: {\n register(ns: string, dicts: { zh: unknown; en: unknown }): () => void\n bind(ns: string): (key: string, params?: Record<string, unknown>) => string\n }\n effect(callback: () => unknown, label?: string): unknown\n}\n\nexport function apply(ctx: ClientContext): void {\n // `zh` is the key-set source of truth, matching the official client plugins.\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-model-arena: dictionaries')\n\n ctx.slots.inject('settings.section', () => ctx.slots.register(\n {\n name: 'settings.section',\n id: 'model-arena',\n order: 42,\n label: () => ctx.locale.bind(NS)('nav'),\n locale: NS,\n },\n ArenaPanel,\n ))\n}\n", "/**\n * Dictionaries for the Model Arena page.\n *\n * `zh` is the key-set source of truth, as in the official client plugins, and `en` is\n * typed against it: a key translated in one language but not the other fails the build\n * instead of silently rendering the raw key.\n *\n * @module client/locales\n */\n\n/** Dictionary namespace owned by this plugin. */\nexport const NS = 'modelArena'\n\n/** Simplified Chinese dictionary (the key-set source of truth). */\nexport const zh = {\n 'nav': '\u6A21\u578B\u7ADE\u6280\u573A',\n 'title': '\u6A21\u578B\u7ADE\u6280\u573A',\n 'run': '\u8FD0\u884C',\n 'models': '\u6A21\u578B\u6570',\n 'model': '\u6A21\u578B',\n 'date': '\u65F6\u95F4',\n 'prompt': '\u63D0\u793A\u8BCD',\n 'empty': '\u8FD8\u6CA1\u6709\u8FD0\u884C\u8BB0\u5F55\u3002\u7528\u540C\u4E00\u4E2A\u63D0\u793A\u8BCD\u8DD1\u591A\u4E2A\u6A21\u578B\u540E\uFF0C\u7ED3\u679C\u4F1A\u663E\u793A\u5728\u8FD9\u91CC\u3002',\n 'refresh': '\u5237\u65B0',\n 'loading': '\u6B63\u5728\u52A0\u8F7D\u2026',\n 'failed': '\u52A0\u8F7D\u5931\u8D25',\n 'retry': '\u91CD\u8BD5',\n 'leaderboard': 'Elo \u6392\u884C\u699C',\n 'wins': '\u80DC',\n 'avgRating': '\u5E73\u5747\u8BC4\u5206',\n 'runs': '\u6B21\u6570',\n 'totalRuns': '\u603B\u8FD0\u884C\u6570',\n 'uniqueModels': '\u6A21\u578B\u79CD\u7C7B',\n 'totalRatings': '\u603B\u8BC4\u5206\u6570',\n 'recentRuns': '\u6700\u8FD1\u8FD0\u884C',\n 'winner': '\u80DC\u51FA',\n 'reference': '\u53C2\u8003\u7B54\u6848',\n 'error': '\u9519\u8BEF',\n 'delete': '\u5220\u9664',\n 'confirmDelete': '\u786E\u5B9A\u5220\u9664\u8FD9\u6761\u8FD0\u884C\u8BB0\u5F55\uFF1F',\n 'notFound': '\u672A\u627E\u5230\u8FD0\u884C\u8BB0\u5F55',\n}\n\n/** English dictionary, checked complete against the zh key set. */\nexport const en: typeof zh = {\n 'nav': 'Model Arena',\n 'title': 'Model Arena',\n 'run': 'Run',\n 'models': 'Models',\n 'model': 'Model',\n 'date': 'Date',\n 'prompt': 'Prompt',\n 'empty': 'No runs yet. Run one prompt through several models and the comparison appears here.',\n 'refresh': 'Refresh',\n 'loading': 'Loading\u2026',\n 'failed': 'Failed to load',\n 'retry': 'Retry',\n 'leaderboard': 'Elo Leaderboard',\n 'wins': 'Wins',\n 'avgRating': 'Avg Rating',\n 'runs': 'Runs',\n 'totalRuns': 'Total Runs',\n 'uniqueModels': 'Models',\n 'totalRatings': 'Ratings',\n 'recentRuns': 'Recent Runs',\n 'winner': 'Winner',\n 'reference': 'Reference',\n 'error': 'Error',\n 'delete': 'Delete',\n 'confirmDelete': 'Delete this run?',\n 'notFound': 'Run not found',\n}\n", "/**\n * Pure rendering half of the modelArena page.\n *\n * Separate from `index.tsx` so a static render can assert in Node what the page draws \u2014\n * the shipped bundle is a loader factory only a browser can run. Every user-visible\n * string comes from the `t` seat the renderer binds from this plugin's namespace, so the\n * page follows the UI language; no copy is hardcoded here.\n *\n * @module client/view\n */\n\nimport { useCallback, useEffect, useState } from 'react'\nimport type { CSSProperties } from 'react'\n\nimport type { PanelPayload, LeaderboardEntry, ArenaStats, RunSummary, ArenaRun } from '../types.js'\n\nexport type Translate = (key: string, params?: Record<string, unknown>) => string\n\nexport interface PanelProps {\n t: Translate\n}\n\nconst PANEL_PATH = '/api/arena.panel'\nconst RUN_PATH = '/api/arena.run'\nconst RATE_PATH = '/api/arena.rate'\nconst DELETE_PATH = '/api/arena.delete'\n\n// --- Styles ---\nconst wrap: CSSProperties = { display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 820, fontFamily: 'inherit' }\nconst head: CSSProperties = { display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }\nconst section: CSSProperties = { marginTop: 8 }\nconst sectionTitle: CSSProperties = { fontSize: 13, fontWeight: 600, marginBottom: 6 }\nconst muted: CSSProperties = { fontSize: 12, opacity: 0.75 }\nconst table: CSSProperties = { borderCollapse: 'collapse', width: '100%' }\nconst th: CSSProperties = { textAlign: 'left', padding: '4px 10px 4px 0', fontWeight: 600, fontSize: 12, opacity: 0.8, borderBottom: '0.5px solid rgba(128,128,128,0.4)' }\nconst td: CSSProperties = { padding: '6px 10px 6px 0', fontSize: 13, borderBottom: '0.5px solid rgba(128,128,128,0.18)' }\nconst card: CSSProperties = { padding: '8px 12px', borderRadius: 6, border: '1px solid rgba(128,128,128,0.2)', background: 'rgba(128,128,128,0.04)', fontSize: 13 }\nconst badge: CSSProperties = { display: 'inline-block', padding: '1px 6px', borderRadius: 9, fontSize: 11, fontWeight: 600 }\nconst statRow: CSSProperties = { display: 'flex', gap: 16, flexWrap: 'wrap' }\nconst statBox: CSSProperties = { ...card, flex: 1, minWidth: 100, textAlign: 'center' as const }\nconst starBtn: CSSProperties = { cursor: 'pointer', background: 'none', border: 'none', fontSize: 16, padding: '0 1px' }\nconst clickRow: CSSProperties = { cursor: 'pointer' }\nconst preBlock: CSSProperties = { 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)' }\nconst dangerBtn: CSSProperties = { fontSize: 11, color: '#e55', background: 'none', border: '1px solid #e55', borderRadius: 4, padding: '2px 8px', cursor: 'pointer' }\n\ninterface PanelState {\n payload: PanelPayload | null\n error: string | null\n}\n\nexport function usePanel(): PanelState & { reload: () => void } {\n const [state, setState] = useState<PanelState>({ payload: null, error: null })\n const [tick, setTick] = useState(0)\n const reload = useCallback(() => setTick((v) => v + 1), [])\n useEffect(() => {\n const c = new AbortController()\n setState((p) => ({ ...p, error: null }))\n fetch(PANEL_PATH, { signal: c.signal })\n .then(async (r) => {\n if (!r.ok) throw new Error(String(r.status))\n return r.json() as Promise<PanelPayload>\n })\n .then((payload) => { if (!c.signal.aborted) setState({ payload, error: null }) })\n .catch((e: unknown) => { if (!c.signal.aborted) setState({ payload: null, error: e instanceof Error ? e.message : String(e) }) })\n return () => c.abort()\n }, [tick])\n return { ...state, reload }\n}\n\n// --- Stars ---\nfunction Stars({ rating, onRate }: { rating: number; onRate: (n: number) => void }) {\n return (\n <span>\n {[1, 2, 3, 4, 5].map((n) => (\n <button key={n} type=\"button\" style={starBtn} onClick={() => onRate(n)} title={`${n}/5`}>\n {n <= rating ? '\u2605' : '\u2606'}\n </button>\n ))}\n </span>\n )\n}\n\n// --- Leaderboard ---\nfunction Leaderboard({ entries, t }: { entries: LeaderboardEntry[]; t: Translate }) {\n if (entries.length === 0) return null\n return (\n <div style={section}>\n <div style={sectionTitle}>\uD83C\uDFC6 {t('leaderboard')}</div>\n <table style={table}>\n <thead>\n <tr>\n <th style={th}>#</th>\n <th style={th}>{t('model')}</th>\n <th style={th}>Elo</th>\n <th style={th}>{t('wins')}</th>\n <th style={th}>{t('avgRating')}</th>\n <th style={th}>{t('runs')}</th>\n </tr>\n </thead>\n <tbody>\n {entries.map((e, i) => (\n <tr key={e.model}>\n <td style={td}>{i + 1}</td>\n <td style={td}><strong>{e.model}</strong></td>\n <td style={td}>{e.elo}</td>\n <td style={td}>{e.wins}</td>\n <td style={td}>{e.avgRating > 0 ? e.avgRating.toFixed(1) : '\u2014'}</td>\n <td style={td}>{e.totalRuns}</td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )\n}\n\n// --- Stats summary ---\nfunction StatsSummary({ stats, t }: { stats: ArenaStats; t: Translate }) {\n return (\n <div style={statRow}>\n <div style={statBox}><div style={{ fontSize: 20, fontWeight: 700 }}>{stats.totalRuns}</div><div style={muted}>{t('totalRuns')}</div></div>\n <div style={statBox}><div style={{ fontSize: 20, fontWeight: 700 }}>{stats.uniqueModels}</div><div style={muted}>{t('uniqueModels')}</div></div>\n <div style={statBox}><div style={{ fontSize: 20, fontWeight: 700 }}>{stats.totalRatings}</div><div style={muted}>{t('totalRatings')}</div></div>\n </div>\n )\n}\n\n// --- Run detail expansion ---\nfunction RunDetail({ runId, t, onDelete }: { runId: string; t: Translate; onDelete: () => void }) {\n const [run, setRun] = useState<ArenaRun | null>(null)\n const [loading, setLoading] = useState(true)\n useEffect(() => {\n setLoading(true)\n fetch(`${RUN_PATH}?id=${runId}`)\n .then((r) => r.json() as Promise<ArenaRun>)\n .then(setRun)\n .catch(() => setRun(null))\n .finally(() => setLoading(false))\n }, [runId])\n\n const handleRate = useCallback((model: string, rating: number) => {\n fetch(RATE_PATH, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ runId, model, rating }),\n })\n .then((r) => r.json() as Promise<ArenaRun>)\n .then(setRun)\n .catch(() => {})\n }, [runId])\n\n const handleDelete = useCallback(() => {\n if (!confirm(t('confirmDelete'))) return\n fetch(DELETE_PATH, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ runId }),\n }).then(() => onDelete()).catch(() => {})\n }, [runId, t, onDelete])\n\n if (loading) return <p style={muted}>{t('loading')}</p>\n if (!run) return <p style={muted}>{t('notFound')}</p>\n return (\n <div style={{ ...card, marginTop: 4 }}>\n <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>\n <strong>{t('prompt')}: </strong>\n <button type=\"button\" style={dangerBtn} onClick={handleDelete}>{t('delete')}</button>\n </div>\n <div style={preBlock}>{run.prompt}</div>\n {run.reference && (\n <div style={{ marginTop: 6 }}>\n <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 2 }}>{t('reference')}:</div>\n <div style={preBlock}>{run.reference}</div>\n </div>\n )}\n <div style={{ marginTop: 8 }}>\n {run.results.map((r) => (\n <div key={r.model} style={{ ...card, marginTop: 6 }}>\n <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>\n <strong>{r.model}</strong>\n <span>\n {r.error ? (\n <span style={{ ...badge, background: 'rgba(255,80,80,0.15)', color: '#e55' }}>{t('error')}</span>\n ) : (\n <span style={{ ...badge, background: 'rgba(80,200,120,0.15)', color: '#4a4' }}>{r.latencyMs}ms</span>\n )}\n </span>\n </div>\n <div style={preBlock}>{r.error ?? r.output}</div>\n <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 4 }}>\n <span style={muted}>{r.promptTokens + r.completionTokens} tokens ({r.promptTokens}\u2191 {r.completionTokens}\u2193)</span>\n <Stars rating={r.rating ?? 0} onRate={(n) => handleRate(r.model, n)} />\n </div>\n </div>\n ))}\n </div>\n </div>\n )\n}\n\n// --- Main Panel ---\nexport function ArenaPanel({ t }: PanelProps) {\n const { payload, error, reload } = usePanel()\n const [expandedRun, setExpandedRun] = useState<string | null>(null)\n\n const header = (\n <header style={head}>\n <strong style={{ fontSize: 14 }}>\u2694\uFE0F {t('title')}</strong>\n <span style={{ flex: 1 }} />\n <button type=\"button\" onClick={reload} style={{ fontSize: 12 }}>{t('refresh')}</button>\n </header>\n )\n if (error !== null) {\n return (\n <div style={wrap}>\n {header}\n <p role=\"alert\" style={{ margin: 0, fontSize: 13 }}>{t('failed')}: {error}</p>\n <button type=\"button\" onClick={reload} style={{ alignSelf: 'flex-start', fontSize: 12 }}>{t('retry')}</button>\n </div>\n )\n }\n if (payload === null) return <p style={muted} aria-live=\"polite\">{t('loading')}</p>\n return (\n <div style={wrap}>\n {header}\n\n {/* Stats */}\n <StatsSummary stats={payload.stats} t={t} />\n\n {/* Leaderboard */}\n <Leaderboard entries={payload.leaderboard} t={t} />\n\n {/* Recent runs */}\n <div style={section}>\n <div style={sectionTitle}>\uD83D\uDCCB {t('recentRuns')}</div>\n {payload.recentRuns.length === 0 ? (\n <p style={{ margin: 0, fontSize: 13, opacity: 0.8 }}>{t('empty')}</p>\n ) : (\n <table style={table}>\n <thead>\n <tr><th style={th}>{t('run')}</th><th style={th}>{t('models')}</th><th style={th}>{t('winner')}</th><th style={th}>{t('date')}</th></tr>\n </thead>\n <tbody>\n {payload.recentRuns.map((row) => (\n <>\n <tr key={row.id} style={clickRow} onClick={() => setExpandedRun(expandedRun === row.id ? null : row.id)}>\n <td style={td}>\n <code style={{ fontSize: 11 }}>{row.id}</code>\n <span style={{ marginLeft: 6, fontSize: 10 }}>{expandedRun === row.id ? '\u25BC' : '\u25B6'}</span>\n </td>\n <td style={td}>{row.modelCount}</td>\n <td style={td}>{row.winner ?? '\u2014'}</td>\n <td style={td}>{new Date(row.timestamp).toLocaleString()}</td>\n </tr>\n {expandedRun === row.id && (\n <tr key={`${row.id}-detail`}>\n <td colSpan={4} style={{ padding: '4px 0' }}>\n <RunDetail runId={row.id} t={t} onDelete={() => { setExpandedRun(null); reload() }} />\n </td>\n </tr>\n )}\n </>\n ))}\n </tbody>\n </table>\n )}\n </div>\n </div>\n )\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACWO,IAAM,KAAK;AAGX,IAAM,KAAK;AAAA,EAChB,OAAO;AAAA,EACP,SAAS;AAAA,EACT,OAAO;AAAA,EACP,UAAU;AAAA,EACV,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,SAAS;AAAA,EACT,WAAW;AAAA,EACX,WAAW;AAAA,EACX,UAAU;AAAA,EACV,SAAS;AAAA,EACT,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,gBAAgB;AAAA,EAChB,gBAAgB;AAAA,EAChB,cAAc;AAAA,EACd,UAAU;AAAA,EACV,aAAa;AAAA,EACb,SAAS;AAAA,EACT,UAAU;AAAA,EACV,iBAAiB;AAAA,EACjB,YAAY;AACd;AAGO,IAAM,KAAgB;AAAA,EAC3B,OAAO;AAAA,EACP,SAAS;AAAA,EACT,OAAO;AAAA,EACP,UAAU;AAAA,EACV,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,SAAS;AAAA,EACT,WAAW;AAAA,EACX,WAAW;AAAA,EACX,UAAU;AAAA,EACV,SAAS;AAAA,EACT,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,gBAAgB;AAAA,EAChB,gBAAgB;AAAA,EAChB,cAAc;AAAA,EACd,UAAU;AAAA,EACV,aAAa;AAAA,EACb,SAAS;AAAA,EACT,UAAU;AAAA,EACV,iBAAiB;AAAA,EACjB,YAAY;AACd;;;AC5DA,mBAAiD;AA+DzC;AApDR,IAAM,aAAa;AACnB,IAAM,WAAW;AACjB,IAAM,YAAY;AAClB,IAAM,cAAc;AAGpB,IAAM,OAAsB,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,IAAI,UAAU,KAAK,YAAY,UAAU;AACtH,IAAM,OAAsB,EAAE,SAAS,QAAQ,YAAY,YAAY,KAAK,IAAI,UAAU,OAAO;AACjG,IAAM,UAAyB,EAAE,WAAW,EAAE;AAC9C,IAAM,eAA8B,EAAE,UAAU,IAAI,YAAY,KAAK,cAAc,EAAE;AACrF,IAAM,QAAuB,EAAE,UAAU,IAAI,SAAS,KAAK;AAC3D,IAAM,QAAuB,EAAE,gBAAgB,YAAY,OAAO,OAAO;AACzE,IAAM,KAAoB,EAAE,WAAW,QAAQ,SAAS,kBAAkB,YAAY,KAAK,UAAU,IAAI,SAAS,KAAK,cAAc,oCAAoC;AACzK,IAAM,KAAoB,EAAE,SAAS,kBAAkB,UAAU,IAAI,cAAc,qCAAqC;AACxH,IAAM,OAAsB,EAAE,SAAS,YAAY,cAAc,GAAG,QAAQ,mCAAmC,YAAY,0BAA0B,UAAU,GAAG;AAClK,IAAM,QAAuB,EAAE,SAAS,gBAAgB,SAAS,WAAW,cAAc,GAAG,UAAU,IAAI,YAAY,IAAI;AAC3H,IAAM,UAAyB,EAAE,SAAS,QAAQ,KAAK,IAAI,UAAU,OAAO;AAC5E,IAAM,UAAyB,EAAE,GAAG,MAAM,MAAM,GAAG,UAAU,KAAK,WAAW,SAAkB;AAC/F,IAAM,UAAyB,EAAE,QAAQ,WAAW,YAAY,QAAQ,QAAQ,QAAQ,UAAU,IAAI,SAAS,QAAQ;AACvH,IAAM,WAA0B,EAAE,QAAQ,UAAU;AACpD,IAAM,WAA0B,EAAE,YAAY,YAAY,WAAW,cAAc,UAAU,IAAI,WAAW,KAAK,UAAU,QAAQ,SAAS,GAAG,cAAc,GAAG,YAAY,0BAA0B,QAAQ,mCAAmC;AACjP,IAAM,YAA2B,EAAE,UAAU,IAAI,OAAO,QAAQ,YAAY,QAAQ,QAAQ,kBAAkB,cAAc,GAAG,SAAS,WAAW,QAAQ,UAAU;AAO9J,SAAS,WAAgD;AAC9D,QAAM,CAAC,OAAO,QAAQ,QAAI,uBAAqB,EAAE,SAAS,MAAM,OAAO,KAAK,CAAC;AAC7E,QAAM,CAAC,MAAM,OAAO,QAAI,uBAAS,CAAC;AAClC,QAAM,aAAS,0BAAY,MAAM,QAAQ,CAAC,MAAM,IAAI,CAAC,GAAG,CAAC,CAAC;AAC1D,8BAAU,MAAM;AACd,UAAM,IAAI,IAAI,gBAAgB;AAC9B,aAAS,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,KAAK,EAAE;AACvC,UAAM,YAAY,EAAE,QAAQ,EAAE,OAAO,CAAC,EACnC,KAAK,OAAO,MAAM;AACjB,UAAI,CAAC,EAAE,GAAI,OAAM,IAAI,MAAM,OAAO,EAAE,MAAM,CAAC;AAC3C,aAAO,EAAE,KAAK;AAAA,IAChB,CAAC,EACA,KAAK,CAAC,YAAY;AAAE,UAAI,CAAC,EAAE,OAAO,QAAS,UAAS,EAAE,SAAS,OAAO,KAAK,CAAC;AAAA,IAAE,CAAC,EAC/E,MAAM,CAAC,MAAe;AAAE,UAAI,CAAC,EAAE,OAAO,QAAS,UAAS,EAAE,SAAS,MAAM,OAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,EAAE,CAAC;AAAA,IAAE,CAAC;AAClI,WAAO,MAAM,EAAE,MAAM;AAAA,EACvB,GAAG,CAAC,IAAI,CAAC;AACT,SAAO,EAAE,GAAG,OAAO,OAAO;AAC5B;AAGA,SAAS,MAAM,EAAE,QAAQ,OAAO,GAAoD;AAClF,SACE,4CAAC,UACE,WAAC,GAAG,GAAG,GAAG,GAAG,CAAC,EAAE,IAAI,CAAC,MACpB,4CAAC,YAAe,MAAK,UAAS,OAAO,SAAS,SAAS,MAAM,OAAO,CAAC,GAAG,OAAO,GAAG,CAAC,MAChF,eAAK,SAAS,WAAM,YADV,CAEb,CACD,GACH;AAEJ;AAGA,SAAS,YAAY,EAAE,SAAS,EAAE,GAAkD;AAClF,MAAI,QAAQ,WAAW,EAAG,QAAO;AACjC,SACE,6CAAC,SAAI,OAAO,SACV;AAAA,iDAAC,SAAI,OAAO,cAAc;AAAA;AAAA,MAAI,EAAE,aAAa;AAAA,OAAE;AAAA,IAC/C,6CAAC,WAAM,OAAO,OACZ;AAAA,kDAAC,WACC,uDAAC,QACC;AAAA,oDAAC,QAAG,OAAO,IAAI,eAAC;AAAA,QAChB,4CAAC,QAAG,OAAO,IAAK,YAAE,OAAO,GAAE;AAAA,QAC3B,4CAAC,QAAG,OAAO,IAAI,iBAAG;AAAA,QAClB,4CAAC,QAAG,OAAO,IAAK,YAAE,MAAM,GAAE;AAAA,QAC1B,4CAAC,QAAG,OAAO,IAAK,YAAE,WAAW,GAAE;AAAA,QAC/B,4CAAC,QAAG,OAAO,IAAK,YAAE,MAAM,GAAE;AAAA,SAC5B,GACF;AAAA,MACA,4CAAC,WACE,kBAAQ,IAAI,CAAC,GAAG,MACf,6CAAC,QACC;AAAA,oDAAC,QAAG,OAAO,IAAK,cAAI,GAAE;AAAA,QACtB,4CAAC,QAAG,OAAO,IAAI,sDAAC,YAAQ,YAAE,OAAM,GAAS;AAAA,QACzC,4CAAC,QAAG,OAAO,IAAK,YAAE,KAAI;AAAA,QACtB,4CAAC,QAAG,OAAO,IAAK,YAAE,MAAK;AAAA,QACvB,4CAAC,QAAG,OAAO,IAAK,YAAE,YAAY,IAAI,EAAE,UAAU,QAAQ,CAAC,IAAI,UAAI;AAAA,QAC/D,4CAAC,QAAG,OAAO,IAAK,YAAE,WAAU;AAAA,WANrB,EAAE,KAOX,CACD,GACH;AAAA,OACF;AAAA,KACF;AAEJ;AAGA,SAAS,aAAa,EAAE,OAAO,EAAE,GAAwC;AACvE,SACE,6CAAC,SAAI,OAAO,SACV;AAAA,iDAAC,SAAI,OAAO,SAAS;AAAA,kDAAC,SAAI,OAAO,EAAE,UAAU,IAAI,YAAY,IAAI,GAAI,gBAAM,WAAU;AAAA,MAAM,4CAAC,SAAI,OAAO,OAAQ,YAAE,WAAW,GAAE;AAAA,OAAM;AAAA,IACpI,6CAAC,SAAI,OAAO,SAAS;AAAA,kDAAC,SAAI,OAAO,EAAE,UAAU,IAAI,YAAY,IAAI,GAAI,gBAAM,cAAa;AAAA,MAAM,4CAAC,SAAI,OAAO,OAAQ,YAAE,cAAc,GAAE;AAAA,OAAM;AAAA,IAC1I,6CAAC,SAAI,OAAO,SAAS;AAAA,kDAAC,SAAI,OAAO,EAAE,UAAU,IAAI,YAAY,IAAI,GAAI,gBAAM,cAAa;AAAA,MAAM,4CAAC,SAAI,OAAO,OAAQ,YAAE,cAAc,GAAE;AAAA,OAAM;AAAA,KAC5I;AAEJ;AAGA,SAAS,UAAU,EAAE,OAAO,GAAG,SAAS,GAA0D;AAChG,QAAM,CAAC,KAAK,MAAM,QAAI,uBAA0B,IAAI;AACpD,QAAM,CAAC,SAAS,UAAU,QAAI,uBAAS,IAAI;AAC3C,8BAAU,MAAM;AACd,eAAW,IAAI;AACf,UAAM,GAAG,QAAQ,OAAO,KAAK,EAAE,EAC5B,KAAK,CAAC,MAAM,EAAE,KAAK,CAAsB,EACzC,KAAK,MAAM,EACX,MAAM,MAAM,OAAO,IAAI,CAAC,EACxB,QAAQ,MAAM,WAAW,KAAK,CAAC;AAAA,EACpC,GAAG,CAAC,KAAK,CAAC;AAEV,QAAM,iBAAa,0BAAY,CAAC,OAAe,WAAmB;AAChE,UAAM,WAAW;AAAA,MACf,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,KAAK,UAAU,EAAE,OAAO,OAAO,OAAO,CAAC;AAAA,IAC/C,CAAC,EACE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAsB,EACzC,KAAK,MAAM,EACX,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EACnB,GAAG,CAAC,KAAK,CAAC;AAEV,QAAM,mBAAe,0BAAY,MAAM;AACrC,QAAI,CAAC,QAAQ,EAAE,eAAe,CAAC,EAAG;AAClC,UAAM,aAAa;AAAA,MACjB,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,KAAK,UAAU,EAAE,MAAM,CAAC;AAAA,IAChC,CAAC,EAAE,KAAK,MAAM,SAAS,CAAC,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EAC1C,GAAG,CAAC,OAAO,GAAG,QAAQ,CAAC;AAEvB,MAAI,QAAS,QAAO,4CAAC,OAAE,OAAO,OAAQ,YAAE,SAAS,GAAE;AACnD,MAAI,CAAC,IAAK,QAAO,4CAAC,OAAE,OAAO,OAAQ,YAAE,UAAU,GAAE;AACjD,SACE,6CAAC,SAAI,OAAO,EAAE,GAAG,MAAM,WAAW,EAAE,GAClC;AAAA,iDAAC,SAAI,OAAO,EAAE,SAAS,QAAQ,gBAAgB,iBAAiB,YAAY,UAAU,cAAc,EAAE,GACpG;AAAA,mDAAC,YAAQ;AAAA,UAAE,QAAQ;AAAA,QAAE;AAAA,SAAE;AAAA,MACvB,4CAAC,YAAO,MAAK,UAAS,OAAO,WAAW,SAAS,cAAe,YAAE,QAAQ,GAAE;AAAA,OAC9E;AAAA,IACA,4CAAC,SAAI,OAAO,UAAW,cAAI,QAAO;AAAA,IACjC,IAAI,aACH,6CAAC,SAAI,OAAO,EAAE,WAAW,EAAE,GACzB;AAAA,mDAAC,SAAI,OAAO,EAAE,UAAU,IAAI,YAAY,KAAK,cAAc,EAAE,GAAI;AAAA,UAAE,WAAW;AAAA,QAAE;AAAA,SAAC;AAAA,MACjF,4CAAC,SAAI,OAAO,UAAW,cAAI,WAAU;AAAA,OACvC;AAAA,IAEF,4CAAC,SAAI,OAAO,EAAE,WAAW,EAAE,GACxB,cAAI,QAAQ,IAAI,CAAC,MAChB,6CAAC,SAAkB,OAAO,EAAE,GAAG,MAAM,WAAW,EAAE,GAChD;AAAA,mDAAC,SAAI,OAAO,EAAE,SAAS,QAAQ,gBAAgB,iBAAiB,YAAY,SAAS,GACnF;AAAA,oDAAC,YAAQ,YAAE,OAAM;AAAA,QACjB,4CAAC,UACE,YAAE,QACD,4CAAC,UAAK,OAAO,EAAE,GAAG,OAAO,YAAY,wBAAwB,OAAO,OAAO,GAAI,YAAE,OAAO,GAAE,IAE1F,6CAAC,UAAK,OAAO,EAAE,GAAG,OAAO,YAAY,yBAAyB,OAAO,OAAO,GAAI;AAAA,YAAE;AAAA,UAAU;AAAA,WAAE,GAElG;AAAA,SACF;AAAA,MACA,4CAAC,SAAI,OAAO,UAAW,YAAE,SAAS,EAAE,QAAO;AAAA,MAC3C,6CAAC,SAAI,OAAO,EAAE,SAAS,QAAQ,gBAAgB,iBAAiB,YAAY,UAAU,WAAW,EAAE,GACjG;AAAA,qDAAC,UAAK,OAAO,OAAQ;AAAA,YAAE,eAAe,EAAE;AAAA,UAAiB;AAAA,UAAU,EAAE;AAAA,UAAa;AAAA,UAAG,EAAE;AAAA,UAAiB;AAAA,WAAE;AAAA,QAC1G,4CAAC,SAAM,QAAQ,EAAE,UAAU,GAAG,QAAQ,CAAC,MAAM,WAAW,EAAE,OAAO,CAAC,GAAG;AAAA,SACvE;AAAA,SAfQ,EAAE,KAgBZ,CACD,GACH;AAAA,KACF;AAEJ;AAGO,SAAS,WAAW,EAAE,EAAE,GAAe;AAC5C,QAAM,EAAE,SAAS,OAAO,OAAO,IAAI,SAAS;AAC5C,QAAM,CAAC,aAAa,cAAc,QAAI,uBAAwB,IAAI;AAElE,QAAM,SACJ,6CAAC,YAAO,OAAO,MACb;AAAA,iDAAC,YAAO,OAAO,EAAE,UAAU,GAAG,GAAG;AAAA;AAAA,MAAI,EAAE,OAAO;AAAA,OAAE;AAAA,IAChD,4CAAC,UAAK,OAAO,EAAE,MAAM,EAAE,GAAG;AAAA,IAC1B,4CAAC,YAAO,MAAK,UAAS,SAAS,QAAQ,OAAO,EAAE,UAAU,GAAG,GAAI,YAAE,SAAS,GAAE;AAAA,KAChF;AAEF,MAAI,UAAU,MAAM;AAClB,WACE,6CAAC,SAAI,OAAO,MACT;AAAA;AAAA,MACD,6CAAC,OAAE,MAAK,SAAQ,OAAO,EAAE,QAAQ,GAAG,UAAU,GAAG,GAAI;AAAA,UAAE,QAAQ;AAAA,QAAE;AAAA,QAAG;AAAA,SAAM;AAAA,MAC1E,4CAAC,YAAO,MAAK,UAAS,SAAS,QAAQ,OAAO,EAAE,WAAW,cAAc,UAAU,GAAG,GAAI,YAAE,OAAO,GAAE;AAAA,OACvG;AAAA,EAEJ;AACA,MAAI,YAAY,KAAM,QAAO,4CAAC,OAAE,OAAO,OAAO,aAAU,UAAU,YAAE,SAAS,GAAE;AAC/E,SACE,6CAAC,SAAI,OAAO,MACT;AAAA;AAAA,IAGD,4CAAC,gBAAa,OAAO,QAAQ,OAAO,GAAM;AAAA,IAG1C,4CAAC,eAAY,SAAS,QAAQ,aAAa,GAAM;AAAA,IAGjD,6CAAC,SAAI,OAAO,SACV;AAAA,mDAAC,SAAI,OAAO,cAAc;AAAA;AAAA,QAAI,EAAE,YAAY;AAAA,SAAE;AAAA,MAC7C,QAAQ,WAAW,WAAW,IAC7B,4CAAC,OAAE,OAAO,EAAE,QAAQ,GAAG,UAAU,IAAI,SAAS,IAAI,GAAI,YAAE,OAAO,GAAE,IAEjE,6CAAC,WAAM,OAAO,OACZ;AAAA,oDAAC,WACC,uDAAC,QAAG;AAAA,sDAAC,QAAG,OAAO,IAAK,YAAE,KAAK,GAAE;AAAA,UAAK,4CAAC,QAAG,OAAO,IAAK,YAAE,QAAQ,GAAE;AAAA,UAAK,4CAAC,QAAG,OAAO,IAAK,YAAE,QAAQ,GAAE;AAAA,UAAK,4CAAC,QAAG,OAAO,IAAK,YAAE,MAAM,GAAE;AAAA,WAAK,GACrI;AAAA,QACA,4CAAC,WACE,kBAAQ,WAAW,IAAI,CAAC,QACvB,4EACE;AAAA,uDAAC,QAAgB,OAAO,UAAU,SAAS,MAAM,eAAe,gBAAgB,IAAI,KAAK,OAAO,IAAI,EAAE,GACpG;AAAA,yDAAC,QAAG,OAAO,IACT;AAAA,0DAAC,UAAK,OAAO,EAAE,UAAU,GAAG,GAAI,cAAI,IAAG;AAAA,cACvC,4CAAC,UAAK,OAAO,EAAE,YAAY,GAAG,UAAU,GAAG,GAAI,0BAAgB,IAAI,KAAK,WAAM,UAAI;AAAA,eACpF;AAAA,YACA,4CAAC,QAAG,OAAO,IAAK,cAAI,YAAW;AAAA,YAC/B,4CAAC,QAAG,OAAO,IAAK,cAAI,UAAU,UAAI;AAAA,YAClC,4CAAC,QAAG,OAAO,IAAK,cAAI,KAAK,IAAI,SAAS,EAAE,eAAe,GAAE;AAAA,eAPlD,IAAI,EAQb;AAAA,UACC,gBAAgB,IAAI,MACnB,4CAAC,QACC,sDAAC,QAAG,SAAS,GAAG,OAAO,EAAE,SAAS,QAAQ,GACxC,sDAAC,aAAU,OAAO,IAAI,IAAI,GAAM,UAAU,MAAM;AAAE,2BAAe,IAAI;AAAG,mBAAO;AAAA,UAAE,GAAG,GACtF,KAHO,GAAG,IAAI,EAAE,SAIlB;AAAA,WAEJ,CACD,GACH;AAAA,SACF;AAAA,OAEJ;AAAA,KACF;AAEJ;;;AF7PO,IAAM,SAAS,CAAC,SAAS,QAAQ;AAmBjC,SAAS,MAAM,KAA0B;AAE9C,MAAI,OAAO,MAAM,IAAI,OAAO,SAAS,IAAI,EAAE,IAAI,GAAG,CAAC,GAAG,+BAA+B;AAErF,MAAI,MAAM,OAAO,oBAAoB,MAAM,IAAI,MAAM;AAAA,IACnD;AAAA,MACE,MAAM;AAAA,MACN,IAAI;AAAA,MACJ,OAAO;AAAA,MACP,OAAO,MAAM,IAAI,OAAO,KAAK,EAAE,EAAE,KAAK;AAAA,MACtC,QAAQ;AAAA,IACV;AAAA,IACA;AAAA,EACF,CAAC;AACH;",
6
6
  "names": []
7
7
  }
@@ -15,6 +15,7 @@ export declare const zh: {
15
15
  title: string;
16
16
  run: string;
17
17
  models: string;
18
+ model: string;
18
19
  date: string;
19
20
  prompt: string;
20
21
  empty: string;
@@ -22,6 +23,20 @@ export declare const zh: {
22
23
  loading: string;
23
24
  failed: string;
24
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;
25
40
  };
26
41
  /** English dictionary, checked complete against the zh key set. */
27
42
  export declare const en: typeof zh;
@@ -15,6 +15,7 @@ export const zh = {
15
15
  'title': '模型竞技场',
16
16
  'run': '运行',
17
17
  'models': '模型数',
18
+ 'model': '模型',
18
19
  'date': '时间',
19
20
  'prompt': '提示词',
20
21
  'empty': '还没有运行记录。用同一个提示词跑多个模型后,结果会显示在这里。',
@@ -22,6 +23,20 @@ export const zh = {
22
23
  'loading': '正在加载…',
23
24
  'failed': '加载失败',
24
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': '未找到运行记录',
25
40
  };
26
41
  /** English dictionary, checked complete against the zh key set. */
27
42
  export const en = {
@@ -29,6 +44,7 @@ export const en = {
29
44
  'title': 'Model Arena',
30
45
  'run': 'Run',
31
46
  'models': 'Models',
47
+ 'model': 'Model',
32
48
  'date': 'Date',
33
49
  'prompt': 'Prompt',
34
50
  'empty': 'No runs yet. Run one prompt through several models and the comparison appears here.',
@@ -36,4 +52,18 @@ export const en = {
36
52
  'loading': 'Loading…',
37
53
  'failed': 'Failed to load',
38
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',
39
69
  };
@@ -9,17 +9,14 @@
9
9
  * @module client/view
10
10
  */
11
11
  import type { PanelPayload } from '../types.js';
12
- /** The translate seat the renderer binds from this plugin's locale namespace. */
13
12
  export type Translate = (key: string, params?: Record<string, unknown>) => string;
14
13
  export interface PanelProps {
15
- /** Bound translate function for this plugin's namespace. */
16
14
  t: Translate;
17
15
  }
18
16
  interface PanelState {
19
17
  payload: PanelPayload | null;
20
18
  error: string | null;
21
19
  }
22
- /** Fetch the host panel payload; `reload` re-runs the request. */
23
20
  export declare function usePanel(): PanelState & {
24
21
  reload: () => void;
25
22
  };
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * Pure rendering half of the modelArena page.
4
4
  *
@@ -10,48 +10,108 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  * @module client/view
11
11
  */
12
12
  import { useCallback, useEffect, useState } from 'react';
13
- /** Panel route registered by the host half on the web connection. */
14
- const PANEL_PATH = "/api/arena.panel";
15
- const wrap = { display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 760, fontFamily: 'inherit' };
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' };
16
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 };
17
22
  const muted = { fontSize: 12, opacity: 0.75 };
18
23
  const table = { borderCollapse: 'collapse', width: '100%' };
19
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)' };
20
25
  const td = { padding: '6px 10px 6px 0', fontSize: 13, borderBottom: '0.5px solid rgba(128,128,128,0.18)' };
21
- /** Fetch the host panel payload; `reload` re-runs the request. */
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' };
22
34
  export function usePanel() {
23
35
  const [state, setState] = useState({ payload: null, error: null });
24
36
  const [tick, setTick] = useState(0);
25
- const reload = useCallback(() => setTick((value) => value + 1), []);
37
+ const reload = useCallback(() => setTick((v) => v + 1), []);
26
38
  useEffect(() => {
27
- const controller = new AbortController();
28
- setState((previous) => ({ ...previous, error: null }));
29
- fetch(PANEL_PATH, { signal: controller.signal })
30
- .then(async (response) => {
31
- if (!response.ok)
32
- throw new Error(String(response.status));
33
- return response.json();
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();
34
46
  })
35
- .then((payload) => {
36
- if (!controller.signal.aborted)
37
- setState({ payload, error: null });
38
- })
39
- .catch((cause) => {
40
- if (controller.signal.aborted)
41
- return;
42
- setState({ payload: null, error: cause instanceof Error ? cause.message : String(cause) });
43
- });
44
- return () => controller.abort();
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();
45
52
  }, [tick]);
46
53
  return { ...state, reload };
47
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 ---
48
107
  export function ArenaPanel({ t }) {
49
108
  const { payload, error, reload } = usePanel();
50
- const header = (_jsxs("header", { style: head, children: [_jsx("strong", { style: { fontSize: 13 }, children: t('title') }), _jsx("span", { style: { flex: 1 } }), _jsx("button", { type: "button", onClick: reload, style: { fontSize: 12 }, children: t('refresh') })] }));
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') })] }));
51
111
  if (error !== null) {
52
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') })] }));
53
113
  }
54
114
  if (payload === null)
55
115
  return _jsx("p", { style: muted, "aria-live": "polite", children: t('loading') });
56
- return (_jsxs("div", { style: wrap, children: [header, 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('date') }), _jsx("th", { style: th, children: t('prompt') })] }) }), _jsx("tbody", { children: payload.recentRuns.map((row) => (_jsxs("tr", { children: [_jsx("td", { style: td, children: _jsx("code", { style: { fontSize: 11 }, children: row.id }) }), _jsx("td", { style: td, children: row.modelCount }), _jsx("td", { style: td, children: new Date(row.timestamp).toLocaleString() }), _jsx("td", { style: td, children: row.prompt.length > 60 ? `${row.prompt.slice(0, 60)}…` : row.prompt })] }, row.id))) })] }))] }));
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`))] }))) })] }))] })] }));
57
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
  }
package/lib/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
1
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  export class ArenaStore {
@@ -40,6 +40,13 @@ export class ArenaStore {
40
40
  return null;
41
41
  return JSON.parse(readFileSync(path, 'utf8'));
42
42
  }
43
+ deleteRun(id) {
44
+ const path = join(this.arenaDir, `${id}.json`);
45
+ if (!existsSync(path))
46
+ return false;
47
+ unlinkSync(path);
48
+ return true;
49
+ }
43
50
  static generateId(prompt, models) {
44
51
  const hash = createHash('sha256');
45
52
  hash.update(prompt);
package/lib/types.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface ModelResult {
5
5
  completionTokens: number;
6
6
  latencyMs: number;
7
7
  error?: string;
8
+ rating?: number;
8
9
  }
9
10
  export interface Score {
10
11
  userRating?: number;
@@ -18,6 +19,7 @@ export interface ArenaRun {
18
19
  timestamp: number;
19
20
  results: ModelResult[];
20
21
  scores: Record<string, Score>;
22
+ reference?: string | undefined;
21
23
  }
22
24
  export interface Suite {
23
25
  name: string;
@@ -36,11 +38,53 @@ export interface RegressionReport {
36
38
  currentOutput: string;
37
39
  }[];
38
40
  }
39
- export interface PanelPayload {
40
- recentRuns: {
41
- id: string;
42
- prompt: string;
43
- timestamp: number;
44
- modelCount: number;
41
+ export interface LeaderboardEntry {
42
+ model: string;
43
+ elo: number;
44
+ wins: number;
45
+ losses: number;
46
+ draws: number;
47
+ games: number;
48
+ totalRuns: number;
49
+ avgRating: number;
50
+ avgLatencyMs: number;
51
+ avgTokens: number;
52
+ }
53
+ export interface RunSummary {
54
+ id: string;
55
+ prompt: string;
56
+ timestamp: number;
57
+ modelCount: number;
58
+ winner?: string | undefined;
59
+ }
60
+ export interface ComparePayload {
61
+ runId: string;
62
+ modelA: string;
63
+ modelB: string;
64
+ outputA: string;
65
+ outputB: string;
66
+ diff: {
67
+ type: 'same' | 'added' | 'removed';
68
+ text: string;
45
69
  }[];
70
+ summary: {
71
+ added: number;
72
+ removed: number;
73
+ changed: number;
74
+ };
75
+ scoreA: Score;
76
+ scoreB: Score;
77
+ }
78
+ export interface ArenaStats {
79
+ totalRuns: number;
80
+ totalModels: number;
81
+ uniqueModels: number;
82
+ uniquePrompts: number;
83
+ totalRatings: number;
84
+ avgModelsPerRun: number;
85
+ }
86
+ export interface PanelPayload {
87
+ recentRuns: RunSummary[];
88
+ leaderboard: LeaderboardEntry[];
89
+ stats: ArenaStats;
46
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-arena",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Run the same prompt through every model you have, and see the difference without squinting.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",