@wendellresearch-ai/benchwarmers 0.3.3 → 0.5.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/GATEWAY.md +9 -5
- package/PRIVACY.md +9 -3
- package/README.md +31 -8
- package/USAGE.md +81 -15
- package/VERSION +1 -1
- package/cli.py +17 -5
- package/gateway.py +34 -2
- package/launcher.py +2 -1
- package/metrics/aliases.py +24 -3
- package/metrics/discover.py +68 -18
- package/metrics/endpoints.py +36 -4
- package/metrics/model_roles.py +3 -1
- package/metrics/pacing.py +23 -3
- package/metrics/quotaview.py +460 -0
- package/metrics/route.py +3 -2
- package/metrics/scheduler.py +2 -1
- package/metrics/sources.py +29 -6
- package/metrics/wrong.py +3 -1
- package/package.json +1 -1
- package/scripts/check.py +3 -0
- package/scripts/onboard.py +12 -1
- package/scripts/test_hubs.py +10 -5
- package/scripts/test_no_owui.py +14 -2
- package/scripts/test_quota.py +12 -2
- package/scripts/test_serve_edit.py +58 -5
- package/scripts/test_server_json.py +22 -2
- package/serve.py +570 -128
- package/suite/README.md +33 -0
- package/suite/pace.py +8 -4
- package/suite/run.py +144 -16
- package/suite/tasks/vision-bars/fixtures/bars.png +0 -0
- package/suite/tasks/vision-bars/task.json +40 -0
- package/suite/tasks/vision-invoice/fixtures/invoice.png +0 -0
- package/suite/tasks/vision-invoice/task.json +40 -0
- package/suite/tasks/vision-label/fixtures/label.png +0 -0
- package/suite/tasks/vision-label/task.json +52 -0
- package/suite/tasks/vision-receipt/fixtures/receipt.png +0 -0
- package/suite/tasks/vision-receipt/task.json +44 -0
- package/suite/tasks/vision-screen/fixtures/screen.png +0 -0
- package/suite/tasks/vision-screen/task.json +48 -0
- package/suite/tasks/vision-shapes/fixtures/shapes.png +0 -0
- package/suite/tasks/vision-shapes/task.json +40 -0
- package/suite/tasks/vision-table/fixtures/table.png +0 -0
- package/suite/tasks/vision-table/task.json +40 -0
- package/suite/test_vision_tasks.py +201 -0
- package/suite/vision_keys.py +74 -0
- package/suite/vision_sample.py +232 -0
- package/suite/vision_tasks.py +338 -0
- package/ui/bench.css +71 -6
- package/ui/bench.js +543 -49
package/ui/bench.js
CHANGED
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
// The page's own confirm, a Promise of true/false: window.confirm is
|
|
21
21
|
// silently false where a browser blocks dialogs. Opens over any open popup.
|
|
22
22
|
var askD = $('ask-dialog'), askDone = null;
|
|
23
|
-
function ask(text, yes, danger) {
|
|
23
|
+
function ask(text, yes, danger, onboard) {
|
|
24
24
|
if (askDone) askDone(false);
|
|
25
25
|
$('ask-text').textContent = text;
|
|
26
|
+
$('ask-onboard').hidden = !onboard; // Discover's Add: the Onboard dialog's mode and Vision box
|
|
27
|
+
$('ask-vision').checked = false;
|
|
26
28
|
$('ask-yes').textContent = yes || 'OK';
|
|
27
29
|
$('ask-yes').className = 'btn ' + (danger ? 'danger' : 'primary');
|
|
28
30
|
askD.showModal(); $('ask-no').focus();
|
|
@@ -33,6 +35,34 @@
|
|
|
33
35
|
$('ask-no').addEventListener('click', function () { answer(false); });
|
|
34
36
|
askD.addEventListener('close', function () { answer(false); }); // Escape
|
|
35
37
|
|
|
38
|
+
// ---- restart (serve.restart): the page reloads once the new one answers ----
|
|
39
|
+
var rs = $('restart');
|
|
40
|
+
function restart(force) {
|
|
41
|
+
var out = $('restart-out');
|
|
42
|
+
rs.disabled = true; out.textContent = 'Restarting…';
|
|
43
|
+
post('/api/restart', {force: force}).then(function (res) {
|
|
44
|
+
if (res.ok) return back(Date.now());
|
|
45
|
+
rs.disabled = false; out.textContent = '';
|
|
46
|
+
if (!res.busy) { listErrors(out, res.errors); return; }
|
|
47
|
+
ask('Still in progress: ' + res.errors.join('; ') + '. Restart anyway and cut them off?', 'Restart anyway', true)
|
|
48
|
+
.then(function (yes) { if (yes) restart(true); });
|
|
49
|
+
}).catch(function (err) { rs.disabled = false; out.textContent = String(err); });
|
|
50
|
+
}
|
|
51
|
+
// The old one stops half a second after answering; /health answers again once the new one is up.
|
|
52
|
+
function back(since) {
|
|
53
|
+
setTimeout(function () {
|
|
54
|
+
fetch('/health', {cache: 'no-store'}).then(function (r) { if (!r.ok) throw r.status; location.reload(); })
|
|
55
|
+
.catch(function () {
|
|
56
|
+
if (Date.now() - since < 120000) back(since);
|
|
57
|
+
else $('restart-out').textContent = 'Not back after two minutes: look at whatever runs it.';
|
|
58
|
+
});
|
|
59
|
+
}, 1500);
|
|
60
|
+
}
|
|
61
|
+
if (rs) rs.addEventListener('click', function () {
|
|
62
|
+
ask('Restart Benchwarmers? The dashboard and the gateway on this port stop for a few seconds.', 'Restart')
|
|
63
|
+
.then(function (yes) { if (yes) restart(false); });
|
|
64
|
+
});
|
|
65
|
+
|
|
36
66
|
// ---- tabs, remembered per browser ----------------------------------
|
|
37
67
|
var tabs = document.querySelectorAll('.tabs button[data-tab]'); // not the sidebar's own toggle
|
|
38
68
|
var onShow = {}; // a page's loader, run each time it is shown
|
|
@@ -509,30 +539,42 @@
|
|
|
509
539
|
o.picker.appendChild(d);
|
|
510
540
|
}
|
|
511
541
|
}
|
|
512
|
-
|
|
513
|
-
|
|
542
|
+
// The server keeps each endpoint's list a few minutes (serve.LISTED_FOR);
|
|
543
|
+
// Refresh asks them all again.
|
|
544
|
+
function showAge(s) {
|
|
545
|
+
var age = $('o-age');
|
|
546
|
+
age.hidden = s == null;
|
|
547
|
+
if (s == null) return;
|
|
548
|
+
age.firstChild.textContent = s < 60 ? 'Listed just now.' : 'Listed ' + Math.round(s / 60) + ' min ago.';
|
|
549
|
+
}
|
|
550
|
+
function loadCandidates(fresh) {
|
|
551
|
+
showAge(null);
|
|
552
|
+
o.picker.textContent = ''; o.picker.appendChild(el('p', {class: 'empty'}, 'Please wait. Gathering models from your endpoints…'));
|
|
514
553
|
o.start.disabled = o.dismiss.disabled = true;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
554
|
+
// Typing before the list arrives is lost, or lands on a row; the mode waits with it.
|
|
555
|
+
o.find.readOnly = true; $('o-mode').disabled = $('o-vision').disabled = true;
|
|
556
|
+
function ready() { o.find.readOnly = false; $('o-mode').disabled = $('o-vision').disabled = false; }
|
|
557
|
+
fetch('/api/candidates' + (fresh ? '?fresh=1' : '')).then(function (r) { return r.json(); }).then(function (res) {
|
|
558
|
+
ready(); o.find.focus();
|
|
518
559
|
if (!res.ok) { o.picker.textContent = ''; listErrors(o.errors, res.errors); return; }
|
|
519
560
|
if (res.errors.length) listErrors(o.errors, res.errors); // one endpoint down; the rest listed
|
|
520
|
-
data = res;
|
|
561
|
+
data = res; showAge(res.age);
|
|
521
562
|
// Only what is still offered stays ticked.
|
|
522
563
|
var offered = {};
|
|
523
564
|
res.candidates.forEach(function (c) { offered[c.key] = 1; });
|
|
524
565
|
Object.keys(chosen).forEach(function (k) { if (!offered[k]) delete chosen[k]; });
|
|
525
566
|
renderPicker(); armStart();
|
|
526
|
-
}).catch(function (err) {
|
|
567
|
+
}).catch(function (err) { ready(); o.errors.textContent = String(err); });
|
|
527
568
|
}
|
|
528
569
|
$('onboard-open').addEventListener('click', function () {
|
|
529
570
|
preStep = null;
|
|
530
571
|
o.errors.textContent = ''; o.find.value = ''; o.choose.hidden = false; o.run.hidden = true;
|
|
531
|
-
$('o-queue').hidden = true; chosen = {}; waitToken++;
|
|
572
|
+
$('o-queue').hidden = true; chosen = {}; waitToken++; $('o-vision').checked = false;
|
|
532
573
|
o.start.hidden = o.dismiss.hidden = false; armStart(); o.title.textContent = 'Onboard a model';
|
|
533
574
|
od.showModal(); o.find.focus(); loadCandidates();
|
|
534
575
|
});
|
|
535
576
|
o.find.addEventListener('input', renderPicker);
|
|
577
|
+
$('o-refresh').addEventListener('click', function () { o.errors.textContent = ''; loadCandidates(true); });
|
|
536
578
|
o.close.addEventListener('click', function () { od.close(); });
|
|
537
579
|
od.addEventListener('close', function () { if (timer) { clearInterval(timer); timer = null; } });
|
|
538
580
|
|
|
@@ -573,8 +615,8 @@
|
|
|
573
615
|
// model at a time. The popup closes: the header's chip shows the batch, and
|
|
574
616
|
// its Details the queue. Refused, it stays open with the reasons.
|
|
575
617
|
o.start.addEventListener('click', function () {
|
|
576
|
-
var mode = $('o-mode').value;
|
|
577
|
-
var items = Object.keys(chosen).map(function (k) { return Object.assign({mode: mode}, chosen[k]); });
|
|
618
|
+
var mode = $('o-mode').value, vision = $('o-vision').checked;
|
|
619
|
+
var items = Object.keys(chosen).map(function (k) { return Object.assign({mode: mode, vision: vision}, chosen[k]); });
|
|
578
620
|
if (!items.length) return;
|
|
579
621
|
o.errors.textContent = ''; o.start.disabled = o.dismiss.disabled = true; o.start.textContent = 'Starting…';
|
|
580
622
|
post('/api/onboard-queue', {items: items}).then(function (res) {
|
|
@@ -731,19 +773,21 @@
|
|
|
731
773
|
function addVia(x, b) {
|
|
732
774
|
var direct = x.via === 'direct';
|
|
733
775
|
ask('Add ' + x.model + ' via ' + x.label + (x.free ? '' : ' (PAID)') + '?\n\n'
|
|
734
|
-
+ (direct ? 'The bench only: register it on the ' + x.endpoint + ' endpoint
|
|
776
|
+
+ (direct ? 'The bench only: register it on the ' + x.endpoint + ' endpoint and onboard it as chosen below.'
|
|
735
777
|
// A LiteLLM or CLI Proxy API route says its own steps: only what that hub takes.
|
|
736
778
|
: x.steps ? x.steps.map(function (s, i) { return (i + 1) + '. ' + s; }).join('\n')
|
|
737
779
|
: '1. Open WebUI: add it to the ' + x.connection + ' connection\'s Model IDs (if that connection lists them)\n'
|
|
738
780
|
+ '2. Open WebUI: a public model record, tagged and with the logo of the connection\'s other models\n'
|
|
739
|
-
+ '3. The bench: register
|
|
781
|
+
+ '3. The bench: register it and onboard it as chosen below'), 'Add', false, true).then(function (yes) {
|
|
782
|
+
if (yes) addNow(x, b, direct, {mode: $('ask-mode').value, vision: $('ask-vision').checked});
|
|
783
|
+
});
|
|
740
784
|
}
|
|
741
|
-
function addNow(x, b, direct) {
|
|
785
|
+
function addNow(x, b, direct, how) {
|
|
742
786
|
var title = 'Adding ' + x.model + ' via ' + x.label;
|
|
743
787
|
$('d-errors').textContent = '';
|
|
744
788
|
if (direct) {
|
|
745
789
|
b.disabled = true; b.textContent = 'Adding…';
|
|
746
|
-
post('/api/onboard-queue', {items: [{served: x.model, endpoint: x.endpoint}]}).then(function (res) {
|
|
790
|
+
post('/api/onboard-queue', {items: [Object.assign({served: x.model, endpoint: x.endpoint}, how)]}).then(function (res) {
|
|
747
791
|
if (!res.ok) { listErrors($('d-errors'), res.errors); b.disabled = false; b.textContent = 'Add via this'; return; }
|
|
748
792
|
dd.close(); preStep = null; o.errors.textContent = ''; od.showModal();
|
|
749
793
|
afterQueued(res, {served: x.model, endpoint: x.endpoint}, title, '');
|
|
@@ -758,7 +802,7 @@
|
|
|
758
802
|
runView(title, 'Adding it to ' + x.label + '…');
|
|
759
803
|
oq.box.hidden = false; oq.chip.classList.remove('done', 'fail'); oq.chip.style.setProperty('--fill', '3%');
|
|
760
804
|
oq.text.textContent = oq.text.title = title; oq.stop.hidden = oq.clear.hidden = true;
|
|
761
|
-
post('/api/discover/add', {connection: x.connection, model: x.model, endpoint: x.endpoint}).then(function (res) {
|
|
805
|
+
post('/api/discover/add', Object.assign({connection: x.connection, model: x.model, endpoint: x.endpoint}, how)).then(function (res) {
|
|
762
806
|
var hubDone = !!(res.served && res.steps && res.steps.length); // in the hub, even if queueing it was refused
|
|
763
807
|
preStep.state = hubDone ? 'done' : 'fail';
|
|
764
808
|
preStep.note = (res.steps || []).join('. ') + (hubDone ? '' : (res.steps && res.steps.length ? '. ' : '') + res.errors.join(' '));
|
|
@@ -790,8 +834,16 @@
|
|
|
790
834
|
if (res.errors.length) listErrors($('d-errors'), res.errors); // a hub that could not be asked
|
|
791
835
|
var r = res.model, p = $('d-profile'), list = $('d-routes');
|
|
792
836
|
$('d-title').textContent = r.name;
|
|
793
|
-
|
|
794
|
-
|
|
837
|
+
// Each source a link: FreeLLMAPI's page for the model where its sitemap has one, else its catalog.
|
|
838
|
+
var sv = $('d-served');
|
|
839
|
+
sv.textContent = r.provider_name + ' · ' + r.model + ' · listed by ';
|
|
840
|
+
r.sources.forEach(function (s, i) {
|
|
841
|
+
var fl = s === 'freellmapi';
|
|
842
|
+
if (i) sv.appendChild(document.createTextNode(' and '));
|
|
843
|
+
sv.appendChild(el('a', {href: fl ? r.page || 'https://freellmapi.co/models' : 'https://models.dev/',
|
|
844
|
+
target: '_blank', rel: 'noopener'}, fl ? 'FreeLLMAPI' : 'models.dev'));
|
|
845
|
+
});
|
|
846
|
+
if (r.on_bench_as) sv.appendChild(document.createTextNode(' · on the bench as ' + r.on_bench_as));
|
|
795
847
|
p.textContent = '';
|
|
796
848
|
[['Input', r.in_mods], ['Output', r.out_mods]].forEach(function (x) {
|
|
797
849
|
var row = el('div', {class: 'prof'});
|
|
@@ -802,16 +854,26 @@
|
|
|
802
854
|
var can = [['tools', r.tools], ['vision', r.vision], ['reasons', r.reasoning]]
|
|
803
855
|
.filter(function (x) { return x[1]; }).map(function (x) { return x[0]; });
|
|
804
856
|
var facts = [
|
|
857
|
+
r.rank != null ? 'smartest #' + r.rank + (r.speed ? ', fastest #' + r.speed : '') + ' on FreeLLMAPI' : '',
|
|
858
|
+
r.size || '',
|
|
805
859
|
r.context ? 'context ' + Number(r.context).toLocaleString() + ' tokens' : 'context unknown',
|
|
806
860
|
r.output ? 'output up to ' + Number(r.output).toLocaleString() : '',
|
|
807
861
|
can.length ? 'can: ' + can.join(', ') : '',
|
|
808
862
|
price(r),
|
|
809
863
|
r.limits ? 'limits ' + limits(r.limits) : '',
|
|
864
|
+
r.budget ? r.budget + ' tokens a month' : '',
|
|
810
865
|
r.released ? 'released ' + r.released : '',
|
|
811
866
|
r.knowledge ? 'knowledge to ' + r.knowledge : '',
|
|
812
867
|
r.open_weights != null ? (r.open_weights ? 'open weights' : 'closed weights') : '',
|
|
813
868
|
'first seen ' + (r.first_seen === 'baseline' ? 'before the list began' : r.first_seen)];
|
|
814
869
|
p.appendChild(el('div', {class: 'prof'}, facts.filter(Boolean).join(' · ')));
|
|
870
|
+
// FreeLLMAPI's operating notes for this provider, e.g. thinking tokens that eat the output cap.
|
|
871
|
+
(r.quirks || []).forEach(function (q) {
|
|
872
|
+
var row = el('div', {class: 'prof'});
|
|
873
|
+
row.appendChild(el('strong', {}, q.title + ': '));
|
|
874
|
+
row.appendChild(document.createTextNode(q.body || ''));
|
|
875
|
+
p.appendChild(row);
|
|
876
|
+
});
|
|
815
877
|
if (r.also_on && r.also_on.length) p.appendChild(el('div', {class: 'prof muted'}, 'Also on the bench as ' + r.also_on.join(', ')));
|
|
816
878
|
list.textContent = '';
|
|
817
879
|
res.routes.forEach(function (x) {
|
|
@@ -1267,7 +1329,8 @@
|
|
|
1267
1329
|
shape: $('c-shape'), desc: $('c-desc'), tags: $('c-tags'), errors: $('c-errors'), done: $('c-done'),
|
|
1268
1330
|
head: $('c-form-head'), maxCalls: $('g-max-calls'), maxExp: $('g-max-explorers'),
|
|
1269
1331
|
gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
|
|
1270
|
-
del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter')
|
|
1332
|
+
del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
|
|
1333
|
+
seats: $('c-seats')};
|
|
1271
1334
|
var C = null, open = null; // the collection whose details are showing
|
|
1272
1335
|
function explorersFor(n) {
|
|
1273
1336
|
return Math.max(0, Math.min(C.settings.max_explorers, C.settings.max_calls - n));
|
|
@@ -1287,10 +1350,37 @@
|
|
|
1287
1350
|
(shown.length < rows.length ? ' · showing ' + shown.length + ' of ' + rows.length + ' models' : '') +
|
|
1288
1351
|
(paid ? ' (' + paid + ' paid hidden by the free tag)' : '') +
|
|
1289
1352
|
(greyed ? ' · ' + greyed + ' greyed out by the tags (hover one for why)' : '') +
|
|
1290
|
-
(clash ? ' · ' + clash + ' ticked member' + (clash === 1 ? ' does' : 's do') + ' not fit the tags' : '')
|
|
1353
|
+
(clash ? ' · ' + clash + ' ticked member' + (clash === 1 ? ' does' : 's do') + ' not fit the tags' : '') +
|
|
1354
|
+
twice().map(function (t) {
|
|
1355
|
+
return ' · ' + t.name + ' is ticked on ' + t.on.join(' and ') + ': one model agrees with itself, it does not check itself';
|
|
1356
|
+
}).join('');
|
|
1291
1357
|
c.members.querySelectorAll('input').forEach(function (x) {
|
|
1292
1358
|
x.disabled = !x.checked && (n >= C.settings.max_calls || !!x.dataset.blocked);
|
|
1293
1359
|
});
|
|
1360
|
+
drawSeats(ex);
|
|
1361
|
+
}
|
|
1362
|
+
function fact(alias) { return (C.facts || {})[alias] || {}; }
|
|
1363
|
+
// The same model ticked on two providers (route.family_of, as the gateway
|
|
1364
|
+
// keeps explorers off a member's model): allowed, and said.
|
|
1365
|
+
function twice() {
|
|
1366
|
+
var by = {};
|
|
1367
|
+
picked().forEach(function (a) { var f = fact(a).family || a; (by[f] = by[f] || []).push(a); });
|
|
1368
|
+
return Object.keys(by).filter(function (f) { return by[f].length > 1; }).map(function (f) {
|
|
1369
|
+
var row = c.members.querySelector('.m-row[data-family="' + CSS.escape(f) + '"]');
|
|
1370
|
+
return {name: row ? row.dataset.name : by[f][0], on: by[f].map(function (a) { return fact(a).provider || a; })};
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
// The panel as it will sit, as the Collections list draws it.
|
|
1374
|
+
function drawSeats(ex) {
|
|
1375
|
+
c.seats.textContent = '';
|
|
1376
|
+
picked().forEach(function (a) {
|
|
1377
|
+
var seat = el('span', {class: 'seat'}, a);
|
|
1378
|
+
if (fact(a).provider) seat.appendChild(el('span', {class: 'k after'}, fact(a).provider));
|
|
1379
|
+
c.seats.appendChild(seat);
|
|
1380
|
+
});
|
|
1381
|
+
for (var i = 0; i < ex; i++) {
|
|
1382
|
+
c.seats.appendChild(el('span', {class: 'seat explorer'}, 'explorer' + (c.shape.value ? ' · ' + c.shape.value : '')));
|
|
1383
|
+
}
|
|
1294
1384
|
}
|
|
1295
1385
|
// Tags that say what every member must do: why a model does not fit them
|
|
1296
1386
|
// (none: it fits). From collections_view's facts; an unknown context is
|
|
@@ -1328,20 +1418,28 @@
|
|
|
1328
1418
|
lab.classList.toggle('clash', why.length > 0 && box.checked);
|
|
1329
1419
|
lab.dataset.paid = paid ? '1' : '';
|
|
1330
1420
|
if (why.length) box.dataset.blocked = '1'; else delete box.dataset.blocked;
|
|
1421
|
+
// A chip says only the provider: the alias and served name are its first line.
|
|
1331
1422
|
var served = lab.dataset.served;
|
|
1332
|
-
lab.title = (served && served !== box.value ?
|
|
1423
|
+
lab.title = [box.value + (served && served !== box.value ? ' (' + served + ')' : '')].concat(why).join('\n');
|
|
1333
1424
|
});
|
|
1334
1425
|
filterMembers();
|
|
1335
1426
|
}
|
|
1336
1427
|
// The member search: the filter boxes' match (any text, case-insensitive),
|
|
1337
|
-
// over
|
|
1338
|
-
//
|
|
1428
|
+
// over the model's name and each chip's alias, served name and provider.
|
|
1429
|
+
// It only hides chips, and never a ticked one, so what is picked is always
|
|
1430
|
+
// in view and every count still includes it; a row goes when all its chips do.
|
|
1339
1431
|
function filterMembers() {
|
|
1340
1432
|
var q = c.find.value.trim().toLowerCase();
|
|
1341
|
-
c.members.querySelectorAll('
|
|
1342
|
-
var
|
|
1343
|
-
|
|
1344
|
-
|
|
1433
|
+
c.members.querySelectorAll('.m-row').forEach(function (row) {
|
|
1434
|
+
var named = !q || row.dataset.name.toLowerCase().indexOf(q) !== -1, any = false;
|
|
1435
|
+
row.querySelectorAll('label.check').forEach(function (lab) {
|
|
1436
|
+
var box = lab.querySelector('input');
|
|
1437
|
+
var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
|
|
1438
|
+
lab.textContent).toLowerCase();
|
|
1439
|
+
lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1);
|
|
1440
|
+
any = any || !lab.hidden;
|
|
1441
|
+
});
|
|
1442
|
+
row.hidden = !any;
|
|
1345
1443
|
});
|
|
1346
1444
|
recount();
|
|
1347
1445
|
}
|
|
@@ -1372,6 +1470,7 @@
|
|
|
1372
1470
|
c.tags.addEventListener('input', function () { drawTagPicks(); applyTags(); });
|
|
1373
1471
|
function showMerger() { $('c-merger-row').hidden = c.reply.value !== 'merged'; }
|
|
1374
1472
|
c.reply.addEventListener('change', showMerger);
|
|
1473
|
+
c.shape.addEventListener('change', recount); // the explorer seats say their shape
|
|
1375
1474
|
// A collection opens in a popup, as every other edit on the dashboard does:
|
|
1376
1475
|
// the one clicked, or a new one from New collection.
|
|
1377
1476
|
function closeForm() {
|
|
@@ -1388,14 +1487,37 @@
|
|
|
1388
1487
|
c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
|
|
1389
1488
|
drawTagPicks();
|
|
1390
1489
|
c.members.textContent = ''; c.find.value = '';
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1490
|
+
// One row per model, named by its shortest alias, with what the first
|
|
1491
|
+
// route says it holds and can do; each provider it is on is a chip that
|
|
1492
|
+
// ticks that alias. A chip whose window differs from the row's says its own.
|
|
1493
|
+
var groups = {};
|
|
1494
|
+
C.models.forEach(function (a) { var f = fact(a).family || a; (groups[f] = groups[f] || []).push(a); });
|
|
1495
|
+
Object.keys(groups).map(function (f) {
|
|
1496
|
+
var routes = groups[f].slice().sort(function (x, y) { return x.length - y.length || (x < y ? -1 : 1); });
|
|
1497
|
+
return {family: f, name: routes[0], routes: routes};
|
|
1498
|
+
}).sort(function (x, y) { return x.name.toLowerCase() < y.name.toLowerCase() ? -1 : 1; }).forEach(function (g) {
|
|
1499
|
+
var first = fact(g.name), caps = first.caps || {};
|
|
1500
|
+
var can = ['vision', 'tools'].filter(function (k) { return caps[k]; });
|
|
1501
|
+
var line = el('div', {class: 'm-row', role: 'group', 'aria-label': g.name, 'data-family': g.family, 'data-name': g.name});
|
|
1502
|
+
var name = el('div', {class: 'm-name'});
|
|
1503
|
+
name.appendChild(el('span', {class: 'mono'}, g.name));
|
|
1504
|
+
name.appendChild(el('span', {class: 'm-facts'}, [first.context ? tokens(first.context) : '', can.join(', '),
|
|
1505
|
+
first.price === 'unknown' ? '' : first.price].filter(Boolean).join(' · ')));
|
|
1506
|
+
line.appendChild(name);
|
|
1507
|
+
var chips = el('div', {class: 'm-routes'});
|
|
1508
|
+
g.routes.forEach(function (alias) {
|
|
1509
|
+
var f = fact(alias);
|
|
1510
|
+
var box = el('input', {type: 'checkbox', value: alias, 'aria-label': alias + ' on ' + (f.provider || 'its endpoint')});
|
|
1511
|
+
box.checked = !!row && row.members.indexOf(alias) !== -1;
|
|
1512
|
+
box.addEventListener('change', applyTags); // ticked or not changes what a misfit shows as
|
|
1513
|
+
var lab = el('label', {class: 'check', 'data-served': (C.served || {})[alias] || ''});
|
|
1514
|
+
lab.appendChild(box);
|
|
1515
|
+
lab.appendChild(el('span', null, (f.provider || alias) +
|
|
1516
|
+
(f.context && first.context && f.context !== first.context ? ' · ' + tokens(f.context) : '')));
|
|
1517
|
+
chips.appendChild(lab);
|
|
1518
|
+
});
|
|
1519
|
+
line.appendChild(chips);
|
|
1520
|
+
c.members.appendChild(line);
|
|
1399
1521
|
});
|
|
1400
1522
|
c.shape.textContent = '';
|
|
1401
1523
|
c.shape.appendChild(el('option', {value: ''}, 'automatic'));
|
|
@@ -1406,10 +1528,18 @@
|
|
|
1406
1528
|
c.merger.textContent = '';
|
|
1407
1529
|
c.merger.appendChild(el('option', {value: ''}, 'automatic'));
|
|
1408
1530
|
C.models.forEach(function (alias) {
|
|
1409
|
-
var o = el('option', {value: alias}, alias
|
|
1531
|
+
var o = el('option', {value: alias}, alias + (fact(alias).provider ? ' · ' + fact(alias).provider : ''));
|
|
1532
|
+
if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
|
|
1410
1533
|
});
|
|
1411
1534
|
showMerger(); applyTags();
|
|
1412
1535
|
}
|
|
1536
|
+
// What a collection's members can all do, in the words the tag checks use.
|
|
1537
|
+
var CAN = {reasoning: 'reason'};
|
|
1538
|
+
Object.keys(CAP_TAGS).forEach(function (t) { CAN[CAP_TAGS[t][0]] = CAP_TAGS[t][1]; });
|
|
1539
|
+
function tokens(n) { // 512k, 1M (2^20), 1.9M
|
|
1540
|
+
return n >= 1000000 ? String(Math.round(n / 104857.6) / 10).replace(/\.0$/, '') + 'M'
|
|
1541
|
+
: Math.floor(n / 1000).toLocaleString() + 'k';
|
|
1542
|
+
}
|
|
1413
1543
|
function loadCollections(keep) {
|
|
1414
1544
|
fetch('/api/collections').then(function (r) { return r.json(); }).then(function (res) {
|
|
1415
1545
|
C = res; c.list.textContent = '';
|
|
@@ -1418,24 +1548,38 @@
|
|
|
1418
1548
|
var box = el('button', {class: 'endpoint c-row', type: 'button', 'data-name': row.name,
|
|
1419
1549
|
'aria-label': 'Open bench/' + row.name});
|
|
1420
1550
|
var left = el('div');
|
|
1421
|
-
var title = el('div', {class: '
|
|
1422
|
-
title.appendChild(el('span', {class: 'alias'}, 'bench/' + row.name
|
|
1423
|
-
row.tags.forEach(function (t) { title.appendChild(el('span', {class: 'chip'
|
|
1551
|
+
var title = el('div', {class: 'top'});
|
|
1552
|
+
title.appendChild(el('span', {class: 'alias'}, 'bench/' + row.name));
|
|
1553
|
+
row.tags.forEach(function (t) { title.appendChild(el('span', {class: 'chip'}, t)); });
|
|
1424
1554
|
left.appendChild(title);
|
|
1425
|
-
if (row.description) left.appendChild(el('
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1555
|
+
if (row.description) left.appendChild(el('p', {class: 'desc'}, row.description));
|
|
1556
|
+
// The panel as it sits: a seat per member, an empty one per explorer,
|
|
1557
|
+
// and the merger after an arrow.
|
|
1558
|
+
var seats = el('div', {class: 'seats'});
|
|
1559
|
+
row.members.forEach(function (m) { seats.appendChild(el('span', {class: 'seat'}, m)); });
|
|
1560
|
+
for (var i = 0; i < row.explorers; i++) {
|
|
1561
|
+
seats.appendChild(el('span', {class: 'seat explorer'}, 'explorer' + (row.shape ? ' · ' + row.shape : '')));
|
|
1562
|
+
}
|
|
1563
|
+
if (row.reply === 'merged') {
|
|
1564
|
+
var merge = el('span', {class: 'merge'}), seat = el('span', {class: 'seat merger'});
|
|
1565
|
+
merge.appendChild(el('span', {'aria-hidden': 'true'}, '→'));
|
|
1566
|
+
seat.appendChild(el('span', {class: 'k'}, 'merger'));
|
|
1567
|
+
seat.appendChild(document.createTextNode(row.merger || 'chosen by verdicts'));
|
|
1568
|
+
merge.appendChild(seat); seats.appendChild(merge);
|
|
1569
|
+
}
|
|
1570
|
+
left.appendChild(seats);
|
|
1429
1571
|
var t = row.together;
|
|
1430
1572
|
if (t) {
|
|
1431
1573
|
var lim = Object.keys(t.limits || {}).filter(function (k) { return t.limits[k]; })
|
|
1432
1574
|
.map(function (k) { return t.limits[k].toLocaleString() + ' ' + k; });
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
'
|
|
1437
|
-
|
|
1438
|
-
(
|
|
1575
|
+
var can = t.capabilities.map(function (k) { return CAN[k] || k; });
|
|
1576
|
+
var n = t.connections.length + ' connection' + (t.connections.length === 1 ? '' : 's');
|
|
1577
|
+
left.appendChild(el('p', {class: 'together'},
|
|
1578
|
+
(t.context_window ? 'Prompts up to ' + tokens(t.context_window) + ' tokens' : 'Context window unknown') +
|
|
1579
|
+
(t.context_unknown.length ? ' (unknown for ' + t.context_unknown.join(', ') + '). ' : '. ') +
|
|
1580
|
+
(can.length ? 'All can ' + (can.length > 1 ? can.slice(0, -1).join(', ') + ' and ' : '') + can[can.length - 1] + '. '
|
|
1581
|
+
: 'Nothing known they can all do. ') +
|
|
1582
|
+
(lim.length ? 'Tightest limit ' + lim.join(', ') + ' across ' + n + '.' : 'No limits known across ' + n + '.')));
|
|
1439
1583
|
}
|
|
1440
1584
|
box.appendChild(left);
|
|
1441
1585
|
box.appendChild(el('span', {class: 'open-hint', 'aria-hidden': 'true'}, 'Open ›'));
|
|
@@ -1532,6 +1676,290 @@
|
|
|
1532
1676
|
}).catch(function (err) { $('v-errors').textContent = String(err); });
|
|
1533
1677
|
});
|
|
1534
1678
|
|
|
1679
|
+
// ---- Quota: a tile per provider (serve.quota_tiles, metrics/quotaview.py) ----
|
|
1680
|
+
// Each tile takes its provider's shape: groups of windows (a 5-hour and a
|
|
1681
|
+
// weekly limit boxed together), one pool (OpenRouter's free requests a day,
|
|
1682
|
+
// a bar per key) or, where nothing is stated, each model's last day. A
|
|
1683
|
+
// meter is ten segments filled like a bar: 85% is eight and a half.
|
|
1684
|
+
var QD = $('quota-data') ? JSON.parse($('quota-data').textContent) : null;
|
|
1685
|
+
var qBox = $('q-tiles');
|
|
1686
|
+
function qWhen(iso) {
|
|
1687
|
+
if (!iso) return '';
|
|
1688
|
+
var d = new Date(iso), s = (d - Date.now()) / 1000;
|
|
1689
|
+
if (s <= 0) return 'now';
|
|
1690
|
+
if (s < 86400) {
|
|
1691
|
+
var h = Math.floor(s / 3600), m = Math.round((s % 3600) / 60);
|
|
1692
|
+
return 'in ' + (h ? h + 'h ' : '') + m + 'm';
|
|
1693
|
+
}
|
|
1694
|
+
if (s < 6 * 86400) return d.toLocaleDateString([], {weekday: 'short'}) + ' ' + d.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
|
|
1695
|
+
return d.toLocaleDateString([], {month: 'numeric', day: 'numeric'}) + ' ' + d.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
|
|
1696
|
+
}
|
|
1697
|
+
function qAgo(iso) {
|
|
1698
|
+
var s = (Date.now() - new Date(iso)) / 1000;
|
|
1699
|
+
return s < 90 ? 'just now' : s < 5400 ? Math.round(s / 60) + ' min ago' : s < 129600 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' days ago';
|
|
1700
|
+
}
|
|
1701
|
+
function qTone(left) { return left >= 70 ? 'ok' : left >= 25 ? 'warn' : 'err'; }
|
|
1702
|
+
function qSegs(left) {
|
|
1703
|
+
var box = el('div', {class: 'segs ' + qTone(left), role: 'img', 'aria-label': left + '% left'});
|
|
1704
|
+
for (var i = 0; i < 10; i++) {
|
|
1705
|
+
var s = el('span'), f = el('i');
|
|
1706
|
+
f.style.width = Math.round(Math.min(Math.max(left / 10 - i, 0), 1) * 100) + '%';
|
|
1707
|
+
s.appendChild(f); box.appendChild(s);
|
|
1708
|
+
}
|
|
1709
|
+
return box;
|
|
1710
|
+
}
|
|
1711
|
+
function qLine(label, right) {
|
|
1712
|
+
var row = el('div', {class: 'q-line'});
|
|
1713
|
+
row.appendChild(el('span', {}, label));
|
|
1714
|
+
var r = el('span'); r.innerHTML = right; row.appendChild(r);
|
|
1715
|
+
return row;
|
|
1716
|
+
}
|
|
1717
|
+
function qNum(n) { return Number(n).toLocaleString(); }
|
|
1718
|
+
function qWindow(w) {
|
|
1719
|
+
var frag = document.createDocumentFragment();
|
|
1720
|
+
var row = qLine(w.label, '<b>' + w.left + '%</b>' + (w.reset_at ? ' · ' + qWhen(w.reset_at) : ''));
|
|
1721
|
+
if (w.of) row.title = qNum(w.remaining) + ' of ' + qNum(w.of) + ' left';
|
|
1722
|
+
frag.appendChild(row);
|
|
1723
|
+
frag.appendChild(qSegs(w.left));
|
|
1724
|
+
return frag;
|
|
1725
|
+
}
|
|
1726
|
+
function qResting(m) { return m.until && new Date(m.until) > Date.now(); }
|
|
1727
|
+
function qTile(t) {
|
|
1728
|
+
var node = el('article', {class: 'q-tile' + (t.out ? ' out' : ''), draggable: 'true', tabindex: '0',
|
|
1729
|
+
'data-id': t.id, 'aria-label': t.name + ': drag, or use the arrow keys, to move it'});
|
|
1730
|
+
var head = el('div', {class: 'q-head'});
|
|
1731
|
+
head.appendChild(el('b', {class: 'nm'}, t.name));
|
|
1732
|
+
var f = t.fresh, how = f.how === 'live' ? 'read ' : f.how === 'seen' ? 'seen ' : 'answered ';
|
|
1733
|
+
head.appendChild(el('span', {class: 'chip ' + (f.how === 'live' ? 'ok' : 'via')}, f.at ? how + qAgo(f.at) : 'nothing yet'));
|
|
1734
|
+
node.appendChild(head);
|
|
1735
|
+
node.appendChild(el('div', {class: 'q-tag mono'}, t.tag + (t.plan ? ' · ' + t.plan : '')));
|
|
1736
|
+
if (t.kind === 'groups') {
|
|
1737
|
+
t.groups.forEach(function (g) {
|
|
1738
|
+
var box = el('section', {class: g.label ? 'q-group' : 'q-group bare'});
|
|
1739
|
+
if (g.label) box.appendChild(el('h4', {}, g.label));
|
|
1740
|
+
g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
|
|
1741
|
+
node.appendChild(box);
|
|
1742
|
+
});
|
|
1743
|
+
} else if (t.kind === 'pool') {
|
|
1744
|
+
t.pool.forEach(function (p) {
|
|
1745
|
+
var box = el('section', {class: 'q-group bare'});
|
|
1746
|
+
if (p.of) {
|
|
1747
|
+
box.appendChild(qLine(p.label, '<b>' + qNum(Math.max(0, p.of - p.used)) + '</b> of ' + qNum(p.of) + ' left'
|
|
1748
|
+
+ (p.reset_at ? ' · ' + qWhen(p.reset_at) : '')));
|
|
1749
|
+
box.appendChild(qSegs(p.left));
|
|
1750
|
+
} else {
|
|
1751
|
+
box.appendChild(qLine(p.label, '<b>' + qNum(p.used) + '</b> sent · limit not known yet'));
|
|
1752
|
+
}
|
|
1753
|
+
node.appendChild(box);
|
|
1754
|
+
});
|
|
1755
|
+
} else {
|
|
1756
|
+
var shown = t.models.slice(0, 6);
|
|
1757
|
+
shown.forEach(function (m) {
|
|
1758
|
+
var right = qResting(m) ? '<span class="warn-t">resting · ' + qWhen(m.until) + '</span>'
|
|
1759
|
+
: m.ok + m.failed ? m.ok + ' ok · ' + m.failed + ' failed' : 'no calls today';
|
|
1760
|
+
var row = qLine('', right); row.firstChild.className = 'mono'; row.firstChild.textContent = m.alias;
|
|
1761
|
+
node.appendChild(row);
|
|
1762
|
+
});
|
|
1763
|
+
if (t.models.length > shown.length) node.appendChild(el('div', {class: 'muted q-small'}, '+ ' + (t.models.length - shown.length) + ' more in Models · last 24 h'));
|
|
1764
|
+
else node.appendChild(el('div', {class: 'muted q-small'}, 'last 24 h'));
|
|
1765
|
+
}
|
|
1766
|
+
if (t.kind !== 'models') {
|
|
1767
|
+
t.models.filter(qResting).forEach(function (m) {
|
|
1768
|
+
node.appendChild(el('div', {class: 'q-rest'}, m.alias + ' resting · back ' + qWhen(m.until)));
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
if (t.conn_limit) node.appendChild(el('div', {class: 'muted q-small'}, t.conn_limit + ', set by you'));
|
|
1772
|
+
(t.notes || []).forEach(function (n) { node.appendChild(el('div', {class: 'muted q-small'}, n)); });
|
|
1773
|
+
var msg = el('div', {class: 'q-msg', 'aria-live': 'polite'});
|
|
1774
|
+
node.appendChild(msg);
|
|
1775
|
+
var foot = el('div', {class: 'q-foot'});
|
|
1776
|
+
var rb = el('button', {class: 'btn small', type: 'button', 'aria-label': 'Refresh ' + t.name,
|
|
1777
|
+
title: t.refresh === 'live' ? 'Ask the provider what is left (free)' : 'Read the bench\'s own records again (free)'});
|
|
1778
|
+
rb.innerHTML = '↻ Refresh';
|
|
1779
|
+
rb.addEventListener('click', function () { qAct('refresh', t.id, node); });
|
|
1780
|
+
foot.appendChild(rb);
|
|
1781
|
+
if (t.check) {
|
|
1782
|
+
var cb = el('button', {class: 'btn small', type: 'button', title: 'One short call to one of its models; its reply says what is left'}, 'Check now · 1 request');
|
|
1783
|
+
cb.addEventListener('click', function () { qAct('check', t.id, node); });
|
|
1784
|
+
foot.appendChild(cb);
|
|
1785
|
+
}
|
|
1786
|
+
var mb = el('button', {class: 'btn small', type: 'button'}, 'Models ');
|
|
1787
|
+
mb.appendChild(el('span', {class: 'chip ' + (t.live === t.bench ? 'ok' : t.live ? 'warn' : 'err')}, t.live + '/' + t.bench));
|
|
1788
|
+
mb.title = t.live + ' of the ' + t.bench + ' bench models on it are free to call';
|
|
1789
|
+
mb.addEventListener('click', function () { qModels(t); });
|
|
1790
|
+
foot.appendChild(mb);
|
|
1791
|
+
node.appendChild(foot);
|
|
1792
|
+
return node;
|
|
1793
|
+
}
|
|
1794
|
+
function qAct(which, id, node) {
|
|
1795
|
+
var msg = node.querySelector('.q-msg');
|
|
1796
|
+
msg.className = 'q-msg muted'; msg.textContent = which === 'check' ? 'Asking…' : 'Refreshing…';
|
|
1797
|
+
return post('/api/quota/' + which, {tag: id}).then(function (r) {
|
|
1798
|
+
if (!r.ok) { msg.className = 'q-msg errors'; msg.textContent = r.errors.join('; '); return; }
|
|
1799
|
+
var i = QD.tiles.findIndex(function (t) { return t.id === id; });
|
|
1800
|
+
if (r.tile && i >= 0) {
|
|
1801
|
+
QD.tiles[i] = r.tile;
|
|
1802
|
+
var fresh = qTile(r.tile);
|
|
1803
|
+
node.replaceWith(fresh);
|
|
1804
|
+
if (r.note) { var m = fresh.querySelector('.q-msg'); m.className = 'q-msg muted'; m.textContent = r.note; }
|
|
1805
|
+
}
|
|
1806
|
+
drawCharts();
|
|
1807
|
+
}).catch(function (e) { msg.className = 'q-msg errors'; msg.textContent = String(e); });
|
|
1808
|
+
}
|
|
1809
|
+
function qModels(t) {
|
|
1810
|
+
$('q-models-h').textContent = t.name + ': ' + t.bench + ' on the bench';
|
|
1811
|
+
$('q-models-sub').textContent = t.live + ' free to call now · the last 24 hours of calls';
|
|
1812
|
+
var list = $('q-models-list'); list.textContent = '';
|
|
1813
|
+
var tb = el('table'), hd = el('tr');
|
|
1814
|
+
['Model', 'Now', 'Last 24 h', 'Last answered', 'Known limit'].forEach(function (h) { hd.appendChild(el('th', {}, h)); });
|
|
1815
|
+
tb.appendChild(el('thead')).appendChild(hd);
|
|
1816
|
+
var body = tb.appendChild(el('tbody'));
|
|
1817
|
+
t.models.forEach(function (m) {
|
|
1818
|
+
var tr = el('tr');
|
|
1819
|
+
tr.appendChild(el('td', {class: 'mono'}, m.alias));
|
|
1820
|
+
var now = el('td'); now.appendChild(el('span', {class: 'chip ' + (qResting(m) ? 'warn' : 'ok')}, qResting(m) ? 'resting · ' + qWhen(m.until) : 'ready'));
|
|
1821
|
+
tr.appendChild(now);
|
|
1822
|
+
tr.appendChild(el('td', {}, m.ok + ' ok · ' + m.failed + ' failed'));
|
|
1823
|
+
tr.appendChild(el('td', {}, m.last_ok ? qAgo(m.last_ok) : '—'));
|
|
1824
|
+
tr.appendChild(el('td', {}, m.limit || '—'));
|
|
1825
|
+
body.appendChild(tr);
|
|
1826
|
+
});
|
|
1827
|
+
list.appendChild(tb);
|
|
1828
|
+
$('q-models').showModal();
|
|
1829
|
+
}
|
|
1830
|
+
function qDefault(a, b) {
|
|
1831
|
+
return (b.out - a.out) || ((a.next_reset || '9999') < (b.next_reset || '9999') ? -1 : (a.next_reset || '9999') > (b.next_reset || '9999') ? 1 : 0)
|
|
1832
|
+
|| (a.id < b.id ? -1 : 1);
|
|
1833
|
+
}
|
|
1834
|
+
function qDraw() {
|
|
1835
|
+
qBox.textContent = '';
|
|
1836
|
+
QD.tiles.forEach(function (t) { qBox.appendChild(qTile(t)); });
|
|
1837
|
+
if (!QD.tiles.length) qBox.appendChild(el('p', {class: 'muted'}, 'No model on the bench yet.'));
|
|
1838
|
+
}
|
|
1839
|
+
function qSaveOrder() {
|
|
1840
|
+
var ids = Array.prototype.map.call(qBox.querySelectorAll('.q-tile'), function (n) { return n.dataset.id; });
|
|
1841
|
+
var at = {}; ids.forEach(function (id, i) { at[id] = i; });
|
|
1842
|
+
QD.tiles.sort(function (a, b) { return at[a.id] - at[b.id]; });
|
|
1843
|
+
post('/api/quota/order', {order: ids}).then(function (r) {
|
|
1844
|
+
$('q-done').textContent = r.ok ? 'Order kept.' : ''; $('q-errors').textContent = r.ok ? '' : r.errors.join('; ');
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
if (QD && qBox) {
|
|
1848
|
+
qDraw();
|
|
1849
|
+
$('q-models-close').addEventListener('click', function () { $('q-models').close(); });
|
|
1850
|
+
var dragged = null;
|
|
1851
|
+
qBox.addEventListener('dragstart', function (e) {
|
|
1852
|
+
dragged = e.target.closest && e.target.closest('.q-tile');
|
|
1853
|
+
if (!dragged) return;
|
|
1854
|
+
dragged.classList.add('dragging');
|
|
1855
|
+
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragged.dataset.id);
|
|
1856
|
+
});
|
|
1857
|
+
qBox.addEventListener('dragover', function (e) {
|
|
1858
|
+
if (!dragged) return;
|
|
1859
|
+
e.preventDefault();
|
|
1860
|
+
var over = e.target.closest && e.target.closest('.q-tile');
|
|
1861
|
+
if (!over || over === dragged) return;
|
|
1862
|
+
var r = over.getBoundingClientRect();
|
|
1863
|
+
var after = e.clientX > r.left + r.width / 2;
|
|
1864
|
+
over.parentNode.insertBefore(dragged, after ? over.nextSibling : over);
|
|
1865
|
+
});
|
|
1866
|
+
qBox.addEventListener('dragend', function () {
|
|
1867
|
+
if (!dragged) return;
|
|
1868
|
+
dragged.classList.remove('dragging'); dragged = null; qSaveOrder();
|
|
1869
|
+
});
|
|
1870
|
+
qBox.addEventListener('keydown', function (e) {
|
|
1871
|
+
var t = e.target;
|
|
1872
|
+
if (!t.classList || !t.classList.contains('q-tile')) return;
|
|
1873
|
+
var back = e.key === 'ArrowLeft' || e.key === 'ArrowUp', fwd = e.key === 'ArrowRight' || e.key === 'ArrowDown';
|
|
1874
|
+
if (!back && !fwd) return;
|
|
1875
|
+
e.preventDefault();
|
|
1876
|
+
var sib = back ? t.previousElementSibling : t.nextElementSibling;
|
|
1877
|
+
if (!sib) return;
|
|
1878
|
+
qBox.insertBefore(t, back ? sib : sib.nextSibling);
|
|
1879
|
+
t.focus(); qSaveOrder();
|
|
1880
|
+
});
|
|
1881
|
+
$('q-reorder').addEventListener('click', function () {
|
|
1882
|
+
QD.tiles.sort(qDefault); qDraw();
|
|
1883
|
+
post('/api/quota/order', {order: []}).then(function (r) { $('q-done').textContent = r.ok ? 'Sorted: out first, then the soonest reset.' : ''; });
|
|
1884
|
+
});
|
|
1885
|
+
$('q-all').addEventListener('click', function () {
|
|
1886
|
+
var live = QD.tiles.filter(function (t) { return t.refresh === 'live'; });
|
|
1887
|
+
$('q-done').textContent = 'Refreshing ' + live.length + '…';
|
|
1888
|
+
live.reduce(function (p, t) {
|
|
1889
|
+
return p.then(function () {
|
|
1890
|
+
var node = qBox.querySelector('.q-tile[data-id="' + CSS.escape(t.id) + '"]');
|
|
1891
|
+
return node ? qAct('refresh', t.id, node) : null;
|
|
1892
|
+
});
|
|
1893
|
+
}, Promise.resolve()).then(function () { $('q-done').textContent = 'Refreshed ' + live.length + '.'; });
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// The Quota page's two charts, in the theme's colours like the Benchmarks ones.
|
|
1898
|
+
function drawQuotaCharts(tok, style, width) {
|
|
1899
|
+
if (!QD) return;
|
|
1900
|
+
if (!window.Plot) {
|
|
1901
|
+
['chart-sent', 'chart-resets'].forEach(function (id) {
|
|
1902
|
+
if ($(id)) $(id).textContent = 'Charts need Observable Plot from cdn.jsdelivr.net, which did not load.';
|
|
1903
|
+
});
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
var ink = tok('--ink', '#24331f'), good = tok('--ok', '#327a16'), warn = tok('--warn', '#7a4d00'), line = tok('--line', '#e4dfd2');
|
|
1907
|
+
var sent = $('chart-sent');
|
|
1908
|
+
if (sent) {
|
|
1909
|
+
var R = QD.sent.slice().sort(function (a, b) { return b.month - a.month; });
|
|
1910
|
+
sent.textContent = '';
|
|
1911
|
+
var lg = el('div', {class: 'q-legend'});
|
|
1912
|
+
[['This month, requests', 0.3], ['Last 24 hours, requests', 1]].forEach(function (k) {
|
|
1913
|
+
var s = el('span'), sw = el('i'); sw.style.background = good; sw.style.opacity = k[1];
|
|
1914
|
+
s.appendChild(sw); s.appendChild(document.createTextNode(k[0])); lg.appendChild(s);
|
|
1915
|
+
});
|
|
1916
|
+
sent.appendChild(lg);
|
|
1917
|
+
sent.appendChild(R.length ? Plot.plot({
|
|
1918
|
+
width: width(), height: 22 * R.length + 60, style: style, marginLeft: 120, marginRight: 90,
|
|
1919
|
+
x: {label: 'Requests →', grid: true}, y: {domain: R.map(function (r) { return r.provider; }), label: null},
|
|
1920
|
+
marks: [
|
|
1921
|
+
Plot.barX(R, {x: 'month', y: 'provider', fill: good, fillOpacity: 0.3,
|
|
1922
|
+
title: function (r) { return r.provider + '\nthis month: ' + qNum(r.month) + '\nlast 24 hours: ' + qNum(r.day); }}),
|
|
1923
|
+
Plot.barX(R, {x: 'day', y: 'provider', fill: good, insetTop: 4, insetBottom: 4}),
|
|
1924
|
+
Plot.text(R, {x: 'month', y: 'provider', text: function (r) { return qNum(r.day) + ' / ' + qNum(r.month); }, dx: 6, textAnchor: 'start', fill: ink}),
|
|
1925
|
+
Plot.ruleX([0], {stroke: ink})
|
|
1926
|
+
]}) : el('p', {class: 'hint'}, 'No call in the ledger this month.'));
|
|
1927
|
+
}
|
|
1928
|
+
var res = $('chart-resets');
|
|
1929
|
+
if (res) {
|
|
1930
|
+
var now = Date.now(), end = now + 7 * 86400e3, dots = [], bars = [];
|
|
1931
|
+
QD.tiles.forEach(function (t) {
|
|
1932
|
+
var wins = [];
|
|
1933
|
+
t.groups.forEach(function (g) { g.windows.forEach(function (w) { wins.push({label: (g.label ? g.label + ' · ' : '') + w.label, at: w.reset_at}); }); });
|
|
1934
|
+
t.pool.forEach(function (p) { if (p.reset_at) wins.push({label: p.label, at: p.reset_at, daily: true}); });
|
|
1935
|
+
wins.forEach(function (w) {
|
|
1936
|
+
var at = +new Date(w.at), daily = w.daily || / day$/.test(w.label);
|
|
1937
|
+
for (; at && at <= end; at += 86400e3) {
|
|
1938
|
+
if (at >= now) dots.push({tile: t.name, at: new Date(at), what: w.label});
|
|
1939
|
+
if (!daily) break;
|
|
1940
|
+
}
|
|
1941
|
+
});
|
|
1942
|
+
t.models.filter(qResting).forEach(function (m) {
|
|
1943
|
+
bars.push({tile: t.name, x1: new Date(now), x2: new Date(Math.min(+new Date(m.until), end)), what: m.alias + ' resting until ' + new Date(m.until).toLocaleString()});
|
|
1944
|
+
});
|
|
1945
|
+
});
|
|
1946
|
+
var names = QD.tiles.map(function (t) { return t.name; }).filter(function (n) {
|
|
1947
|
+
return dots.some(function (d) { return d.tile === n; }) || bars.some(function (b) { return b.tile === n; });
|
|
1948
|
+
});
|
|
1949
|
+
res.textContent = '';
|
|
1950
|
+
res.appendChild(names.length ? Plot.plot({
|
|
1951
|
+
width: width(), height: 26 * names.length + 60, style: style, marginLeft: 120,
|
|
1952
|
+
x: {domain: [new Date(now), new Date(end)], label: null, grid: true}, y: {domain: names, label: null},
|
|
1953
|
+
marks: [
|
|
1954
|
+
Plot.barX(bars, {x1: 'x1', x2: 'x2', y: 'tile', fill: warn, fillOpacity: 0.35, insetTop: 6, insetBottom: 6, title: 'what'}),
|
|
1955
|
+
Plot.dot(dots, {x: 'at', y: 'tile', r: 4, fill: good, stroke: ink, strokeWidth: 0.5,
|
|
1956
|
+
title: function (d) { return d.what + ' resets ' + d.at.toLocaleString(); }}),
|
|
1957
|
+
Plot.ruleX([new Date(now)], {stroke: tok('--err', '#a12b2b'), strokeWidth: 1.5}),
|
|
1958
|
+
Plot.ruleY(names, {stroke: line, strokeOpacity: 0.5})
|
|
1959
|
+
]}) : el('p', {class: 'hint'}, 'No reset or rest known for the next 7 days.'));
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1535
1963
|
// ---- Benchmarks charts: Observable Plot, data from serve.charts_data ------
|
|
1536
1964
|
// A function, not run once: a theme switched on Settings redraws them in its colours.
|
|
1537
1965
|
function drawCharts() {
|
|
@@ -1554,6 +1982,7 @@
|
|
|
1554
1982
|
var col = document.querySelector('.panes') || document.querySelector('main');
|
|
1555
1983
|
return Math.max(520, (col.clientWidth || 900) - 50);
|
|
1556
1984
|
}
|
|
1985
|
+
drawQuotaCharts(tok, style, width);
|
|
1557
1986
|
function put(id, make) {
|
|
1558
1987
|
var box = $(id);
|
|
1559
1988
|
if (!box) return;
|
|
@@ -1737,6 +2166,71 @@
|
|
|
1737
2166
|
});
|
|
1738
2167
|
});
|
|
1739
2168
|
|
|
2169
|
+
// Vision tasks: one square per field and model, the task's name over its first field.
|
|
2170
|
+
put('chart-vision-fields', function (w) {
|
|
2171
|
+
var V = C.vision;
|
|
2172
|
+
if (!V.heat.length) return el('p', {class: 'hint'}, 'No vision task answers yet: the suite runs them for models'
|
|
2173
|
+
+ ' that take images, at onboarding (Full) or through the scheduled passes.');
|
|
2174
|
+
// Grouped by where the pictures come from, the group's name over its first field.
|
|
2175
|
+
var order = ['made', 'own', 'receipts', 'screens', 'tables', 'objects'], keys = [], head = {};
|
|
2176
|
+
Object.keys(V.tasks).sort(function (a, b) {
|
|
2177
|
+
return order.indexOf(V.tasks[a].family) - order.indexOf(V.tasks[b].family) || (a < b ? -1 : 1);
|
|
2178
|
+
}).forEach(function (t) {
|
|
2179
|
+
var fam = V.tasks[t].family;
|
|
2180
|
+
V.tasks[t].fields.forEach(function (f) {
|
|
2181
|
+
if (!keys.some(function (k) { return head[k] === fam; })) head[t + '·' + f] = fam;
|
|
2182
|
+
keys.push(t + '·' + f);
|
|
2183
|
+
});
|
|
2184
|
+
});
|
|
2185
|
+
var S = V.summary;
|
|
2186
|
+
function share(m) { return S[m].fields ? S[m].right / S[m].fields : 0; }
|
|
2187
|
+
var models = Object.keys(S).sort(function (a, b) { return share(b) - share(a) || S[a].taken - S[b].taken; });
|
|
2188
|
+
var colour = {right: good, misread: signal, 'left out': line, 'trap taken': err, 'no answer': muted};
|
|
2189
|
+
function tip(d) { return d.model + '\n' + d.task.replace(/^vision-/, '') + ': ' + d.field + (d.trap ? ' (a trap)' : '') + '\n' + d.state; }
|
|
2190
|
+
return Plot.plot({
|
|
2191
|
+
// The group names tilted: a narrow group (a few tables) would run into its neighbour's.
|
|
2192
|
+
width: w, height: 22 * models.length + 140, style: style, marginLeft: 300, marginTop: 80, padding: 0.08,
|
|
2193
|
+
x: {domain: keys, axis: 'top', label: null, tickSize: 0, tickRotate: -60, textAnchor: 'start',
|
|
2194
|
+
tickFormat: function (k) { return head[k] || ''; }},
|
|
2195
|
+
y: {domain: models, label: null, tickSize: 0,
|
|
2196
|
+
tickFormat: function (m) { return m + ' (' + S[m].right + '/' + S[m].fields + ')'; }},
|
|
2197
|
+
color: {legend: true, domain: V.states, range: V.states.map(function (s) { return colour[s]; })},
|
|
2198
|
+
marks: [
|
|
2199
|
+
Plot.cell(V.heat, {x: 'key', y: 'model', fill: 'state', title: tip}),
|
|
2200
|
+
Plot.dot(V.heat.filter(function (d) { return d.trap; }), {x: 'key', y: 'model', r: 1.8, fill: ink, fillOpacity: 0.55}),
|
|
2201
|
+
Plot.ruleX(Object.keys(head), {x: function (k) { return k; }, stroke: ink, strokeOpacity: 0.25, dx: -2})
|
|
2202
|
+
]
|
|
2203
|
+
});
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
// Vision, real work: each model's seats on image panels by how they were judged.
|
|
2207
|
+
put('chart-vision-work', function (w) {
|
|
2208
|
+
var W = C.vision.work, kinds = C.vision.seatKinds, data = [], total = {};
|
|
2209
|
+
Object.keys(W).forEach(function (m) {
|
|
2210
|
+
kinds.forEach(function (k) { if (W[m][k]) data.push({model: m, kind: k, count: W[m][k]}); total[m] = (total[m] || 0) + W[m][k]; });
|
|
2211
|
+
});
|
|
2212
|
+
if (!data.length) return el('p', {class: 'hint'}, 'No vision panels through the gateway yet.');
|
|
2213
|
+
// The models doing the most image work first: their bars are the evidence.
|
|
2214
|
+
var models = Object.keys(total).sort(function (a, b) { return total[b] - total[a] || (a < b ? -1 : 1); });
|
|
2215
|
+
var colour = {'checked, helped': good, 'checked, did not help': err, 'useful, no claims': sky,
|
|
2216
|
+
'no use, no claims': warn, 'not judged': line, 'no answer': muted};
|
|
2217
|
+
// The claims checked by hand, beside the bar: the only right-or-wrong count real work has.
|
|
2218
|
+
var claims = models.filter(function (m) { return W[m].real + W[m].false; }).map(function (m) {
|
|
2219
|
+
return {model: m, text: W[m].real + ' of ' + (W[m].real + W[m].false) + ' claims right'};
|
|
2220
|
+
});
|
|
2221
|
+
return Plot.plot({
|
|
2222
|
+
width: w, height: rows(models.length), style: style, marginLeft: 300, marginRight: 150,
|
|
2223
|
+
x: {label: 'Share of its seats on image panels →', tickFormat: '.0%', grid: true, domain: [0, 1]},
|
|
2224
|
+
y: {domain: models, label: null, tickFormat: function (m) { return m + ' (' + total[m] + ')'; }},
|
|
2225
|
+
color: {legend: true, domain: kinds, range: kinds.map(function (k) { return colour[k]; })},
|
|
2226
|
+
marks: [
|
|
2227
|
+
Plot.barX(data, Plot.stackX({x: 'count', y: 'model', fill: 'kind', offset: 'normalize', order: kinds,
|
|
2228
|
+
title: function (d) { return d.model + ': ' + d.kind + ', ' + d.count + ' of ' + total[d.model] + ' seats'; }})),
|
|
2229
|
+
Plot.text(claims, {x: 1, y: 'model', text: 'text', dx: 8, textAnchor: 'start', fill: ink})
|
|
2230
|
+
]
|
|
2231
|
+
});
|
|
2232
|
+
});
|
|
2233
|
+
|
|
1740
2234
|
// Confidently wrong: stacked by kind, worst at the top.
|
|
1741
2235
|
put('chart-wrong', function (w) {
|
|
1742
2236
|
var T = C.wrongTotals;
|