@oddessentials/agent-guild 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/manager/main.mjs +3 -0
- package/src/manager/model-stats.mjs +338 -0
- package/src/manager/server.mjs +4 -0
- package/web/app.js +486 -1
- package/web/art/art.css +44 -0
- package/web/index.html +44 -1
- package/web/styles.css +138 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/manager/main.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { ProviderRegistry } from './providers.mjs';
|
|
8
8
|
import { SessionManager } from './session-manager.mjs';
|
|
9
9
|
import { UsageMonitor } from './usage.mjs';
|
|
10
|
+
import { ModelStats } from './model-stats.mjs';
|
|
10
11
|
import { createManagerServer } from './server.mjs';
|
|
11
12
|
import { resolveBaseEnv, pathReader } from './shell-env.mjs';
|
|
12
13
|
import { writeReportShims } from './report-shims.mjs';
|
|
@@ -52,6 +53,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
52
53
|
let api;
|
|
53
54
|
const manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir });
|
|
54
55
|
const usage = new UsageMonitor({ registry, env: baseEnv });
|
|
56
|
+
const modelStats = new ModelStats({ registry });
|
|
55
57
|
let closing = null;
|
|
56
58
|
|
|
57
59
|
const versionTimer = setInterval(() => registry.refreshVersions().catch(() => {}), VERSION_REFRESH_MS);
|
|
@@ -79,6 +81,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
79
81
|
manager,
|
|
80
82
|
registry,
|
|
81
83
|
usage,
|
|
84
|
+
modelStats,
|
|
82
85
|
token,
|
|
83
86
|
host,
|
|
84
87
|
port,
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { toNumber } from './usage.mjs';
|
|
2
|
+
|
|
3
|
+
const CATALOG_URL = 'https://openrouter.ai/api/v1/models';
|
|
4
|
+
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000;
|
|
5
|
+
const RETRY_MS = 10 * 60 * 1000;
|
|
6
|
+
const FETCH_TIMEOUT_MS = 20000;
|
|
7
|
+
const NEW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
8
|
+
const ID_RE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?::[a-z0-9-]+)?$/;
|
|
9
|
+
|
|
10
|
+
const AA = 'Artificial Analysis';
|
|
11
|
+
const DA_MODELS = 'Design Arena · Models';
|
|
12
|
+
const DA_AGENTS = 'Design Arena · Agents';
|
|
13
|
+
|
|
14
|
+
const STATS = [
|
|
15
|
+
{
|
|
16
|
+
id: 'coding', label: 'Coding Index', short: 'Coding', group: AA, index: 'coding_index',
|
|
17
|
+
about: 'How well the model writes and fixes code, scored by Artificial Analysis on coding evaluations such as Terminal-Bench (real tasks in a terminal). When building: working code with fewer retries.',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'intelligence', label: 'Intelligence Index', short: 'Intelligence', group: AA, index: 'intelligence_index',
|
|
21
|
+
about: 'Artificial Analysis\'s overall score across its evaluations of agentic work, coding, scientific reasoning and general knowledge. When building: understanding what you ask, planning changes, solving hard problems.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'agentic', label: 'Agentic Index', short: 'Agentic', group: AA, index: 'agentic_index',
|
|
25
|
+
about: 'How well the model completes multi-step tasks on its own, using tools until the job is done (Artificial Analysis). When building: longer agent runs that stay on track without your help.',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'overall', label: 'Overall', group: DA_MODELS, arena: 'models', category: 'codecategories',
|
|
29
|
+
about: 'Design Arena\'s combined result for Website, UI Component, Game Dev, Data Visualization and 3D, judged by real users\' votes. When building: overall front-end quality.',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'website', label: 'Website', group: DA_MODELS, arena: 'models', category: 'website',
|
|
33
|
+
about: 'Complete websites built as one HTML file from real users\' prompts; users vote for the better one. When building: landing pages and site layouts.',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'uicomponent', label: 'UI Component', group: DA_MODELS, arena: 'models', category: 'uicomponent',
|
|
37
|
+
about: 'Focused interface components as one HTML file; users vote. When building: individual pieces of an interface.',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'gamedev', label: 'Game Dev', group: DA_MODELS, arena: 'models', category: 'gamedev',
|
|
41
|
+
about: 'Playable browser games as one HTML file; users play them and vote. When building: interactive, game-like front ends.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'dataviz', label: 'Data Visualization', group: DA_MODELS, arena: 'models', category: 'dataviz',
|
|
45
|
+
about: 'Interactive visualizations of provided data as one HTML file; users vote. When building: charts and dashboards.',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: '3d', label: '3D', group: DA_MODELS, arena: 'models', category: '3d',
|
|
49
|
+
about: 'Interactive 3D experiences in the browser as one HTML file; users try them and vote. When building: 3D scenes.',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'svg', label: 'SVG', group: DA_MODELS, arena: 'models', category: 'svg',
|
|
53
|
+
about: 'Scalable vector graphics as SVG markup: icons, illustrations, diagrams. When building: graphics as code.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'webapps', label: 'Web Apps', group: DA_AGENTS, arena: 'agents', category: 'webapps',
|
|
57
|
+
about: 'Multi-file React front ends built by the model as an agent over many steps; users compare results. When building: the multi-file work a coding agent does.',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'fullstack', label: 'Full Stack', group: DA_AGENTS, arena: 'agents', category: 'fullstack',
|
|
61
|
+
about: 'Complete React apps with sign-in, a database and a back end, built by the model as an agent. When building: whole applications, not just pages.',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'mobileapps', label: 'Mobile Apps', group: DA_AGENTS, arena: 'agents', category: 'mobileapps',
|
|
65
|
+
about: 'Cross-platform React Native mobile apps built by the model as an agent; users compare them. When building: mobile apps.',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
const INDEXES = STATS.filter((stat) => stat.index).map((stat) => stat.id);
|
|
69
|
+
|
|
70
|
+
class CatalogError extends Error {}
|
|
71
|
+
|
|
72
|
+
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
73
|
+
const newestFirst = (a, b) => (b.created ?? 0) - (a.created ?? 0) || compare(a.id, b.id);
|
|
74
|
+
const record = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
|
75
|
+
const finite = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
|
|
76
|
+
const isMeasured = (model) => Object.keys(model.values).length > 0;
|
|
77
|
+
|
|
78
|
+
function text(value, max) {
|
|
79
|
+
return typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function strings(value) {
|
|
83
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === 'string' && v).map((v) => v.slice(0, 40)) : [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function perMillion(value) {
|
|
87
|
+
const n = toNumber(value);
|
|
88
|
+
return n === null || n < 0 ? null : Math.round(n * 1e10) / 1e4;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function displayName(name, id) {
|
|
92
|
+
const full = text(name, 120) ?? id;
|
|
93
|
+
const colon = full.indexOf(': ');
|
|
94
|
+
return colon > 0 && colon <= 40 && full.length > colon + 2 ? full.slice(colon + 2) : full;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readValues(benchmarks) {
|
|
98
|
+
const analysis = record(benchmarks.artificial_analysis);
|
|
99
|
+
const arena = Array.isArray(benchmarks.design_arena) ? benchmarks.design_arena.map(record) : [];
|
|
100
|
+
const values = {};
|
|
101
|
+
for (const stat of STATS) {
|
|
102
|
+
if (stat.index) {
|
|
103
|
+
const value = finite(analysis[stat.index]);
|
|
104
|
+
if (value !== null) values[stat.id] = { value, rank: null };
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const rows = arena.filter((row) => row.arena === stat.arena && row.category === stat.category && finite(row.elo) !== null);
|
|
108
|
+
if (rows.length === 1) values[stat.id] = { value: rows[0].elo, rank: finite(rows[0].rank) };
|
|
109
|
+
}
|
|
110
|
+
return values;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseCatalog(payload) {
|
|
114
|
+
const data = Array.isArray(payload?.data) ? payload.data : [];
|
|
115
|
+
const listings = [];
|
|
116
|
+
for (const raw of data) {
|
|
117
|
+
const entry = record(raw);
|
|
118
|
+
const id = typeof entry.id === 'string' ? entry.id : '';
|
|
119
|
+
if (!ID_RE.test(id)) continue;
|
|
120
|
+
const architecture = record(entry.architecture);
|
|
121
|
+
const pricing = record(entry.pricing);
|
|
122
|
+
listings.push({
|
|
123
|
+
id,
|
|
124
|
+
slug: id.slice(id.indexOf('/') + 1),
|
|
125
|
+
canonical: text(entry.canonical_slug, 200) ?? '',
|
|
126
|
+
name: displayName(entry.name, id),
|
|
127
|
+
created: finite(entry.created),
|
|
128
|
+
context: finite(entry.context_length),
|
|
129
|
+
maxOutput: finite(record(entry.top_provider).max_completion_tokens),
|
|
130
|
+
input: strings(architecture.input_modalities),
|
|
131
|
+
text: strings(architecture.output_modalities).includes('text'),
|
|
132
|
+
tools: strings(entry.supported_parameters).includes('tools'),
|
|
133
|
+
reasoning: strings(record(entry.reasoning).supported_efforts),
|
|
134
|
+
price: { input: perMillion(pricing.prompt), output: perMillion(pricing.completion) },
|
|
135
|
+
expires: text(entry.expiration_date, 40),
|
|
136
|
+
values: readValues(record(entry.benchmarks)),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return listings;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function signature(listing) {
|
|
143
|
+
return JSON.stringify(STATS.map((stat) => listing.values[stat.id] ?? null));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function indexCatalog(listings) {
|
|
147
|
+
const byId = new Map();
|
|
148
|
+
for (const listing of listings) byId.set(listing.id, [...(byId.get(listing.id) ?? []), listing]);
|
|
149
|
+
const groups = new Map();
|
|
150
|
+
for (const copies of byId.values()) {
|
|
151
|
+
if (new Set(copies.map(signature)).size > 1) continue;
|
|
152
|
+
const key = copies[0].canonical || copies[0].id;
|
|
153
|
+
groups.set(key, [...(groups.get(key) ?? []), copies[0]]);
|
|
154
|
+
}
|
|
155
|
+
const models = new Map();
|
|
156
|
+
const aliasOf = new Map();
|
|
157
|
+
for (const group of groups.values()) {
|
|
158
|
+
const sets = new Set(group.map(signature)).size === 1 ? [group] : group.map((listing) => [listing]);
|
|
159
|
+
for (const set of sets) {
|
|
160
|
+
const [main] = [...set].sort((a, b) => a.id.includes(':') - b.id.includes(':') || compare(a.id, b.id));
|
|
161
|
+
models.set(main.id, main);
|
|
162
|
+
for (const listing of set) aliasOf.set(listing.id, main.id);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const bySlug = new Map();
|
|
166
|
+
for (const [id, main] of aliasOf) {
|
|
167
|
+
const slug = id.slice(id.indexOf('/') + 1);
|
|
168
|
+
bySlug.set(slug, bySlug.has(slug) && bySlug.get(slug) !== main ? null : main);
|
|
169
|
+
}
|
|
170
|
+
return { models, aliasOf, bySlug };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function tierFor(level) {
|
|
174
|
+
if (level >= 90) return 'S';
|
|
175
|
+
if (level >= 75) return 'A';
|
|
176
|
+
if (level >= 50) return 'B';
|
|
177
|
+
if (level >= 25) return 'C';
|
|
178
|
+
return 'D';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function standing(value, others) {
|
|
182
|
+
if (others.length === 0) return null;
|
|
183
|
+
let above = 0;
|
|
184
|
+
let tied = 0;
|
|
185
|
+
for (const other of others) {
|
|
186
|
+
if (other > value) above++;
|
|
187
|
+
else if (other === value) tied++;
|
|
188
|
+
}
|
|
189
|
+
const of = others.length + 1;
|
|
190
|
+
const level = Math.round((100 * (of - 1 - above - tied / 2)) / (of - 1));
|
|
191
|
+
return { level, tier: tierFor(level), place: 1 + above, tied: tied > 0, of };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function patternFor(provider) {
|
|
195
|
+
if (!provider.modelPattern) return null;
|
|
196
|
+
try {
|
|
197
|
+
return new RegExp(`^(?:${provider.modelPattern})$`, 'i');
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function providerModels(index, provider) {
|
|
204
|
+
const pattern = patternFor(provider);
|
|
205
|
+
if (!pattern) return [];
|
|
206
|
+
return [...index.models.values()].filter((model) => model.text && model.tools && pattern.test(model.slug)).sort(newestFirst);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function modelNames(raw) {
|
|
210
|
+
const exact = String(raw ?? '').trim().toLowerCase()
|
|
211
|
+
.replace(/\[[^\]]*\]$/, '')
|
|
212
|
+
.trim()
|
|
213
|
+
.replace(/\s+/g, '-');
|
|
214
|
+
if (!exact) return [];
|
|
215
|
+
const name = exact.replace(/:[a-z0-9-]+$/, '');
|
|
216
|
+
const undated = name.replace(/(?:-\d{8}|-\d{4}-\d{2}-\d{2}|@\d{8})$/, '');
|
|
217
|
+
const dotted = undated.replace(/(\d+)-(\d+)(?=$|-)/g, '$1.$2');
|
|
218
|
+
return [...new Set([exact, name, undated, dotted])];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function resolveModel(index, related, model) {
|
|
222
|
+
for (const raw of [model?.name, model?.displayName]) {
|
|
223
|
+
const names = modelNames(raw);
|
|
224
|
+
for (const name of names) {
|
|
225
|
+
const id = name.includes('/') ? index.aliasOf.get(name) : index.bySlug.get(name);
|
|
226
|
+
if (id) return id;
|
|
227
|
+
}
|
|
228
|
+
const last = names.at(-1);
|
|
229
|
+
if (!last || !/[a-z]/.test(last)) continue;
|
|
230
|
+
const hits = related.filter((m) => m.slug.slice(m.slug.indexOf('-') + 1) === last);
|
|
231
|
+
if (hits.length === 1) return hits[0].id;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cardFor(model, pool, retrievedAt) {
|
|
237
|
+
const stats = {};
|
|
238
|
+
for (const stat of STATS) {
|
|
239
|
+
const own = model.values[stat.id];
|
|
240
|
+
if (!own) continue;
|
|
241
|
+
const others = pool[stat.id].filter((entry) => entry.id !== model.id).map((entry) => entry.value);
|
|
242
|
+
const place = standing(own.value, others) ?? { level: null, tier: null, place: null, tied: false, of: null };
|
|
243
|
+
stats[stat.id] = { ...place, value: own.value, rank: own.rank };
|
|
244
|
+
}
|
|
245
|
+
const created = model.created === null ? null : model.created * 1000;
|
|
246
|
+
return {
|
|
247
|
+
id: model.id,
|
|
248
|
+
name: model.name,
|
|
249
|
+
created: created === null ? null : new Date(created).toISOString(),
|
|
250
|
+
new: created !== null && Date.parse(retrievedAt) - created < NEW_MS,
|
|
251
|
+
expires: model.expires,
|
|
252
|
+
context: model.context,
|
|
253
|
+
maxOutput: model.maxOutput,
|
|
254
|
+
input: model.input,
|
|
255
|
+
reasoning: model.reasoning,
|
|
256
|
+
price: model.price,
|
|
257
|
+
stats,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function describeCatalog({ index, retrievedAt, stale, error }, providers, sessions = []) {
|
|
262
|
+
const stats = STATS.map((stat) => ({ id: stat.id, label: stat.label, short: stat.short ?? stat.label, group: stat.group, about: stat.about }));
|
|
263
|
+
const result = { retrievedAt, stale, error, stats, pool: null, providers: {}, models: {}, sessions: {} };
|
|
264
|
+
if (!index) return result;
|
|
265
|
+
const related = new Map(providers.map((provider) => [provider.id, providerModels(index, provider)]));
|
|
266
|
+
const listed = new Map([...related].map(([id, models]) => [id, models.filter(isMeasured)]));
|
|
267
|
+
const members = new Map();
|
|
268
|
+
for (const models of listed.values()) for (const model of models) members.set(model.id, model);
|
|
269
|
+
const pool = {};
|
|
270
|
+
for (const stat of STATS) {
|
|
271
|
+
pool[stat.id] = [...members.values()].filter((m) => m.values[stat.id]).map((m) => ({ id: m.id, value: m.values[stat.id].value }));
|
|
272
|
+
}
|
|
273
|
+
result.pool = { tools: providers.filter((p) => listed.get(p.id).length).map((p) => p.tool) };
|
|
274
|
+
const card = (id) => (result.models[id] ??= cardFor(index.models.get(id), pool, retrievedAt));
|
|
275
|
+
for (const provider of providers) {
|
|
276
|
+
const models = listed.get(provider.id);
|
|
277
|
+
if (models.length === 0) continue;
|
|
278
|
+
const featured = models.find((m) => INDEXES.every((stat) => m.values[stat])) ?? models[0];
|
|
279
|
+
result.providers[provider.id] = { featured: featured.id, models: models.map((m) => card(m.id).id) };
|
|
280
|
+
}
|
|
281
|
+
for (const session of sessions) {
|
|
282
|
+
if (!session.model) continue;
|
|
283
|
+
const id = resolveModel(index, related.get(session.provider?.id) ?? [], session.model);
|
|
284
|
+
result.sessions[session.id] = id;
|
|
285
|
+
if (id) card(id);
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function failure(err) {
|
|
291
|
+
if (err instanceof CatalogError) return err.message;
|
|
292
|
+
if (err?.name === 'TimeoutError') return `OpenRouter did not answer within ${FETCH_TIMEOUT_MS / 1000} seconds`;
|
|
293
|
+
if (err instanceof SyntaxError) return 'OpenRouter sent a model list that could not be read';
|
|
294
|
+
return `OpenRouter could not be reached (${err?.cause?.code || err?.message || 'unknown error'})`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export class ModelStats {
|
|
298
|
+
constructor({ registry, fetchImpl = fetch, ttlMs = CATALOG_TTL_MS, retryMs = RETRY_MS } = {}) {
|
|
299
|
+
this.registry = registry;
|
|
300
|
+
this.fetchImpl = fetchImpl;
|
|
301
|
+
this.ttlMs = ttlMs;
|
|
302
|
+
this.retryMs = retryMs;
|
|
303
|
+
this.current = null;
|
|
304
|
+
this.inflight = null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async snapshot(sessions = []) {
|
|
308
|
+
return describeCatalog(await this.catalog(), this.registry.providers, sessions);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
catalog() {
|
|
312
|
+
if (this.inflight) return this.inflight;
|
|
313
|
+
const entry = this.current;
|
|
314
|
+
if (entry && Date.now() - entry.at < (entry.catalog.error ? this.retryMs : this.ttlMs)) return Promise.resolve(entry.catalog);
|
|
315
|
+
this.inflight = this._fetch().then((catalog) => {
|
|
316
|
+
this.current = { catalog, at: Date.now() };
|
|
317
|
+
return catalog;
|
|
318
|
+
}).finally(() => { this.inflight = null; });
|
|
319
|
+
return this.inflight;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async _fetch() {
|
|
323
|
+
try {
|
|
324
|
+
const res = await this.fetchImpl(CATALOG_URL, {
|
|
325
|
+
headers: { Accept: 'application/json', 'User-Agent': 'agent-guild' },
|
|
326
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
327
|
+
});
|
|
328
|
+
if (!res.ok) throw new CatalogError(`OpenRouter answered HTTP ${res.status}`);
|
|
329
|
+
const listings = parseCatalog(await res.json());
|
|
330
|
+
if (listings.length === 0) throw new CatalogError('OpenRouter sent an empty model list');
|
|
331
|
+
return { index: indexCatalog(listings), retrievedAt: new Date().toISOString(), stale: false, error: null };
|
|
332
|
+
} catch (err) {
|
|
333
|
+
const last = this.current?.catalog;
|
|
334
|
+
if (last?.index) return { ...last, stale: true, error: failure(err) };
|
|
335
|
+
return { index: null, retrievedAt: null, stale: false, error: failure(err) };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
package/src/manager/server.mjs
CHANGED
|
@@ -100,6 +100,7 @@ export function createManagerServer({
|
|
|
100
100
|
manager,
|
|
101
101
|
registry,
|
|
102
102
|
usage,
|
|
103
|
+
modelStats,
|
|
103
104
|
token,
|
|
104
105
|
host = '127.0.0.1',
|
|
105
106
|
port = 0,
|
|
@@ -218,6 +219,9 @@ export function createManagerServer({
|
|
|
218
219
|
if (route === '/usage' && method === 'GET') {
|
|
219
220
|
return sendJson(res, 200, { usage: await usage.all() });
|
|
220
221
|
}
|
|
222
|
+
if (route === '/model-stats' && method === 'GET') {
|
|
223
|
+
return sendJson(res, 200, await modelStats.snapshot(manager.list()));
|
|
224
|
+
}
|
|
221
225
|
const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
|
|
222
226
|
if (installMatch && method === 'POST') {
|
|
223
227
|
const body = await readJsonBody(req);
|
package/web/app.js
CHANGED
|
@@ -10,6 +10,8 @@ const state = {
|
|
|
10
10
|
token: null,
|
|
11
11
|
providers: [],
|
|
12
12
|
usage: new Map(),
|
|
13
|
+
stats: null,
|
|
14
|
+
statsFor: new Map(),
|
|
13
15
|
sessions: new Map(),
|
|
14
16
|
views: new Map(),
|
|
15
17
|
activeId: null,
|
|
@@ -185,6 +187,7 @@ function renderProviders() {
|
|
|
185
187
|
renderCopies(node.querySelector('.copies'), provider);
|
|
186
188
|
renderConsoleLinks(node, provider);
|
|
187
189
|
renderUsage(node, provider);
|
|
190
|
+
renderModelStats(node, provider);
|
|
188
191
|
return node;
|
|
189
192
|
}));
|
|
190
193
|
}
|
|
@@ -317,6 +320,440 @@ async function loadUsage() {
|
|
|
317
320
|
}
|
|
318
321
|
}
|
|
319
322
|
|
|
323
|
+
const ARTIFICIAL_ANALYSIS = 'Artificial Analysis';
|
|
324
|
+
const MODELS_SHOWN = 8;
|
|
325
|
+
const modelsView = { providerId: null, sessionId: null, focus: null, all: false, expanded: new Set() };
|
|
326
|
+
let modelsOpener = null;
|
|
327
|
+
let statsLoading = null;
|
|
328
|
+
let statsAgain = false;
|
|
329
|
+
let statsTimer;
|
|
330
|
+
|
|
331
|
+
function modelKey(s) {
|
|
332
|
+
return s.model ? `${s.model.name}\n${s.model.displayName ?? ''}` : '';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function sessionModelId(s) {
|
|
336
|
+
return state.statsFor.get(s.id) === modelKey(s) ? state.stats?.sessions[s.id] ?? null : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function loadStats() {
|
|
340
|
+
if (statsLoading) {
|
|
341
|
+
statsAgain = true;
|
|
342
|
+
return statsLoading;
|
|
343
|
+
}
|
|
344
|
+
const asked = new Map([...state.sessions.values()].map((s) => [s.id, modelKey(s)]));
|
|
345
|
+
statsLoading = api('GET', '/model-stats').then((stats) => {
|
|
346
|
+
state.stats = stats;
|
|
347
|
+
state.statsFor = asked;
|
|
348
|
+
for (const card of $('providers').children) {
|
|
349
|
+
const provider = state.providers.find((p) => p.id === card.dataset.id);
|
|
350
|
+
if (provider) renderModelStats(card, provider);
|
|
351
|
+
}
|
|
352
|
+
renderSessions();
|
|
353
|
+
if ($('models').open) {
|
|
354
|
+
const body = document.querySelector('.models-body');
|
|
355
|
+
const top = body.scrollTop;
|
|
356
|
+
const focus = modelsFocus();
|
|
357
|
+
const withTip = Boolean(tipFor?.closest('#models-list'));
|
|
358
|
+
renderModels();
|
|
359
|
+
body.scrollTop = top;
|
|
360
|
+
restoreModelsFocus(focus, withTip);
|
|
361
|
+
}
|
|
362
|
+
if (tipFor && !tipFor.isConnected) hideTip();
|
|
363
|
+
}, () => {}).finally(() => {
|
|
364
|
+
statsLoading = null;
|
|
365
|
+
if (statsAgain) {
|
|
366
|
+
statsAgain = false;
|
|
367
|
+
loadStats();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
return statsLoading;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function scheduleStats() {
|
|
374
|
+
clearTimeout(statsTimer);
|
|
375
|
+
statsTimer = setTimeout(loadStats, 300);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function indexStats() {
|
|
379
|
+
return state.stats?.stats.filter((stat) => stat.group === ARTIFICIAL_ANALYSIS) ?? [];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function ordinal(n) {
|
|
383
|
+
const suffixes = ['th', 'st', 'nd', 'rd'];
|
|
384
|
+
const v = n % 100;
|
|
385
|
+
return `${n}${suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function listJoin(items) {
|
|
389
|
+
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function formatTokens(n) {
|
|
393
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '—';
|
|
394
|
+
if (n >= 1e6) return `${Number((n / 1e6).toFixed(2))}M`;
|
|
395
|
+
if (n >= 1e3) return `${Math.round(n / 1e3)}K`;
|
|
396
|
+
return String(n);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function formatPrice(n) {
|
|
400
|
+
return `$${Number(n.toFixed(n < 1 ? 3 : 2))}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function formatDate(iso, options = { year: 'numeric', month: 'short', day: 'numeric' }) {
|
|
404
|
+
const date = new Date(iso);
|
|
405
|
+
return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString(undefined, options);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function statValue(stat, entry) {
|
|
409
|
+
return stat.group === ARTIFICIAL_ANALYSIS ? String(entry.value) : `Elo ${entry.value}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function statPlace(entry) {
|
|
413
|
+
return `${entry.tied ? 'tied ' : ''}${ordinal(entry.place)} of ${entry.of}`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function statSummary(stat, entry) {
|
|
417
|
+
if (!entry) return `${stat.label}: no published result`;
|
|
418
|
+
const value = `${stat.label} ${statValue(stat, entry)}`;
|
|
419
|
+
if (entry.level === null) return `${value}: no other model has this result to compare with`;
|
|
420
|
+
const rank = entry.rank === null ? '' : `, Design Arena rank ${entry.rank}`;
|
|
421
|
+
return `${value}: level ${entry.level}, tier ${entry.tier}, ${statPlace(entry)}${rank}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function levelValue(entry) {
|
|
425
|
+
if (!entry) return ['—'];
|
|
426
|
+
if (entry.level === null) return [String(entry.value)];
|
|
427
|
+
const tier = document.createElement('span');
|
|
428
|
+
tier.className = 'stat-tier';
|
|
429
|
+
tier.textContent = entry.tier;
|
|
430
|
+
const level = document.createElement('span');
|
|
431
|
+
level.className = 'level';
|
|
432
|
+
level.textContent = entry.level;
|
|
433
|
+
return [tier, level];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function tierClass(entry) {
|
|
437
|
+
return entry?.tier ? `tier-${entry.tier.toLowerCase()}` : '';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function statRow(stat, card, { detail = false } = {}) {
|
|
441
|
+
const node = $('stat-template').content.firstElementChild.cloneNode(true);
|
|
442
|
+
const entry = card.stats[stat.id];
|
|
443
|
+
node.querySelector('.stat-name').textContent = detail ? stat.label : stat.short;
|
|
444
|
+
const info = node.querySelector('.info');
|
|
445
|
+
info.dataset.stat = stat.id;
|
|
446
|
+
info.setAttribute('aria-label', `${stat.label}: ${stat.about}`);
|
|
447
|
+
const reading = node.querySelector('.stat-reading');
|
|
448
|
+
reading.querySelector('.stat-value').replaceChildren(...levelValue(entry));
|
|
449
|
+
node.classList.toggle('unmeasured', !entry);
|
|
450
|
+
if (entry?.tier) {
|
|
451
|
+
node.classList.add(tierClass(entry));
|
|
452
|
+
reading.querySelector('.stat-fill').style.width = `${entry.level}%`;
|
|
453
|
+
}
|
|
454
|
+
if (detail) {
|
|
455
|
+
const rank = entry?.rank == null ? '' : ` · Rank ${entry.rank}`;
|
|
456
|
+
reading.querySelector('.stat-note').textContent = !entry ? 'No published result'
|
|
457
|
+
: entry.level === null ? statValue(stat, entry) : `${statPlace(entry)} · ${statValue(stat, entry)}${rank}`;
|
|
458
|
+
}
|
|
459
|
+
reading.title = statSummary(stat, entry);
|
|
460
|
+
reading.setAttribute('aria-label', reading.title);
|
|
461
|
+
return node;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
let tipFor = null;
|
|
465
|
+
let quietFocus = false;
|
|
466
|
+
|
|
467
|
+
function showTip(button) {
|
|
468
|
+
const stat = state.stats?.stats.find((s) => s.id === button.dataset.stat);
|
|
469
|
+
if (!stat) return;
|
|
470
|
+
const tip = $('tip');
|
|
471
|
+
$('tip-title').textContent = stat.label;
|
|
472
|
+
$('tip-source').textContent = stat.group;
|
|
473
|
+
$('tip-text').textContent = stat.about;
|
|
474
|
+
const host = $('models').open ? $('models') : document.body;
|
|
475
|
+
if (tip.parentElement !== host) host.append(tip);
|
|
476
|
+
tip.hidden = false;
|
|
477
|
+
const box = button.getBoundingClientRect();
|
|
478
|
+
const left = Math.min(Math.max(8, box.left + box.width / 2 - tip.offsetWidth / 2), innerWidth - tip.offsetWidth - 8);
|
|
479
|
+
const below = box.bottom + 8 + tip.offsetHeight <= innerHeight;
|
|
480
|
+
tip.style.left = `${Math.round(left)}px`;
|
|
481
|
+
tip.style.top = `${Math.round(below ? box.bottom + 8 : box.top - tip.offsetHeight - 8)}px`;
|
|
482
|
+
tipFor = button;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function hideTip() {
|
|
486
|
+
$('tip').hidden = true;
|
|
487
|
+
tipFor = null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function renderModelStats(card, provider) {
|
|
491
|
+
const host = card.querySelector('.model-stats');
|
|
492
|
+
const stats = state.stats;
|
|
493
|
+
const entry = stats?.providers[provider.id];
|
|
494
|
+
if (!entry) {
|
|
495
|
+
if (!stats?.error || !provider.modelPattern) return host.replaceChildren();
|
|
496
|
+
const note = document.createElement('div');
|
|
497
|
+
note.className = 'usage-note';
|
|
498
|
+
note.textContent = `Benchmarks: ${stats.error}`;
|
|
499
|
+
note.title = note.textContent;
|
|
500
|
+
return host.replaceChildren(note);
|
|
501
|
+
}
|
|
502
|
+
const featured = stats.models[entry.featured];
|
|
503
|
+
const count = entry.models.length;
|
|
504
|
+
const complete = indexStats().every((stat) => featured.stats[stat.id]);
|
|
505
|
+
const head = document.createElement('button');
|
|
506
|
+
head.type = 'button';
|
|
507
|
+
head.className = 'model-stats-head';
|
|
508
|
+
head.textContent = `${featured.name} · ${count} model${count === 1 ? '' : 's'}`;
|
|
509
|
+
head.setAttribute('aria-label', head.textContent);
|
|
510
|
+
head.title = `${featured.name} is the newest ${provider.tool} model with ${complete ? 'all three Artificial Analysis indexes' : 'published benchmarks'}. Open to compare ${count === 1 ? 'it' : `all ${count}`}.`;
|
|
511
|
+
head.addEventListener('click', () => showModels({ providerId: provider.id, focus: featured.id }));
|
|
512
|
+
host.replaceChildren(head, ...indexStats().map((stat) => statRow(stat, featured)));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function modelStatsLine(s) {
|
|
516
|
+
const id = sessionModelId(s);
|
|
517
|
+
const card = id ? state.stats.models[id] : null;
|
|
518
|
+
if (!card) return '';
|
|
519
|
+
return indexStats().map((stat) => {
|
|
520
|
+
const entry = card.stats[stat.id];
|
|
521
|
+
return `${stat.short} ${entry?.tier ? `${entry.tier} ${entry.level}` : '—'}`;
|
|
522
|
+
}).join(' · ');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function modelsFocus() {
|
|
526
|
+
const active = document.activeElement;
|
|
527
|
+
const row = active?.closest?.('#models-list .model');
|
|
528
|
+
return row ? { id: row.dataset.id, stat: active.dataset.stat ?? null } : null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function restoreModelsFocus(focus, withTip) {
|
|
532
|
+
const row = focus && [...$('models-list').children].find((el) => el.dataset.id === focus.id);
|
|
533
|
+
if (!row) return;
|
|
534
|
+
const info = focus.stat && row.querySelector(`.info[data-stat="${focus.stat}"]`);
|
|
535
|
+
quietFocus = true;
|
|
536
|
+
(info || row.querySelector('.model-toggle')).focus({ preventScroll: true });
|
|
537
|
+
quietFocus = false;
|
|
538
|
+
if (withTip && info) showTip(info);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function showModels({ providerId = null, sessionId = null, focus = null }) {
|
|
542
|
+
Object.assign(modelsView, { providerId, sessionId, focus, all: false, expanded: new Set(focus ? [focus] : []) });
|
|
543
|
+
const dialog = $('models');
|
|
544
|
+
renderModels();
|
|
545
|
+
if (!dialog.open) {
|
|
546
|
+
modelsOpener = document.activeElement;
|
|
547
|
+
dialog.showModal();
|
|
548
|
+
}
|
|
549
|
+
const toggle = $('models-list').querySelector('[aria-expanded="true"]');
|
|
550
|
+
toggle?.focus();
|
|
551
|
+
toggle?.scrollIntoView({ block: 'nearest' });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function openSessionModel(id) {
|
|
555
|
+
const s = state.sessions.get(id);
|
|
556
|
+
if (!s?.model) return;
|
|
557
|
+
if (!state.stats || state.statsFor.get(id) !== modelKey(s)) scheduleStats();
|
|
558
|
+
showModels({ sessionId: id, focus: sessionModelId(s) });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function unmatchedText(s) {
|
|
562
|
+
const stats = state.stats;
|
|
563
|
+
if (!stats) return 'Loading benchmarks from OpenRouter…';
|
|
564
|
+
if (!stats.pool) return `Benchmarks are unavailable: ${stats.error}.`;
|
|
565
|
+
if (!(s.id in stats.sessions) || state.statsFor.get(s.id) !== modelKey(s)) return 'Looking this model up in OpenRouter’s catalog…';
|
|
566
|
+
const names = [s.model.name, s.model.displayName].filter((name, i, all) => name && all.indexOf(name) === i);
|
|
567
|
+
return `OpenRouter’s catalog has no model named ${names.map((name) => `“${name}”`).join(' or ')}.`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function sourceText(stats, { levels = true } = {}) {
|
|
571
|
+
if (!stats?.retrievedAt) return '';
|
|
572
|
+
const parts = [`Benchmarks from Artificial Analysis and Design Arena via OpenRouter, fetched ${relativeTime(stats.retrievedAt)}.`];
|
|
573
|
+
if (stats.stale) parts.push(`The last refresh failed (${stats.error}), so these results may be out of date.`);
|
|
574
|
+
if (stats.pool && levels) {
|
|
575
|
+
parts.push('Artificial Analysis results are index scores. Design Arena results are Elo ratings from real users\' head-to-head votes, and Rank is the model\'s place on Design Arena\'s own leaderboard.');
|
|
576
|
+
parts.push(`Level 0–100 is a model's standing on each benchmark among the models ${listJoin(stats.pool.tools)} run; 100 is the best result.`);
|
|
577
|
+
parts.push('Tier: S 90+, A 75+, B 50+, C 25+, D below 25.');
|
|
578
|
+
}
|
|
579
|
+
return parts.join(' ');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function usedBy(id) {
|
|
583
|
+
return [...state.sessions.values()]
|
|
584
|
+
.filter((s) => s.status === 'running' && sessionModelId(s) === id)
|
|
585
|
+
.map((s) => s.name);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function badge(kind, label, title) {
|
|
589
|
+
const el = document.createElement('span');
|
|
590
|
+
el.className = `badge ${kind}`;
|
|
591
|
+
el.textContent = label;
|
|
592
|
+
el.title = title;
|
|
593
|
+
return el;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function modelFacts(card) {
|
|
597
|
+
const list = document.createElement('dl');
|
|
598
|
+
list.className = 'model-facts';
|
|
599
|
+
const fact = (term, value) => {
|
|
600
|
+
if (!value) return;
|
|
601
|
+
const row = document.createElement('div');
|
|
602
|
+
const dt = document.createElement('dt');
|
|
603
|
+
const dd = document.createElement('dd');
|
|
604
|
+
dt.textContent = term;
|
|
605
|
+
dd.textContent = value;
|
|
606
|
+
row.append(dt, dd);
|
|
607
|
+
list.append(row);
|
|
608
|
+
};
|
|
609
|
+
fact('Context', card.context ? `${formatTokens(card.context)} tokens` : '');
|
|
610
|
+
fact('Max output', card.maxOutput ? `${formatTokens(card.maxOutput)} tokens` : '');
|
|
611
|
+
fact('Input', card.input.join(', '));
|
|
612
|
+
fact('Reasoning effort', card.reasoning.join(', '));
|
|
613
|
+
if (card.price.input !== null && card.price.output !== null) {
|
|
614
|
+
fact('Price', `${formatPrice(card.price.input)} input · ${formatPrice(card.price.output)} output per 1M tokens`);
|
|
615
|
+
}
|
|
616
|
+
fact('Created', card.created ? formatDate(card.created) : '');
|
|
617
|
+
fact('Expires', card.expires ? formatDate(card.expires, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : '');
|
|
618
|
+
const link = document.createElement('a');
|
|
619
|
+
link.className = 'console-link';
|
|
620
|
+
link.href = httpsHref(`https://openrouter.ai/${card.id}`) ?? '';
|
|
621
|
+
link.target = '_blank';
|
|
622
|
+
link.rel = 'noopener noreferrer';
|
|
623
|
+
link.textContent = 'OpenRouter ↗';
|
|
624
|
+
link.setAttribute('aria-label', `${card.name} on OpenRouter (opens in a new tab)`);
|
|
625
|
+
const facts = document.createElement('div');
|
|
626
|
+
facts.className = 'model-more';
|
|
627
|
+
facts.append(list, link);
|
|
628
|
+
return facts;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function modelDetail(card) {
|
|
632
|
+
const groups = [];
|
|
633
|
+
for (const stat of state.stats.stats) {
|
|
634
|
+
if (groups.at(-1)?.name !== stat.group) groups.push({ name: stat.group, stats: [] });
|
|
635
|
+
groups.at(-1).stats.push(stat);
|
|
636
|
+
}
|
|
637
|
+
const sections = groups.map(({ name, stats }) => {
|
|
638
|
+
const section = document.createElement('div');
|
|
639
|
+
section.className = 'model-group';
|
|
640
|
+
const heading = document.createElement('h4');
|
|
641
|
+
heading.textContent = name;
|
|
642
|
+
section.append(heading);
|
|
643
|
+
if (stats.some((stat) => card.stats[stat.id])) {
|
|
644
|
+
section.append(...stats.map((stat) => statRow(stat, card, { detail: true })));
|
|
645
|
+
} else {
|
|
646
|
+
const none = document.createElement('p');
|
|
647
|
+
none.className = 'model-none';
|
|
648
|
+
none.textContent = 'No published results';
|
|
649
|
+
section.append(none);
|
|
650
|
+
}
|
|
651
|
+
return section;
|
|
652
|
+
});
|
|
653
|
+
return [...sections, modelFacts(card)];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function modelRow(card) {
|
|
657
|
+
const node = $('model-template').content.firstElementChild.cloneNode(true);
|
|
658
|
+
node.dataset.id = card.id;
|
|
659
|
+
const toggle = node.querySelector('.model-toggle');
|
|
660
|
+
const detail = node.querySelector('.model-detail');
|
|
661
|
+
node.querySelector('.model-name').textContent = card.name;
|
|
662
|
+
const users = usedBy(card.id);
|
|
663
|
+
const badges = [];
|
|
664
|
+
if (users.length) badges.push(badge('in-use', 'In use', `Used by ${listJoin(users)}`));
|
|
665
|
+
if (card.new) badges.push(badge('new', 'New', `Created ${formatDate(card.created)}`));
|
|
666
|
+
if (card.expires) {
|
|
667
|
+
const day = formatDate(card.expires, { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
|
668
|
+
badges.push(badge('expires', `Expires ${day}`, `OpenRouter lists this model as expiring on ${formatDate(card.expires, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })}`));
|
|
669
|
+
}
|
|
670
|
+
node.querySelector('.model-title').append(...badges);
|
|
671
|
+
for (const stat of indexStats()) {
|
|
672
|
+
const entry = card.stats[stat.id];
|
|
673
|
+
const cell = document.createElement('span');
|
|
674
|
+
cell.className = ['model-index', tierClass(entry), entry ? '' : 'unmeasured'].filter(Boolean).join(' ');
|
|
675
|
+
cell.replaceChildren(...levelValue(entry));
|
|
676
|
+
cell.title = statSummary(stat, entry);
|
|
677
|
+
toggle.append(cell);
|
|
678
|
+
}
|
|
679
|
+
const context = document.createElement('span');
|
|
680
|
+
context.className = 'model-context';
|
|
681
|
+
context.textContent = formatTokens(card.context);
|
|
682
|
+
context.title = card.context ? `Context: ${card.context.toLocaleString()} tokens` : 'Context: not listed';
|
|
683
|
+
toggle.append(context);
|
|
684
|
+
const summary = indexStats().map((stat) => statSummary(stat, card.stats[stat.id])).join('. ');
|
|
685
|
+
toggle.setAttribute('aria-description', summary);
|
|
686
|
+
const expand = (open) => {
|
|
687
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
688
|
+
if (open && !detail.childElementCount) detail.replaceChildren(...modelDetail(card));
|
|
689
|
+
detail.hidden = !open;
|
|
690
|
+
};
|
|
691
|
+
expand(modelsView.expanded.has(card.id));
|
|
692
|
+
toggle.addEventListener('click', () => {
|
|
693
|
+
const open = toggle.getAttribute('aria-expanded') !== 'true';
|
|
694
|
+
if (open) modelsView.expanded.add(card.id);
|
|
695
|
+
else modelsView.expanded.delete(card.id);
|
|
696
|
+
expand(open);
|
|
697
|
+
});
|
|
698
|
+
return node;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function renderModels() {
|
|
702
|
+
const stats = state.stats;
|
|
703
|
+
const session = modelsView.sessionId ? state.sessions.get(modelsView.sessionId) : null;
|
|
704
|
+
const provider = session?.provider ?? state.providers.find((p) => p.id === modelsView.providerId);
|
|
705
|
+
if (!provider || (modelsView.sessionId && !session?.model)) return $('models').close();
|
|
706
|
+
paintProviderIcon($('models-icon'), provider);
|
|
707
|
+
const list = stats?.providers[provider.id]?.models ?? [];
|
|
708
|
+
let ids = list;
|
|
709
|
+
let title = `${provider.tool} models`;
|
|
710
|
+
let note = '';
|
|
711
|
+
if (session) {
|
|
712
|
+
const id = sessionModelId(session);
|
|
713
|
+
if (id && !modelsView.focus) {
|
|
714
|
+
modelsView.focus = id;
|
|
715
|
+
modelsView.expanded.add(id);
|
|
716
|
+
}
|
|
717
|
+
if (!id) {
|
|
718
|
+
ids = [];
|
|
719
|
+
title = `${provider.tool} · ${modelText(session)}`;
|
|
720
|
+
note = unmatchedText(session);
|
|
721
|
+
} else if (!list.includes(id)) {
|
|
722
|
+
ids = [id];
|
|
723
|
+
title = `${provider.tool} · ${stats.models[id].name}`;
|
|
724
|
+
}
|
|
725
|
+
} else if (!stats) {
|
|
726
|
+
note = 'Loading benchmarks from OpenRouter…';
|
|
727
|
+
} else if (list.length === 0) {
|
|
728
|
+
note = stats.error ? `Benchmarks are unavailable: ${stats.error}.` : `OpenRouter lists no benchmarked ${provider.tool} models.`;
|
|
729
|
+
}
|
|
730
|
+
$('models-title').textContent = title;
|
|
731
|
+
$('models-sub').textContent = ids.length > 1
|
|
732
|
+
? `${ids.length} models with published benchmarks, newest first`
|
|
733
|
+
: session ? `Reported by ${session.name} as “${session.model.name}”` : '';
|
|
734
|
+
const all = modelsView.all || ids.length <= MODELS_SHOWN || ids.indexOf(modelsView.focus) >= MODELS_SHOWN;
|
|
735
|
+
const shown = all ? ids : ids.slice(0, MODELS_SHOWN);
|
|
736
|
+
const columns = $('models-columns');
|
|
737
|
+
columns.hidden = shown.length === 0;
|
|
738
|
+
columns.replaceChildren(...['Model', ...indexStats().map((stat) => stat.short), 'Context'].map((label) => {
|
|
739
|
+
const span = document.createElement('span');
|
|
740
|
+
span.textContent = label;
|
|
741
|
+
return span;
|
|
742
|
+
}));
|
|
743
|
+
$('models-list').replaceChildren(...shown.map((id) => modelRow(stats.models[id])));
|
|
744
|
+
const more = $('models-more');
|
|
745
|
+
more.hidden = all;
|
|
746
|
+
more.textContent = `Show ${ids.length - shown.length} older models`;
|
|
747
|
+
$('models-note').textContent = note;
|
|
748
|
+
$('models-note').hidden = !note;
|
|
749
|
+
$('models-source').textContent = sourceText(stats, { levels: shown.length > 0 });
|
|
750
|
+
$('models-source').hidden = !$('models-source').textContent;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function closeModels() {
|
|
754
|
+
if ($('models').open) $('models').close();
|
|
755
|
+
}
|
|
756
|
+
|
|
320
757
|
const CHANNEL_LABELS = {
|
|
321
758
|
npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
|
|
322
759
|
};
|
|
@@ -433,6 +870,7 @@ function buildCard(session) {
|
|
|
433
870
|
node.querySelector('.stop').addEventListener('click', () => stopSession(session.id));
|
|
434
871
|
node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
|
|
435
872
|
node.querySelector('.rename').addEventListener('click', () => renameSession(session.id));
|
|
873
|
+
node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
|
|
436
874
|
return node;
|
|
437
875
|
}
|
|
438
876
|
|
|
@@ -460,7 +898,8 @@ function updateCard(node, s) {
|
|
|
460
898
|
const model = node.querySelector('.model-pill');
|
|
461
899
|
model.hidden = !s.model;
|
|
462
900
|
model.textContent = modelText(s);
|
|
463
|
-
model.title = modelTitle(s);
|
|
901
|
+
model.title = [modelTitle(s), modelStatsLine(s)].filter(Boolean).join('\n');
|
|
902
|
+
model.setAttribute('aria-label', `Benchmarks for ${modelText(s)}`);
|
|
464
903
|
model.className = `model-pill ${s.model?.source || ''}`;
|
|
465
904
|
const cwd = node.querySelector('.cwd-line');
|
|
466
905
|
// The LRM keeps a leading "/" in place under the right-to-left truncation style.
|
|
@@ -492,6 +931,7 @@ function renderSessions() {
|
|
|
492
931
|
$('session-count').textContent = sessions.length ? `· ${running} running` : '';
|
|
493
932
|
$('empty').hidden = sessions.length > 0;
|
|
494
933
|
if (state.activeId) updatePanel();
|
|
934
|
+
if (state.stats && sessions.some((s) => s.model && state.statsFor.get(s.id) !== modelKey(s))) scheduleStats();
|
|
495
935
|
}
|
|
496
936
|
|
|
497
937
|
function upsertSession(session) {
|
|
@@ -749,6 +1189,7 @@ function enterStopping(running = 0) {
|
|
|
749
1189
|
state.stopping = true;
|
|
750
1190
|
state.stopRemaining = null;
|
|
751
1191
|
closePanel();
|
|
1192
|
+
closeModels();
|
|
752
1193
|
for (const view of state.views.values()) view.dispose();
|
|
753
1194
|
state.views.clear();
|
|
754
1195
|
state.sessions.clear();
|
|
@@ -818,6 +1259,7 @@ function connectEvents() {
|
|
|
818
1259
|
} else if (msg.type === 'providers.updated') {
|
|
819
1260
|
state.providers = msg.providers;
|
|
820
1261
|
renderProviders();
|
|
1262
|
+
scheduleStats();
|
|
821
1263
|
}
|
|
822
1264
|
};
|
|
823
1265
|
ws.onclose = () => {
|
|
@@ -854,8 +1296,10 @@ function readTokenFromHash() {
|
|
|
854
1296
|
}
|
|
855
1297
|
|
|
856
1298
|
let usageTimer;
|
|
1299
|
+
let statsInterval;
|
|
857
1300
|
|
|
858
1301
|
function showAuth(message = '') {
|
|
1302
|
+
closeModels();
|
|
859
1303
|
$('app').hidden = true;
|
|
860
1304
|
$('terminal-panel').hidden = true;
|
|
861
1305
|
$('stopped').hidden = true;
|
|
@@ -885,6 +1329,9 @@ async function boot() {
|
|
|
885
1329
|
loadUsage();
|
|
886
1330
|
clearInterval(usageTimer);
|
|
887
1331
|
usageTimer = setInterval(loadUsage, 60000);
|
|
1332
|
+
loadStats();
|
|
1333
|
+
clearInterval(statsInterval);
|
|
1334
|
+
statsInterval = setInterval(loadStats, 60 * 60 * 1000);
|
|
888
1335
|
}
|
|
889
1336
|
|
|
890
1337
|
$('auth-form').addEventListener('submit', (e) => {
|
|
@@ -895,6 +1342,44 @@ $('auth-form').addEventListener('submit', (e) => {
|
|
|
895
1342
|
boot();
|
|
896
1343
|
});
|
|
897
1344
|
$('panel-close').addEventListener('click', closePanel);
|
|
1345
|
+
$('models-close').addEventListener('click', closeModels);
|
|
1346
|
+
$('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
|
|
1347
|
+
$('models').addEventListener('close', () => {
|
|
1348
|
+
hideTip();
|
|
1349
|
+
const opener = modelsOpener?.isConnected ? modelsOpener
|
|
1350
|
+
: modelsView.sessionId ? cards.get(modelsView.sessionId)?.querySelector('.model-pill')
|
|
1351
|
+
: $('providers').querySelector(`.provider[data-id="${modelsView.providerId}"] .model-stats-head`);
|
|
1352
|
+
opener?.focus();
|
|
1353
|
+
modelsOpener = null;
|
|
1354
|
+
});
|
|
1355
|
+
document.addEventListener('pointerover', (e) => {
|
|
1356
|
+
const info = e.target.closest?.('.info');
|
|
1357
|
+
if (info && e.pointerType !== 'touch') showTip(info);
|
|
1358
|
+
});
|
|
1359
|
+
document.addEventListener('pointerout', (e) => {
|
|
1360
|
+
if (tipFor && e.target === tipFor && document.activeElement !== tipFor) hideTip();
|
|
1361
|
+
});
|
|
1362
|
+
document.addEventListener('pointerdown', (e) => { if (tipFor && !e.target.closest?.('.info')) hideTip(); });
|
|
1363
|
+
document.addEventListener('click', (e) => {
|
|
1364
|
+
const info = e.target.closest?.('.info');
|
|
1365
|
+
if (info) showTip(info);
|
|
1366
|
+
});
|
|
1367
|
+
document.addEventListener('focusin', (e) => { if (!quietFocus && e.target.matches?.('.info')) showTip(e.target); });
|
|
1368
|
+
document.addEventListener('focusout', (e) => { if (e.target === tipFor) hideTip(); });
|
|
1369
|
+
document.addEventListener('keydown', (e) => {
|
|
1370
|
+
if (e.key !== 'Escape' || !tipFor) return;
|
|
1371
|
+
hideTip();
|
|
1372
|
+
e.preventDefault();
|
|
1373
|
+
e.stopPropagation();
|
|
1374
|
+
}, true);
|
|
1375
|
+
addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
|
|
1376
|
+
addEventListener('resize', () => { if (tipFor) hideTip(); });
|
|
1377
|
+
$('models-more').addEventListener('click', () => {
|
|
1378
|
+
const before = $('models-list').childElementCount;
|
|
1379
|
+
modelsView.all = true;
|
|
1380
|
+
renderModels();
|
|
1381
|
+
$('models-list').children[before]?.querySelector('.model-toggle')?.focus();
|
|
1382
|
+
});
|
|
898
1383
|
$('stop-manager').addEventListener('click', () => stopManager());
|
|
899
1384
|
$('panel-stop').addEventListener('click', () => {
|
|
900
1385
|
const s = state.sessions.get(state.activeId);
|
package/web/art/art.css
CHANGED
|
@@ -109,6 +109,50 @@
|
|
|
109
109
|
.meter.low .meter-fill { background: var(--warn); box-shadow: 0 0 8px rgba(224, 168, 74, 0.5); }
|
|
110
110
|
.meter.empty .meter-fill { background: var(--danger); box-shadow: 0 0 8px rgba(239, 107, 102, 0.5); }
|
|
111
111
|
|
|
112
|
+
.model-stats-head { color: var(--gold); }
|
|
113
|
+
.info { color: #b89a5e; }
|
|
114
|
+
.info:hover, .info:focus-visible { color: var(--gold); }
|
|
115
|
+
.tip {
|
|
116
|
+
background: #12141a; color: #e9e2cf; border: 1px solid #4a3d25;
|
|
117
|
+
box-shadow: inset 0 0 0 1px rgba(232, 196, 124, 0.06), 0 10px 28px rgba(0, 0, 0, 0.6);
|
|
118
|
+
}
|
|
119
|
+
.tip strong { color: var(--gold); font-family: "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; }
|
|
120
|
+
.stat-bar { height: 7px; background: #0c0e12; box-shadow: inset 0 0 0 1px rgba(232, 196, 124, 0.18); }
|
|
121
|
+
.stat-fill {
|
|
122
|
+
background: linear-gradient(90deg, color-mix(in srgb, var(--tier) 55%, #000), var(--tier));
|
|
123
|
+
box-shadow: 0 0 8px color-mix(in srgb, var(--tier) 50%, transparent);
|
|
124
|
+
}
|
|
125
|
+
.stat-tier {
|
|
126
|
+
border-radius: 3px; font-family: "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
|
|
127
|
+
background: linear-gradient(color-mix(in srgb, var(--tier) 34%, #1a1d26), #0d0f15);
|
|
128
|
+
border-color: color-mix(in srgb, var(--tier) 70%, #000);
|
|
129
|
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 0 6px color-mix(in srgb, var(--tier) 30%, transparent);
|
|
130
|
+
}
|
|
131
|
+
.tier-s .stat-tier {
|
|
132
|
+
color: #2b1b06; border-color: #f6dea6;
|
|
133
|
+
background: linear-gradient(180deg, #fbe9bd 0%, #e2b867 55%, #a97b35 100%);
|
|
134
|
+
box-shadow: 0 0 10px rgba(232, 196, 124, 0.6);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.models {
|
|
138
|
+
border-radius: 6px; border-color: #4a3d25; background: #12141a;
|
|
139
|
+
box-shadow: inset 0 0 0 1px rgba(232, 196, 124, 0.06), 0 24px 60px rgba(0, 0, 0, 0.6);
|
|
140
|
+
}
|
|
141
|
+
.models::after {
|
|
142
|
+
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none;
|
|
143
|
+
border: 44px solid transparent; border-image: url("/art/ui/frame.png") 168;
|
|
144
|
+
}
|
|
145
|
+
.models::backdrop { background: rgba(5, 6, 9, 0.72); }
|
|
146
|
+
.models-bar { padding: 20px 24px 12px; }
|
|
147
|
+
.models-body { padding: 0 24px 14px; }
|
|
148
|
+
.models-source { padding: 10px 24px 20px; }
|
|
149
|
+
.models-bar, .models-source, .model-columns, .model { border-color: #2a2418; }
|
|
150
|
+
.model-columns { background: #12141a; }
|
|
151
|
+
.model-toggle:hover { background: rgba(232, 196, 124, 0.06); }
|
|
152
|
+
.model-group h4 { color: #c9ab72; }
|
|
153
|
+
.badge { background: rgba(0, 0, 0, 0.45); border-color: #4a3d25; }
|
|
154
|
+
.model-pill:hover { border-color: var(--gold-dim); }
|
|
155
|
+
|
|
112
156
|
.session-card { padding-left: 152px; min-height: 196px; }
|
|
113
157
|
.session-card::before {
|
|
114
158
|
inset: 0 auto 0 0; width: 168px; background-size: 168px auto;
|
package/web/index.html
CHANGED
|
@@ -83,6 +83,26 @@
|
|
|
83
83
|
<div id="terminal-host" class="terminal-host"></div>
|
|
84
84
|
</div>
|
|
85
85
|
|
|
86
|
+
<dialog id="models" class="models" aria-labelledby="models-title">
|
|
87
|
+
<div class="models-bar">
|
|
88
|
+
<span id="models-icon" class="provider-icon small"></span>
|
|
89
|
+
<div class="models-heading">
|
|
90
|
+
<h2 id="models-title"></h2>
|
|
91
|
+
<p id="models-sub" class="sub"></p>
|
|
92
|
+
</div>
|
|
93
|
+
<button id="models-close" class="btn" type="button">Close</button>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="models-body">
|
|
96
|
+
<div id="models-columns" class="model-columns" aria-hidden="true"></div>
|
|
97
|
+
<div id="models-list"></div>
|
|
98
|
+
<button id="models-more" class="btn models-more" type="button" hidden></button>
|
|
99
|
+
<p id="models-note" class="models-note"></p>
|
|
100
|
+
</div>
|
|
101
|
+
<p id="models-source" class="models-source"></p>
|
|
102
|
+
</dialog>
|
|
103
|
+
|
|
104
|
+
<div id="tip" class="tip" aria-hidden="true" hidden><strong id="tip-title"></strong><span id="tip-source"></span><p id="tip-text"></p></div>
|
|
105
|
+
|
|
86
106
|
<div id="toast" class="toast" role="alert" hidden></div>
|
|
87
107
|
|
|
88
108
|
<template id="provider-template">
|
|
@@ -94,6 +114,7 @@
|
|
|
94
114
|
<span class="state"></span>
|
|
95
115
|
</span>
|
|
96
116
|
<div class="usage"></div>
|
|
117
|
+
<div class="model-stats"></div>
|
|
97
118
|
<div class="provider-links">
|
|
98
119
|
<a class="console-link usage-link" target="_blank" rel="noopener noreferrer">Usage ↗</a>
|
|
99
120
|
<a class="console-link billing-link" target="_blank" rel="noopener noreferrer">Billing ↗</a>
|
|
@@ -120,6 +141,28 @@
|
|
|
120
141
|
</div>
|
|
121
142
|
</template>
|
|
122
143
|
|
|
144
|
+
<template id="stat-template">
|
|
145
|
+
<div class="stat">
|
|
146
|
+
<span class="stat-label"><span class="stat-name"></span><button class="info" type="button">i</button></span>
|
|
147
|
+
<span class="stat-reading" role="img">
|
|
148
|
+
<span class="stat-bar"><span class="stat-fill"></span></span>
|
|
149
|
+
<span class="stat-value"></span>
|
|
150
|
+
<span class="stat-note"></span>
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
</template>
|
|
154
|
+
|
|
155
|
+
<template id="model-template">
|
|
156
|
+
<section class="model">
|
|
157
|
+
<h3 class="model-head">
|
|
158
|
+
<button class="model-toggle" type="button" aria-expanded="false">
|
|
159
|
+
<span class="model-title"><span class="model-name"></span></span>
|
|
160
|
+
</button>
|
|
161
|
+
</h3>
|
|
162
|
+
<div class="model-detail" hidden></div>
|
|
163
|
+
</section>
|
|
164
|
+
</template>
|
|
165
|
+
|
|
123
166
|
<template id="session-template">
|
|
124
167
|
<article class="session-card" role="listitem" tabindex="0">
|
|
125
168
|
<span class="level-badge" aria-hidden="true"></span>
|
|
@@ -129,7 +172,7 @@
|
|
|
129
172
|
<div class="name"></div>
|
|
130
173
|
<div class="meta"></div>
|
|
131
174
|
</div>
|
|
132
|
-
<
|
|
175
|
+
<button class="model-pill" type="button"></button>
|
|
133
176
|
<span class="status-pill"></span>
|
|
134
177
|
</div>
|
|
135
178
|
<div class="cwd-line"></div>
|
package/web/styles.css
CHANGED
|
@@ -69,7 +69,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
|
|
|
69
69
|
|
|
70
70
|
.providers { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
|
71
71
|
.provider {
|
|
72
|
-
display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 10px 12px; padding: 14px;
|
|
72
|
+
display: grid; grid-template-columns: auto 1fr; align-items: center; align-content: start; gap: 10px 12px; padding: 14px;
|
|
73
73
|
border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow);
|
|
74
74
|
}
|
|
75
75
|
.provider.unavailable .provider-icon { filter: grayscale(1); opacity: 0.45; }
|
|
@@ -96,6 +96,69 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
|
|
|
96
96
|
.meter.empty .meter-fill { background: var(--danger); }
|
|
97
97
|
.meter-value { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
98
98
|
.usage-note { font-size: 0.75rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
99
|
+
.provider .model-stats {
|
|
100
|
+
grid-column: 1 / -1; display: grid; grid-template-columns: max-content minmax(24px, 1fr) auto; align-items: center; gap: 2px 8px;
|
|
101
|
+
}
|
|
102
|
+
.provider .model-stats:empty { display: none; }
|
|
103
|
+
.provider .model-stats > .usage-note { grid-column: 1 / -1; contain: inline-size; }
|
|
104
|
+
.model-stats-head {
|
|
105
|
+
grid-column: 1 / -1; justify-self: start; max-width: 100%; min-height: 24px; padding: 0; border: 0; background: none; color: var(--accent);
|
|
106
|
+
font: inherit; font-size: 0.78rem; text-align: left; cursor: pointer;
|
|
107
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
108
|
+
}
|
|
109
|
+
.model-stats-head::after { content: " ›"; }
|
|
110
|
+
.model-stats-head:hover { text-decoration: underline; }
|
|
111
|
+
.model-stats-head:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
|
112
|
+
|
|
113
|
+
.stat {
|
|
114
|
+
--tier: var(--border);
|
|
115
|
+
grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center;
|
|
116
|
+
min-height: 24px; font-size: 0.75rem; color: var(--muted);
|
|
117
|
+
}
|
|
118
|
+
.stat-label { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
|
119
|
+
.stat-reading { grid-column: 2 / -1; display: grid; grid-template-columns: subgrid; align-items: center; }
|
|
120
|
+
.info {
|
|
121
|
+
position: relative; flex: none; display: inline-grid; place-items: center; width: 1.3em; height: 1.3em; padding: 0;
|
|
122
|
+
border: 1px solid color-mix(in srgb, currentColor 50%, transparent); border-radius: 50%; background: none; color: var(--muted);
|
|
123
|
+
font: italic 700 0.68rem/1 Georgia, "Times New Roman", serif; cursor: help;
|
|
124
|
+
}
|
|
125
|
+
.info::after { content: ""; position: absolute; inset: -6px; }
|
|
126
|
+
.info:hover, .info:focus-visible { color: var(--accent); border-color: currentColor; }
|
|
127
|
+
.info:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
128
|
+
.tip {
|
|
129
|
+
position: fixed; z-index: 30; width: max-content; max-width: min(320px, calc(100vw - 16px)); margin: 0; padding: 9px 11px;
|
|
130
|
+
border-radius: 8px; background: var(--text); color: var(--bg); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
|
131
|
+
font-size: 0.78rem; line-height: 1.45; pointer-events: none;
|
|
132
|
+
}
|
|
133
|
+
.tip strong { font-weight: 600; }
|
|
134
|
+
.tip span { margin-left: 6px; font-size: 0.72rem; opacity: 0.75; }
|
|
135
|
+
.tip span::before { content: "· "; }
|
|
136
|
+
.tip p { margin: 4px 0 0; }
|
|
137
|
+
.stat-bar { height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; }
|
|
138
|
+
.stat-fill { display: block; width: 0; height: 100%; border-radius: 3px; background: var(--tier); }
|
|
139
|
+
.stat-value, .model-index { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--text); }
|
|
140
|
+
.stat-value { justify-content: flex-end; }
|
|
141
|
+
.stat-value .level, .model-index .level { min-width: 3ch; text-align: right; }
|
|
142
|
+
.stat.unmeasured .stat-value, .model-index.unmeasured { color: var(--muted); }
|
|
143
|
+
.stat-note { display: none; }
|
|
144
|
+
.stat-tier {
|
|
145
|
+
display: inline-grid; place-items: center; width: 1.7em; height: 1.7em; border-radius: 4px;
|
|
146
|
+
font-size: 0.68rem; font-weight: 700; line-height: 1;
|
|
147
|
+
color: var(--tier-ink); background: color-mix(in srgb, var(--tier) 16%, var(--surface));
|
|
148
|
+
border: 1px solid color-mix(in srgb, var(--tier) 50%, transparent);
|
|
149
|
+
}
|
|
150
|
+
.tier-s { --tier: #c99a2e; --tier-ink: #7a5200; }
|
|
151
|
+
.tier-a { --tier: #8b5cf6; --tier-ink: #5b21b6; }
|
|
152
|
+
.tier-b { --tier: #3b82f6; --tier-ink: #1d4ed8; }
|
|
153
|
+
.tier-c { --tier: #22a35a; --tier-ink: #166534; }
|
|
154
|
+
.tier-d { --tier: #94a3b8; --tier-ink: #475569; }
|
|
155
|
+
@media (prefers-color-scheme: dark) {
|
|
156
|
+
.tier-s { --tier: #e0b45c; --tier-ink: #f6dea6; }
|
|
157
|
+
.tier-a { --tier: #a78bfa; --tier-ink: #ddd3ff; }
|
|
158
|
+
.tier-b { --tier: #60a5fa; --tier-ink: #cfe3ff; }
|
|
159
|
+
.tier-c { --tier: #4ade80; --tier-ink: #c6f6d5; }
|
|
160
|
+
.tier-d { --tier: #94a3b8; --tier-ink: #d4dbe4; }
|
|
161
|
+
}
|
|
99
162
|
.provider-links { grid-column: 1 / -1; display: flex; gap: 14px; font-size: 0.78rem; }
|
|
100
163
|
.console-link { color: var(--accent); text-decoration: none; }
|
|
101
164
|
.console-link:hover { text-decoration: underline; }
|
|
@@ -139,7 +202,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
|
|
|
139
202
|
font-size: 0.72rem; font-weight: 600; padding: 3px 8px; border-radius: 999px; white-space: nowrap;
|
|
140
203
|
max-width: 11em; overflow: hidden; text-overflow: ellipsis;
|
|
141
204
|
background: color-mix(in srgb, var(--accent) 14%, var(--surface)); color: var(--accent);
|
|
205
|
+
border: 0; font-family: inherit; cursor: pointer;
|
|
142
206
|
}
|
|
207
|
+
.model-pill:hover { background: color-mix(in srgb, var(--accent) 24%, var(--surface)); }
|
|
208
|
+
.model-pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
143
209
|
.model-pill.screen { font-weight: 500; }
|
|
144
210
|
.status-pill.active { color: var(--ok); }
|
|
145
211
|
.status-pill.active::before { content: "● "; animation: pulse 1.2s ease-in-out infinite; }
|
|
@@ -203,6 +269,67 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
|
|
|
203
269
|
.terminal-host { flex: 1; min-height: 0; padding: 8px 4px 4px 10px; }
|
|
204
270
|
.terminal-host > div { height: 100%; }
|
|
205
271
|
|
|
272
|
+
.models {
|
|
273
|
+
width: min(880px, calc(100vw - 32px)); max-height: calc(100vh - 48px); padding: 0; overflow: hidden;
|
|
274
|
+
border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text);
|
|
275
|
+
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
|
|
276
|
+
}
|
|
277
|
+
.models[open] { display: flex; flex-direction: column; }
|
|
278
|
+
.models::backdrop { background: rgba(15, 17, 21, 0.5); }
|
|
279
|
+
.models-bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
|
280
|
+
.models-heading { flex: 1; min-width: 0; }
|
|
281
|
+
.models-heading .sub { margin: 2px 0 0; color: var(--muted); font-size: 0.78rem; }
|
|
282
|
+
.models-body { flex: 1; min-height: 0; overflow: auto; padding: 0 14px 14px; }
|
|
283
|
+
.model-columns, .model-toggle { display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, 6.5rem) 4.5rem; align-items: center; gap: 8px; }
|
|
284
|
+
.model-columns {
|
|
285
|
+
position: sticky; top: 0; z-index: 1; padding: 10px 8px 6px; background: var(--surface);
|
|
286
|
+
border-bottom: 1px solid var(--border); color: var(--muted); font-size: 0.72rem; font-weight: 600;
|
|
287
|
+
}
|
|
288
|
+
.model-columns span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
289
|
+
.model-columns span:last-child, .model-context { text-align: right; }
|
|
290
|
+
.model { border-bottom: 1px solid var(--border); }
|
|
291
|
+
.model-head { margin: 0; font: inherit; }
|
|
292
|
+
.model-toggle {
|
|
293
|
+
width: 100%; padding: 9px 8px; border: 0; border-radius: 6px; background: none; color: var(--text);
|
|
294
|
+
font: inherit; font-size: 0.85rem; text-align: left; cursor: pointer;
|
|
295
|
+
}
|
|
296
|
+
.model-toggle:hover { background: var(--surface-2); }
|
|
297
|
+
.model-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
298
|
+
.model-title { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; }
|
|
299
|
+
.model-name { font-weight: 600; overflow-wrap: anywhere; }
|
|
300
|
+
.model-name::before { content: "▸"; content: "▸" / ""; display: inline-block; width: 1em; color: var(--muted); transition: transform 0.15s; }
|
|
301
|
+
.model-toggle[aria-expanded="true"] .model-name::before { transform: rotate(90deg); }
|
|
302
|
+
.model-context { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
303
|
+
.badge {
|
|
304
|
+
font-size: 0.66rem; font-weight: 600; padding: 1px 7px; border-radius: 999px; white-space: nowrap;
|
|
305
|
+
background: var(--surface-2); color: var(--muted); border: 1px solid var(--border);
|
|
306
|
+
}
|
|
307
|
+
.badge.in-use { --badge: var(--accent); color: color-mix(in srgb, var(--badge) 80%, #000); }
|
|
308
|
+
.badge.new { --badge: var(--ok); color: color-mix(in srgb, var(--badge) 72%, #000); }
|
|
309
|
+
.badge.expires { --badge: var(--warn); color: color-mix(in srgb, var(--badge) 62%, #000); }
|
|
310
|
+
.badge.in-use, .badge.new, .badge.expires { border-color: color-mix(in srgb, var(--badge) 45%, transparent); }
|
|
311
|
+
@media (prefers-color-scheme: dark) {
|
|
312
|
+
.badge.in-use, .badge.new, .badge.expires { color: var(--badge); }
|
|
313
|
+
}
|
|
314
|
+
.model-detail {
|
|
315
|
+
display: grid; grid-template-columns: max-content minmax(48px, 1fr) auto minmax(0, 19em); gap: 14px 10px;
|
|
316
|
+
padding: 4px 8px 16px calc(8px + 1em);
|
|
317
|
+
}
|
|
318
|
+
.model-group { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; row-gap: 2px; }
|
|
319
|
+
.model-group h4, .model-none { grid-column: 1 / -1; }
|
|
320
|
+
.model-group h4 { margin: 0 0 2px; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
|
|
321
|
+
.model-detail .stat-note { display: block; font-size: 0.72rem; }
|
|
322
|
+
.model-detail .stat-fill { transform-origin: left; animation: stat-fill 0.45s ease-out; }
|
|
323
|
+
.model-none { margin: 0; font-size: 0.75rem; color: var(--muted); }
|
|
324
|
+
.model-more { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 16px; font-size: 0.78rem; }
|
|
325
|
+
.model-facts { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; }
|
|
326
|
+
.model-facts div { display: flex; gap: 6px; }
|
|
327
|
+
.model-facts dt { color: var(--muted); }
|
|
328
|
+
.model-facts dd { margin: 0; }
|
|
329
|
+
.models-more { margin-top: 12px; }
|
|
330
|
+
.models-note { margin: 14px 0 0; color: var(--muted); }
|
|
331
|
+
.models-source { margin: 0; padding: 10px 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.75rem; line-height: 1.45; }
|
|
332
|
+
|
|
206
333
|
.toast {
|
|
207
334
|
position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 20;
|
|
208
335
|
max-width: min(640px, calc(100vw - 32px)); padding: 10px 14px; border-radius: 8px;
|
|
@@ -211,9 +338,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
|
|
|
211
338
|
|
|
212
339
|
@keyframes pulse { 50% { opacity: 0.35; } }
|
|
213
340
|
@keyframes ring { 0% { transform: scale(0.85); opacity: 0.8; } 100% { transform: scale(1.3); opacity: 0; } }
|
|
341
|
+
@keyframes stat-fill { from { transform: scaleX(0); } }
|
|
214
342
|
|
|
215
343
|
@media (prefers-reduced-motion: reduce) {
|
|
216
|
-
.status-pill.active::before, .agent.working::after { animation: none; }
|
|
344
|
+
.status-pill.active::before, .agent.working::after, .model-detail .stat-fill { animation: none; }
|
|
345
|
+
.model-name::before { transition: none; }
|
|
217
346
|
}
|
|
218
347
|
|
|
219
348
|
@media (max-width: 640px) {
|
|
@@ -223,4 +352,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
|
|
|
223
352
|
.terminal-panel { inset: var(--topbar-h, 56px) 0 0; border-radius: 0; }
|
|
224
353
|
.session-grid { grid-template-columns: 1fr; }
|
|
225
354
|
.cwd { flex-basis: 100%; }
|
|
355
|
+
.models { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; }
|
|
356
|
+
.model-columns, .model-toggle { grid-template-columns: minmax(0, 1fr) repeat(3, 4.6rem); }
|
|
357
|
+
.model-columns span:last-child, .model-context { display: none; }
|
|
358
|
+
.model-detail { padding-left: 8px; grid-template-columns: max-content minmax(32px, 1fr) auto; }
|
|
359
|
+
.model-detail .stat { padding-bottom: 6px; }
|
|
360
|
+
.model-detail .stat-reading { row-gap: 1px; }
|
|
361
|
+
.model-detail .stat-note { grid-column: 1 / -1; }
|
|
226
362
|
}
|