@prplkane/roundtable 0.0.0-stage → 0.6.4
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/CHANGELOG.md +529 -0
- package/LICENSE +21 -0
- package/README.md +189 -3
- package/SECURITY.md +299 -0
- package/docs/workflow.md +1344 -0
- package/package.json +52 -6
- package/scripts/agent.mjs +40 -0
- package/scripts/export.mjs +20 -0
- package/scripts/mcp.mjs +175 -0
- package/scripts/run-debate.mjs +70 -0
- package/scripts/server.mjs +28 -0
- package/scripts/setup.mjs +182 -0
- package/skills/roundtable/SKILL.md +57 -0
- package/src/adapters/agy.js +7 -0
- package/src/adapters/catalogue.js +49 -0
- package/src/adapters/claude.js +6 -0
- package/src/adapters/codex.js +6 -0
- package/src/adapters/common.js +304 -0
- package/src/adapters/config.js +432 -0
- package/src/adapters/endpoint.js +178 -0
- package/src/adapters/errors.js +69 -0
- package/src/adapters/index.js +11 -0
- package/src/adapters/models.js +106 -0
- package/src/adapters/quota.js +305 -0
- package/src/adapters/spawn.js +178 -0
- package/src/agent-client.js +108 -0
- package/src/cli.js +44 -0
- package/src/contract.js +256 -0
- package/src/diagnostics.js +133 -0
- package/src/doctor.js +101 -0
- package/src/engine.js +902 -0
- package/src/estimate.js +331 -0
- package/src/export.js +307 -0
- package/src/fake.js +75 -0
- package/src/folder-picker.js +46 -0
- package/src/history.js +220 -0
- package/src/local-http.js +39 -0
- package/src/local-server.js +110 -0
- package/src/notes.js +16 -0
- package/src/personas.js +49 -0
- package/src/preflight.js +126 -0
- package/src/progress.js +30 -0
- package/src/prompts.js +278 -0
- package/src/research.js +113 -0
- package/src/review-view.js +157 -0
- package/src/seats.js +272 -0
- package/src/server.js +928 -0
- package/src/settings.js +27 -0
- package/src/setup-toml.js +124 -0
- package/src/snapshot.js +93 -0
- package/src/store.js +303 -0
- package/src/terminal.js +77 -0
- package/src/terms.js +52 -0
- package/src/ui/app.js +1967 -0
- package/src/ui/avatars.js +132 -0
- package/src/ui/ceremony.js +87 -0
- package/src/ui/design/app.css +714 -0
- package/src/ui/design/preset-dusk.css +91 -0
- package/src/ui/design/tokens.css +129 -0
- package/src/ui/diagnostics.js +61 -0
- package/src/ui/help.js +122 -0
- package/src/ui/icons.js +64 -0
- package/src/ui/index.html +272 -0
- package/src/ui/loader.js +73 -0
- package/src/ui/logo.svg +4 -0
- package/src/ui/scan.js +57 -0
- package/src/ui/scribe.js +64 -0
- package/src/ui/sound.js +80 -0
- package/src/validate.js +112 -0
- package/src/version.js +5 -0
package/src/ui/app.js
ADDED
|
@@ -0,0 +1,1967 @@
|
|
|
1
|
+
import { rollup, hasUsableRound, positions } from '/src/engine.js';
|
|
2
|
+
import { optionLabel } from '/src/contract.js';
|
|
3
|
+
import { poolKey, poolVendor, poolAccount, hostKey, seatKind, initialSeatVendors, nextSeatLens, fillMissingSeatLenses, seatSetupIssue, hiddenMood, lightestModel, keyEnvName, HIDDEN_MOODS } from '/src/seats.js';
|
|
4
|
+
import { avatar } from '/ui/avatars.js';
|
|
5
|
+
import { modelsByCapability, retired, quotaWindows, quotaGroup, modelEfforts } from '/src/adapters/models.js';
|
|
6
|
+
import { project, projectedWindows, unknownUsageMessage } from '/src/estimate.js';
|
|
7
|
+
import { PREFLIGHT_TTL_MS, lamp as preflightLamp } from '/src/preflight.js';
|
|
8
|
+
import { createBrowserDiagnostics, requestAction } from '/ui/diagnostics.js';
|
|
9
|
+
import { installHelp, revealText, MOOD_HELP } from '/ui/help.js';
|
|
10
|
+
import { installCeremony } from '/ui/ceremony.js';
|
|
11
|
+
import { scribeMarkup, startScribe, stopScribe } from '/ui/scribe.js';
|
|
12
|
+
import { scanMarkup, startScan, stopScan } from '/ui/scan.js';
|
|
13
|
+
import { icon } from '/ui/icons.js';
|
|
14
|
+
import { loaderMarkup } from '/ui/loader.js';
|
|
15
|
+
import { noteRecord, noteLabel } from '/src/notes.js';
|
|
16
|
+
import { progressText } from '/src/progress.js';
|
|
17
|
+
import { unlock as unlockSound, setSound, soundEnabled, playCue } from '/ui/sound.js';
|
|
18
|
+
import { groupQuestions, answerMessage, stopExplanation, cancellationReason, remainingWindow, argumentHtml, ceremonyOutcome, verdictView, verdictResultText, verdictHeadline, defaultSearchQuery } from '/src/review-view.js';
|
|
19
|
+
|
|
20
|
+
const $ = (id) => document.getElementById(id);
|
|
21
|
+
const helpNotes = installHelp();
|
|
22
|
+
const ceremony = installCeremony();
|
|
23
|
+
let openingSlug = null;
|
|
24
|
+
const replyReveals = new Map();
|
|
25
|
+
// Reply cards mid-motion (folding cards, below): reply key -> { kind: 'unfold' | 'fold', at }.
|
|
26
|
+
const cardMotion = new Map();
|
|
27
|
+
const replyKey = (r) => `${r.type}:${r.attemptId || `${r.seat?.id || 'chairman'}:${r.round ?? -1}`}`;
|
|
28
|
+
// The launch token the server put in the page; every POST carries it (server.js refuse()).
|
|
29
|
+
const SEND = { 'content-type': 'application/json', 'x-roundtable-token': document.querySelector('meta[name="roundtable-token"]')?.content || '' };
|
|
30
|
+
let diagnosticStorage;
|
|
31
|
+
try { diagnosticStorage = sessionStorage; } catch { /* private browsing may disable storage */ }
|
|
32
|
+
// The server has restarted since this page was served: its token opens nothing any more, so
|
|
33
|
+
// every write would be refused (401) and retrying only fills the log. One banner, one Reload.
|
|
34
|
+
let stale = false;
|
|
35
|
+
function markStale() {
|
|
36
|
+
if (stale) return;
|
|
37
|
+
stale = true;
|
|
38
|
+
const banner = $('page-error');
|
|
39
|
+
banner.textContent = 'This board belongs to an earlier server session. Nothing here can be sent until the page is reloaded; copy any unsent note first. ';
|
|
40
|
+
const reload = document.createElement('button');
|
|
41
|
+
reload.type = 'button'; reload.className = 'btn btn-small'; reload.textContent = 'Reload';
|
|
42
|
+
reload.addEventListener('click', () => location.reload());
|
|
43
|
+
banner.append(reload); banner.hidden = false;
|
|
44
|
+
}
|
|
45
|
+
const clientDiagnostics = createBrowserDiagnostics({ storage: diagnosticStorage,
|
|
46
|
+
send: async (events) => {
|
|
47
|
+
if (stale) throw new Error('Diagnostic log unavailable until reload.');
|
|
48
|
+
const res = await fetch('/api/diagnostics/report', { method: 'POST', headers: SEND, body: JSON.stringify({ events }), signal: AbortSignal.timeout(5000) });
|
|
49
|
+
if (res.status === 401) markStale();
|
|
50
|
+
if (!res.ok) throw new Error('Diagnostic log unavailable.');
|
|
51
|
+
return res.json();
|
|
52
|
+
},
|
|
53
|
+
onPending: (count) => {
|
|
54
|
+
if (stale) return; // the banner says what to do; nothing will reach the log before a reload
|
|
55
|
+
if (count) { $('diagnostics-status').textContent = 'Some error details have not reached the local log. Keep this tab open; they will retry when the server is available. Save error log includes pending details.'; $('diagnostics-status').hidden = false; }
|
|
56
|
+
else if ($('diagnostics-status').textContent.startsWith('Some error details')) $('diagnostics-status').hidden = true;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const errorReference = (diagnostic) => diagnostic?.id ? ` Reference ${diagnostic.id}.${diagnostic.saved === false ? ' The local log could not save this entry.' : ''}` : '';
|
|
60
|
+
const callDetails = (errors, progress) => {
|
|
61
|
+
const text = (errors || []).join('\n'), details = progressText(progress);
|
|
62
|
+
return details && !text.includes(details) ? `${text}\n${details}` : text;
|
|
63
|
+
};
|
|
64
|
+
function browserFailure(code, action, message, details) {
|
|
65
|
+
const id = clientDiagnostics.report(code, action, details);
|
|
66
|
+
return new Error(`${message}${errorReference({ id })}`);
|
|
67
|
+
}
|
|
68
|
+
function pageFailure(code, event) {
|
|
69
|
+
if (stale) return; // the banner already says what to do, and its Reload button must stay
|
|
70
|
+
const id = clientDiagnostics.report(code, 'unknown', { line: event?.lineno, column: event?.colno });
|
|
71
|
+
$('page-error').textContent = `A page error interrupted an action. Copy any unsent note, reload, then check the review before retrying.${errorReference({ id })}`;
|
|
72
|
+
$('page-error').hidden = false;
|
|
73
|
+
}
|
|
74
|
+
window.addEventListener('error', (e) => pageFailure('browser-error', e));
|
|
75
|
+
window.addEventListener('unhandledrejection', () => pageFailure('browser-rejection'));
|
|
76
|
+
window.addEventListener('online', () => void clientDiagnostics.flush());
|
|
77
|
+
setInterval(() => { if (!stale) void clientDiagnostics.flush(); }, 15_000);
|
|
78
|
+
void clientDiagnostics.flush();
|
|
79
|
+
// A failed or timed-out write can already have reached the server. Never retry it automatically.
|
|
80
|
+
async function requestJson(url, body, timeoutMs = 15_000) {
|
|
81
|
+
const action = requestAction(url, body !== undefined);
|
|
82
|
+
let res;
|
|
83
|
+
try {
|
|
84
|
+
res = await fetch(url, { ...(body === undefined ? {} : { method: 'POST', headers: SEND, body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeoutMs) });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
const timeout = ['TimeoutError', 'AbortError'].includes(e.name);
|
|
87
|
+
throw browserFailure(timeout ? 'browser-timeout' : 'browser-network', action,
|
|
88
|
+
`${timeout ? `The server did not respond within ${Math.round(timeoutMs / 1000)} seconds.` : 'Could not reach the local server.'} ${body === undefined ? 'Check that it is running, then reload.' : 'The action may already have happened. Keep your note and check the review before retrying.'}`);
|
|
89
|
+
}
|
|
90
|
+
let out;
|
|
91
|
+
try { out = await res.json(); } catch { throw browserFailure('browser-response', action, 'The server returned an unreadable response. Check the current state before retrying.', { status: res.status }); }
|
|
92
|
+
if (!out || typeof out !== 'object') throw browserFailure('browser-response', action, 'The server returned an unreadable response. Check the current state before retrying.', { status: res.status });
|
|
93
|
+
if (!res.ok || out.ok === false) {
|
|
94
|
+
const message = res.status === 401 ? 'This page is out of date. Copy any unsent note, reload, then check the review before retrying.' : out.error || 'This action is no longer available. Check the current state before retrying.';
|
|
95
|
+
if (res.status === 401) { markStale(); throw new Error(message); } // not a diagnostic: the banner explains it, and the log cannot be reached
|
|
96
|
+
if (out.diagnostic) throw Object.assign(new Error(message + errorReference(out.diagnostic)), { field: out.field, seatIndex: out.seatIndex });
|
|
97
|
+
throw browserFailure('browser-response', action, message, { status: res.status });
|
|
98
|
+
}
|
|
99
|
+
void clientDiagnostics.flush();
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
$('save-diagnostics').addEventListener('click', async () => {
|
|
103
|
+
const button = $('save-diagnostics'); button.disabled = true;
|
|
104
|
+
try {
|
|
105
|
+
await clientDiagnostics.flush();
|
|
106
|
+
const out = await requestJson('/api/diagnostics', {});
|
|
107
|
+
const blob = new Blob([JSON.stringify({ ...out, pendingBrowserEvents: clientDiagnostics.pending() }, null, 2)], { type: 'application/json' });
|
|
108
|
+
const url = URL.createObjectURL(blob), link = document.createElement('a');
|
|
109
|
+
link.href = url; link.download = `roundtable-errors-${new Date().toISOString().slice(0, 10)}.json`; link.click();
|
|
110
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
111
|
+
$('diagnostics-status').textContent = `Error log saved. Nothing was uploaded.${!out.available ? ' The server could not read or write part of the log; check disk space and data-folder permissions.' : ''}${out.dropped ? ' Repeated errors exceeded the logging rate limit; some entries were omitted.' : ''}`;
|
|
112
|
+
} catch (e) { $('diagnostics-status').textContent = `Could not save the error log. ${e.message}`; }
|
|
113
|
+
finally { button.disabled = false; $('diagnostics-status').hidden = false; }
|
|
114
|
+
});
|
|
115
|
+
const initialDraft = (vendors) => vendors.map((vendor, i) => ({ vendor, lens: ['risk and failure modes', 'cost and effort'][i], mood: 'grill', moodText: '', model: '', effort: '' }));
|
|
116
|
+
const VENDOR_LABEL = {};
|
|
117
|
+
const VENDOR_META = {}; // vendor -> { kind, usageUrl, pool, catalogue, colour, profiles } from /api/seats
|
|
118
|
+
let MOODS = {}, COLOURS = ['rust', 'olive', 'teal', 'dusk', 'steel'], BASELINES = {};
|
|
119
|
+
// Known OpenAI-compatible endpoints, offered as the endpoint field's suggestions. A hosted API
|
|
120
|
+
// names the environment variable its key is usually kept in; the card prefills it, never a key.
|
|
121
|
+
const ENDPOINT_PRESETS = [
|
|
122
|
+
{ label: 'Ollama (this machine)', url: 'http://localhost:11434/v1' },
|
|
123
|
+
{ label: 'LM Studio (this machine)', url: 'http://localhost:1234/v1' },
|
|
124
|
+
{ label: 'vLLM (this machine)', url: 'http://localhost:8000/v1' },
|
|
125
|
+
{ label: 'llama.cpp server (this machine)', url: 'http://localhost:8080/v1' },
|
|
126
|
+
{ label: 'DeepSeek', url: 'https://api.deepseek.com/v1', keyEnv: 'DEEPSEEK_API_KEY' },
|
|
127
|
+
{ label: 'Moonshot (Kimi)', url: 'https://api.moonshot.ai/v1', keyEnv: 'MOONSHOT_API_KEY' },
|
|
128
|
+
{ label: 'OpenAI', url: 'https://api.openai.com/v1', keyEnv: 'OPENAI_API_KEY' },
|
|
129
|
+
];
|
|
130
|
+
const isApi = (vendor) => VENDOR_META[vendor]?.kind === 'api';
|
|
131
|
+
// The pool a drafted seat draws on: a CLI's login profile, or an endpoint's host.
|
|
132
|
+
const seatAccount = (s) => (isApi(s.vendor) ? hostKey(s.endpoint || '') : (s.account || '').trim());
|
|
133
|
+
const chairAccount = () => (isApi($('chairman').value) ? hostKey($('chairman-endpoint').value) : $('chairman-account').value.trim());
|
|
134
|
+
|
|
135
|
+
// ---------- state ----------
|
|
136
|
+
const S = {
|
|
137
|
+
down: {}, // board: logins out for this debate (pool-down events)
|
|
138
|
+
preflight: {}, preflighting: false, startConfirm: null, // setup screen: the lamps' answers per pool key
|
|
139
|
+
preflightError: null, preflightCheckedAt: null,
|
|
140
|
+
slug: null, seats: [], fieldNotes: null, rows: [], rounds: [], humanNotes: [], initialNotes: [], gate: null, deadline: null, held: false,
|
|
141
|
+
verdict: null, status: 'idle', startedAt: null, stopReason: null, pending: new Set(), quota: {}, calib: {}, pools: {}, chairman: null, configuredChairman: null,
|
|
142
|
+
conn: {}, // seat id -> connection state (idle, thinking, replied, reprompted, failed, timed-out), from seat-state events
|
|
143
|
+
positions: [], // per round: seat id -> agreed | disagreed | neutral | passed | null, from round-done
|
|
144
|
+
opened: new Set(), // reply keys opened by a click on the card head; every reply starts folded to its seat and stance
|
|
145
|
+
// Folds the person set by hand, so a redraw on the next event keeps them: roundOpen by round
|
|
146
|
+
// index (unset: the latest round is open), openDetails by reply key and details class.
|
|
147
|
+
roundOpen: {}, openDetails: new Set(), timedOut: {},
|
|
148
|
+
unattended: false, // the review skips every pause (from its options, so a replay from disk knows too)
|
|
149
|
+
draft: [], // setup screen: the seat rows being edited
|
|
150
|
+
budget: {}, // setup screen: vendor -> { tokens, usd } caps
|
|
151
|
+
};
|
|
152
|
+
const CONN_LABEL = { idle: 'idle', thinking: 'thinking', replied: 'replied', reprompted: 're-prompted', failed: 'failed', cancelled: 'cancelled', 'timed-out': 'timed out', down: 'out for this debate' };
|
|
153
|
+
// What a failed call's kind means for the human, in one line (adapters/errors.js classifies).
|
|
154
|
+
const ERROR_LINE = {
|
|
155
|
+
policy: 'The provider tool policy stopped this seat. Its rejected answer is excluded from the discussion.',
|
|
156
|
+
compatibility: 'The CLI does not support a required restriction. Update it before starting a new review.',
|
|
157
|
+
quota: 'Provider usage limit reached. Wait for the reset or choose another provider.',
|
|
158
|
+
auth: 'Provider login needs attention. Sign in with its CLI, then refresh status.',
|
|
159
|
+
missing: 'Provider CLI not found. Install it and restart Roundtable.',
|
|
160
|
+
network: 'Could not reach the provider. Check your connection and its service status.',
|
|
161
|
+
timeout: 'This provider reached its per-call time limit. Other replies are kept. Check the saved progress and provider status before retrying.',
|
|
162
|
+
killed: 'Stopped before it answered.', format: 'The reply did not follow the required format. Try another model.',
|
|
163
|
+
budget: 'The budget blocked this call. Check the cap and reported usage before continuing.',
|
|
164
|
+
};
|
|
165
|
+
const POSITION_LABEL = { agreed: 'with the majority', disagreed: 'against the majority', neutral: 'own stance', passed: 'passed' };
|
|
166
|
+
const led = (state) => `<span class="led led-${esc(state || 'idle')}" title="${esc(CONN_LABEL[state] || 'idle')}" aria-label="${esc(CONN_LABEL[state] || 'idle')}"></span>`;
|
|
167
|
+
|
|
168
|
+
// ---------- setup screen ----------
|
|
169
|
+
async function setup() {
|
|
170
|
+
const { vendors, moods, colours, defaults, baselines, calibration, pools, runtime } = await requestJson('/api/seats');
|
|
171
|
+
const demo = runtime?.mode === 'demo';
|
|
172
|
+
$('run-mode').textContent = demo ? 'Demo' : 'Live';
|
|
173
|
+
$('run-mode').className = `run-mode ${demo ? 'demo' : 'live'}`;
|
|
174
|
+
$('mode-note').textContent = demo ? 'Demo: replies and usage are scripted. No model calls are made.' : 'Live: debates send your field notes to the providers you select and use their quota or billing.';
|
|
175
|
+
$('data-dir').textContent = runtime?.dataDir || 'unavailable — check the server terminal';
|
|
176
|
+
const legacy = Array.isArray(runtime?.legacyDataDirs) ? runtime.legacyDataDirs : [];
|
|
177
|
+
$('legacy-data').hidden = !legacy.length;
|
|
178
|
+
$('legacy-data').textContent = legacy.length ? `Older history remains at ${legacy.join('; ')}. Stop any server using ${legacy.length === 1 ? 'that folder before moving its reviews' : 'those folders before moving their reviews'}; see the user guide.` : '';
|
|
179
|
+
if (runtime?.diagnostics?.available === false) { $('diagnostics-status').textContent = 'The local error log is unavailable. Check disk space and data-folder permissions.'; $('diagnostics-status').hidden = false; }
|
|
180
|
+
MOODS = moods; COLOURS = colours; BASELINES = baselines || {}; S.calib = calibration || {}; S.pools = pools || {};
|
|
181
|
+
const chair = $('chairman');
|
|
182
|
+
for (const v of vendors) {
|
|
183
|
+
VENDOR_LABEL[v.vendor] = v.label;
|
|
184
|
+
VENDOR_META[v.vendor] = { kind: v.kind, usageUrl: v.usageUrl, pool: v.pool, catalogue: v.catalogue, colour: v.colour, profiles: v.profiles, limitedIsolation: v.limitedIsolation === true, terms: v.terms || null };
|
|
185
|
+
if (v.quota) S.quota[v.vendor] = v.quota;
|
|
186
|
+
chair.insertAdjacentHTML('beforeend', `<option value="${v.vendor}">${esc(v.label)}</option>`);
|
|
187
|
+
if (v.search) $('searcher-vendor').insertAdjacentHTML('beforeend', `<option value="${v.vendor}">${esc(v.label)}</option>`);
|
|
188
|
+
// one datalist per vendor, so the model field offers what that CLI is known to accept
|
|
189
|
+
$('datalists').insertAdjacentHTML('beforeend', `<datalist id="models-${v.vendor}">${(v.catalogue?.models || []).map((m) => `<option value="${esc(m.id)}">${esc(m.label)}</option>`).join('')}</datalist>`);
|
|
190
|
+
}
|
|
191
|
+
$('datalists').insertAdjacentHTML('beforeend', `<datalist id="endpoints">${ENDPOINT_PRESETS.map((e) => `<option value="${esc(e.url)}">${esc(e.label)}</option>`).join('')}</datalist>`);
|
|
192
|
+
$('critique-rounds').value = defaults.critiqueRounds;
|
|
193
|
+
$('auto').value = defaults.autoContinueMs / 1000;
|
|
194
|
+
$('question-wait').value = (defaults.questionWaitMs || 120000) / 1000;
|
|
195
|
+
$('wall').value = defaults.wallClockMs / 60000;
|
|
196
|
+
$('memory').value = defaults.memory || 'stateless';
|
|
197
|
+
try { const saved = JSON.parse(localStorage.getItem('roundtable.notes') || 'null'); if (saved) for (const [k, v] of Object.entries(saved)) if (k !== 'options' && $(k)) $(k).value = v; S.options = String(saved?.options || '').split('\n').map((o) => o.trim()).filter(Boolean); } catch {}
|
|
198
|
+
if (!S.options || S.options.length < 2) S.options = [...(S.options || []), '', ''].slice(0, Math.max(2, S.options?.length || 0));
|
|
199
|
+
renderOptions();
|
|
200
|
+
setupMoreContext();
|
|
201
|
+
let saved = null, settings = {};
|
|
202
|
+
try { saved = JSON.parse(localStorage.getItem('roundtable.seats') || 'null'); } catch {}
|
|
203
|
+
try { settings = JSON.parse(localStorage.getItem('roundtable.settings') || '{}') || {}; } catch {}
|
|
204
|
+
// Migrate old preferences before restoring inputs, including fields on a changed vendor.
|
|
205
|
+
if (Array.isArray(saved)) {
|
|
206
|
+
saved = saved.map((s) => s && typeof s === 'object' ? { ...s, keyEnv: keyEnvName(s.keyEnv) } : s);
|
|
207
|
+
try { localStorage.setItem('roundtable.seats', JSON.stringify(saved)); } catch {}
|
|
208
|
+
}
|
|
209
|
+
if (settings.chairman && typeof settings.chairman === 'object') {
|
|
210
|
+
settings.chairman = { ...settings.chairman, keyEnv: keyEnvName(settings.chairman.keyEnv) };
|
|
211
|
+
try { localStorage.setItem('roundtable.settings', JSON.stringify(settings)); } catch {}
|
|
212
|
+
}
|
|
213
|
+
const retained = Array.isArray(saved) ? saved.filter((s) => s && VENDOR_META[s.vendor]) : [];
|
|
214
|
+
S.draft = retained.length >= 2 ? fillMissingSeatLenses(retained) : initialDraft(initialSeatVendors(vendors));
|
|
215
|
+
const filled = retained.length >= 2 ? retained.filter((s) => !String(s.lens || '').trim()).length : 0;
|
|
216
|
+
$('seat-setup-note').hidden = !filled;
|
|
217
|
+
$('seat-setup-note').textContent = filled ? `Added starter lenses to ${filled} unfinished seat${filled === 1 ? '' : 's'}. Review or edit them before opening the table.` : '';
|
|
218
|
+
S.initialDraft = retained.length >= 2 ? null : JSON.stringify(S.draft);
|
|
219
|
+
S.initialChair = !settings.chairman;
|
|
220
|
+
for (const [key, id, scale, min, max] of [
|
|
221
|
+
['critiqueRounds', 'critique-rounds', 1, 0, 8], ['autoContinueMs', 'auto', 1000, 100, 600_000], ['questionWaitMs', 'question-wait', 1000, 100, 600_000], ['wallClockMs', 'wall', 60_000, 60_000, 10_800_000],
|
|
222
|
+
]) {
|
|
223
|
+
const value = settings.options?.[key];
|
|
224
|
+
if (Number.isInteger(value) && value >= min && value <= max) $(id).value = value / scale;
|
|
225
|
+
}
|
|
226
|
+
if (['stateless', 'resume'].includes(settings.options?.memory)) $('memory').value = settings.options.memory;
|
|
227
|
+
$('unattended').checked = settings.options?.unattended === true;
|
|
228
|
+
const research = settings.options?.research;
|
|
229
|
+
$('research-query').value = research?.query || '';
|
|
230
|
+
$('research-query-mode').value = settings.researchQueryMode === 'decision' || !research?.query ? 'decision' : 'custom';
|
|
231
|
+
syncResearchQuery();
|
|
232
|
+
$('notes').addEventListener('input', syncResearchQuery);
|
|
233
|
+
$('research-query-mode').addEventListener('change', () => { syncResearchQuery(); $('research-query').focus(); });
|
|
234
|
+
$('research-searches').value = research?.maxSearches || 4;
|
|
235
|
+
$('research-time').value = (research?.timeoutMs || 120000) / 1000;
|
|
236
|
+
// a new searcher starts on its vendor's lightest model at low effort; a saved one keeps its own
|
|
237
|
+
// (settings saved before the searcher had its own model name none, so they start light too)
|
|
238
|
+
const searchers = [...$('searcher-vendor').options].map((o) => o.value);
|
|
239
|
+
$('searcher-vendor').value = searchers.includes(research?.vendor) ? research.vendor : searchers.includes(DEFAULT_SEARCHER) ? DEFAULT_SEARCHER : searchers[0] || '';
|
|
240
|
+
const named = !!research && ['model', 'effort', 'account'].some((key) => Object.hasOwn(research, key)) && $('searcher-vendor').value === (research.vendor || DEFAULT_SEARCHER);
|
|
241
|
+
if (named) { $('searcher-model').value = research.model || ''; $('searcher-effort').value = research.effort || ''; } else lightSearcher($('searcher-vendor').value);
|
|
242
|
+
$('add-searcher').disabled = !searchers.length;
|
|
243
|
+
$('searcher-account').value = research?.account || '';
|
|
244
|
+
$('searcher-wrap').hidden = !research; $('add-searcher').hidden = !!research;
|
|
245
|
+
const ch = settings.chairman;
|
|
246
|
+
if (ch && VENDOR_META[ch.vendor]) {
|
|
247
|
+
chair.value = ch.vendor;
|
|
248
|
+
for (const field of ['model', 'account', 'endpoint', 'keyEnv']) if (typeof ch[field] === 'string') $(`chairman-${field === 'keyEnv' ? 'keyenv' : field}`).value = ch[field];
|
|
249
|
+
}
|
|
250
|
+
chairEffortOptions();
|
|
251
|
+
if (ch?.effort && [...$('chairman-effort').options].some((o) => o.value === ch.effort)) $('chairman-effort').value = ch.effort;
|
|
252
|
+
renderSeats();
|
|
253
|
+
|
|
254
|
+
$('add-seat').addEventListener('click', () => { S.initialDraft = null; clearSetupValidation(); S.draft.push({ vendor: vendors[0].vendor, lens: nextSeatLens(S.draft), mood: 'grill', moodText: '', model: '', effort: '' }); renderSeats(); });
|
|
255
|
+
$('seats').addEventListener('input', onSeatEdit);
|
|
256
|
+
$('seats').addEventListener('change', onSeatEdit);
|
|
257
|
+
$('seats').addEventListener('click', (ev) => {
|
|
258
|
+
const pip = ev.target.closest('.pip');
|
|
259
|
+
if (pip) {
|
|
260
|
+
S.initialDraft = null;
|
|
261
|
+
const s = S.draft[Number(pip.closest('.seat').dataset.i)];
|
|
262
|
+
const fam = s.vendor === 'agy' && familyOf('agy', s.model);
|
|
263
|
+
if (fam) Object.assign(s, pickedModel('agy', fam.id, pip.dataset.level)); // a variant is always one level
|
|
264
|
+
else s.effort = s.effort === pip.dataset.level ? '' : pip.dataset.level;
|
|
265
|
+
renderSeats(); return;
|
|
266
|
+
}
|
|
267
|
+
const btn = ev.target.closest('.remove'); if (!btn) return;
|
|
268
|
+
S.initialDraft = null;
|
|
269
|
+
if (S.draft.length <= 2) { $('seats-error').textContent = 'A table needs at least two seats.'; return; }
|
|
270
|
+
S.draft.splice(Number(btn.closest('.seat').dataset.i), 1); renderSeats();
|
|
271
|
+
});
|
|
272
|
+
chair.addEventListener('change', () => { S.customChairModel = false; $('chairman-model').value = ''; chairEffortOptions(); renderChair(); renderProjection(); });
|
|
273
|
+
$('chair-card').addEventListener('input', () => { S.initialChair = false; });
|
|
274
|
+
$('chair-card').addEventListener('change', () => { S.initialChair = false; });
|
|
275
|
+
$('chair-pips').addEventListener('click', (ev) => {
|
|
276
|
+
const pip = ev.target.closest('.pip'); if (!pip) return;
|
|
277
|
+
S.initialChair = false;
|
|
278
|
+
const sel = $('chairman-effort');
|
|
279
|
+
const fam = $('chairman').value === 'agy' && familyOf('agy', $('chairman-model').value.trim());
|
|
280
|
+
if (fam) { const p = pickedModel('agy', fam.id, pip.dataset.level); $('chairman-model').value = p.model; sel.value = p.effort; }
|
|
281
|
+
else sel.value = sel.value === pip.dataset.level ? '' : pip.dataset.level; // the filled last pip again: back to default
|
|
282
|
+
renderChair(); renderProjection();
|
|
283
|
+
});
|
|
284
|
+
$('chair-card').addEventListener('change', (ev) => {
|
|
285
|
+
if (ev.target.id !== 'chair-model-pick') return;
|
|
286
|
+
const v = ev.target.value;
|
|
287
|
+
S.customChairModel = v === '__other';
|
|
288
|
+
if (S.customChairModel) { $('chairman-model').value = ''; renderChair(); $('chairman-model').focus(); return; }
|
|
289
|
+
const p = pickedModel($('chairman').value, v, $('chairman-effort').value);
|
|
290
|
+
$('chairman-model').value = p.model; if (p.effort !== undefined) $('chairman-effort').value = p.effort || '';
|
|
291
|
+
renderChair(); renderProjection();
|
|
292
|
+
});
|
|
293
|
+
$('chairman-account').addEventListener('input', () => { renderChair(); requestPreflight(); });
|
|
294
|
+
$('chairman').addEventListener('change', () => requestPreflight());
|
|
295
|
+
$('refresh-status').addEventListener('click', () => requestPreflight({ fresh: true }));
|
|
296
|
+
// an endpoint chairman: a preset URL prefills the key variable's usual name (never a key)
|
|
297
|
+
$('chairman-endpoint').addEventListener('change', () => { const pre = ENDPOINT_PRESETS.find((e) => e.url === $('chairman-endpoint').value.trim()); if (pre?.keyEnv && !$('chairman-keyenv').value.trim()) $('chairman-keyenv').value = pre.keyEnv; renderChair(); requestPreflight(); });
|
|
298
|
+
for (const id of ['chairman-endpoint', 'chairman-keyenv']) $(id).addEventListener('input', () => { renderChair(); requestPreflight(); });
|
|
299
|
+
renderChair();
|
|
300
|
+
// first, so the projection below reads the level the new model takes, not the one it dropped
|
|
301
|
+
$('chairman-model').addEventListener('input', chairEffortOptions); // a model that takes fewer levels offers only those
|
|
302
|
+
for (const id of ['chairman-model', 'chairman-effort', 'critique-rounds', 'memory']) $(id).addEventListener('input', renderProjection);
|
|
303
|
+
for (const id of ['chairman-model', 'searcher-model']) $(id).addEventListener('input', () => updateLamps()); // agy: the lamp follows the model's quota group
|
|
304
|
+
$('add-searcher').addEventListener('click', () => { setSearcher(true); $('research-query').focus(); });
|
|
305
|
+
$('remove-searcher').addEventListener('click', () => { setSearcher(false); $('add-searcher').focus(); });
|
|
306
|
+
// another vendor: its own lightest model and login, not the last vendor's
|
|
307
|
+
$('searcher-vendor').addEventListener('change', () => { lightSearcher($('searcher-vendor').value); renderSearcher(); renderProjection(); requestPreflight(); });
|
|
308
|
+
$('searcher-card').addEventListener('change', (ev) => {
|
|
309
|
+
if (ev.target.id !== 'searcher-model-pick') return;
|
|
310
|
+
S.customSearcherModel = ev.target.value === '__other';
|
|
311
|
+
if (S.customSearcherModel) { $('searcher-model').value = ''; renderSearcher(); $('searcher-model').focus(); return; }
|
|
312
|
+
const p = pickedModel($('searcher-vendor').value, ev.target.value, $('searcher-effort').value);
|
|
313
|
+
$('searcher-model').value = p.model; if (p.effort !== undefined) $('searcher-effort').value = p.effort || '';
|
|
314
|
+
renderSearcher(); renderProjection();
|
|
315
|
+
});
|
|
316
|
+
$('searcher-pips').addEventListener('click', (ev) => {
|
|
317
|
+
const pip = ev.target.closest('.pip'); if (!pip) return;
|
|
318
|
+
const fam = $('searcher-vendor').value === 'agy' && familyOf('agy', $('searcher-model').value.trim());
|
|
319
|
+
if (fam) { const p = pickedModel('agy', fam.id, pip.dataset.level); $('searcher-model').value = p.model; $('searcher-effort').value = p.effort; }
|
|
320
|
+
else $('searcher-effort').value = $('searcher-effort').value === pip.dataset.level ? '' : pip.dataset.level; // the filled last pip again: back to default
|
|
321
|
+
renderSearcher(); renderProjection();
|
|
322
|
+
});
|
|
323
|
+
$('searcher-model').addEventListener('input', renderProjection);
|
|
324
|
+
$('searcher-account').addEventListener('input', () => { renderSearcher(); renderProjection(); requestPreflight(); });
|
|
325
|
+
renderSearcher();
|
|
326
|
+
$('start').addEventListener('click', start);
|
|
327
|
+
try { S.budget = JSON.parse(localStorage.getItem('roundtable.budget') || '{}') || {}; } catch {}
|
|
328
|
+
renderProjection();
|
|
329
|
+
$('projection').addEventListener('input', (ev) => {
|
|
330
|
+
const inp = ev.target.closest('.pool-cap input'); if (!inp) return;
|
|
331
|
+
const v = inp.closest('.pool-cap').dataset.vendor;
|
|
332
|
+
const b = (S.budget[v] ??= {});
|
|
333
|
+
b[inp.name] = inp.value === '' ? undefined : Number(inp.value);
|
|
334
|
+
const warning = inp.closest('.pool').querySelector('[data-dollar-warning]');
|
|
335
|
+
if (warning) warning.hidden = !(b.usd > 0);
|
|
336
|
+
});
|
|
337
|
+
$('projection').addEventListener('submit', async (ev) => {
|
|
338
|
+
const form = ev.target.closest('form.pool-entry'); if (!form) return;
|
|
339
|
+
ev.preventDefault();
|
|
340
|
+
const body = Object.fromEntries(new FormData(form).entries());
|
|
341
|
+
const button = form.querySelector('button');
|
|
342
|
+
if (button.disabled) return;
|
|
343
|
+
button.disabled = true;
|
|
344
|
+
try { const out = await requestJson(`/api/pools/${form.dataset.vendor}`, body); S.pools[form.dataset.vendor] = out.pool; renderProjection(); }
|
|
345
|
+
catch (e) { form.querySelector('.error').textContent = e.message; }
|
|
346
|
+
finally { button.disabled = false; }
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The pool figure a user entered for a vendor with no feed: remaining, its age, and what this
|
|
351
|
+
// table would take from it when a units-per-token rate was given.
|
|
352
|
+
function poolEntry(vendor, weightedTokens) {
|
|
353
|
+
const pl = S.pools[vendor];
|
|
354
|
+
const shown = pl ? `<p class="pool-note"><b>${esc(pl.remaining.toLocaleString())} ${esc(pl.unit)}</b> left, entered ${agoText(pl.at)}${pl.note ? `, ${esc(pl.note)}` : ''}.${pl.perToken ? ` This table takes about ${esc(Math.round(weightedTokens * pl.perToken).toLocaleString())} ${esc(pl.unit)}, ${pct(Math.min(1, weightedTokens * pl.perToken / (pl.remaining || 1)))} of it.` : ''}</p>` : '';
|
|
355
|
+
return `${shown}
|
|
356
|
+
<details class="pool-edit"><summary>${pl ? 'Update the remaining figure' : 'Track a remaining figure from the usage page'}</summary>
|
|
357
|
+
<form class="pool-entry" data-vendor="${esc(vendor)}">
|
|
358
|
+
<input name="remaining" type="number" min="0" step="any" placeholder="remaining" value="${pl ? esc(pl.remaining) : ''}" required>
|
|
359
|
+
<input name="unit" placeholder="unit" value="${esc(pl?.unit || 'credits')}">
|
|
360
|
+
<input name="perToken" type="number" min="0" step="any" placeholder="units per token (optional)" value="${pl?.perToken ?? ''}">
|
|
361
|
+
<input name="note" placeholder="note" value="${esc(pl?.note || '')}">
|
|
362
|
+
<button class="btn btn-small" type="submit">Save</button><span class="error"></span>
|
|
363
|
+
</form>
|
|
364
|
+
</details>`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// "default (<level>)" when the catalogue knows the CLI's own default level; today no vendor
|
|
368
|
+
// reports one (codex's config.toml is not a source: the seat runs with --ignore-user-config).
|
|
369
|
+
const defaultEffortLabel = (vendor) => { const d = VENDOR_META[vendor]?.catalogue?.defaultEffort; return d ? `default (${d})` : 'default'; };
|
|
370
|
+
// model: a model that takes fewer levels than its vendor offers (models.js modelEfforts) offers only those
|
|
371
|
+
const effortOptions = (vendor, chosen, model = '', none = defaultEffortLabel(vendor)) => `<option value="">${esc(none)}</option>${modelEfforts(vendor, model, VENDOR_META[vendor]?.catalogue?.efforts || []).map((e) => `<option value="${e}" ${e === chosen ? 'selected' : ''}>${e}</option>`).join('')}`;
|
|
372
|
+
|
|
373
|
+
function chairEffortOptions() {
|
|
374
|
+
const sel = $('chairman-effort');
|
|
375
|
+
const keep = sel.value;
|
|
376
|
+
sel.innerHTML = effortOptions($('chairman').value, keep, $('chairman-model').value.trim());
|
|
377
|
+
$('chairman-model').setAttribute('list', `models-${$('chairman').value}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// A seat's colour is its vendor's (signature-design family accents): rust Claude, olive Codex,
|
|
381
|
+
// teal Antigravity. Two seats of one vendor share it and differ by number, lens and avatar.
|
|
382
|
+
const vendorColour = (vendor) => VENDOR_META[vendor]?.colour || 'ink';
|
|
383
|
+
|
|
384
|
+
// The model picker: capability order, simple names and a separate custom-id mode.
|
|
385
|
+
// Antigravity lists one entry per model; its effort
|
|
386
|
+
// pips pick the variant. Returns { html, other } (other: show the free-text id field).
|
|
387
|
+
const familyOf = (vendor, model) => (VENDOR_META[vendor]?.catalogue?.families || []).find((f) => model === f.id || String(model).startsWith(`${f.id}-`));
|
|
388
|
+
function modelPicker(vendor, value, idAttr = '', custom = false) {
|
|
389
|
+
const cat = VENDOR_META[vendor]?.catalogue || {};
|
|
390
|
+
const models = modelsByCapability(vendor === 'agy' ? cat.families || [] : cat.models || []);
|
|
391
|
+
const selected = vendor === 'agy' ? familyOf(vendor, value)?.id || value : value;
|
|
392
|
+
let current = !value ? '' : models.some((m) => m.id === selected) ? selected : '__other';
|
|
393
|
+
if (custom) current = '__other';
|
|
394
|
+
const opt = (m) => `<option value="${esc(m.id)}" ${m.id === current ? 'selected' : ''}>${esc(m.label)}${m.note ? ` · ${esc(m.note)}` : ''}</option>`;
|
|
395
|
+
const html = `<select class="flat model-pick" ${idAttr} aria-label="Model"><option value="">default</option>${models.length ? `<optgroup label="Models · most capable first">${models.map(opt).join('')}</optgroup>` : ''}<option value="__other" ${current === '__other' ? 'selected' : ''}>Custom model ID…</option></select>`;
|
|
396
|
+
return { html, other: current === '__other' };
|
|
397
|
+
}
|
|
398
|
+
// A picked model as the id to send: an Antigravity model takes its effort as the suffix.
|
|
399
|
+
function pickedModel(vendor, picked, effort) {
|
|
400
|
+
// a level the newly picked model does not take is dropped, back to the CLI's default
|
|
401
|
+
if (vendor !== 'agy' || !picked) return { model: picked, effort: effort && !modelEfforts(vendor, picked, VENDOR_META[vendor]?.catalogue?.efforts || []).includes(effort) ? '' : effort };
|
|
402
|
+
const fam = familyOf(vendor, picked);
|
|
403
|
+
const level = fam?.levels.includes(effort) ? effort : fam?.levels.includes('medium') ? 'medium' : fam?.levels[0];
|
|
404
|
+
return { model: level ? `${picked}-${level}` : picked, effort: level || '' };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Effort as pips, one per level the CLI accepts; none filled is the CLI's own default. An
|
|
408
|
+
// Antigravity model offers the levels its id comes in.
|
|
409
|
+
function pipsHtml(vendor, chosen, model = '') {
|
|
410
|
+
const levels = (vendor === 'agy' && familyOf(vendor, model)?.levels) || modelEfforts(vendor, model, VENDOR_META[vendor]?.catalogue?.efforts || []);
|
|
411
|
+
const at = levels.indexOf(chosen);
|
|
412
|
+
return '<span class="pip-buttons">' + levels.map((l, i) => `<button type="button" class="pip ${i <= at ? 'on' : ''}" data-level="${l}" title="${l}" aria-label="Effort ${l}" aria-pressed="${l === chosen}"></button>`).join('') + '</span>'
|
|
413
|
+
+ `<span class="pip-label">${esc(chosen || defaultEffortLabel(vendor))}</span>`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// The lamp: the pre-flight answer for the seat's pool when there is one (installed, logged in,
|
|
417
|
+
// quota; authenticated POST /api/preflight), else what the quota alone says. model: the seat's,
|
|
418
|
+
// for a quota in groups (agy), where only its group's windows count. The server's answer is for
|
|
419
|
+
// the whole pool, so its quota part is redone here with the latest figure for this model.
|
|
420
|
+
function poolLamp(vendor, account, model = '') {
|
|
421
|
+
const terms = VENDOR_META[vendor]?.terms;
|
|
422
|
+
if (terms && !terms.acknowledgedAt) return { cls: 'warn', kind: 'terms', note: 'Review the account notice before using this provider.' };
|
|
423
|
+
const key = poolKey(vendor, account, !!VENDOR_META[vendor]?.profiles);
|
|
424
|
+
const pf = S.preflight[key];
|
|
425
|
+
const q = S.quota[key];
|
|
426
|
+
if (S.preflighting) return { cls: 'idle', note: 'checking…' };
|
|
427
|
+
if (pf) {
|
|
428
|
+
const own = q && pf.installed && pf.loggedIn !== false && !['busy', 'check-failed'].includes(pf.kind)
|
|
429
|
+
? preflightLamp({ vendor, installed: true, loggedIn: pf.loggedIn, quota: q, account, model }) : null;
|
|
430
|
+
const at = own ? { ...pf, ...own } : pf;
|
|
431
|
+
const stale = pf.stale || pf.checkedAt != null && Date.now() - pf.checkedAt >= PREFLIGHT_TTL_MS;
|
|
432
|
+
return { cls: stale && at.state === 'ok' ? 'warn' : at.state,
|
|
433
|
+
note: at.note + (stale && pf.checkedAt != null ? ' · refresh status' : ''), kind: at.kind };
|
|
434
|
+
}
|
|
435
|
+
if (!quotaWindows(q, [model]).length) return { cls: 'idle', note: 'not checked yet' };
|
|
436
|
+
const own = preflightLamp({ vendor, installed: true, loggedIn: null, quota: q, account, model });
|
|
437
|
+
return { cls: own.state, note: own.note, kind: own.kind };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// A custom mood that is a hidden mood's code word: its avatar, and the cheapest model and effort.
|
|
441
|
+
const seatHidden = (s) => (s.mood === 'custom' ? hiddenMood(s.moodText) : null);
|
|
442
|
+
function lockHidden(s) {
|
|
443
|
+
if (!seatHidden(s)) return;
|
|
444
|
+
s.customModel = false;
|
|
445
|
+
const lightest = lightestModel(s.vendor, VENDOR_META[s.vendor]?.catalogue?.models);
|
|
446
|
+
if (lightest) s.model = lightest;
|
|
447
|
+
s.effort = 'low';
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function seatRow(s, i) {
|
|
451
|
+
const colour = vendorColour(s.vendor);
|
|
452
|
+
const hidden = seatHidden(s);
|
|
453
|
+
const api = isApi(s.vendor);
|
|
454
|
+
const same = S.draft.filter((x) => x.vendor === s.vendor);
|
|
455
|
+
const n = same.length > 1 ? ` ${same.indexOf(s) + 1}` : '';
|
|
456
|
+
const lamp = poolLamp(s.vendor, seatAccount(s), s.model);
|
|
457
|
+
// an endpoint seat has no catalogue: the model id is typed, and the card says it is required
|
|
458
|
+
const picker = api ? { html: '', other: true } : modelPicker(s.vendor, s.model || '', '', s.customModel);
|
|
459
|
+
const gone = retired(s.vendor, (s.model || '').trim());
|
|
460
|
+
const issue = S.seatIssue?.seatIndex === i ? S.seatIssue : null;
|
|
461
|
+
const invalid = (field) => issue?.field === field ? 'aria-invalid="true"' : '';
|
|
462
|
+
return `<article class="seat seat-card ${issue ? 'seat-invalid' : ''}" role="listitem" data-i="${i}" style="--seat: var(--seat-${colour})">
|
|
463
|
+
<div class="seat-hd">
|
|
464
|
+
<span class="avatar" title="${esc(s.mood === 'custom' ? 'custom mood' : s.mood)}">${avatar(hidden ? hidden.art : s.mood === 'custom' ? 'custom' : s.mood, hidden?.sprite)}</span>
|
|
465
|
+
<div class="seat-id">
|
|
466
|
+
<span class="chair-title"><select class="flat vendor" aria-label="Vendor">${Object.keys(VENDOR_META).map((v) => `<option value="${v}" ${v === s.vendor ? 'selected' : ''}>${esc(VENDOR_LABEL[v])}</option>`).join('')}</select>${n ? `<span class="seat-note">${n.trim()}</span>` : ''}<span class="kind-tag ${api ? 'api' : ''}" title="${api ? 'Billed by the endpoint, not a subscription' : 'The vendor CLI on your subscription'}">${esc(seatKind(VENDOR_META[s.vendor], s.endpoint))}</span></span>
|
|
467
|
+
${hidden && !api ? picker.html.replace('<select', '<select disabled title="Locked to the lightest model"') : picker.html}<input class="flat model" aria-label="Model id" list="models-${s.vendor}" value="${esc(s.model)}" placeholder="${api ? 'model id (required)' : 'model id'}" ${picker.other ? '' : 'hidden'}>
|
|
468
|
+
</div>
|
|
469
|
+
<span class="lamp ${lamp.cls}" title="${esc(lamp.note)}"></span>
|
|
470
|
+
</div>
|
|
471
|
+
<button class="btn remove" type="button" aria-label="Remove seat" title="Remove seat">×</button>
|
|
472
|
+
<div class="seat-bd">
|
|
473
|
+
<div class="f"><label class="label help-label" data-help="lens" tabindex="0" for="seat-lens-${i}" aria-describedby="help-lens">Lens</label><input id="seat-lens-${i}" class="flat lens" value="${esc(s.lens)}" placeholder="e.g. long-term effects" aria-describedby="help-lens seat-error-${i}" ${invalid('lens')}></div>
|
|
474
|
+
<div class="f"><label class="label help-label" data-help="mood" tabindex="0" for="seat-mood-${i}" aria-describedby="help-mood seat-mood-help-${i}">Mood</label><select id="seat-mood-${i}" class="flat mood" title="${esc(MOOD_HELP[s.mood] || '')}" aria-describedby="help-mood seat-mood-help-${i}">${Object.keys(MOODS).map((m) => `<option value="${m}" title="${esc(MOOD_HELP[m] || '')}" ${m === s.mood ? 'selected' : ''}>${m}</option>`).join('')}<option value="custom" title="${esc(MOOD_HELP.custom)}" ${s.mood === 'custom' ? 'selected' : ''}>custom…</option></select></div>
|
|
475
|
+
<span class="sr-only" id="seat-mood-help-${i}">${esc(MOOD_HELP[s.mood] || '')}</span>
|
|
476
|
+
<input class="flat mood-text" aria-label="Custom mood" aria-describedby="seat-error-${i}" ${invalid('moodText')} value="${esc(s.moodText)}" placeholder="One sentence: how this seat approaches the question" ${s.mood === 'custom' ? '' : 'hidden'}>
|
|
477
|
+
<p class="seat-field-error error" id="seat-error-${i}" ${issue ? '' : 'hidden'}>${esc(issue?.message || '')}</p>
|
|
478
|
+
${gone ? `<span class="seat-note danger">${esc(s.model)} was retired (${esc(gone.date)}); use ${esc(gone.instead)}.</span>` : ''}
|
|
479
|
+
${api ? `<label class="f"><span class="label">Endpoint</span><input class="flat endpoint" aria-label="Endpoint base URL" list="endpoints" value="${esc(s.endpoint || '')}" placeholder="http://localhost:11434/v1" title="The base URL of an OpenAI-compatible server, ending in /v1. Plain http only on this machine or a private network."></label>
|
|
480
|
+
<label class="f"><span class="label">Key var</span><input class="flat keyenv" aria-label="Key environment variable" value="${esc(s.keyEnv || '')}" placeholder="none (local server)" title="The NAME of the environment variable that holds the key, set in the shell roundtable was started from. Never the key itself."></label>` : ''}
|
|
481
|
+
<div class="f"><span class="label help-label" data-help="effort" tabindex="0" aria-describedby="help-effort">Effort</span><span class="pips ${hidden ? 'locked' : ''}">${pipsHtml(s.vendor, s.effort, s.model)}</span><select class="effort" hidden>${effortOptions(s.vendor, s.effort, s.model)}</select></div>
|
|
482
|
+
<span class="seat-note lamp-note ${lamp.cls === 'danger' || lamp.cls === 'warn' ? lamp.cls : ''}" title="${esc(lamp.note)}" tabindex="0">${esc(lamp.note)}</span>
|
|
483
|
+
${cardNotices(s.vendor)}
|
|
484
|
+
${api ? '' : `<details class="seat-more"><summary>Login profile${s.account ? `: ${esc(s.account)}` : ''}</summary><input class="flat account" aria-label="Login profile" value="${esc(s.account || '')}" placeholder="none: your usual login" title="A second login of this CLI, for example a work subscription; node src/doctor.js shows how to log a profile in"></details>`}
|
|
485
|
+
</div>
|
|
486
|
+
</article>`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Ask the server for every pool on the table (seats and chairman), then relight the lamps in
|
|
490
|
+
// place, so a field being typed in keeps its focus. Debounced: one request per pause.
|
|
491
|
+
let preflightTimer = null;
|
|
492
|
+
let preflightRevision = 0;
|
|
493
|
+
function requestPreflight({ fresh = false } = {}) {
|
|
494
|
+
const revision = ++preflightRevision;
|
|
495
|
+
clearTimeout(preflightTimer);
|
|
496
|
+
preflightTimer = setTimeout(async () => {
|
|
497
|
+
const seats = [...S.draft, { vendor: $('chairman').value, account: $('chairman-account').value.trim(), endpoint: $('chairman-endpoint').value, keyEnv: $('chairman-keyenv').value }];
|
|
498
|
+
if (searcherOn()) seats.push(draftSearcher());
|
|
499
|
+
const keys = [...new Set([...seats.map((s) => poolKey(s.vendor, seatAccount(s), !!VENDOR_META[s.vendor]?.profiles)),
|
|
500
|
+
...(S.initialDraft ? Object.keys(VENDOR_META).filter((v) => !isApi(v)) : [])])];
|
|
501
|
+
// an API pool is checked from its URL and key-variable NAME (no request leaves the server);
|
|
502
|
+
// those travel in the body, so its lamp follows every edit
|
|
503
|
+
const endpoints = {};
|
|
504
|
+
for (const s of seats) if (isApi(s.vendor)) endpoints[poolKey(s.vendor, seatAccount(s), true)] = { url: (s.endpoint || '').trim(), keyEnv: (s.keyEnv || '').trim() || null };
|
|
505
|
+
const epSig = JSON.stringify(endpoints);
|
|
506
|
+
const needsCheck = keys.some((k2) => !S.preflight[k2] || S.preflight[k2].stale ||
|
|
507
|
+
S.preflight[k2].checkedAt != null && Date.now() - S.preflight[k2].checkedAt >= PREFLIGHT_TTL_MS);
|
|
508
|
+
if (!needsCheck && !fresh && epSig === S.preflightEndpoints) { S.preflighting = false; updateLamps(); return; }
|
|
509
|
+
S.preflighting = true; S.preflightError = null; updateLamps();
|
|
510
|
+
try {
|
|
511
|
+
const { pools } = await requestJson('/api/preflight', { pools: keys, endpoints, fresh });
|
|
512
|
+
if (revision !== preflightRevision) return; // a slow answer cannot overwrite a later edit
|
|
513
|
+
S.preflightError = pools.some((p) => ['busy', 'check-failed'].includes(p.kind)) ? 'Some checks could not finish. Try Refresh status again.' : null;
|
|
514
|
+
const times = pools.map((p) => p.checkedAt).filter((t) => t != null);
|
|
515
|
+
S.preflightCheckedAt = times.length ? Math.min(...times) : Date.now();
|
|
516
|
+
S.preflightEndpoints = epSig;
|
|
517
|
+
for (const p of pools) {
|
|
518
|
+
S.preflight[p.key] = p;
|
|
519
|
+
if (p.quota && (p.quota.at ?? 0) >= (S.quota[p.key]?.at ?? 0)) S.quota[p.key] = p.quota;
|
|
520
|
+
}
|
|
521
|
+
if (S.initialDraft) {
|
|
522
|
+
const unchanged = JSON.stringify(S.draft) === S.initialDraft;
|
|
523
|
+
S.initialDraft = null; // choose once; a refresh never replaces an existing table
|
|
524
|
+
if (unchanged) {
|
|
525
|
+
const chosen = initialDraft(initialSeatVendors(Object.entries(VENDOR_META).map(([vendor, meta]) => ({ vendor, ...meta })), pools));
|
|
526
|
+
if (JSON.stringify(chosen) !== JSON.stringify(S.draft)) {
|
|
527
|
+
S.draft = chosen;
|
|
528
|
+
if (S.initialChair && chosen.length) { $('chairman').value = chosen[0].vendor; chairEffortOptions(); }
|
|
529
|
+
S.preflighting = false; renderSeats(); renderChair(); return;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
} catch (e) {
|
|
534
|
+
if (revision === preflightRevision) S.preflightError = e.message || 'Could not refresh status. Try Refresh status again.';
|
|
535
|
+
} finally {
|
|
536
|
+
if (revision === preflightRevision) { S.preflighting = false; updateLamps(); }
|
|
537
|
+
}
|
|
538
|
+
}, 400);
|
|
539
|
+
}
|
|
540
|
+
function updateLamps() {
|
|
541
|
+
$('refresh-status').disabled = S.preflighting;
|
|
542
|
+
$('refresh-status').textContent = S.preflighting ? 'Checking…' : 'Refresh status';
|
|
543
|
+
$('preflight-status').textContent = S.preflighting ? 'Checking provider status…' : S.preflightError ||
|
|
544
|
+
(S.preflightCheckedAt != null ? `Last check ${new Date(S.preflightCheckedAt).toLocaleTimeString()}` : '');
|
|
545
|
+
if (searcherOn()) { const lamp = poolLamp($('searcher-vendor').value, searcherAccount(), $('searcher-model').value.trim()); $('searcher-lamp').className = `lamp ${lamp.cls}`; $('searcher-lamp').title = lamp.note; $('searcher-note').textContent = lamp.note; $('searcher-note').title = lamp.note; }
|
|
546
|
+
$('seats').querySelectorAll('.seat').forEach((card) => {
|
|
547
|
+
const s = S.draft[Number(card.dataset.i)]; if (!s) return;
|
|
548
|
+
const l = poolLamp(s.vendor, seatAccount(s), s.model);
|
|
549
|
+
const lampEl = card.querySelector('.lamp'); lampEl.className = `lamp ${l.cls}`; lampEl.title = l.note;
|
|
550
|
+
const note = card.querySelector('.lamp-note');
|
|
551
|
+
if (note) { note.textContent = l.note; note.title = l.note; note.className = `seat-note lamp-note ${l.cls === 'danger' || l.cls === 'warn' ? l.cls : ''}`; }
|
|
552
|
+
});
|
|
553
|
+
const c = poolLamp($('chairman').value, chairAccount(), $('chairman-model').value.trim());
|
|
554
|
+
$('chair-lamp').className = `lamp ${c.cls}`; $('chair-lamp').title = c.note;
|
|
555
|
+
$('chair-note').textContent = c.note; $('chair-note').title = c.note; $('chair-note').className = `seat-note lamp-note ${c.cls === 'danger' || c.cls === 'warn' ? c.cls : ''}`;
|
|
556
|
+
S.startConfirm = null; $('start').disabled = !!S.startPending; $('start').textContent = S.startPending ? 'Opening…' : 'Open the table';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// The chairman card: vendor colour, the gavel, effort pips; its controls keep their ids.
|
|
560
|
+
function renderChair() {
|
|
561
|
+
const restoreFocus = rememberFocus($('chair-card'));
|
|
562
|
+
const v = $('chairman').value;
|
|
563
|
+
const card = $('chair-card');
|
|
564
|
+
card.style.setProperty('--seat', `var(--seat-${vendorColour(v)})`);
|
|
565
|
+
$('chair-avatar').innerHTML = avatar('chairman');
|
|
566
|
+
const api = isApi(v);
|
|
567
|
+
$('chair-kind').textContent = seatKind(VENDOR_META[v], $('chairman-endpoint').value);
|
|
568
|
+
$('chair-kind').classList.toggle('api', api);
|
|
569
|
+
$('chair-api').hidden = !api; $('chair-profile').hidden = api;
|
|
570
|
+
const picker = api ? { html: '', other: true } : modelPicker(v, $('chairman-model').value.trim(), 'id="chair-model-pick"', S.customChairModel);
|
|
571
|
+
$('chair-model-slot').innerHTML = picker.html;
|
|
572
|
+
$('chairman-model').hidden = !picker.other;
|
|
573
|
+
$('chairman-model').placeholder = api ? 'model id (required)' : 'model id';
|
|
574
|
+
$('chair-pips').innerHTML = pipsHtml(v, $('chairman-effort').value, $('chairman-model').value.trim());
|
|
575
|
+
$('chair-iso').innerHTML = cardNotices(v);
|
|
576
|
+
updateLamps();
|
|
577
|
+
restoreFocus();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Antigravity cannot be sealed off (config.js limitedIsolation). The card that runs on it says so,
|
|
581
|
+
// its tip explains, and choosing it there is the consent the board sends (isolationAccepted).
|
|
582
|
+
// An agent request has no card, so it must send options.allowLimitedIsolation itself.
|
|
583
|
+
const isoNote = (vendor) => (VENDOR_META[vendor]?.limitedIsolation
|
|
584
|
+
? '<span class="seat-note warn iso-note help-label" data-help="isolation" tabindex="0" aria-describedby="help-isolation">Limited isolation: your Antigravity customizations and MCP servers still load</span>' : '');
|
|
585
|
+
const isolationAccepted = () => [...draftSeats(), draftChairman(), searcherOn() ? draftSearcher() : null].some((s) => VENDOR_META[s?.vendor]?.limitedIsolation);
|
|
586
|
+
|
|
587
|
+
// A vendor whose own docs say wrapping its CLI is unsupported on some plans (config.js terms,
|
|
588
|
+
// src/terms.js). The card that runs on it opens the notice; until the person acknowledges it
|
|
589
|
+
// there, the server sends nothing to that CLI and the table does not open with it. The
|
|
590
|
+
// acknowledgment is kept by the server, so every board and the next start know it.
|
|
591
|
+
const termsNote = (vendor) => {
|
|
592
|
+
const t = VENDOR_META[vendor]?.terms;
|
|
593
|
+
if (!t) return '';
|
|
594
|
+
return t.acknowledgedAt
|
|
595
|
+
? `<button type="button" class="seat-note warn terms-note" data-terms="${esc(vendor)}">Account risk acknowledged · review</button>`
|
|
596
|
+
: `<button type="button" class="seat-note danger terms-note" data-terms="${esc(vendor)}">Account risk: read before using ${esc(VENDOR_LABEL[vendor])}</button>`;
|
|
597
|
+
};
|
|
598
|
+
function cardNotices(vendor) {
|
|
599
|
+
const meta = VENDOR_META[vendor] || {};
|
|
600
|
+
const count = Number(!!meta.limitedIsolation) + Number(!!meta.terms);
|
|
601
|
+
if (!count) return '<span class="card-notices-empty" aria-hidden="true"></span>';
|
|
602
|
+
const pending = meta.terms && !meta.terms.acknowledgedAt;
|
|
603
|
+
return `<details class="card-notices ${pending ? 'danger' : 'warn'}"><summary title="Expand provider warnings"><span aria-hidden="true">⚠</span> ${pending ? 'Review required' : 'Warnings'} · ${count}</summary><div class="card-notices-body">${isoNote(vendor)}${termsNote(vendor)}</div></details>`;
|
|
604
|
+
}
|
|
605
|
+
const termsCards = () => [...draftSeats(), draftChairman(), searcherOn() ? draftSearcher() : null].filter((s) => VENDOR_META[s?.vendor]?.terms);
|
|
606
|
+
const termsPending = () => [...new Set(termsCards().map((s) => s.vendor))].filter((v) => !VENDOR_META[v].terms.acknowledgedAt);
|
|
607
|
+
const termsAccepted = () => termsCards().length > 0 && !termsPending().length;
|
|
608
|
+
|
|
609
|
+
let termsVendor = null;
|
|
610
|
+
function openTerms(vendor) {
|
|
611
|
+
const t = VENDOR_META[vendor]?.terms;
|
|
612
|
+
if (!t) return;
|
|
613
|
+
termsVendor = vendor;
|
|
614
|
+
const label = VENDOR_LABEL[vendor];
|
|
615
|
+
$('vendor-terms-title').textContent = `${label} in a third-party app`;
|
|
616
|
+
$('vendor-terms-notice').textContent = t.notice;
|
|
617
|
+
$('vendor-terms-what').textContent = `Roundtable runs the ${label} CLI for every seat, chairman or searcher card set to ${label}, and reads its model list and remaining quota. Until you acknowledge this, it sends nothing to that CLI. Roundtable cannot tell which plan your login is on: check your plan's terms, or choose another provider.`;
|
|
618
|
+
$('vendor-terms-link').href = t.url;
|
|
619
|
+
$('vendor-terms-link').textContent = `Read the FAQ (${new URL(t.url).host})`;
|
|
620
|
+
$('vendor-terms-state').textContent = t.acknowledgedAt ? `You acknowledged this on ${new Date(t.acknowledgedAt).toLocaleDateString()}. Withdrawing it stops new reads; a review already running keeps its calls.` : '';
|
|
621
|
+
$('vendor-terms-state').hidden = !t.acknowledgedAt;
|
|
622
|
+
$('vendor-terms-confirm').hidden = !!t.acknowledgedAt;
|
|
623
|
+
$('vendor-terms-withdraw').hidden = !t.acknowledgedAt;
|
|
624
|
+
$('vendor-terms-error').hidden = true;
|
|
625
|
+
$('vendor-terms').showModal();
|
|
626
|
+
}
|
|
627
|
+
async function answerTerms(acknowledged) {
|
|
628
|
+
const vendor = termsVendor;
|
|
629
|
+
if (!vendor) return;
|
|
630
|
+
const buttons = ['vendor-terms-confirm', 'vendor-terms-withdraw', 'vendor-terms-cancel'].map($);
|
|
631
|
+
for (const b of buttons) b.disabled = true;
|
|
632
|
+
if (acknowledged) $('vendor-terms-confirm').textContent = 'Reading the model list…';
|
|
633
|
+
$('vendor-terms-error').hidden = true;
|
|
634
|
+
try {
|
|
635
|
+
// acknowledging waits for the CLI's model list, a network call of up to 30 s
|
|
636
|
+
const out = await requestJson(`/api/terms/${vendor}`, { acknowledged }, 45_000);
|
|
637
|
+
VENDOR_META[vendor].terms = out.terms;
|
|
638
|
+
// The old held/ready snapshot no longer describes this consent state. An ordinary
|
|
639
|
+
// card edit may replace the queued refresh, so invalidate it before any redraw.
|
|
640
|
+
for (const key of Object.keys(S.preflight)) if (poolVendor(key) === vendor) delete S.preflight[key];
|
|
641
|
+
if (out.catalogue) {
|
|
642
|
+
VENDOR_META[vendor].catalogue = out.catalogue;
|
|
643
|
+
const list = $(`models-${vendor}`);
|
|
644
|
+
if (list) list.innerHTML = (out.catalogue.models || []).map((m) => `<option value="${esc(m.id)}">${esc(m.label)}</option>`).join('');
|
|
645
|
+
}
|
|
646
|
+
$('vendor-terms').close();
|
|
647
|
+
renderSeats(); renderChair(); renderSearcher(); requestPreflight({ fresh: true });
|
|
648
|
+
} catch (e) {
|
|
649
|
+
$('vendor-terms-error').textContent = e.message; $('vendor-terms-error').hidden = false;
|
|
650
|
+
} finally {
|
|
651
|
+
for (const b of buttons) b.disabled = false;
|
|
652
|
+
$('vendor-terms-confirm').textContent = 'I understand, use it';
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
document.addEventListener('click', (ev) => { const note = ev.target.closest?.('.terms-note'); if (note) openTerms(note.dataset.terms); });
|
|
656
|
+
$('vendor-terms-confirm').addEventListener('click', () => answerTerms(true));
|
|
657
|
+
$('vendor-terms-withdraw').addEventListener('click', () => answerTerms(false));
|
|
658
|
+
$('vendor-terms').addEventListener('cancel', (event) => { if ($('vendor-terms-cancel').disabled) event.preventDefault(); });
|
|
659
|
+
|
|
660
|
+
// ---------- the searcher ----------
|
|
661
|
+
// Not a seat: one call before round 0 that gathers cited sources from its visible query. A card
|
|
662
|
+
// next to the chairman's, added and removed like a seat, with its own vendor, model, effort and
|
|
663
|
+
// login. The vendor list holds only CLIs that can search with every other tool off (/api/seats).
|
|
664
|
+
const DEFAULT_SEARCHER = 'codex'; // research.js: what an agent request without a vendor gets
|
|
665
|
+
const searcherOn = () => !$('searcher-wrap').hidden;
|
|
666
|
+
const searcherAccount = () => (VENDOR_META[$('searcher-vendor').value]?.profiles ? $('searcher-account').value.trim() : '');
|
|
667
|
+
function draftSearcher() {
|
|
668
|
+
return { vendor: $('searcher-vendor').value, model: $('searcher-model').value.trim() || undefined, effort: $('searcher-effort').value || undefined, account: searcherAccount() || undefined };
|
|
669
|
+
}
|
|
670
|
+
// The lightest model at low effort: finding and quoting sources needs little reasoning. An
|
|
671
|
+
// Antigravity id carries its level, so its "low" is part of the model.
|
|
672
|
+
function lightSearcher(vendor) {
|
|
673
|
+
S.customSearcherModel = false;
|
|
674
|
+
const cat = VENDOR_META[vendor]?.catalogue || {};
|
|
675
|
+
const model = lightestModel(vendor, cat.models || []) || '';
|
|
676
|
+
const p = vendor === 'agy' ? pickedModel(vendor, familyOf(vendor, model)?.id || model, 'low') : { model, effort: (cat.efforts || []).includes('low') ? 'low' : '' };
|
|
677
|
+
$('searcher-model').value = p.model || ''; $('searcher-effort').value = p.effort || '';
|
|
678
|
+
}
|
|
679
|
+
function setSearcher(on) {
|
|
680
|
+
$('searcher-wrap').hidden = !on; $('add-searcher').hidden = on;
|
|
681
|
+
syncResearchQuery();
|
|
682
|
+
renderSearcher(); renderProjection(); requestPreflight();
|
|
683
|
+
}
|
|
684
|
+
function syncResearchQuery() {
|
|
685
|
+
const automatic = $('research-query-mode').value === 'decision';
|
|
686
|
+
const query = $('research-query');
|
|
687
|
+
query.readOnly = automatic;
|
|
688
|
+
if (automatic) query.value = defaultSearchQuery({ decision: $('notes').querySelector('[name="decision"]').value, options: S.options });
|
|
689
|
+
$('research-query-note').textContent = automatic
|
|
690
|
+
? 'The question and options below will be used for web research. Choose Custom query to edit or remove private details. Other context and answers stay out.'
|
|
691
|
+
: 'Only this query is used for web research. Keep private details out. The searcher gathers evidence; it does not vote.';
|
|
692
|
+
}
|
|
693
|
+
function renderSearcher() {
|
|
694
|
+
if (!searcherOn()) return;
|
|
695
|
+
const restoreFocus = rememberFocus($('searcher-card'));
|
|
696
|
+
const v = $('searcher-vendor').value;
|
|
697
|
+
$('searcher-card').style.setProperty('--seat', `var(--seat-${vendorColour(v)})`);
|
|
698
|
+
$('searcher-avatar').innerHTML = avatar('searcher');
|
|
699
|
+
$('searcher-profile').hidden = !VENDOR_META[v]?.profiles; // no login profiles for a CLI without a state directory
|
|
700
|
+
const picker = modelPicker(v, $('searcher-model').value.trim(), 'id="searcher-model-pick"', S.customSearcherModel);
|
|
701
|
+
$('searcher-model-slot').innerHTML = picker.html;
|
|
702
|
+
$('searcher-model').hidden = !picker.other;
|
|
703
|
+
$('searcher-model').setAttribute('list', `models-${v}`);
|
|
704
|
+
$('searcher-pips').innerHTML = pipsHtml(v, $('searcher-effort').value, $('searcher-model').value.trim());
|
|
705
|
+
const lamp = poolLamp(v, searcherAccount(), $('searcher-model').value.trim());
|
|
706
|
+
$('searcher-lamp').className = `lamp ${lamp.cls}`; $('searcher-lamp').title = lamp.note;
|
|
707
|
+
$('searcher-note').textContent = lamp.note; $('searcher-note').title = lamp.note;
|
|
708
|
+
$('searcher-iso').innerHTML = cardNotices(v);
|
|
709
|
+
restoreFocus();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Options as chips: opt-1, opt-2 ... as the seats will see them. Enter adds the next one,
|
|
713
|
+
// Backspace in an empty chip removes it, a repeated option is marked.
|
|
714
|
+
function renderOptions(focus = null) {
|
|
715
|
+
const seen = new Map();
|
|
716
|
+
for (const o of S.options) { const kk = o.trim().toLowerCase(); if (kk) seen.set(kk, (seen.get(kk) || 0) + 1); }
|
|
717
|
+
$('options-editor').innerHTML = S.options.map((o, i) => `<span class="opt ${seen.get(o.trim().toLowerCase()) > 1 ? 'dup' : ''}" data-i="${i}"><b>opt-${i + 1}</b><input value="${esc(o)}" size="${Math.max(8, o.length + 1)}" aria-label="Option ${i + 1}" placeholder="${i === 0 ? 'Stay as we are' : i === 1 ? 'Change it' : 'Another option'}"><button type="button" class="opt-x" aria-label="Remove option ${i + 1}" ${S.options.length <= 2 ? 'hidden' : ''}>×</button></span>`).join('')
|
|
718
|
+
+ `<button type="button" class="btn btn-small opt-add" ${S.options.length >= 12 ? 'hidden' : ''}>+ option</button>`;
|
|
719
|
+
if (focus != null) $('options-editor').querySelectorAll('.opt input')[focus]?.focus();
|
|
720
|
+
syncResearchQuery();
|
|
721
|
+
}
|
|
722
|
+
function wireOptions() {
|
|
723
|
+
const ed = $('options-editor');
|
|
724
|
+
ed.addEventListener('input', (ev) => {
|
|
725
|
+
const chip = ev.target.closest('.opt'); if (!chip) return;
|
|
726
|
+
S.options[Number(chip.dataset.i)] = ev.target.value;
|
|
727
|
+
syncResearchQuery();
|
|
728
|
+
ev.target.size = Math.max(8, ev.target.value.length + 1);
|
|
729
|
+
const seen = S.options.map((o) => o.trim().toLowerCase());
|
|
730
|
+
ed.querySelectorAll('.opt').forEach((c, i) => c.classList.toggle('dup', !!seen[i] && seen.indexOf(seen[i]) !== seen.lastIndexOf(seen[i])));
|
|
731
|
+
});
|
|
732
|
+
ed.addEventListener('keydown', (ev) => {
|
|
733
|
+
const chip = ev.target.closest('.opt'); if (!chip) return;
|
|
734
|
+
const i = Number(chip.dataset.i);
|
|
735
|
+
if (ev.key === 'Enter') { ev.preventDefault(); if (S.options.length < 12) { S.options.splice(i + 1, 0, ''); renderOptions(i + 1); } }
|
|
736
|
+
if (ev.key === 'Backspace' && !ev.target.value && S.options.length > 2) { ev.preventDefault(); S.options.splice(i, 1); renderOptions(Math.max(0, i - 1)); }
|
|
737
|
+
});
|
|
738
|
+
ed.addEventListener('click', (ev) => {
|
|
739
|
+
if (ev.target.closest('.opt-add')) { S.options.push(''); renderOptions(S.options.length - 1); return; }
|
|
740
|
+
const x = ev.target.closest('.opt-x'); if (!x) return;
|
|
741
|
+
S.options.splice(Number(x.closest('.opt').dataset.i), 1); renderOptions();
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// "More context" starts folded like every fold; its summary says how many fields have content.
|
|
746
|
+
const MORE = ['known', 'assumed', 'reversibility', 'deadline', 'exportTo'];
|
|
747
|
+
function setupMoreContext() {
|
|
748
|
+
const count = () => MORE.filter((id) => $(id).value.trim()).length;
|
|
749
|
+
const show = () => { const n = count(); $('more-filled').textContent = n ? `· ${n} filled` : ''; };
|
|
750
|
+
show();
|
|
751
|
+
for (const id of MORE) $(id).addEventListener('input', show);
|
|
752
|
+
wireOptions();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Replacing a card must keep keyboard users on the same control, including effort pips.
|
|
756
|
+
function rememberFocus(root) {
|
|
757
|
+
const active = document.activeElement;
|
|
758
|
+
if (!root.contains(active)) return () => {};
|
|
759
|
+
const index = active.closest('.seat')?.dataset.i;
|
|
760
|
+
const control = ['vendor', 'model-pick', 'lens', 'mood', 'mood-text', 'model', 'account', 'endpoint', 'keyenv', 'remove'].find((c) => active.classList.contains(c));
|
|
761
|
+
const selector = active.id ? `#${CSS.escape(active.id)}` : active.matches('.pip') ? `.pip[data-level="${CSS.escape(active.dataset.level)}"]` : control ? `.${control}` : null;
|
|
762
|
+
return () => {
|
|
763
|
+
if (!selector || active.isConnected) return;
|
|
764
|
+
const row = index === undefined ? root : root.querySelectorAll('.seat')[Math.min(Number(index), S.draft.length - 1)];
|
|
765
|
+
row?.querySelector(selector)?.focus({ preventScroll: true });
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
function renderSeats() {
|
|
769
|
+
helpNotes.close();
|
|
770
|
+
const restoreFocus = rememberFocus($('seats'));
|
|
771
|
+
$('seats').innerHTML = S.draft.map(seatRow).join('');
|
|
772
|
+
requestPreflight();
|
|
773
|
+
renderProjection();
|
|
774
|
+
restoreFocus();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function onSeatEdit(ev) {
|
|
778
|
+
const row = ev.target.closest('.seat'); if (!row) return;
|
|
779
|
+
clearSetupValidation();
|
|
780
|
+
S.initialDraft = null;
|
|
781
|
+
const s = S.draft[Number(row.dataset.i)];
|
|
782
|
+
if (ev.target.classList.contains('model-pick')) {
|
|
783
|
+
if (ev.type !== 'change') return;
|
|
784
|
+
const v = ev.target.value;
|
|
785
|
+
s.customModel = v === '__other';
|
|
786
|
+
if (s.customModel) { s.model = ''; renderSeats(); $('seats').querySelectorAll('.seat')[S.draft.indexOf(s)]?.querySelector('.model')?.focus(); return; }
|
|
787
|
+
Object.assign(s, pickedModel(s.vendor, v, s.effort));
|
|
788
|
+
renderSeats(); renderProjection(); return;
|
|
789
|
+
}
|
|
790
|
+
const vendorBefore = s.vendor;
|
|
791
|
+
s.vendor = row.querySelector('.vendor').value;
|
|
792
|
+
s.lens = row.querySelector('.lens').value;
|
|
793
|
+
s.mood = row.querySelector('.mood').value;
|
|
794
|
+
s.moodText = row.querySelector('.mood-text').value;
|
|
795
|
+
s.model = row.querySelector('.model').value;
|
|
796
|
+
s.effort = row.querySelector('.effort').value;
|
|
797
|
+
s.account = row.querySelector('.account')?.value ?? ''; // an endpoint card has no login profile
|
|
798
|
+
if (row.querySelector('.endpoint')) {
|
|
799
|
+
s.endpoint = row.querySelector('.endpoint').value;
|
|
800
|
+
s.keyEnv = row.querySelector('.keyenv').value;
|
|
801
|
+
// a preset URL prefills the key variable's usual NAME, once, never a key
|
|
802
|
+
const pre = ev.type === 'change' && ev.target.classList.contains('endpoint') && ENDPOINT_PRESETS.find((e) => e.url === s.endpoint.trim());
|
|
803
|
+
if (pre?.keyEnv && !s.keyEnv.trim()) s.keyEnv = pre.keyEnv;
|
|
804
|
+
}
|
|
805
|
+
if (s.vendor !== vendorBefore) { s.customModel = false; s.model = ''; s.effort = ''; lockHidden(s); renderSeats(); return; } // the catalogue changed
|
|
806
|
+
if (ev.type === 'change' && (ev.target.classList.contains('mood-text') || ev.target.classList.contains('mood'))) lockHidden(s);
|
|
807
|
+
if (ev.type === 'change' && ev.target.classList.contains('mood-text')) { renderSeats(); renderProjection(); return; }
|
|
808
|
+
// the avatar follows the mood, the lamp the login profile or the endpoint: redraw the cards (not while typing)
|
|
809
|
+
if (['account', 'endpoint', 'keyenv'].some((c) => ev.target.classList.contains(c))) requestPreflight();
|
|
810
|
+
if (ev.type === 'change' && ['mood', 'account', 'endpoint', 'keyenv'].some((c) => ev.target.classList.contains(c))) { renderSeats(); if (s.mood === 'custom') $('seats').querySelectorAll('.seat')[S.draft.indexOf(s)]?.querySelector('.mood-text')?.focus(); return; }
|
|
811
|
+
row.querySelector('.mood-text').hidden = s.mood !== 'custom';
|
|
812
|
+
if (ev.target.classList.contains('model')) {
|
|
813
|
+
// a typed id: drop a level that model does not take, and redraw its pips
|
|
814
|
+
if (s.vendor !== 'agy' && s.effort && !modelEfforts(s.vendor, s.model, VENDOR_META[s.vendor]?.catalogue?.efforts || []).includes(s.effort)) { s.effort = ''; row.querySelector('.effort').value = ''; }
|
|
815
|
+
const pips = row.querySelector('.pips'); if (pips) pips.innerHTML = pipsHtml(s.vendor, s.effort, s.model);
|
|
816
|
+
updateLamps(); // agy: the lamp follows the model's quota group
|
|
817
|
+
}
|
|
818
|
+
renderProjection();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function draftSeats() {
|
|
822
|
+
return S.draft.map((s, i) => ({
|
|
823
|
+
vendor: s.vendor, lens: s.lens.trim(), colour: vendorColour(s.vendor),
|
|
824
|
+
mood: s.mood === 'custom' ? { preset: 'custom', text: s.moodText.trim() } : { preset: s.mood },
|
|
825
|
+
model: s.model.trim() || undefined, effort: s.effort || undefined, account: seatAccount(s) || undefined,
|
|
826
|
+
...(isApi(s.vendor) ? { endpoint: (s.endpoint || '').trim(), keyEnv: (s.keyEnv || '').trim() || undefined } : {}),
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
// The chairman as the server takes it (normaliseChairman): an endpoint chairman carries its URL,
|
|
830
|
+
// key variable name and host-derived account, like a seat.
|
|
831
|
+
function draftChairman() {
|
|
832
|
+
const vendor = $('chairman').value;
|
|
833
|
+
return { vendor, model: $('chairman-model').value.trim() || undefined, effort: $('chairman-effort').value || undefined, account: chairAccount() || undefined,
|
|
834
|
+
...(isApi(vendor) ? { endpoint: $('chairman-endpoint').value.trim(), keyEnv: $('chairman-keyenv').value.trim() || undefined } : {}) };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : String(Math.round(n)));
|
|
838
|
+
const pct = (x) => `${(x * 100).toFixed(x * 100 < 10 ? 1 : 0)}%`;
|
|
839
|
+
const level = (x) => (x >= 0.9 ? 'danger' : x >= 0.7 ? 'warn' : '');
|
|
840
|
+
const width = (x) => Math.max(0, Math.min(100, x * 100)).toFixed(1);
|
|
841
|
+
|
|
842
|
+
// The measured draw of one window per 100k tokens: a short form for the readout and the full
|
|
843
|
+
// sentence (every model key that has one) for its title.
|
|
844
|
+
function rateFor(vendor, label) {
|
|
845
|
+
const keys = Object.keys(S.calib).filter((k2) => k2 === vendor || k2.startsWith(`${vendor}:`));
|
|
846
|
+
const long = [], short = [];
|
|
847
|
+
for (const key of keys) {
|
|
848
|
+
const x = S.calib[key][label]; if (!x || !(x.samples > 0 || x.ceilPerToken)) continue;
|
|
849
|
+
const who = key === vendor ? '' : `, ${key.split(':')[1]}`;
|
|
850
|
+
if (x.samples > 0) { long.push(`${label}: ${pct(x.pctPerToken * 100000)} per 100k tokens (${x.samples} measured move${x.samples === 1 ? '' : 's'}${who})`); if (key === vendor || !short.length) short[0] = `${pct(x.pctPerToken * 100000)}/100k`; }
|
|
851
|
+
else { long.push(`${label}: no movement seen over ${k(x.pending)} tokens yet${who}`); if (!short.length) short[0] = 'no move seen'; }
|
|
852
|
+
}
|
|
853
|
+
return { short: short[0] || '', long: long.join('\n') };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Which window to show first, given each window's used share (0..1), its reset (epoch seconds
|
|
857
|
+
// or null) and, on the setup screen, this debate's projected draw `adds` (0..1, else null).
|
|
858
|
+
// A display order only: every window stays visible at every width. Returns an index.
|
|
859
|
+
function leadWindow(windows) {
|
|
860
|
+
const now = Date.now() / 1000;
|
|
861
|
+
const horizon = (Number($('wall').value) || 30) * 60; // the debate's wall clock
|
|
862
|
+
const score = (w) => {
|
|
863
|
+
const after = w.used + (w.adds || 0);
|
|
864
|
+
if (after >= 1) return 2 + after; // this debate would not finish inside it
|
|
865
|
+
if (after >= 0.9) return 1 + after; // nearly spent: first, whatever its reset
|
|
866
|
+
// a window that resets before the debate could end frees itself, so it counts for half
|
|
867
|
+
return w.resetsAt != null && w.resetsAt - now < horizon ? after / 2 : after;
|
|
868
|
+
};
|
|
869
|
+
return windows.reduce((best, w, i) => (score(w) > score(windows[best]) ? i : best), 0);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// One limit window: figure, window name, a track with the used share and the projected draw,
|
|
873
|
+
// and the reset beneath (signature-design "Window readout").
|
|
874
|
+
function readout(vendor, w, { lead = false, adds = null, upper = false } = {}) {
|
|
875
|
+
const after = w.used + (adds || 0);
|
|
876
|
+
const rate = rateFor(vendor, w.label);
|
|
877
|
+
const sub = [
|
|
878
|
+
w.resetsAt ? `resets ${untilText(w.resetsAt * 1000).replace(/^in /, '')}` : '',
|
|
879
|
+
adds != null ? (upper ? 'no move seen yet' : `+${pct(adds)} this debate`) : rate.short,
|
|
880
|
+
].filter(Boolean).join(' · ');
|
|
881
|
+
return `<div class="readout ${lead ? 'lead' : ''} ${level(after) ? `lvl-${level(after)}` : ''}" title="${esc(rate.long)}">
|
|
882
|
+
<div class="readout-fig"><b>${pct(w.used)}</b><span>${esc(w.label)} used</span></div>
|
|
883
|
+
<div class="track"><span class="used" style="width: ${width(w.used)}%"></span>${adds ? `<span class="adds" style="width: ${width(Math.min(adds, 1 - w.used))}%"></span>` : ''}</div>
|
|
884
|
+
<div class="readout-sub">${esc(sub)}</div>
|
|
885
|
+
</div>`;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Lead window first; the rest keep their order.
|
|
889
|
+
function readouts(vendor, windows) {
|
|
890
|
+
const i = windows.length > 1 ? leadWindow(windows) : 0;
|
|
891
|
+
const lead = Number.isInteger(i) && i >= 0 && i < windows.length ? i : 0;
|
|
892
|
+
const order = [lead, ...windows.map((_, i) => i).filter((i) => i !== lead)];
|
|
893
|
+
return order.map((i) => readout(vendor, windows[i], { lead: i === lead, adds: windows[i].adds ?? null, upper: windows[i].upper })).join('');
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// key: a vendor or a pool key (seats.poolKey); a named profile shows as "Codex (work)"
|
|
897
|
+
const seatPool = (s) => poolKey(s.vendor, s.account, !!VENDOR_META[s.vendor]?.profiles);
|
|
898
|
+
// Out for this debate (pool-down events): the seat's whole login, or the limit group its model
|
|
899
|
+
// draws on (agy), told the way the server tells it (models.js quotaGroup on the quota read).
|
|
900
|
+
const seatDown = (s) => Object.values(S.down).find((x) => x.vendor === s.vendor && (x.account || '') === (s.account || '')
|
|
901
|
+
&& (!x.group || x.group === quotaGroup(S.quota[seatPool(s)]?.groups, s.model))) || null;
|
|
902
|
+
const poolId = (key) => { const vendor = poolVendor(key), account = poolAccount(key); return `<div class="pool-id"><b>${esc(VENDOR_LABEL[vendor] || vendor)}${account ? ` (${esc(account)})` : ''}</b><span title="${esc(VENDOR_META[vendor]?.pool || '')}">${esc(VENDOR_META[vendor]?.pool || '')}</span></div>`; };
|
|
903
|
+
const usageLink = (key) => { const m = VENDOR_META[poolVendor(key)]; return m?.usageUrl ? `<a href="${esc(m.usageUrl)}" target="_blank" rel="noopener">usage page ↗</a>` : ''; };
|
|
904
|
+
|
|
905
|
+
// One block per vendor pool: live windows, the projected draw for this table stacked per seat,
|
|
906
|
+
// and the measured per-round share where a calibration exists.
|
|
907
|
+
function renderProjection() {
|
|
908
|
+
const seats = draftSeats().map((s, i) => ({ ...s, id: `seat-${i}` }));
|
|
909
|
+
const chairman = draftChairman();
|
|
910
|
+
const p = project({ seats, chairman, critiqueRounds: Number($('critique-rounds').value) || 0, baselines: BASELINES, memory: $('memory').value, research: searcherOn() ? draftSearcher() : null });
|
|
911
|
+
const researcher = draftSearcher();
|
|
912
|
+
const label = (id) => (id === 'chairman' ? 'chairman' : id === 'research' ? 'searcher' : `${seats[Number(id.slice(5))]?.lens || id}`);
|
|
913
|
+
const colourOf = (id) => (id === 'chairman' ? vendorColour(chairman.vendor) : id === 'research' ? vendorColour(researcher.vendor) : seats[Number(id.slice(5))]?.colour || 'ink');
|
|
914
|
+
$('projection').innerHTML = Object.values(p.byVendor).map((v) => {
|
|
915
|
+
// the vendor's seats all on one account: that account's pool; mixed: the default one
|
|
916
|
+
const seatOf = (id) => (id === 'chairman' ? chairman : id === 'research' ? researcher : seats[Number(id.slice(5))]);
|
|
917
|
+
const keys = new Set(v.seats.map((id) => seatPool(seatOf(id))));
|
|
918
|
+
const pool = keys.size === 1 ? [...keys][0] : v.vendor;
|
|
919
|
+
const q = S.quota[pool];
|
|
920
|
+
const pw = projectedWindows(S.calib, pool, v.seats.map((id) => p.bySeat[id])) || [];
|
|
921
|
+
// Live windows carry this debate's projected draw; without a live figure the projection is
|
|
922
|
+
// all there is, so it stays a sentence. A quota in groups (agy) shows the seated models' groups.
|
|
923
|
+
const windows = q ? quotaWindows(q, v.seats.map((id) => seatOf(id)?.model || '')).map((w) => { const x = pw.find((y) => y.label === w.label); return { ...w, adds: x ? x.pct : null, upper: !!x?.upperBound }; }) : [];
|
|
924
|
+
const projected = q ? '' : pw.length ? pw.map((w) => (w.upperBound
|
|
925
|
+
? `No movement of the ${esc(w.label)} window seen over ${k(w.pending)} tokens yet.`
|
|
926
|
+
: `About +${pct(w.pct)} of the ${esc(w.label)} window (${w.samples} measured move${w.samples === 1 ? '' : 's'}).`)).join(' ')
|
|
927
|
+
: 'Window share is unmeasured until a debate runs.';
|
|
928
|
+
const cap = S.budget[v.vendor];
|
|
929
|
+
// Seats of one vendor share its colour, so each segment of the bar gets its own shade and the
|
|
930
|
+
// chairman a hatch; the legend beside each seat uses the same fill, so the split reads.
|
|
931
|
+
const fill = (id, i) => (id === 'chairman' ? 'chair' : `shade-${Math.min(i, 3)}`);
|
|
932
|
+
const segments = v.seats.filter((id) => id !== 'chairman');
|
|
933
|
+
const order = [...segments, ...v.seats.filter((id) => id === 'chairman')];
|
|
934
|
+
return `<div class="pool">
|
|
935
|
+
<div class="pool-top">${poolId(pool)}<div class="pool-total"><b>≈ ${k(v.total)}</b><span>tokens${v.cost != null ? ` · ≈ $${v.cost.toFixed(2)}` : ''}</span></div></div>
|
|
936
|
+
<div class="bar" aria-label="Projected share of the pool per seat">${order.map((id) => `<span class="${fill(id, segments.indexOf(id))}" style="--seat: var(--seat-${colourOf(id)}); width: ${(100 * p.bySeat[id].weighted / (v.weighted || 1)).toFixed(1)}%" title="${esc(label(id))}"></span>`).join('')}</div>
|
|
937
|
+
<div class="pool-seats">${order.map((id) => {
|
|
938
|
+
const row = p.bySeat[id];
|
|
939
|
+
return `<span class="chip ${fill(id, segments.indexOf(id))}" style="--seat: var(--seat-${colourOf(id)})" title="${esc(row.calls)} calls${row.model ? ', ' + esc(row.model) : ''}${row.weight !== 1 ? `, model weight ×${row.weight}` : ''}"><span>${esc(label(id))}</span><b>${k(row.total)}</b><span>${row.cost != null ? `$${row.cost.toFixed(2)}` : ''}</span></span>`;
|
|
940
|
+
}).join('')}</div>
|
|
941
|
+
${v.weighted !== v.total ? `<p class="pool-note">≈ ${k(v.weighted)} pool units after model weight.</p>` : ''}
|
|
942
|
+
<div class="pool-window-section">${windows.length ? `<div class="readouts">${readouts(pool, windows)}</div>` : `<p class="pool-note">${projected}</p>${poolEntry(v.vendor, v.weighted)}`}</div>
|
|
943
|
+
<div class="pool-cap" data-vendor="${esc(v.vendor)}"><label for="cap-${esc(v.vendor)}" title="Projected before each call, with the calls in flight and the verdict reserved. The vendor does not enforce it.">Cap</label><input id="cap-${esc(v.vendor)}" name="tokens" type="number" min="0" step="1000" placeholder="tokens" value="${cap?.tokens ?? ''}" aria-label="Token cap for ${esc(VENDOR_LABEL[v.vendor] || v.vendor)}">${v.cost != null || cap?.usd > 0 ? `<input name="usd" type="number" min="0" step="0.05" placeholder="$" value="${cap?.usd ?? ''}" aria-label="Dollar cap for ${esc(VENDOR_LABEL[v.vendor] || v.vendor)}">` : ''}${cap?.tokens > 0 && v.total > cap.tokens ? '<span class="warn">the projection is over this cap</span>' : ''}${usageLink(v.vendor)}</div>
|
|
944
|
+
${cap?.usd > 0 && v.cost == null ? '<p class="pool-note warn" data-dollar-warning>No dollar estimate is available. This dollar cap blocks calls; clear it to proceed with a token cap instead.</p>' : ''}
|
|
945
|
+
</div>`;
|
|
946
|
+
}).join('') || '<span class="muted">Seat at least one instance.</span>';
|
|
947
|
+
$('projection-summary').textContent = Object.values(p.byVendor).map((v) => `${VENDOR_LABEL[v.vendor] || v.vendor} ≈ ${k(v.total)}`).join(' · ');
|
|
948
|
+
const vs = Object.values(p.byVendor);
|
|
949
|
+
const rounds = Number($('critique-rounds').value) || 0;
|
|
950
|
+
const priced = vs.filter((v) => v.cost != null);
|
|
951
|
+
$('start-summary').innerHTML = seats.length ? `<b>${seats.length} seats</b> · ${seats.length * (rounds + 1) + 1 + (searcherOn() ? 1 : 0)} planned calls before retries · ≈ ${k(vs.reduce((n, v) => n + v.total, 0))} tokens${priced.length ? ` · ≈ $${priced.reduce((n, v) => n + v.cost, 0).toFixed(2)} for ${priced.map((v) => esc(VENDOR_LABEL[v.vendor] || v.vendor)).join(', ')}` : ''}` : '';
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function clearSetupValidation() {
|
|
955
|
+
S.seatIssue = null;
|
|
956
|
+
$('setup-error').textContent = ''; $('seats-error').textContent = '';
|
|
957
|
+
for (const el of $('seats').querySelectorAll('[aria-invalid]')) el.removeAttribute('aria-invalid');
|
|
958
|
+
for (const el of $('seats').querySelectorAll('.seat-invalid')) el.classList.remove('seat-invalid');
|
|
959
|
+
for (const el of $('seats').querySelectorAll('.seat-field-error')) { el.textContent = ''; el.hidden = true; }
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function showSetupError(error) {
|
|
963
|
+
$('setup-error').textContent = error.message;
|
|
964
|
+
if (!['lens', 'moodText'].includes(error.field) || !Number.isInteger(error.seatIndex) || !S.draft[error.seatIndex]) return;
|
|
965
|
+
S.seatIssue = error; renderSeats();
|
|
966
|
+
const row = $('seats').querySelectorAll('.seat')[error.seatIndex];
|
|
967
|
+
const field = row.querySelector(error.field === 'lens' ? '.lens' : '.mood-text');
|
|
968
|
+
field.focus({ preventScroll: true });
|
|
969
|
+
row.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' });
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function start() {
|
|
973
|
+
if (S.startPending) return;
|
|
974
|
+
unlockSound(); // inside the click, so the opening cue may play once the board is up
|
|
975
|
+
clearSetupValidation();
|
|
976
|
+
const f = $('notes');
|
|
977
|
+
const modelInputs = [...$('seats').querySelectorAll('.model:not([hidden])'), $('chairman-model'), ...(searcherOn() ? [$('searcher-model')] : [])];
|
|
978
|
+
const emptyModel = modelInputs.find((input) => !input.hidden && !input.value.trim());
|
|
979
|
+
if (emptyModel) { $('setup-error').textContent = 'Enter a model ID, or choose a model from the list.'; emptyModel.focus(); return; }
|
|
980
|
+
syncResearchQuery();
|
|
981
|
+
if (searcherOn() && $('research-query').value.length > 2000) { $('setup-error').textContent = 'The search query exceeds 2,000 characters. Choose Custom query and shorten it.'; $('research-query-mode').focus(); return; }
|
|
982
|
+
const notes = {};
|
|
983
|
+
for (const el of f.querySelectorAll('input[name], textarea[name]')) notes[el.name] = el.value.trim();
|
|
984
|
+
notes.options = S.options.map((o) => o.trim()).filter(Boolean).join('\n');
|
|
985
|
+
const fieldNotes = { ...notes, options: notes.options.split('\n').filter(Boolean) };
|
|
986
|
+
const body = {
|
|
987
|
+
fieldNotes, seats: draftSeats(),
|
|
988
|
+
chairman: draftChairman(),
|
|
989
|
+
options: { critiqueRounds: Number($('critique-rounds').value), autoContinueMs: Number($('auto').value) * 1000, questionWaitMs: Number($('question-wait').value) * 1000, wallClockMs: Number($('wall').value) * 60000, memory: $('memory').value, unattended: $('unattended').checked, allowLimitedIsolation: isolationAccepted(), acknowledgeTermsRisk: termsAccepted(), budget: budgetBody(),
|
|
990
|
+
...(searcherOn() ? { research: { vendor: $('searcher-vendor').value, query: $('research-query').value.trim(), maxSearches: Number($('research-searches').value), timeoutMs: Number($('research-time').value) * 1000,
|
|
991
|
+
model: $('searcher-model').value.trim(), effort: $('searcher-effort').value, account: searcherAccount() } } : {}) },
|
|
992
|
+
};
|
|
993
|
+
try {
|
|
994
|
+
localStorage.setItem('roundtable.notes', JSON.stringify(notes));
|
|
995
|
+
localStorage.setItem('roundtable.seats', JSON.stringify(S.draft.map((s) => ({ ...s, keyEnv: keyEnvName(s.keyEnv) }))));
|
|
996
|
+
localStorage.setItem('roundtable.budget', JSON.stringify(S.budget));
|
|
997
|
+
localStorage.setItem('roundtable.settings', JSON.stringify({ options: body.options, researchQueryMode: $('research-query-mode').value, chairman: {
|
|
998
|
+
vendor: $('chairman').value, model: $('chairman-model').value, effort: $('chairman-effort').value,
|
|
999
|
+
account: $('chairman-account').value, endpoint: $('chairman-endpoint').value, keyEnv: keyEnvName($('chairman-keyenv').value),
|
|
1000
|
+
} }));
|
|
1001
|
+
} catch {}
|
|
1002
|
+
const issue = seatSetupIssue(S.draft);
|
|
1003
|
+
if (issue) {
|
|
1004
|
+
const id = clientDiagnostics.report('browser-validation', 'create', { field: issue.field, seatIndex: issue.seatIndex });
|
|
1005
|
+
showSetupError({ ...issue, message: issue.message + errorReference({ id }) });
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
// A card on a vendor whose terms notice is not acknowledged: ask now, rather than send a table
|
|
1009
|
+
// the server would refuse. The person opens the table again once they have answered.
|
|
1010
|
+
const unacknowledged = termsPending();
|
|
1011
|
+
if (unacknowledged.length) { openTerms(unacknowledged[0]); return; }
|
|
1012
|
+
// Confirm a known unavailable provider only after the form itself is valid.
|
|
1013
|
+
const red = [...S.draft.map((s) => ({ name: `${VENDOR_LABEL[s.vendor]} (${s.lens})`, l: poolLamp(s.vendor, seatAccount(s), s.model) })),
|
|
1014
|
+
{ name: `the chairman (${VENDOR_LABEL[$('chairman').value]})`, l: poolLamp($('chairman').value, chairAccount(), $('chairman-model').value.trim()) },
|
|
1015
|
+
...(searcherOn() ? [{ name: `the searcher (${VENDOR_LABEL[$('searcher-vendor').value]})`, l: poolLamp($('searcher-vendor').value, searcherAccount(), $('searcher-model').value.trim()) }] : [])].filter((x) => x.l.cls === 'danger');
|
|
1016
|
+
const sig = red.map((x) => `${x.name}:${x.l.note}`).join('|');
|
|
1017
|
+
if (red.length && S.startConfirm !== sig) {
|
|
1018
|
+
S.startConfirm = sig;
|
|
1019
|
+
showSetupError({ message: `${red.map((x) => `${x.name}: ${x.l.note}`).join('; ')}. Those calls will fail.` });
|
|
1020
|
+
$('start').textContent = 'Open anyway';
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const revision = viewRevision;
|
|
1024
|
+
S.startPending = true; updateLamps();
|
|
1025
|
+
try {
|
|
1026
|
+
const out = await requestJson('/api/debates', body);
|
|
1027
|
+
if (revision === viewRevision) { openingSlug = out.slug; location.hash = out.slug; } // route() opens the board
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
showSetupError(e);
|
|
1030
|
+
loadRecent();
|
|
1031
|
+
} finally { S.startPending = false; updateLamps(); }
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Only vendors with a positive cap are sent; an empty field is no cap.
|
|
1035
|
+
function budgetBody() {
|
|
1036
|
+
const out = {};
|
|
1037
|
+
for (const [v, b] of Object.entries(S.budget)) {
|
|
1038
|
+
const cap = {};
|
|
1039
|
+
if (b?.tokens > 0) cap.tokens = b.tokens;
|
|
1040
|
+
if (b?.usd > 0) cap.usd = b.usd;
|
|
1041
|
+
if (Object.keys(cap).length) out[v] = cap;
|
|
1042
|
+
}
|
|
1043
|
+
return out;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ---------- board ----------
|
|
1047
|
+
// The hash decides the screen: '#<slug>' is that debate's board, no hash is the setup screen with
|
|
1048
|
+
// the list of debates. Back and forward work, and a board link can be shared or bookmarked.
|
|
1049
|
+
let live = null; // the open board's { es, timer }
|
|
1050
|
+
let viewRevision = 0;
|
|
1051
|
+
function closeBoard() { ceremony.cancel(); if (live) { live.es.close(); clearInterval(live.timer); live = null; } }
|
|
1052
|
+
function route() {
|
|
1053
|
+
viewRevision++;
|
|
1054
|
+
closeBoard();
|
|
1055
|
+
const slug = decodeURIComponent(location.hash.slice(1));
|
|
1056
|
+
if (/^[a-z0-9-]+$/.test(slug)) openBoard(slug); else showSetup();
|
|
1057
|
+
}
|
|
1058
|
+
function showSetup() {
|
|
1059
|
+
S.slug = null; resetBoard({ clearDrafts: true });
|
|
1060
|
+
$('setup').hidden = false; $('board').hidden = true; $('humanbar').hidden = true; $('nav-debates').hidden = true;
|
|
1061
|
+
$('sys').className = 'status ok'; $('sys').textContent = 'idle';
|
|
1062
|
+
for (const id of ['st-round', 'st-seats', 'st-elapsed']) $(id).textContent = '';
|
|
1063
|
+
document.title = 'Roundtable';
|
|
1064
|
+
loadRecent();
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// The debates on disk, newest first: status, the pick, size, and a link to each board.
|
|
1068
|
+
const STATE_CLASS = { running: 'warn', interrupted: 'warn', accepted: 'ok', recommended: 'info', 'no verdict': 'danger', corrupt: 'danger' };
|
|
1069
|
+
const STATE_LABEL = { running: 'live', interrupted: 'interrupted', accepted: 'accepted', recommended: 'recommended', split: 'split', stopped: 'stopped', 'no verdict': 'no verdict', corrupt: 'needs repair' };
|
|
1070
|
+
const deletableHistory = (entry) => ['finished', 'interrupted'].includes(entry.state);
|
|
1071
|
+
let recentRevision = 0;
|
|
1072
|
+
let historyEntries = [], historySelection = null, historyBusy = false;
|
|
1073
|
+
async function loadRecent() {
|
|
1074
|
+
const revision = ++recentRevision;
|
|
1075
|
+
let list;
|
|
1076
|
+
try { list = (await requestJson('/api/debates')).debates || []; }
|
|
1077
|
+
catch (e) {
|
|
1078
|
+
if (revision !== recentRevision) return;
|
|
1079
|
+
$('recent-count').textContent = 'unavailable';
|
|
1080
|
+
historyEntries = []; $('clear-history').disabled = true; $('recent-foot').hidden = true;
|
|
1081
|
+
$('recent-list').innerHTML = `<div class="empty"><span role="alert">Could not load history. ${esc(e.message)}</span><button class="btn btn-small" id="retry-recent" type="button">Retry history</button></div>`;
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (revision !== recentRevision) return;
|
|
1085
|
+
historyEntries = list;
|
|
1086
|
+
$('clear-history').disabled = historyBusy || !list.some(deletableHistory);
|
|
1087
|
+
$('recent-count').textContent = list.length ? `${list.length} on this machine` : '';
|
|
1088
|
+
$('recent-foot').hidden = true;
|
|
1089
|
+
if (!list.length) {
|
|
1090
|
+
$('recent-list').innerHTML = '<div class="empty"><span><span class="prompt">></span> no debates yet<br> write the field notes below and open the table_</span></div>';
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
const row = (d) => {
|
|
1094
|
+
// the human's choice once accepted, otherwise the recommendation when there is a real one
|
|
1095
|
+
const pick = d.status === 'accepted' ? d.accepted : ['no consensus', 'no pick', 'no verdict'].includes(d.recommended) ? '' : d.recommended;
|
|
1096
|
+
const cost = d.cost != null ? ` · $${d.cost.toFixed(2)}${d.costPartial ? '+' : ''}` : '';
|
|
1097
|
+
const tag = d.state === 'corrupt' ? 'div' : 'a';
|
|
1098
|
+
const deletable = deletableHistory(d);
|
|
1099
|
+
return `<div class="history-row"><${tag} class="debate-row" ${tag === 'a' ? `href="#${esc(d.slug)}"` : ''} title="${esc(d.decision)}">
|
|
1100
|
+
<span class="data">${esc(d.date)}</span>
|
|
1101
|
+
<span class="debate-title">${esc(d.decision)}</span>
|
|
1102
|
+
<span class="status ${STATE_CLASS[d.status] || 'muted'}">${esc(STATE_LABEL[d.status] || d.status)}</span>
|
|
1103
|
+
<span class="debate-pick">${esc(pick || '')}</span>
|
|
1104
|
+
<span class="data">${d.state === 'corrupt' ? 'unavailable' : `${d.rounds} rd · ${d.tokens == null ? 'tokens unknown' : `${k(d.tokens)}${d.tokensPartial ? '+' : ''}`}${cost}`}</span>
|
|
1105
|
+
${d.warning ? `<span class="debate-warning">${esc(d.warning)}</span>` : ''}
|
|
1106
|
+
</${tag}><button class="text-btn history-delete" type="button" data-delete="${esc(d.slug)}" aria-label="Delete ${esc(d.decision)}" ${historyBusy || !deletable ? 'disabled' : ''} title="${deletable ? 'Delete this saved session' : d.state === 'running' ? 'Stop this session before deleting it' : 'Repair this record before deleting it to preserve usage statistics'}">Delete</button></div>`;
|
|
1107
|
+
};
|
|
1108
|
+
const shown = S.showAllDebates ? list : list.slice(0, 5);
|
|
1109
|
+
$('recent-list').innerHTML = shown.map(row).join('');
|
|
1110
|
+
// the list's foot: Show all on the left, Clear on the right, both quiet text actions
|
|
1111
|
+
$('debates-more-slot').innerHTML = list.length > 5 ? `<button class="text-btn" id="debates-more" type="button">${S.showAllDebates ? 'Show the latest 5' : `Show all ${list.length}`}</button>` : '';
|
|
1112
|
+
$('recent-foot').hidden = false;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function confirmHistoryDeletion(id = null) {
|
|
1116
|
+
if (historyBusy) return;
|
|
1117
|
+
const selected = historyEntries.filter((d) => deletableHistory(d) && (!id || d.slug === id)).slice(0, 1000);
|
|
1118
|
+
if (!selected.length) return;
|
|
1119
|
+
historySelection = { ids: selected.map((d) => d.slug), bulk: !id };
|
|
1120
|
+
$('delete-history-title').textContent = id ? 'Delete session?' : `Clear ${selected.length} session${selected.length === 1 ? '' : 's'}?`;
|
|
1121
|
+
$('delete-history-detail').textContent = id ? selected[0].decision : 'Finished and interrupted sessions will be removed. Running sessions are kept.';
|
|
1122
|
+
$('delete-history-error').hidden = true;
|
|
1123
|
+
$('delete-history-confirm').textContent = id ? 'Delete session' : 'Clear';
|
|
1124
|
+
$('delete-history').showModal();
|
|
1125
|
+
}
|
|
1126
|
+
$('clear-history').addEventListener('click', (ev) => { ev.preventDefault(); confirmHistoryDeletion(); });
|
|
1127
|
+
$('delete-history').addEventListener('cancel', (event) => { if (historyBusy) event.preventDefault(); });
|
|
1128
|
+
$('delete-history-confirm').addEventListener('click', async () => {
|
|
1129
|
+
if (!historySelection || historyBusy) return;
|
|
1130
|
+
const { ids, bulk } = historySelection;
|
|
1131
|
+
historyBusy = true; $('delete-history-confirm').disabled = $('delete-history-cancel').disabled = true;
|
|
1132
|
+
$('delete-history-error').hidden = true;
|
|
1133
|
+
try {
|
|
1134
|
+
await requestJson(bulk ? '/api/debates/clear' : `/api/debates/${ids[0]}/delete`, bulk ? { ids } : {});
|
|
1135
|
+
$('delete-history').close(); historySelection = null;
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
$('delete-history-error').textContent = error.message; $('delete-history-error').hidden = false;
|
|
1138
|
+
} finally {
|
|
1139
|
+
historyBusy = false; $('delete-history-confirm').disabled = $('delete-history-cancel').disabled = false;
|
|
1140
|
+
await loadRecent();
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
function openBoard(slug) {
|
|
1145
|
+
resetBoard({ clearDrafts: true });
|
|
1146
|
+
S.slug = slug;
|
|
1147
|
+
$('setup').hidden = true; $('board').hidden = false; $('humanbar').hidden = false; $('nav-debates').hidden = false;
|
|
1148
|
+
// EventSource reconnects by itself and sends Last-Event-ID, so a dropped connection costs
|
|
1149
|
+
// nothing. After a server restart the stream begins again with `started`, which resets the
|
|
1150
|
+
// board; a slug with no transcript keeps retrying and says so.
|
|
1151
|
+
const es = new EventSource(`/api/debates/${slug}/events`);
|
|
1152
|
+
es.onmessage = (m) => {
|
|
1153
|
+
const event = JSON.parse(m.data);
|
|
1154
|
+
if (event.type === 'deleted') { closeBoard(); location.hash = ''; return; }
|
|
1155
|
+
S.connected = true; handle(event);
|
|
1156
|
+
};
|
|
1157
|
+
es.onerror = () => { if (S.connected !== false) clientDiagnostics.report('browser-stream', 'events'); S.connected = false; renderGate(); renderBanners(); };
|
|
1158
|
+
es.onopen = () => { S.connected = true; void clientDiagnostics.flush(); renderGate(); renderBanners(); };
|
|
1159
|
+
live = { es, timer: setInterval(tick, 1000) };
|
|
1160
|
+
render(); // clear what the last board showed, before this one's first event (or a 404)
|
|
1161
|
+
if (openingSlug === slug) ceremony.play('open');
|
|
1162
|
+
openingSlug = null;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function resetBoard({ clearDrafts = false } = {}) {
|
|
1166
|
+
replyReveals.clear();
|
|
1167
|
+
Object.assign(S, { seats: [], fieldNotes: null, rows: [], rounds: [], opened: new Set(), roundOpen: {}, openDetails: new Set(), humanNotes: [], initialNotes: [], gate: null, deadline: null, held: false, verdict: null, status: 'idle', startedAt: null, stopReason: null, pending: new Set(), chairman: null, configuredChairman: null, interrupted: null, stopQueued: false, finishQueued: false, error: null, conn: {}, positions: [], accepted: null, synthesis: null, down: {},
|
|
1168
|
+
budgetStop: null, exported: null, exportError: null, replayed: false, connected: null, chairmanFallback: null, transcriptWarning: null, unattended: false, research: null,
|
|
1169
|
+
actionPending: null, actionError: null, holdPending: false, timedOut: {}, synthChoice: null, stopContext: null, reviewDeadline: null }); // hash routing reuses S: nothing from the last board
|
|
1170
|
+
if (clearDrafts || !S.questionDrafts) S.questionDrafts = {};
|
|
1171
|
+
cardMotion.clear(); // a fold still rolling up belongs to the board that is gone
|
|
1172
|
+
if (clearDrafts) { $('question-inputs').replaceChildren(); delete $('question-inputs').dataset.signature; setUsageOpen(false); $('decision-actions').open = false; }
|
|
1173
|
+
if (clearDrafts) {
|
|
1174
|
+
$('human-msg').value = ''; $('accept-note').value = ''; $('export-status').textContent = '';
|
|
1175
|
+
delete $('accept-option').dataset.applied;
|
|
1176
|
+
S.acceptEdited = false; S.acceptNoteEdited = false;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function handle(e) {
|
|
1181
|
+
if (!e.replayed && !e.failed && !e.skipped && ['reply', 'verdict'].includes(e.type)) replyReveals.set(replyKey(e), performance.now());
|
|
1182
|
+
switch (e.type) {
|
|
1183
|
+
case 'started': resetBoard(); S.seats = e.seats; S.fieldNotes = e.fieldNotes; S.unattended = !!e.options?.unattended; S.configuredChairman = e.chairman || null; S.reviewDeadline = e.deadlineAt; S.status = 'running'; S.startedAt = Date.now(); $('board-title').textContent = e.fieldNotes.decision; document.title = `${e.fieldNotes.decision} · Roundtable`; $('board-empty').hidden = false; break;
|
|
1184
|
+
case 'resumed': S.status = 'running'; S.interrupted = null; S.reviewDeadline = e.deadlineAt; S.startedAt = Date.now(); S.pending.clear(); break;
|
|
1185
|
+
case 'research': S.research = e; if (!e.replayed) S.status = e.status === 'running' ? 'researching' : 'running'; break;
|
|
1186
|
+
case 'interrupted': S.status = 'interrupted'; S.interrupted = e; S.pending.clear(); break;
|
|
1187
|
+
case 'round-start': S.rounds[e.round] = []; S.pending = new Set(S.seats.map((s) => s.index)); $('board-empty').hidden = true; break;
|
|
1188
|
+
case 'seat-start': break;
|
|
1189
|
+
case 'seat-state': S.conn[e.seat.id] = e.state; break;
|
|
1190
|
+
case 'reprompt': S.pending.add(e.seat.index); break;
|
|
1191
|
+
case 'reply': S.rows.push(e); S.rounds[e.round] = S.rounds[e.round] || []; S.rounds[e.round].push(e); S.pending.delete(e.seat.index); break;
|
|
1192
|
+
case 'round-done': S.pending.clear(); if (e.positions) S.positions[e.round] = e.positions; break;
|
|
1193
|
+
case 'gate':
|
|
1194
|
+
S.gate = e; S.reviewDeadline = e.deadlineAt; S.deadline = null; S.held = false; S.status = 'waiting';
|
|
1195
|
+
if (!e.replayed && e.questions.length) playCue('waiting'); // a real question, including in a background tab
|
|
1196
|
+
break;
|
|
1197
|
+
case 'gate-expired': S.stopContext = 'waiting'; S.gate = null; S.deadline = null; break;
|
|
1198
|
+
case 'countdown': S.deadline = e.deadline; S.held = false; break;
|
|
1199
|
+
case 'gate-timeout': S.timedOut[e.round] = e; break;
|
|
1200
|
+
case 'held': S.held = true; S.deadline = null; break;
|
|
1201
|
+
case 'gate-closed': S.gate = null; S.deadline = null; S.status = 'running'; break;
|
|
1202
|
+
case 'stop-queued': S.stopQueued = true; S.finishQueued = false; break;
|
|
1203
|
+
case 'finish-queued': S.finishQueued = true; break;
|
|
1204
|
+
case 'human': (e.round === -1 ? S.initialNotes : (S.humanNotes[e.round] ??= [])).push(noteRecord(e)); break;
|
|
1205
|
+
case 'quota': S.quota[e.vendor] = e.quota; break;
|
|
1206
|
+
case 'calibration': S.calib[e.key || e.vendor] = e.windows; break;
|
|
1207
|
+
case 'pool': S.pools[e.vendor] = e.pool; break;
|
|
1208
|
+
case 'pool-down': S.down[e.key] = e; break;
|
|
1209
|
+
case 'budget': S.budgetStop = e; break;
|
|
1210
|
+
case 'stopped': S.stopReason = e.reason; S.stopContext = e.context || S.stopContext; S.gate = null; S.deadline = null; S.pending.clear(); S.status = 'verdict'; break;
|
|
1211
|
+
case 'chairman-start': S.chairman = e.seat; S.finishQueued = false; break;
|
|
1212
|
+
case 'chairman-fallback': S.chairmanFallback = e; break;
|
|
1213
|
+
case 'attempt': S.rows.push(e); break;
|
|
1214
|
+
case 'verdict': S.rows.push(e); S.verdict = e; break;
|
|
1215
|
+
case 'done': S.status = 'done'; S.gate = null; S.deadline = null; S.pending.clear(); S.stopReason = e.reason || S.stopReason; S.replayed = !!e.replayed; S.synthesis = e.synthesis || (S.verdict ? (S.verdict.skipped ? 'skipped' : S.verdict.failed ? 'failed' : 'done') : 'missing'); break;
|
|
1216
|
+
case 'synthesize': S.status = 'verdict'; S.verdict = null; S.stopQueued = false; S.finishQueued = false; break;
|
|
1217
|
+
case 'accepted': S.accepted = e; break;
|
|
1218
|
+
case 'exported': S.exported = e; S.exportError = null; break;
|
|
1219
|
+
case 'export-failed': S.exportError = e.error + errorReference(e.diagnostic); break;
|
|
1220
|
+
case 'transcript-warning': S.transcriptWarning = e; break;
|
|
1221
|
+
case 'error':
|
|
1222
|
+
S.status = e.recoverable ? e.recoveryFinished ? 'done' : 'interrupted' : 'error';
|
|
1223
|
+
if (e.recoverable && !e.recoveryFinished) S.interrupted = { round: S.round };
|
|
1224
|
+
S.gate = null; S.deadline = null; S.pending.clear(); S.error = e.message + errorReference(e.diagnostic);
|
|
1225
|
+
if (e.fatal) { closeBoard(); $('board-title').textContent = 'Transcript needs repair'; document.title = 'Transcript needs repair · Roundtable'; }
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
const cue = ceremonyOutcome(e);
|
|
1229
|
+
if (cue) ceremony.play(cue);
|
|
1230
|
+
render();
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
1234
|
+
const list = (items) => (items && items.length ? `<ul>${items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul>` : '<span class="muted">none</span>');
|
|
1235
|
+
const seatName = (seat) => seat.label || VENDOR_LABEL[seat.vendor] || seat.vendor;
|
|
1236
|
+
const seatMeta = (seat) => [seat.endpoint ? 'API' : 'CLI', seat.model, seat.effort].filter(Boolean).join(' · ');
|
|
1237
|
+
// What a folded card hides: everything under the seat and position (or a failed call's one line).
|
|
1238
|
+
const foldable = (html) => `<div class="reply-more"><div>${html}</div></div>`;
|
|
1239
|
+
|
|
1240
|
+
// position: the seat's standing against the round's majority; provisional while the round is
|
|
1241
|
+
// still coming in (the majority so far), final from round-done.
|
|
1242
|
+
function card(r, position = null, provisional = false) {
|
|
1243
|
+
const seat = r.seat;
|
|
1244
|
+
const cancelled = cancellationReason(r, S.stopReason, S.rounds.length - 1);
|
|
1245
|
+
const flags = [];
|
|
1246
|
+
if (r.question) flags.push('<span class="status" title="This seat asked you a question">asked you</span>'); // visible on a folded card too
|
|
1247
|
+
if (r.reprompted) flags.push('<span class="status warn">re-prompted</span>');
|
|
1248
|
+
if (r.usedTools) flags.push(`<span class="status ${r.toolFail ? 'danger' : 'warn'}">used tools</span>`);
|
|
1249
|
+
// a recovered stall, saved with the attempt (progress.reconnects): the reply is complete, the cost may not be
|
|
1250
|
+
if (Number.isSafeInteger(r.progress?.reconnects) && r.progress.reconnects > 0) flags.push(`<span class="status warn" title="The provider connection went silent for the idle limit; the CLI reconnected ${r.progress.reconnects} time${r.progress.reconnects === 1 ? '' : 's'} and started the answer again. The interrupted attempt may have been charged without appearing in usage.">reconnected ×${r.progress.reconnects}</span>`);
|
|
1251
|
+
if (r.failed) flags.push(`<span class="status ${cancelled ? 'warn' : 'danger'}">${cancelled ? 'cancelled' : r.timedOut ? 'call timed out' : 'no reply'}</span>`);
|
|
1252
|
+
else if (!r.valid) flags.push('<span class="status warn">off contract</span>');
|
|
1253
|
+
let body;
|
|
1254
|
+
if (r.failed) {
|
|
1255
|
+
const line = cancelled === 'human' ? 'Cancelled when you pressed Stop. Earlier replies are kept; this seat did not finish this round.'
|
|
1256
|
+
: cancelled === 'finish' ? 'Discussion ended for the verdict. Earlier replies are kept; this seat did not finish this round.'
|
|
1257
|
+
: cancelled === 'wall-clock' ? 'Cancelled because the review time limit expired. Earlier replies are kept.'
|
|
1258
|
+
: (r.errorKind === 'policy' ? r.errors?.[0] || 'This seat is stopped by the provider tool policy.' : r.skipped ? `Skipped: ${VENDOR_LABEL[seat.vendor] || seat.vendor} is out for this debate.` :
|
|
1259
|
+
(r.errorKind === 'budget' && r.errors?.[0] ? r.errors[0] : ERROR_LINE[r.errorKind] || 'The provider call failed. Check the details and save the error log.')) + errorReference(r.diagnostic);
|
|
1260
|
+
body = `<p class="fail-line ${cancelled ? 'muted' : 'danger'}">${esc(line)}${r.resetsAt ? ` Resets ${esc(new Date(r.resetsAt).toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }))}.` : ''}</p>${foldable(`<details class="fail-detail"><summary>Details</summary><pre>${esc(callDetails(r.errors, r.progress) + (cancelled ? errorReference(r.diagnostic) : ''))}</pre></details>`)}`;
|
|
1261
|
+
}
|
|
1262
|
+
else if (r.parsed.pass) body = '<span class="muted">Nothing new to add.</span>';
|
|
1263
|
+
else if (!r.valid) body = foldable(`<pre>${esc(r.text)}</pre>`);
|
|
1264
|
+
else {
|
|
1265
|
+
const section = (title, items, tone) => `<section class="argument-section argument-${tone}"><h4>${title}</h4>${items.length ? `<ul>${items.map((text) => `<li>${argumentHtml(text, tone)}</li>`).join('')}</ul>` : '<span class="muted">None stated.</span>'}</section>`;
|
|
1266
|
+
body = `<div class="seat-stance"><span class="label">Position</span><strong>${esc(optionLabel(r.stanceKey, S.fieldNotes?.options))}</strong></div>
|
|
1267
|
+
${foldable(`${r.parsed.agreeWith.length ? section('Agrees with', r.parsed.agreeWith, 'agree') : ''}
|
|
1268
|
+
${section('Disagrees with · why', r.parsed.disagree, 'disagree')}
|
|
1269
|
+
${section('New points', r.parsed.new, 'new')}
|
|
1270
|
+
${section('Assumptions', r.parsed.assumptions, 'assumptions')}
|
|
1271
|
+
${r.question ? `<details class="question"><summary>Question for you${S.gate?.round === r.round ? ' · answer above' : ''}</summary><p>${esc(r.question)}</p></details>` : ''}`)}`;
|
|
1272
|
+
}
|
|
1273
|
+
const meta = seatMeta(seat);
|
|
1274
|
+
// the head's spoken name: its spans carry no separators, so a screen reader would run them together
|
|
1275
|
+
const headLabel = [seatName(seat), seat.lens, ...flags.map((f) => f.replace(/<[^>]*>/g, ''))].join(', ');
|
|
1276
|
+
// the dot is the live connection state in the current round, the recorded one in older rounds
|
|
1277
|
+
const current = r.round === S.rounds.length - 1;
|
|
1278
|
+
const conn = cancelled ? 'cancelled' : current ? (S.conn[seat.id] || r.state) : (r.state || (r.failed ? 'failed' : 'replied'));
|
|
1279
|
+
return `<article class="card ${S.opened.has(replyKey(r)) ? '' : 'folded'}" data-key="${esc(replyKey(r))}" style="--seat: var(--seat-${esc(seat.colour)})" data-position="${esc(position || 'none')}" title="${esc(position ? POSITION_LABEL[position] + (provisional ? ' so far' : '') : '')}">
|
|
1280
|
+
<div class="card-head" role="button" tabindex="0" aria-expanded="${S.opened.has(replyKey(r))}" aria-label="${esc(headLabel)}"><span class="vendor" title="${esc(meta)}">${esc(seatName(seat))}</span><span class="lens">${esc(seat.lens)}</span>${led(conn)}${seat.mood?.hidden ? `<span class="avatar card-avatar">${avatar(seat.mood.hidden, HIDDEN_MOODS.find((h) => h.art === seat.mood.hidden)?.sprite)}</span>` : seat.mood?.preset && seat.mood.preset !== 'grill' ? `<span class="muted" title="${esc(MOOD_HELP[seat.mood.preset] || '')}">${esc(seat.mood.preset)}</span>` : ''}<span class="flags">${flags.join('')}</span></div>
|
|
1281
|
+
<div class="reply-text" data-reveal="${esc(replyKey(r))}">${body}</div>
|
|
1282
|
+
</article>`;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function pendingCard(seat) {
|
|
1286
|
+
return `<article class="card pending" style="--seat: var(--seat-${esc(seat.colour)})"><div class="card-head"><span class="vendor">${esc(seatName(seat))}</span><span class="lens">${esc(seat.lens)}</span>${led(S.conn[seat.id] || 'thinking')}</div></article>`;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// A round is one column per option, in option order, present from the first reply on and the
|
|
1290
|
+
// same in every round; a reply lands at the foot of its column and nothing else moves. Other
|
|
1291
|
+
// stances and failed calls get columns after those, only when there are any; seats still
|
|
1292
|
+
// thinking sit in a strip under the columns, so the strip shrinking moves nothing above it.
|
|
1293
|
+
function renderRound(i, replies, isCurrent) {
|
|
1294
|
+
const options = S.fieldNotes?.options || [];
|
|
1295
|
+
const groups = new Map(options.map((_, n) => [`opt-${n + 1}`, []]));
|
|
1296
|
+
const keyOf = (r) => (r.failed ? (cancellationReason(r, S.stopReason, S.rounds.length - 1) ? 'Cancelled calls' : r.timedOut ? 'Calls that timed out' : 'No reply') : r.stanceKey || 'Unformatted reply');
|
|
1297
|
+
for (const r of replies) { const k = keyOf(r); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(r); }
|
|
1298
|
+
const pendingSeats = isCurrent ? S.seats.filter((s) => S.pending.has(s.index) && !replies.some((r) => r.seat.index === s.index)) : [];
|
|
1299
|
+
const final = S.positions[i] || null;
|
|
1300
|
+
const pos = final || (isCurrent ? positions(replies) : {});
|
|
1301
|
+
const tally = [...groups.entries()].filter(([, v]) => v.length).map(([k, v]) => `${optionLabel(k, options)} (${v.length})`).join(' · ') || (pendingSeats.length ? 'in progress' : '');
|
|
1302
|
+
const groupHtml = [...groups.entries()].map(([k, v]) => `<section class="group ${v.length ? '' : 'empty'}">
|
|
1303
|
+
<div class="group-title"><span>${esc(optionLabel(k, options))}</span><span class="count">${v.length ? `${v.length} seat${v.length > 1 ? 's' : ''}` : pendingSeats.length ? 'nobody yet' : 'nobody'}</span></div>
|
|
1304
|
+
<div class="cards">${v.map((r) => card(r, pos[r.seat.id] || null, !final)).join('')}</div></section>`).join('');
|
|
1305
|
+
const pendingHtml = pendingSeats.length ? `<section class="group thinking span"><div class="group-title"><span class="muted">thinking</span><span class="count">${pendingSeats.length} seat${pendingSeats.length > 1 ? 's' : ''}</span></div><div class="cards">${pendingSeats.map(pendingCard).join('')}</div></section>` : '';
|
|
1306
|
+
// Unattended: once the round is in, say what the skipped pause left behind (counted from the
|
|
1307
|
+
// replies themselves, so a review replayed from disk says it too).
|
|
1308
|
+
const asked = replies.filter((r) => r.question).length;
|
|
1309
|
+
const skipped = S.unattended && replies.length && !pendingSeats.length ? `<div class="human-note"><b>Unattended</b>${asked ? `${asked} question${asked > 1 ? 's' : ''} went unanswered.` : 'The pause was skipped.'}</div>` : '';
|
|
1310
|
+
const timedOut = S.timedOut[i]?.unanswered ? '<div class="human-note"><b>Continued automatically</b>The answer window ended. Unanswered questions remain unknown.</div>' : '';
|
|
1311
|
+
const note = skipped + timedOut + (S.humanNotes[i] || []).map((n) => `<div class="human-note"><b>${noteLabel(n)}</b>${esc(noteRecord(n).text)}</div>`).join('');
|
|
1312
|
+
// Folded unless it is the round in progress: a finished review leads with its conclusion.
|
|
1313
|
+
const live = isCurrent && !['done', 'error'].includes(S.status);
|
|
1314
|
+
return `<details class="round" ${(S.roundOpen[i] ?? live) ? 'open' : ''} data-round="${i}">
|
|
1315
|
+
<summary><span>${i === 0 ? 'Round 0 · blind' : `Round ${i}`}</span><span class="tally">${esc(tally)}</span></summary>
|
|
1316
|
+
<div class="groups"><div class="columns">${groupHtml}</div>${pendingHtml}${note}</div>
|
|
1317
|
+
</details>`;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// The strip: one pool row per vendor account (live windows, measured share per round, usage
|
|
1321
|
+
// link), its seats beneath it, then the current round and the debate total.
|
|
1322
|
+
function renderUsage() {
|
|
1323
|
+
const u = rollup(S.rows);
|
|
1324
|
+
const fmt = (n) => (n || 0).toLocaleString();
|
|
1325
|
+
// A sum over calls of which some reported nothing carries a "+" (at least this much), the same
|
|
1326
|
+
// mark the debates list uses; the counts are in its title. A figure nobody reported is a dash.
|
|
1327
|
+
const plus = (cov, what) => (cov && cov.reported < cov.of ? `<span class="partial" title="${cov.reported} of ${cov.of} calls report ${what}">+</span>` : '');
|
|
1328
|
+
const tokens = (n, x) => x.tokenCoverage?.reported === 0 ? '<span class="muted" title="No call reported token usage">unknown</span>'
|
|
1329
|
+
: `${fmt(n)}${plus(x.tokenCoverage, 'token usage')}`;
|
|
1330
|
+
const cost = (c, x) => (c == null ? '<span class="muted" title="This CLI does not report a cost">—</span>' : `$${c.toFixed(2)}${plus(x?.costCoverage, 'a cost')}`);
|
|
1331
|
+
const secs = (ms) => `${Math.round((ms || 0) / 1000)}s`;
|
|
1332
|
+
// last: the seat's input in the latest round it took part in (cache included), the figure that
|
|
1333
|
+
// says whether rounds stay flat (stateless) or grow (resume)
|
|
1334
|
+
const row = (name, x, cls = '', last = null, position = null) => `<tr class="${cls}" data-position="${esc(position || 'none')}"><td>${name}</td><td class="data num">${tokens(x.input, x)}</td><td class="data num">${tokens(x.cacheRead + x.cacheWrite, x)}</td><td class="data num">${tokens(x.output, x)}</td><td class="data num">${cost(x.cost, x)}</td><td class="data num">${secs(x.ms)}</td><td class="data num">${last ? `<span title="Round ${esc(last.round)}">${tokens(last.usage.input + last.usage.cacheRead + last.usage.cacheWrite, last.usage)}</span>` : ''}</td></tr>`;
|
|
1335
|
+
const pools = [...new Set([...S.seats.map(seatPool), ...(S.chairman ? [seatPool(S.chairman)] : []), ...(u.bySeat.research ? [seatPool(u.bySeat.research.seat)] : [])])];
|
|
1336
|
+
// the models this debate runs on a pool: a quota in groups (agy) shows only their groups
|
|
1337
|
+
const seatedModels = (key) => [...S.seats, S.chairman || S.configuredChairman, u.bySeat.research?.seat].filter((s) => s && seatPool(s) === key).map((s) => s.model || '');
|
|
1338
|
+
const rows = [];
|
|
1339
|
+
for (const pool of pools) {
|
|
1340
|
+
rows.push(`<tr class="pool-row"><td colspan="7">${poolLine(pool, seatedModels(pool))}</td></tr>`);
|
|
1341
|
+
if (u.bySeat.research && seatPool(u.bySeat.research.seat) === pool) { const rs = u.bySeat.research.seat; rows.push(row(`<span class="seat-cell" style="--seat: var(--seat-${esc(vendorColour(rs.vendor))})"><span class="seat-dot"></span><span><span class="seat-lens">searcher</span><span class="seat-meta">${esc([VENDOR_LABEL[rs.vendor] || rs.vendor, seatMeta(rs)].filter(Boolean).join(' · '))}</span></span></span>`, u.bySeat.research.usage)); }
|
|
1342
|
+
const seated = [...S.seats.filter((s) => seatPool(s) === pool), ...(S.chairman && seatPool(S.chairman) === pool ? [S.chairman] : [])];
|
|
1343
|
+
for (const seat of seated) {
|
|
1344
|
+
const x = u.bySeat[seat.id]?.usage;
|
|
1345
|
+
const perRound = u.bySeatRound[seat.id] || {};
|
|
1346
|
+
const lastRound = Object.keys(perRound).filter((k2) => k2 !== 'verdict').map(Number).sort((a, b) => b - a)[0];
|
|
1347
|
+
const last = lastRound != null ? { round: lastRound, usage: perRound[String(lastRound)] } : null;
|
|
1348
|
+
const isChair = seat.id === 'chairman';
|
|
1349
|
+
const position = isChair ? null : S.positions[S.rounds.length - 1]?.[seat.id] || null;
|
|
1350
|
+
// the lens reads first; who runs it and on what sits beneath in the data face
|
|
1351
|
+
const name = `<span class="seat-cell" style="--seat: var(--seat-${esc(seat.colour)})"><span class="seat-dot"></span><span><span class="seat-lens">${esc(seat.lens)}${isChair ? '' : led(S.conn[seat.id] || 'idle')}</span><span class="seat-meta">${esc([seatName(seat), seatMeta(seat)].filter(Boolean).join(' · '))}</span></span></span>`;
|
|
1352
|
+
const out = seatDown(seat);
|
|
1353
|
+
rows.push(x ? row(name, x, '', last, position) : `<tr data-position="none"><td>${name}</td><td class="data ${out ? 'danger' : 'muted'}" colspan="6">${out ? `out: ${esc(ERROR_LINE[out.kind] || out.kind)} no tokens charged` : 'no call yet'}</td></tr>`);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
const cur = S.rounds.length ? S.rounds.length - 1 : null;
|
|
1357
|
+
if (cur != null && u.byRound[String(cur)]) rows.push(row(`Round ${cur}`, u.byRound[String(cur)], 'subtotal'));
|
|
1358
|
+
rows.push(row('Debate total', u.total, 'total'));
|
|
1359
|
+
$('usage').querySelector('tbody').innerHTML = rows.join('');
|
|
1360
|
+
const keys = [...new Set([...S.seats, ...(S.chairman || S.configuredChairman ? [S.chairman || S.configuredChairman] : [])].map((seat) => poolKey(seat.vendor, seat.account, !!VENDOR_META[seat.vendor]?.profiles)))];
|
|
1361
|
+
$('usage-summary').innerHTML = keys.map((key) => {
|
|
1362
|
+
const q = S.quota[key];
|
|
1363
|
+
const window = remainingWindow(q && { ...q, windows: quotaWindows(q, seatedModels(key)) });
|
|
1364
|
+
const name = `${VENDOR_LABEL[poolVendor(key)] || poolVendor(key)}${poolAccount(key) ? ` · ${poolAccount(key)}` : ''}`;
|
|
1365
|
+
const remaining = window && Math.floor(window.remaining);
|
|
1366
|
+
const manual = S.pools[poolVendor(key)];
|
|
1367
|
+
// One quiet chip per pool: name, a short track and the figure. The window and its age are
|
|
1368
|
+
// the tooltip; Usage details has the rest.
|
|
1369
|
+
return `<span class="quota-chip" title="${esc(window ? `${window.label} · reported ${agoText(window.at)}` : 'Open usage details for available figures')}"><b>${esc(name)}</b>${window
|
|
1370
|
+
? `<span class="quota-track ${remaining < 10 ? 'danger' : remaining < 25 ? 'warn' : 'ok'}" role="meter" aria-label="${esc(name)} quota remaining" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${remaining}"><span style="width:${remaining}%"></span></span><span>${remaining}%</span>`
|
|
1371
|
+
: `<span>${manual ? `${esc(manual.remaining.toLocaleString())} ${esc(manual.unit)} left` : 'not reported'}</span>`}</span>`;
|
|
1372
|
+
}).join('');
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// The pool row on the board: name, one readout per live window, then freshness and the usage link.
|
|
1376
|
+
// One line per pool in the usage table: who and on what plan, each live window as "used share
|
|
1377
|
+
// and window" with a short track, then the usage page. Resets and the read time are the title.
|
|
1378
|
+
// models: those seated on the pool, so a quota in groups (agy) shows the groups they draw on.
|
|
1379
|
+
function poolLine(key, models = []) {
|
|
1380
|
+
const q = S.quota[key];
|
|
1381
|
+
const pl = S.pools[poolVendor(key)];
|
|
1382
|
+
const vendor = poolVendor(key), account = poolAccount(key);
|
|
1383
|
+
const windows = quotaWindows(q, models);
|
|
1384
|
+
const figure = windows.length
|
|
1385
|
+
? windows.map((w) => `<span class="pool-window ${level(w.used) ? level(w.used) : ''}" title="${esc([w.resetsAt ? `resets ${untilText(w.resetsAt * 1000)}` : '', `read ${agoText(q.at)}`].filter(Boolean).join(' · '))}"><span class="quota-track"><span style="width:${width(w.used)}%"></span></span>${pct(w.used)} of ${esc(w.label)} used</span>`).join('')
|
|
1386
|
+
: pl ? `<span class="pool-window">${esc(pl.remaining.toLocaleString())} ${esc(pl.unit)} left · entered ${agoText(pl.at)}</span>`
|
|
1387
|
+
: '<span class="pool-window muted">no live figure from this CLI</span>';
|
|
1388
|
+
// AI credits (agy /credits): what takes over once a group's limits are used up
|
|
1389
|
+
const credits = Number.isFinite(q?.credits?.remaining) ? `<span class="pool-window" title="${esc(`read ${agoText(q.credits.at)}`)}">${esc(q.credits.remaining.toLocaleString())} AI credits left</span>` : '';
|
|
1390
|
+
return `<div class="pool-head"><span class="pool-name"><b>${esc(VENDOR_LABEL[vendor] || vendor)}${account ? ` (${esc(account)})` : ''}</b><span>${esc(VENDOR_META[vendor]?.pool || '')}</span></span><span class="pool-figures">${figure}${credits}</span>${usageLink(key)}</div>`;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function untilText(t) {
|
|
1394
|
+
const s = Math.max(0, Math.round((t - Date.now()) / 1000));
|
|
1395
|
+
if (s < 3600) return `in ${Math.ceil(s / 60)}m`;
|
|
1396
|
+
if (s < 86400) return `in ${Math.floor(s / 3600)}h ${Math.round((s % 3600) / 60)}m`;
|
|
1397
|
+
return `in ${Math.floor(s / 86400)}d ${Math.round((s % 86400) / 3600)}h`;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function agoText(t) {
|
|
1401
|
+
const s = Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
1402
|
+
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)}m ago` : `${Math.floor(s / 3600)}h ago`;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
let shownVerdict = null, copyingResult = false;
|
|
1406
|
+
function renderVerdict() {
|
|
1407
|
+
const v = S.verdict;
|
|
1408
|
+
if (v !== shownVerdict) {
|
|
1409
|
+
shownVerdict = v; copyingResult = false;
|
|
1410
|
+
$('copy-result-status').textContent = '';
|
|
1411
|
+
$('copy-result-text').hidden = true; $('copy-result-text').value = '';
|
|
1412
|
+
}
|
|
1413
|
+
$('copy-result').disabled = !v || !!v.failed || !!v.skipped || copyingResult;
|
|
1414
|
+
// While the chairman writes, the panel shows the hand at work instead of staying hidden.
|
|
1415
|
+
const writing = !v && (S.status === 'verdict' || S.finishQueued);
|
|
1416
|
+
$('verdict').hidden = !v && !writing;
|
|
1417
|
+
$('verdict').classList.toggle('is-writing', writing);
|
|
1418
|
+
if (writing) {
|
|
1419
|
+
const chair = S.chairman || S.configuredChairman;
|
|
1420
|
+
if (!$('verdict-body').querySelector('.scribe')) $('verdict-body').innerHTML = `${scribeMarkup()}<p class="hint scribe-note">${esc(chair ? VENDOR_LABEL[chair.vendor] || chair.vendor : 'The chairman')} is writing the conclusion from ${S.rounds.length} round${S.rounds.length === 1 ? '' : 's'} of replies.</p>`;
|
|
1421
|
+
startScribe($('verdict-body').querySelector('.scribe'));
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
stopScribe();
|
|
1425
|
+
if (!v) return;
|
|
1426
|
+
if (v.skipped) { $('verdict-body').innerHTML = `<p class="wide">${esc(v.text)}</p><div class="verdict-meta">${S.rounds.length} rounds · stopped: ${esc(S.stopReason)} · debates/${esc(S.slug)}/</div>`; return; }
|
|
1427
|
+
// A malformed or failed reply stays diagnostic; it cannot be copied as a completed result.
|
|
1428
|
+
if (v.failed) { $('verdict-body').innerHTML = `<p class="wide danger">${esc(ERROR_LINE[v.errorKind] || 'The chairman could not write a verdict. Check the details and save the error log.')} After resolving the problem, write the verdict again from the bar below.${esc(errorReference(v.diagnostic))}</p><details class="wide"><summary>Details</summary><pre>${esc(callDetails(v.errors || [v.text], v.progress))}</pre></details>`; return; }
|
|
1429
|
+
const view = verdictView(v.text, S.fieldNotes?.options);
|
|
1430
|
+
const section = (title, items, cls = '') => items.length
|
|
1431
|
+
? `<section class="section ${cls}"><h3 class="label">${esc(title)}</h3>${items.length === 1 ? `<p>${esc(items[0])}</p>` : `<ul>${items.map((item) => `<li>${esc(item)}</li>`).join('')}</ul>`}</section>` : '';
|
|
1432
|
+
const questionSection = (title, items, explanation, cls = '') => items.length
|
|
1433
|
+
? `<section class="section wide verdict-questions ${cls}"><h3 class="label">${esc(title)}</h3><p class="hint">${esc(explanation)}</p><ul>${items.map((item) => `<li>${esc(item)}</li>`).join('')}</ul></section>` : '';
|
|
1434
|
+
// Count final eligible positions from replies, never from a model-generated tally.
|
|
1435
|
+
const finals = Object.create(null);
|
|
1436
|
+
for (const seat of S.seats) {
|
|
1437
|
+
let k = null;
|
|
1438
|
+
for (let i = S.rounds.length - 1; i >= 0; i--) {
|
|
1439
|
+
const r = S.rounds[i]?.find((x) => x.seat.id === seat.id);
|
|
1440
|
+
if (!r) continue;
|
|
1441
|
+
if (r.failed || !r.valid) break;
|
|
1442
|
+
if (r.stanceKey !== 'PASS') { k = r.stanceKey; break; }
|
|
1443
|
+
}
|
|
1444
|
+
const label = k ? optionLabel(k, S.fieldNotes?.options) : 'no eligible stance';
|
|
1445
|
+
(finals[label] ??= []).push(seat.lens);
|
|
1446
|
+
}
|
|
1447
|
+
const positions = Object.entries(finals).map(([label, lenses]) => `<li><strong>${esc(label)}</strong> · ${lenses.length} ${lenses.length === 1 ? 'seat' : 'seats'}<span class="hint">${esc(lenses.join(', '))}</span></li>`).join('');
|
|
1448
|
+
const detailsOpen = $('verdict-details')?.open;
|
|
1449
|
+
$('verdict-body').innerHTML = view.parsed
|
|
1450
|
+
? `<section class="section wide verdict-answer"><h3 class="label">${view.questions.needed.length ? 'Answer · clarification needed' : 'Answer'}</h3>
|
|
1451
|
+
${verdictHeadline(view) ? `<p class="verdict-pick"><strong>${esc(verdictHeadline(view))}</strong></p>` : ''}<p>${esc(view.answer || 'No conclusion text was provided.')}</p></section>
|
|
1452
|
+
${view.reasons.length ? `<section class="section wide"><h3 class="label">Why</h3>${view.reasons.map((p) => `<p>${esc(p)}</p>`).join('')}</section>` : ''}
|
|
1453
|
+
${section('Main reservation', view.dissent)}${section('What could change this', view.reconsider)}
|
|
1454
|
+
${questionSection('Clarification needed from you', view.questions.needed, 'These answers could change the conclusion. The review is finished and will not continue automatically.', 'verdict-needed')}
|
|
1455
|
+
${questionSection('Questions for you', view.questions.unclassified, 'These questions remain unanswered. Their importance was not classified in this review; it will not continue automatically.')}
|
|
1456
|
+
<details id="verdict-details" class="verdict-details wide" ${detailsOpen ? 'open' : ''}><summary>Discussion details</summary><div class="verdict-support">
|
|
1457
|
+
${questionSection('Further exploration', view.questions.optional, 'Optional follow-up for you; no answer is required to finish this review.')}
|
|
1458
|
+
${positions ? `<section class="section wide"><h3 class="label">Panel positions</h3><p class="hint">These are the seats’ final positions, not a confidence score. Agreement alone is not evidence.</p><ul class="verdict-positions">${positions}</ul></section>` : ''}
|
|
1459
|
+
<div class="verdict-meta">chairman ${esc(VENDOR_LABEL[v.seat?.vendor] || v.seat?.vendor || 'unknown')}${v.seat && seatMeta(v.seat) ? ` (${esc(seatMeta(v.seat))})` : ''}${Number.isSafeInteger(v.progress?.reconnects) && v.progress.reconnects > 0 ? ` · reconnected ×${v.progress.reconnects}` : ''} · ${S.rounds.length} rounds · stopped: ${esc(S.stopReason)} · debates/${esc(S.slug)}/verdict.md</div>
|
|
1460
|
+
</div></details>`
|
|
1461
|
+
: `<pre class="wide">${esc(v.text)}</pre>`;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
$('copy-result').addEventListener('click', async () => {
|
|
1465
|
+
const v = S.verdict, revision = viewRevision;
|
|
1466
|
+
if (!v || v.failed || v.skipped || copyingResult) return;
|
|
1467
|
+
const text = verdictResultText(v.text, S.fieldNotes?.options, S.fieldNotes?.decision);
|
|
1468
|
+
copyingResult = true; $('copy-result').disabled = true;
|
|
1469
|
+
$('copy-result-status').textContent = 'Copying…'; $('copy-result-text').hidden = true;
|
|
1470
|
+
try {
|
|
1471
|
+
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
|
|
1472
|
+
await navigator.clipboard.writeText(text);
|
|
1473
|
+
if (revision === viewRevision && S.verdict === v) $('copy-result-status').textContent = 'Result copied.';
|
|
1474
|
+
} catch {
|
|
1475
|
+
if (revision === viewRevision && S.verdict === v) {
|
|
1476
|
+
$('copy-result-status').textContent = 'Clipboard unavailable. Copy the selected text below.';
|
|
1477
|
+
const field = $('copy-result-text'); field.value = text; field.hidden = false; field.focus(); field.select();
|
|
1478
|
+
}
|
|
1479
|
+
} finally {
|
|
1480
|
+
if (revision === viewRevision && S.verdict === v) { copyingResult = false; $('copy-result').disabled = false; }
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
function renderGate() {
|
|
1485
|
+
const g = S.gate;
|
|
1486
|
+
const st = $('gate-status');
|
|
1487
|
+
const interrupted = S.status === 'interrupted';
|
|
1488
|
+
renderQuestions();
|
|
1489
|
+
renderReviewState();
|
|
1490
|
+
$('continue').disabled = !!S.actionPending || !g && !interrupted; $('stop').disabled = !!S.actionPending || S.stopQueued || ['done', 'error'].includes(S.status) || interrupted;
|
|
1491
|
+
$('human-form').hidden = ['done', 'error', 'idle'].includes(S.status);
|
|
1492
|
+
$('human-msg').hidden = $('continue').hidden = S.status === 'verdict';
|
|
1493
|
+
const canFinish = hasUsableRound(S.rounds, S.seats.length);
|
|
1494
|
+
$('finish').hidden = !canFinish || S.stopQueued || S.finishQueued || ['verdict', 'done', 'error', 'idle', 'interrupted'].includes(S.status);
|
|
1495
|
+
$('finish').disabled = !!S.actionPending;
|
|
1496
|
+
if (S.finishQueued) { $('human-msg').hidden = $('continue').hidden = true; }
|
|
1497
|
+
$('continue').textContent = ['continue', 'resume'].includes(S.actionPending) ? 'Sending…' : interrupted ? 'Resume' : 'Continue';
|
|
1498
|
+
$('stop').textContent = S.actionPending === 'stop' || S.stopQueued ? 'Stopping…' : 'Stop';
|
|
1499
|
+
const actionError = S.actionError && !['accept', 'export'].includes(S.actionError.action) ? S.actionError.message : '';
|
|
1500
|
+
$('human-error').textContent = actionError; $('human-error').hidden = !actionError;
|
|
1501
|
+
const link = (S.connected === false ? '<span class="status danger">reconnecting</span> ' : '') + budgetLine();
|
|
1502
|
+
if (!g) {
|
|
1503
|
+
// a verdict skipped, failed or never written: write it, with this chairman or another vendor
|
|
1504
|
+
// (the first one out of quota or logged out is not offered first)
|
|
1505
|
+
const chairDown = S.verdict?.seat && seatDown(S.verdict.seat);
|
|
1506
|
+
// the CLI vendors by name; an endpoint only as a seat already at this table (its URL, key
|
|
1507
|
+
// variable and model are the seat's), since the endpoint vendor alone names no server
|
|
1508
|
+
const pickable = Object.keys(VENDOR_LABEL).filter((v) => !isApi(v) && !Object.values(S.down).some((x) => x.vendor === v && !x.account && !x.group));
|
|
1509
|
+
const apiSeats = S.seats.filter((s) => s.endpoint && !seatDown(s)).map((s) => ({ value: `seat:${s.id}`, label: `${s.label} (${s.model})` }));
|
|
1510
|
+
const savedChair = S.configuredChairman || S.chairman;
|
|
1511
|
+
const first = chairDown || ['quota', 'auth', 'missing'].includes(S.verdict?.errorKind) ? pickable.find((v) => v !== S.verdict?.seat?.vendor) || pickable[0] : (savedChair?.vendor || pickable[0]);
|
|
1512
|
+
const synth = S.status === 'done' && S.synthesis && S.synthesis !== 'done' && canFinish
|
|
1513
|
+
? ` <span class="synth">Write the verdict with <select id="synth-chair" aria-label="Chairman for the verdict">${pickable.map((v) => `<option value="${v}" ${v === first ? 'selected' : ''}>${esc(VENDOR_LABEL[v])}${v === savedChair?.vendor ? esc([savedChair.model, savedChair.effort, savedChair.account].filter(Boolean).map((s) => ` · ${s}`).join('')) : ''}</option>`).join('')}${apiSeats.map((o) => `<option value="${esc(o.value)}">${esc(o.label)}</option>`).join('')}</select> <button class="btn btn-small" id="synthesize" type="button" ${S.actionPending ? 'disabled' : ''}>${S.actionPending === 'synthesize' ? 'Starting…' : 'Write'}</button></span>` : '';
|
|
1514
|
+
const focused = st.contains(document.activeElement) ? document.activeElement.id : null;
|
|
1515
|
+
st.innerHTML = link + (S.status === 'done' ? `<span class="status ${S.synthesis === 'done' ? 'ok' : 'warn'}">${S.synthesis === 'done' ? 'Review finished' : S.stopReason === 'human' ? 'Review stopped' : 'Review ended without a verdict'}</span> ${esc(stopExplanation(S.stopReason, S.stopContext))}${synth} Record saved locally.${S.exported ? ' Decision record exported.' : ''}${S.exportError ? ` <span class="danger">Export failed: ${esc(S.exportError)}</span>` : ''}${S.replayed ? ' <span class="muted">saved review</span>' : ''}`
|
|
1516
|
+
: interrupted ? `<span class="status warn">interrupted</span> ${S.interrupted?.round ? `after round ${S.interrupted.round - 1}` : 'before round 0 finished'}${S.interrupted?.partialRound != null ? `, round ${S.interrupted.partialRound} will be re-run` : ''}. Add a note or resume.`
|
|
1517
|
+
: S.status === 'idle' ? '<span class="status warn">loading</span> waiting for the transcript'
|
|
1518
|
+
: S.stopQueued ? '<span class="status warn">Stopping review</span> Cancelling calls and saving their status. No further calls will start.'
|
|
1519
|
+
: S.status === 'verdict' ? '<span class="status warn">Discussion finished</span> The chairman is writing the verdict. Stop cancels this call too.'
|
|
1520
|
+
: S.finishQueued ? '<span class="status warn">Finishing discussion</span> Cancelling unfinished seat calls, then writing the verdict from collected replies.'
|
|
1521
|
+
: S.status === 'error' ? `<span class="status danger">error</span> ${esc(S.error)}`
|
|
1522
|
+
: `<span class="status warn">round ${S.rounds.length - 1} in flight</span> ${S.pending.size} seat${S.pending.size === 1 ? '' : 's'} still thinking`);
|
|
1523
|
+
const select = $('synth-chair');
|
|
1524
|
+
if (select && S.synthChoice && [...select.options].some((o) => o.value === S.synthChoice)) select.value = S.synthChoice;
|
|
1525
|
+
if (focused) $(focused)?.focus({ preventScroll: true });
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const parts = [];
|
|
1529
|
+
if (g.questions.length) parts.push(`<span class="status warn">${groupQuestions(g.questions).length} question${groupQuestions(g.questions).length === 1 ? '' : 's'} for you</span> <span class="gate-guidance">Answer above, or continue without answers.</span>`);
|
|
1530
|
+
else parts.push(`<span class="status warn">review window</span> ${g.reason === 'round0' ? 'The blind positions are ready.' : 'The round is complete.'} Add a note, or let the review continue.`);
|
|
1531
|
+
if (g.stop) parts.push(`<span class="hint gate-guidance">${g.reopenable ? 'Next: the verdict, or one more round if you add a note.' : 'Next: the verdict.'}</span>`);
|
|
1532
|
+
if (g.mode === 'countdown') {
|
|
1533
|
+
if (S.held) parts.push('<span class="status muted">countdown held</span> waiting for you');
|
|
1534
|
+
else if (S.deadline) parts.push(`<span class="countdown">Auto-continue in <span id="cd">${Math.max(0, Math.ceil((S.deadline - Date.now()) / 1000))}</span> s</span>`);
|
|
1535
|
+
parts.push(`<button class="btn btn-small" id="extend-window" type="button" ${S.holdPending || S.actionPending ? 'disabled' : ''}>More time</button>`);
|
|
1536
|
+
}
|
|
1537
|
+
if (S.reviewDeadline) parts.push(`<span class="hint">Review time left: <span id="review-time-left"></span>.<span class="gate-guidance"> Reading and answering count toward this limit.</span></span>`);
|
|
1538
|
+
st.innerHTML = link + parts.join(' ');
|
|
1539
|
+
updateReviewTime();
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function updateReviewTime() {
|
|
1543
|
+
const el = $('review-time-left');
|
|
1544
|
+
if (el && S.reviewDeadline) { const seconds = Math.max(0, Math.ceil((S.reviewDeadline - Date.now()) / 1000)); el.textContent = `${Math.floor(seconds / 60)}m ${seconds % 60}s`; }
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function renderReviewState() {
|
|
1548
|
+
const questionCount = S.status === 'waiting' ? groupQuestions(S.gate?.questions).length : 0;
|
|
1549
|
+
if (S.fieldNotes?.decision) document.title = `${questionCount ? `${questionCount} question${questionCount === 1 ? '' : 's'} for you · ` : ''}${S.fieldNotes.decision} · Roundtable`;
|
|
1550
|
+
const terminal = ['done', 'error'].includes(S.status);
|
|
1551
|
+
if (S.status === 'researching' && !S.stopQueued) {
|
|
1552
|
+
$('review-state').className = 'review-state status warn';
|
|
1553
|
+
$('review-state').title = 'Discussion starts after the sources are saved. Stop cancels this call.';
|
|
1554
|
+
$('review-state').innerHTML = 'Searcher at work<span class="sr-only">. Discussion starts after the sources are saved. Stop cancels this call.</span>';
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
const title = S.status === 'done' ? S.synthesis === 'done' ? 'Review finished' : S.stopReason === 'human' ? 'Review stopped' : 'Review ended · no verdict' : S.stopQueued ? 'Stopping review' : S.status === 'verdict' ? 'Discussion finished · writing verdict' : S.finishQueued ? 'Finishing discussion' : S.status === 'waiting' ? 'Waiting for your input' : S.status === 'interrupted' ? 'Review interrupted' : S.status === 'error' ? 'Review stopped unexpectedly' : 'Discussion in progress';
|
|
1558
|
+
const text = S.status === 'done' ? `${stopExplanation(S.stopReason, S.stopContext)} ${S.synthesis === 'done' ? 'Read the conclusion below, then record your decision if ready.' : hasUsableRound(S.rounds, S.seats.length) ? 'You can request a verdict from the saved replies.' : 'No usable replies were collected. Start a new review to get a verdict.'}` : S.stopQueued ? 'Cancelling calls. No further calls will start.' : S.status === 'verdict' ? 'Your notes are saved. The chairman is preparing the recommendation.' : S.finishQueued ? 'Cancelling unfinished seat calls, then writing the verdict from collected replies.' : S.status === 'waiting' ? 'Questions are collected below. Your answers are sent together.' : terminal ? 'Review the saved replies and error log before retrying.' : '';
|
|
1559
|
+
// A small tag beside the question. What to do next is the bar at the bottom's line, so here
|
|
1560
|
+
// it is only the tooltip and the screen-reader text.
|
|
1561
|
+
const tone = S.status === 'error' ? 'danger' : terminal && S.synthesis === 'done' ? 'ok' : 'warn';
|
|
1562
|
+
$('review-state').className = `review-state status ${tone}`;
|
|
1563
|
+
$('review-state').title = text;
|
|
1564
|
+
$('review-state').innerHTML = `${esc(title)}${text ? `<span class="sr-only">. ${esc(text)}</span>` : ''}`;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const questionKey = (round, q) => `${round}:${q.id}:${q.question}`;
|
|
1568
|
+
// Who asked: the seat's dot, name and lens, with its mood where that is not the default (the
|
|
1569
|
+
// avatar for a persona). Lenses alone when the gate predates seat ids.
|
|
1570
|
+
function askersHtml(q) {
|
|
1571
|
+
const seats = (q.seats || []).map((id) => S.seats.find((s) => s.id === id)).filter(Boolean);
|
|
1572
|
+
if (!seats.length) return q.sources.map(esc).join(' · ');
|
|
1573
|
+
return seats.map((s) => `<span class="asker" style="--seat: var(--seat-${esc(s.colour)})"><span class="seat-dot"></span>${esc(seatName(s))} · ${esc(s.lens)}${s.mood?.hidden ? `<span class="avatar card-avatar">${avatar(s.mood.hidden, HIDDEN_MOODS.find((h) => h.art === s.mood.hidden)?.sprite)}</span>` : s.mood?.preset && s.mood.preset !== 'grill' ? ` · <span title="${esc(MOOD_HELP[s.mood.preset] || '')}">${esc(s.mood.preset)}</span>` : ''}</span>`).join(' · ');
|
|
1574
|
+
}
|
|
1575
|
+
function renderQuestions() {
|
|
1576
|
+
const groups = groupQuestions(S.gate?.questions);
|
|
1577
|
+
const visible = S.status === 'waiting' && groups.length > 0;
|
|
1578
|
+
$('questions-panel').hidden = !visible;
|
|
1579
|
+
$('answer-questions').hidden = !visible;
|
|
1580
|
+
if (!visible) return;
|
|
1581
|
+
$('questions-count').textContent = `${groups.length} distinct · ${S.gate.questions.length} asked`;
|
|
1582
|
+
const inputs = $('question-inputs'), signature = JSON.stringify([S.gate.round, groups]);
|
|
1583
|
+
if (inputs.dataset.signature !== signature) {
|
|
1584
|
+
inputs.dataset.signature = signature;
|
|
1585
|
+
inputs.innerHTML = groups.map((q) => `<div class="question-answer"><label for="answer-${q.id}"><span class="question-sources">${askersHtml(q)}</span><strong>${esc(q.question)}</strong></label><textarea id="answer-${q.id}" data-question="${q.id}" rows="2" maxlength="1200" placeholder="Your answer, or leave blank">${esc(S.questionDrafts[questionKey(S.gate.round, q)] || '')}</textarea></div>`).join('');
|
|
1586
|
+
}
|
|
1587
|
+
const answered = groups.filter((q) => S.questionDrafts[questionKey(S.gate.round, q)]?.trim()).length;
|
|
1588
|
+
$('answer-count').textContent = `${answered} of ${groups.length} answered · one send to all seats`;
|
|
1589
|
+
$('send-answers').disabled = !!S.actionPending;
|
|
1590
|
+
$('send-answers').textContent = S.actionPending === 'continue' ? 'Sending…' : answered ? 'Send answers and continue' : 'Continue without answers';
|
|
1591
|
+
$('answer-questions').innerHTML = `<span class="answer-label-full">Answer ${groups.length} question${groups.length === 1 ? '' : 's'}</span><span class="answer-label-short" aria-hidden="true">Answer</span>`;
|
|
1592
|
+
$('answer-questions').setAttribute('aria-label', `Answer ${groups.length} question${groups.length === 1 ? '' : 's'}`);
|
|
1593
|
+
$('more-time').disabled = !!S.holdPending || !!S.actionPending;
|
|
1594
|
+
updateQuestionTime();
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function updateQuestionTime() {
|
|
1598
|
+
if (!S.gate?.questions.length || !S.deadline) { $('question-timing').textContent = ''; return; }
|
|
1599
|
+
const seconds = Math.max(0, Math.ceil((S.deadline - Date.now()) / 1000));
|
|
1600
|
+
$('question-timing').textContent = `Continuing without answers in ${Math.floor(seconds / 60)}m ${seconds % 60}s. Writing requests more time; drafts are sent only when you submit.`;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Which vendor would cross its cap, and by how much: shown wherever the gate status is.
|
|
1604
|
+
function budgetLine() {
|
|
1605
|
+
const b = S.budgetStop; if (!b) return '';
|
|
1606
|
+
const name = esc(VENDOR_LABEL[b.vendor] || b.vendor);
|
|
1607
|
+
if (b.kind === 'usd-unknown') return `<span class="status danger">budget</span> ${name} has a dollar cap, but its cost is not reported, so the ${esc(b.what)} was not run. `;
|
|
1608
|
+
if (b.kind === 'tokens-unknown') return `<span class="status danger">budget</span> ${name} did not report token usage. Further calls to this vendor are blocked under its token cap; ${esc(b.what)} was not run. `;
|
|
1609
|
+
if (b.kind === 'seat-tokens-unknown') return `<span class="status warn">usage unknown</span> ${esc(unknownUsageMessage(b.cause))} Other seats can continue. `;
|
|
1610
|
+
// one seat reached its own cap for this review: that seat sits out, the others go on
|
|
1611
|
+
if (b.kind === 'seat-tokens') return `<span class="status warn">seat cap</span> the ${esc(b.what)} was not made: that seat has used ${esc(k(b.spent || 0))} of its ${esc(k(b.cap || 0))}-token cap for this review. The others go on. `;
|
|
1612
|
+
const unit = b.kind === 'usd' ? (n) => `$${(n || 0).toFixed(2)}` : (n) => `${k(n || 0)} tokens`;
|
|
1613
|
+
const figures = `${esc(unit(b.spent))} spent plus about ${esc(unit(b.projected))} projected, cap ${esc(unit(b.cap))}`;
|
|
1614
|
+
// a refused re-prompt fails one seat for one round; the debate goes on
|
|
1615
|
+
if (/^re-prompt/.test(b.what)) return `<span class="status warn">budget</span> the ${esc(b.what)} was not made (${name}: ${figures}); the debate goes on. `;
|
|
1616
|
+
return `<span class="status danger">budget</span> ${name} would cross its cap at the ${esc(b.what)}: ${figures}. `;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// Above the board: a login that is out for this debate (quota, login, missing CLI), or one limit
|
|
1620
|
+
// group of it (agy), and a lost connection to the server. Each says what happened and what next.
|
|
1621
|
+
function renderBanners() {
|
|
1622
|
+
const items = Object.values(S.down).map((x) => `<div class="banner danger"><span class="status danger">${esc(VENDOR_LABEL[x.vendor] || x.vendor)}${x.account ? ` (${esc(x.account)})` : ''}${x.group ? ` · ${esc(x.group)}` : ''} out</span> ${esc(x.message)} ${x.group ? 'Seats on these models are' : 'Its seats are'} skipped for the rest of this debate${S.verdict || S.status === 'done' ? '' : '; continue without them or stop'}.</div>`);
|
|
1623
|
+
items.unshift(...S.initialNotes.map((note) => `<div class="human-note"><b>${noteLabel(note)} before round 0</b>${esc(noteRecord(note).text)}</div>`));
|
|
1624
|
+
const unknown = rollup(S.rows).unknownAttempts;
|
|
1625
|
+
if (unknown) items.push(`<div class="banner warn usage-warning"><span class="status warn">usage unknown</span> ${unknown} call${unknown === 1 ? ' did' : 's did'} not report usable token counts. Totals include reported figures only.</div>`);
|
|
1626
|
+
if (S.transcriptWarning) items.unshift(`<div class="banner warn transcript-warning"><span class="status warn">record recovery</span> ${esc(S.transcriptWarning.message)}</div>`);
|
|
1627
|
+
if (S.chairmanFallback) items.push(`<div class="banner warn"><span class="status warn">chairman</span> ${esc(VENDOR_LABEL[S.chairmanFallback.from.vendor] || S.chairmanFallback.from.vendor)} could not write the verdict (${esc(S.chairmanFallback.reason)}), so ${esc(VENDOR_LABEL[S.chairmanFallback.to] || S.chairmanFallback.to)} wrote it at its default model.</div>`);
|
|
1628
|
+
if (S.connected === false && S.status !== 'done') items.unshift('<div class="banner warn"><span class="status warn">reconnecting</span> Lost the connection to the roundtable server. The debate keeps running there; this board catches up when it is back.</div>');
|
|
1629
|
+
$('banners').innerHTML = items.join('');
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function renderResearch() {
|
|
1633
|
+
const r = S.research;
|
|
1634
|
+
$('research-panel').hidden = !r;
|
|
1635
|
+
if (r && !$('research-heading').querySelector('svg')) $('research-heading').innerHTML = `<span class="heading-avatar">${avatar('searcher')}</span>Sources from the searcher`;
|
|
1636
|
+
if (!r) { stopScan(); $('research-body').textContent = ''; return; }
|
|
1637
|
+
// While the searcher works, the panel shows its eye reading results, like the chairman's quill.
|
|
1638
|
+
if (r.status === 'running') {
|
|
1639
|
+
const note = `The searcher is collecting sources: at most ${r.maxSearches} reported search operations, ${Math.round(r.timeoutMs / 1000)} seconds.`;
|
|
1640
|
+
if (!$('research-body').querySelector('.scan')) $('research-body').innerHTML = `${scanMarkup()}<p class="hint scan-note"></p>`;
|
|
1641
|
+
$('research-body').querySelector('.scan-note').textContent = note;
|
|
1642
|
+
startScan($('research-body').querySelector('.scan'));
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
stopScan();
|
|
1646
|
+
if (r.status !== 'done') { $('research-body').textContent = r.error || 'Research was interrupted. It will not rerun automatically.'; return; }
|
|
1647
|
+
$('research-body').innerHTML = `<p class="hint">${r.searches} reported search operation${r.searches === 1 ? '' : 's'} · gathered ${esc(r.gatheredAt)}. Every seat and the chairman receive this same saved bundle. Citations, excerpts and publication dates are supplied by the searcher's provider and are not independently checked.</p>`
|
|
1648
|
+
+ (r.sources || []).map((s) => `<details><summary>${esc(s.id)} · ${esc(s.title)}</summary><p><a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${esc(s.url)}</a></p><p>${esc(s.excerpt)}</p><p class="hint">Published: ${esc(s.publishedAt || 'not reported')}</p></details>`).join('');
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function render() {
|
|
1652
|
+
renderResearch();
|
|
1653
|
+
const focusKey = document.activeElement?.matches?.('#rounds .card-head[role="button"]') ? document.activeElement.closest('.card[data-key]').dataset.key : null;
|
|
1654
|
+
$('rounds').innerHTML = S.rounds.map((r, i) => renderRound(i, r, i === S.rounds.length - 1)).join('');
|
|
1655
|
+
applyCardMotion();
|
|
1656
|
+
if (focusKey) liveCard(focusKey)?.querySelector('.card-head')?.focus({ preventScroll: true });
|
|
1657
|
+
for (const d of $('rounds').querySelectorAll('.card[data-key] details')) d.open = S.openDetails.has(`${d.closest('.card[data-key]').dataset.key}|${d.className}`);
|
|
1658
|
+
renderUsage(); renderVerdict(); renderGate(); renderAccept(); renderExportStatus(); renderBanners();
|
|
1659
|
+
$('verdict-body').dataset.reveal = S.verdict ? replyKey(S.verdict) : '';
|
|
1660
|
+
for (const el of document.querySelectorAll('[data-reveal]')) {
|
|
1661
|
+
const startedAt = replyReveals.get(el.dataset.reveal);
|
|
1662
|
+
if (startedAt != null) revealText(el, { startedAt });
|
|
1663
|
+
}
|
|
1664
|
+
for (const [key, at] of replyReveals) if (performance.now() - at > 960) replyReveals.delete(key);
|
|
1665
|
+
$('sys').className = `status ${S.status === 'error' ? 'danger' : S.status === 'waiting' ? 'warn' : 'ok'}`;
|
|
1666
|
+
$('sys').textContent = S.status;
|
|
1667
|
+
$('st-round').textContent = S.rounds.length ? `round ${S.rounds.length - 1}` : '';
|
|
1668
|
+
$('st-seats').textContent = S.seats.length ? `${S.seats.length} seats` : '';
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function tick() {
|
|
1672
|
+
updateReviewTime();
|
|
1673
|
+
updateQuestionTime();
|
|
1674
|
+
if (S.startedAt && S.status !== 'done') $('st-elapsed').textContent = `${Math.floor((Date.now() - S.startedAt) / 60000)}m ${Math.floor(((Date.now() - S.startedAt) / 1000) % 60)}s`;
|
|
1675
|
+
const cd = $('cd');
|
|
1676
|
+
if (cd && S.deadline) cd.textContent = Math.max(0, Math.ceil((S.deadline - Date.now()) / 1000));
|
|
1677
|
+
if (Date.now() % 30000 < 1000) renderUsage(); // keep "read Nm ago" and reset countdowns honest
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// ---------- human bar ----------
|
|
1681
|
+
const post = (what, body = {}) => requestJson(`/api/debates/${S.slug}/${what}`, body);
|
|
1682
|
+
async function boardAction(action, body, onSuccess) {
|
|
1683
|
+
if (S.actionPending) return;
|
|
1684
|
+
const revision = viewRevision;
|
|
1685
|
+
const focused = document.activeElement.id;
|
|
1686
|
+
S.actionPending = action; S.actionError = null; render();
|
|
1687
|
+
try {
|
|
1688
|
+
const out = await post(action, body);
|
|
1689
|
+
if (revision === viewRevision) onSuccess?.(out);
|
|
1690
|
+
} catch (e) {
|
|
1691
|
+
if (revision === viewRevision) S.actionError = { action, message: e.message };
|
|
1692
|
+
} finally {
|
|
1693
|
+
if (revision === viewRevision) {
|
|
1694
|
+
S.actionPending = null; render();
|
|
1695
|
+
const control = focused && $(focused);
|
|
1696
|
+
if (document.activeElement === document.body && control && !control.disabled && control.getClientRects().length) control.focus({ preventScroll: true });
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
async function hold(ev) {
|
|
1701
|
+
if (!S.gate || !S.deadline || S.holdPending || S.actionPending) return;
|
|
1702
|
+
if (ev && !ev.target.closest('#question-inputs textarea, #human-msg')) return;
|
|
1703
|
+
// Refresh while someone is writing, after ten seconds or halfway through a shorter
|
|
1704
|
+
// window. Incidental clicks never pause the table, and abandoned drafts still expire.
|
|
1705
|
+
const windowMs = S.gate.questionWaitMs || 120000;
|
|
1706
|
+
if (ev && S.deadline - Date.now() > windowMs - Math.min(10000, windowMs / 2)) return;
|
|
1707
|
+
const gate = S.gate, revision = viewRevision;
|
|
1708
|
+
S.holdPending = true;
|
|
1709
|
+
try {
|
|
1710
|
+
const result = await post('hold', { round: gate.round });
|
|
1711
|
+
if (revision === viewRevision && S.gate === gate) {
|
|
1712
|
+
S.deadline = result.autoContinueAt; S.held = false;
|
|
1713
|
+
if (S.actionError?.action === 'hold') S.actionError = null;
|
|
1714
|
+
}
|
|
1715
|
+
} catch (e) {
|
|
1716
|
+
if (revision === viewRevision && S.gate === gate) S.actionError = { action: 'hold', message: `Could not extend the answer window. ${e.message}` };
|
|
1717
|
+
} finally {
|
|
1718
|
+
if (revision === viewRevision) { S.holdPending = false; renderGate(); renderQuestions(); }
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
document.addEventListener('focusin', hold, true);
|
|
1722
|
+
document.addEventListener('input', hold, true);
|
|
1723
|
+
$('more-time').addEventListener('click', () => hold());
|
|
1724
|
+
|
|
1725
|
+
$('human-form').addEventListener('submit', (ev) => {
|
|
1726
|
+
ev.preventDefault();
|
|
1727
|
+
const action = S.status === 'interrupted' ? 'resume' : /^\/stop\b/i.test($('human-msg').value) ? 'stop' : 'continue';
|
|
1728
|
+
sendHuman(action);
|
|
1729
|
+
});
|
|
1730
|
+
function sendHuman(action) {
|
|
1731
|
+
const value = $('human-msg').value;
|
|
1732
|
+
// Stop must not wait for question validation or depend on a gate that may have closed.
|
|
1733
|
+
// Keep answer drafts; the general note can still explain why the review was stopped.
|
|
1734
|
+
if (action === 'stop') return boardAction('stop', { message: value }, () => {
|
|
1735
|
+
if ($('human-msg').value === value) $('human-msg').value = '';
|
|
1736
|
+
});
|
|
1737
|
+
const gate = S.gate, groups = groupQuestions(gate?.questions);
|
|
1738
|
+
const drafts = groups.map((q) => ({ key: questionKey(gate.round, q), id: q.id, text: S.questionDrafts[questionKey(gate.round, q)] || '' }));
|
|
1739
|
+
const answers = drafts.filter((a) => a.text.trim()).map(({ id, text }) => ({ id, text }));
|
|
1740
|
+
const message = answerMessage(gate?.questions, answers, value);
|
|
1741
|
+
if (message.length > 4000) { S.actionError = { action, message: 'The answers, questions and note together exceed 4,000 characters. Shorten your answers before sending; your draft is kept.' }; renderGate(); return; }
|
|
1742
|
+
return boardAction(action, { message, ...(gate ? { round: gate.round } : {}) }, () => {
|
|
1743
|
+
if ($('human-msg').value === value) $('human-msg').value = '';
|
|
1744
|
+
for (const draft of drafts) if (S.questionDrafts[draft.key] === draft.text) delete S.questionDrafts[draft.key];
|
|
1745
|
+
delete $('question-inputs').dataset.signature;
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
$('stop').addEventListener('click', () => sendHuman('stop'));
|
|
1749
|
+
$('finish').addEventListener('click', () => sendHuman('finish'));
|
|
1750
|
+
$('question-inputs').addEventListener('input', (event) => {
|
|
1751
|
+
const q = groupQuestions(S.gate?.questions).find((q) => q.id === event.target.dataset.question);
|
|
1752
|
+
if (!q || !S.gate) return;
|
|
1753
|
+
S.questionDrafts[questionKey(S.gate.round, q)] = event.target.value;
|
|
1754
|
+
renderQuestions();
|
|
1755
|
+
});
|
|
1756
|
+
$('send-answers').addEventListener('click', () => sendHuman('continue'));
|
|
1757
|
+
$('answer-questions').addEventListener('click', () => {
|
|
1758
|
+
$('questions-panel').scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' });
|
|
1759
|
+
$('question-inputs').querySelector('textarea')?.focus({ preventScroll: true });
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
// ---------- export ----------
|
|
1763
|
+
try { $('export-target').value = localStorage.getItem('roundtable.exportTarget') || ''; } catch {}
|
|
1764
|
+
|
|
1765
|
+
// Choose… beside an export field: the server opens this machine's own folder dialog (a page cannot
|
|
1766
|
+
// read a real path from the browser's picker) and the chosen folder fills the field. Typing still works.
|
|
1767
|
+
document.addEventListener('click', async (ev) => {
|
|
1768
|
+
const button = ev.target.closest('.pick-folder');
|
|
1769
|
+
if (!button) return;
|
|
1770
|
+
const field = $(button.dataset.for), label = button.textContent;
|
|
1771
|
+
const status = field.id === 'exportTo' ? $('export-folder-status') : $('export-status');
|
|
1772
|
+
status.textContent = ''; field.removeAttribute('aria-invalid');
|
|
1773
|
+
button.disabled = true; button.textContent = 'Opening…';
|
|
1774
|
+
try {
|
|
1775
|
+
// Allow the native dialog's ten-minute wait plus time for its result to reach the browser.
|
|
1776
|
+
const out = await requestJson('/api/pick-folder', {}, 610_000);
|
|
1777
|
+
if (out.path) {
|
|
1778
|
+
field.value = out.path;
|
|
1779
|
+
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1780
|
+
if (field.id === 'export-target') try { localStorage.setItem('roundtable.exportTarget', out.path); } catch {}
|
|
1781
|
+
}
|
|
1782
|
+
} catch (e) {
|
|
1783
|
+
status.textContent = e.message; field.setAttribute('aria-invalid', 'true');
|
|
1784
|
+
} finally { button.disabled = false; button.textContent = label; }
|
|
1785
|
+
});
|
|
1786
|
+
// A verdict skipped (budget, wall clock), failed or never written: run the chairman alone.
|
|
1787
|
+
$('gate-status').addEventListener('click', async (ev) => {
|
|
1788
|
+
if (ev.target.id === 'extend-window') { await hold(); $('extend-window')?.focus({ preventScroll: true }); return; }
|
|
1789
|
+
if (ev.target.id !== 'synthesize') return;
|
|
1790
|
+
const revision = viewRevision;
|
|
1791
|
+
const pick = $('synth-chair').value;
|
|
1792
|
+
const seat = pick.startsWith('seat:') && S.seats.find((s) => s.id === pick.slice(5));
|
|
1793
|
+
const savedChair = S.configuredChairman || S.chairman;
|
|
1794
|
+
const chosen = seat || (pick === savedChair?.vendor ? savedChair : { vendor: pick });
|
|
1795
|
+
const chairman = Object.fromEntries(['vendor', 'endpoint', 'keyEnv', 'model', 'effort', 'account'].filter((key) => chosen[key] != null).map((key) => [key, chosen[key]]));
|
|
1796
|
+
await boardAction('synthesize', { chairman });
|
|
1797
|
+
if (revision === viewRevision) $('synthesize')?.focus({ preventScroll: true });
|
|
1798
|
+
});
|
|
1799
|
+
$('gate-status').addEventListener('change', (ev) => { if (ev.target.id === 'synth-chair') S.synthChoice = ev.target.value; });
|
|
1800
|
+
|
|
1801
|
+
// The human's decision: the recommended option preselected, any option or none allowed.
|
|
1802
|
+
function renderAccept() {
|
|
1803
|
+
const opts = S.fieldNotes?.options || [];
|
|
1804
|
+
const sel = $('accept-option');
|
|
1805
|
+
const rec = verdictView(S.verdict?.text, opts).pick;
|
|
1806
|
+
const want = S.accepted?.option || rec || '';
|
|
1807
|
+
const html = [...opts.map((o, i) => `<option value="opt-${i + 1}">${esc(o)}</option>`), '<option value="none">none of these</option>'].join('');
|
|
1808
|
+
if (sel.dataset.html !== html) { sel.innerHTML = html; sel.dataset.html = html; delete sel.dataset.applied; }
|
|
1809
|
+
// follow the recommendation, then the acceptance, as they arrive (a replay sends them late);
|
|
1810
|
+
// never overwrite what the human is choosing
|
|
1811
|
+
if (want && !S.acceptEdited && sel.dataset.applied !== want && document.activeElement !== sel) { sel.value = want; sel.dataset.applied = want; }
|
|
1812
|
+
if (!S.acceptNoteEdited && S.accepted) $('accept-note').value = S.accepted.note || '';
|
|
1813
|
+
$('accept-form').hidden = S.status !== 'done'; // a finished debate, live or replayed, can be accepted
|
|
1814
|
+
$('accept').disabled = !!S.actionPending;
|
|
1815
|
+
$('accept-status').textContent = S.actionPending === 'accept' ? 'saving…' : S.actionError?.action === 'accept' ? S.actionError.message : S.accepted ? `accepted: ${S.accepted.option === 'none' ? 'none of these' : optionLabel(S.accepted.option, opts)}` : 'not yet accepted';
|
|
1816
|
+
}
|
|
1817
|
+
$('accept-option').addEventListener('change', () => { S.acceptEdited = true; });
|
|
1818
|
+
$('accept-note').addEventListener('input', () => { S.acceptNoteEdited = true; });
|
|
1819
|
+
$('accept-form').addEventListener('submit', async (ev) => {
|
|
1820
|
+
ev.preventDefault();
|
|
1821
|
+
const body = { option: $('accept-option').value, note: $('accept-note').value.trim() };
|
|
1822
|
+
await boardAction('accept', body, () => { S.accepted = body; });
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
function renderExportStatus() {
|
|
1826
|
+
$('export').disabled = !!S.actionPending;
|
|
1827
|
+
$('export-status').textContent = S.actionPending === 'export' ? 'writing…' : S.actionError?.action === 'export' ? `failed: ${S.actionError.message}` : S.exported ? `written: ${S.exported.path} (${S.exported.status}: ${S.exported.chosen})` : '';
|
|
1828
|
+
}
|
|
1829
|
+
$('export-form').addEventListener('submit', async (ev) => {
|
|
1830
|
+
ev.preventDefault();
|
|
1831
|
+
const target = $('export-target').value.trim();
|
|
1832
|
+
try { localStorage.setItem('roundtable.exportTarget', target); } catch {}
|
|
1833
|
+
await boardAction('export', { target }, (out) => { S.exported = out; S.exportError = null; });
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
// Reserve the actual bar height so the final cards remain scrollable above it at any width.
|
|
1837
|
+
new ResizeObserver(() => document.documentElement.style.setProperty('--board-bar-height', `${Math.ceil($('humanbar').getBoundingClientRect().height)}px`)).observe($('humanbar'));
|
|
1838
|
+
|
|
1839
|
+
// ---------- folding cards ----------
|
|
1840
|
+
// Every reply starts folded to its seat and position, also in the round still coming in. A
|
|
1841
|
+
// click on its head or position line, or Enter or Space on the head, opens it; again folds it.
|
|
1842
|
+
// A card the person opens stays open until the review changes.
|
|
1843
|
+
// The body moves on the board's motion tokens: it unrolls over --motion-base as the card opens
|
|
1844
|
+
// and rolls up over --motion-fast before it folds. The board redraws on every event, so a card
|
|
1845
|
+
// mid-motion takes its class again with the elapsed time taken off, as the word reveal does,
|
|
1846
|
+
// and a fold is committed only once the body is shut. Reduced motion sets both tokens to 0, so
|
|
1847
|
+
// the card changes at once.
|
|
1848
|
+
const motionMs = (token) => parseFloat(getComputedStyle(document.documentElement).getPropertyValue(token)) || 0;
|
|
1849
|
+
const liveCard = (key) => $('rounds').querySelector(`.card[data-key="${CSS.escape(key)}"]`);
|
|
1850
|
+
function toggleCard(card) {
|
|
1851
|
+
const key = card.dataset.key;
|
|
1852
|
+
if (cardMotion.has(key)) return; // one motion at a time per card
|
|
1853
|
+
const opening = !S.opened.has(key);
|
|
1854
|
+
const ms = motionMs(opening ? '--motion-base' : '--motion-fast');
|
|
1855
|
+
if (opening) {
|
|
1856
|
+
S.opened.add(key);
|
|
1857
|
+
if (ms) {
|
|
1858
|
+
cardMotion.set(key, { kind: 'unfold', at: performance.now() });
|
|
1859
|
+
setTimeout(() => { cardMotion.delete(key); liveCard(key)?.classList.remove('is-unfolding'); }, ms);
|
|
1860
|
+
}
|
|
1861
|
+
render();
|
|
1862
|
+
} else if (!ms) {
|
|
1863
|
+
S.opened.delete(key); render();
|
|
1864
|
+
} else {
|
|
1865
|
+
cardMotion.set(key, { kind: 'fold', at: performance.now() });
|
|
1866
|
+
applyCardMotion();
|
|
1867
|
+
setTimeout(() => { cardMotion.delete(key); S.opened.delete(key); render(); }, ms);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
function applyCardMotion() {
|
|
1871
|
+
for (const el of $('rounds').querySelectorAll('.card[data-key]')) {
|
|
1872
|
+
const m = cardMotion.get(el.dataset.key);
|
|
1873
|
+
if (!m) continue;
|
|
1874
|
+
el.classList.add(m.kind === 'fold' ? 'is-folding' : 'is-unfolding');
|
|
1875
|
+
el.style.setProperty('--motion-elapsed', `${-Math.round(performance.now() - m.at)}ms`);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
// A fold the person changes by hand is remembered until the review changes (the click lands
|
|
1879
|
+
// before the browser toggles the details, so the state it will have is the opposite of now).
|
|
1880
|
+
$('rounds').addEventListener('click', (ev) => {
|
|
1881
|
+
const summary = ev.target.closest('summary');
|
|
1882
|
+
if (!summary) return;
|
|
1883
|
+
const details = summary.parentElement, willOpen = !details.open;
|
|
1884
|
+
if (details.classList.contains('round')) { S.roundOpen[details.dataset.round] = willOpen; return; }
|
|
1885
|
+
const card = details.closest('.card[data-key]');
|
|
1886
|
+
if (card) S.openDetails[willOpen ? 'add' : 'delete'](`${card.dataset.key}|${details.className}`);
|
|
1887
|
+
});
|
|
1888
|
+
$('rounds').addEventListener('click', (ev) => {
|
|
1889
|
+
if (ev.target.closest('a, button, summary, .question')) return;
|
|
1890
|
+
if (String(getSelection?.() || '')) return; // the mouse-up that ends a text selection is a click too: keep the card as it is
|
|
1891
|
+
const card = ev.target.closest('.card-head, .seat-stance')?.closest('.card[data-key]');
|
|
1892
|
+
if (!card) return;
|
|
1893
|
+
toggleCard(card);
|
|
1894
|
+
});
|
|
1895
|
+
$('rounds').addEventListener('keydown', (ev) => {
|
|
1896
|
+
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
|
1897
|
+
if (!ev.target.matches?.('.card-head[role="button"]')) return;
|
|
1898
|
+
ev.preventDefault(); // Space would scroll the page
|
|
1899
|
+
toggleCard(ev.target.closest('.card[data-key]'));
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
// ---------- theme ----------
|
|
1903
|
+
// The button shows where a click goes: a sun on the night theme, a moon on paper.
|
|
1904
|
+
function renderTheme() {
|
|
1905
|
+
const night = document.documentElement.dataset.theme === 'night';
|
|
1906
|
+
$('theme').innerHTML = icon(night ? 'sun' : 'moon');
|
|
1907
|
+
const label = night ? 'Switch to the paper theme' : 'Switch to the night theme';
|
|
1908
|
+
$('theme').setAttribute('aria-label', label); $('theme').title = label;
|
|
1909
|
+
}
|
|
1910
|
+
$('theme').addEventListener('click', () => {
|
|
1911
|
+
const night = document.documentElement.dataset.theme !== 'night';
|
|
1912
|
+
document.documentElement.dataset.theme = night ? 'night' : 'paper';
|
|
1913
|
+
try { localStorage.setItem('roundtable.theme', night ? 'night' : 'paper'); } catch {}
|
|
1914
|
+
renderTheme();
|
|
1915
|
+
});
|
|
1916
|
+
try { if (localStorage.getItem('roundtable.theme') === 'night') document.documentElement.dataset.theme = 'night'; } catch {}
|
|
1917
|
+
renderTheme();
|
|
1918
|
+
|
|
1919
|
+
// Panel folds start closed; the browser remembers one the person opened. Clear sits in
|
|
1920
|
+
// the debates summary, so its click must not toggle the fold (its handler prevents that).
|
|
1921
|
+
for (const [id, key] of [['recent', 'roundtable.debates'], ['projection-panel', 'roundtable.quota']]) {
|
|
1922
|
+
try { if (localStorage.getItem(key) === 'open') $(id).open = true; } catch {}
|
|
1923
|
+
$(id).addEventListener('toggle', () => { try { localStorage.setItem(key, $(id).open ? 'open' : 'closed'); } catch {} });
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// Usage details: a toggle that stays where it is; the table opens beneath the quota line.
|
|
1927
|
+
function setUsageOpen(open) {
|
|
1928
|
+
$('usage-details').hidden = !open;
|
|
1929
|
+
$('usage-toggle').setAttribute('aria-expanded', String(open));
|
|
1930
|
+
}
|
|
1931
|
+
$('usage-toggle').addEventListener('click', () => setUsageOpen($('usage-details').hidden));
|
|
1932
|
+
|
|
1933
|
+
// ---------- sound ----------
|
|
1934
|
+
// Browsers let a page make sound only after the person has interacted with it.
|
|
1935
|
+
for (const type of ['pointerdown', 'keydown']) document.addEventListener(type, () => { if (soundEnabled()) unlockSound(); }, { passive: true });
|
|
1936
|
+
function renderSound() {
|
|
1937
|
+
const on = soundEnabled();
|
|
1938
|
+
$('sound').setAttribute('aria-pressed', String(on)); $('sound').innerHTML = icon(on ? 'sound' : 'muted');
|
|
1939
|
+
$('sound').setAttribute('aria-label', on ? 'Sound on' : 'Sound off'); $('sound').title = on ? 'Sound on. Click to mute the session cues' : 'Sound off. Click to hear the session cues';
|
|
1940
|
+
}
|
|
1941
|
+
$('sound').addEventListener('click', () => { setSound(!soundEnabled()); renderSound(); playCue('open'); }); // a preview at the real volume
|
|
1942
|
+
renderSound();
|
|
1943
|
+
|
|
1944
|
+
// ---------- boot ----------
|
|
1945
|
+
$('start').disabled = true;
|
|
1946
|
+
// Setup is usually instant, so the loading card waits a moment before it shows: a card that
|
|
1947
|
+
// flashes for a frame would be worse than none.
|
|
1948
|
+
$('boot-card').insertAdjacentHTML('afterbegin', loaderMarkup());
|
|
1949
|
+
const bootTimer = setTimeout(() => { $('boot').hidden = false; }, 300);
|
|
1950
|
+
try {
|
|
1951
|
+
await setup();
|
|
1952
|
+
$('recent').addEventListener('click', (ev) => {
|
|
1953
|
+
const deletion = ev.target.closest('[data-delete]');
|
|
1954
|
+
if (deletion) confirmHistoryDeletion(deletion.dataset.delete);
|
|
1955
|
+
if (ev.target.id === 'debates-more') { S.showAllDebates = !S.showAllDebates; loadRecent(); }
|
|
1956
|
+
if (ev.target.id === 'retry-recent') loadRecent();
|
|
1957
|
+
});
|
|
1958
|
+
window.addEventListener('hashchange', route);
|
|
1959
|
+
route();
|
|
1960
|
+
} catch (e) {
|
|
1961
|
+
$('start').disabled = true;
|
|
1962
|
+
$('setup-error').textContent = `Could not load setup. ${e.message} `;
|
|
1963
|
+
const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'btn btn-small'; retry.textContent = 'Reload';
|
|
1964
|
+
retry.addEventListener('click', () => location.reload()); $('setup-error').append(retry);
|
|
1965
|
+
} finally {
|
|
1966
|
+
clearTimeout(bootTimer); $('boot').hidden = true;
|
|
1967
|
+
}
|