konsul-ai 0.2.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.
@@ -0,0 +1,760 @@
1
+ const state = {
2
+ preset: "balanced",
3
+ web: false,
4
+ verbose: false,
5
+ query: "",
6
+ activeQuery: "",
7
+ running: false,
8
+ logs: [],
9
+ synthText: "",
10
+ streaming: false,
11
+ result: null,
12
+ error: null,
13
+ showOpinions: false,
14
+ history: [],
15
+ pastRuns: [],
16
+ presets: null,
17
+ };
18
+
19
+ const LOGO_SVG_LARGE = `<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="25 25 140 120">
20
+ <defs>
21
+ <linearGradient id="wn1" x1="0%" y1="0%" x2="100%" y2="100%">
22
+ <stop offset="0%" stop-color="#f472b6"/><stop offset="100%" stop-color="#ec4899"/>
23
+ </linearGradient>
24
+ <linearGradient id="wn2" x1="0%" y1="0%" x2="100%" y2="100%">
25
+ <stop offset="0%" stop-color="#38bdf8"/><stop offset="100%" stop-color="#0ea5e9"/>
26
+ </linearGradient>
27
+ <linearGradient id="wn3" x1="0%" y1="0%" x2="100%" y2="100%">
28
+ <stop offset="0%" stop-color="#a78bfa"/><stop offset="100%" stop-color="#8b5cf6"/>
29
+ </linearGradient>
30
+ <linearGradient id="wc" x1="0%" y1="0%" x2="100%" y2="100%">
31
+ <stop offset="0%" stop-color="#fbbf24"/><stop offset="100%" stop-color="#f59e0b"/>
32
+ </linearGradient>
33
+ </defs>
34
+ <line x1="95" y1="48" x2="55" y2="118" stroke="#f472b6" stroke-width="1.5" opacity="0.3"/>
35
+ <line x1="95" y1="48" x2="135" y2="118" stroke="#a78bfa" stroke-width="1.5" opacity="0.3"/>
36
+ <line x1="55" y1="118" x2="135" y2="118" stroke="#38bdf8" stroke-width="1.5" opacity="0.3"/>
37
+ <line x1="95" y1="48" x2="95" y2="88" stroke="#fbbf24" stroke-width="1.5" opacity="0.4"/>
38
+ <line x1="55" y1="118" x2="95" y2="88" stroke="#fbbf24" stroke-width="1.5" opacity="0.4"/>
39
+ <line x1="135" y1="118" x2="95" y2="88" stroke="#fbbf24" stroke-width="1.5" opacity="0.4"/>
40
+ <circle cx="95" cy="48" r="16" fill="url(#wn1)"/>
41
+ <circle cx="55" cy="118" r="16" fill="url(#wn2)"/>
42
+ <circle cx="135" cy="118" r="16" fill="url(#wn3)"/>
43
+ <circle cx="95" cy="48" r="20" fill="none" stroke="#f472b6" stroke-width="1" opacity="0.25"/>
44
+ <circle cx="55" cy="118" r="20" fill="none" stroke="#38bdf8" stroke-width="1" opacity="0.25"/>
45
+ <circle cx="135" cy="118" r="20" fill="none" stroke="#a78bfa" stroke-width="1" opacity="0.25"/>
46
+ <circle cx="95" cy="88" r="12" fill="url(#wc)"/>
47
+ <circle cx="95" cy="88" r="16" fill="none" stroke="#fbbf24" stroke-width="1" opacity="0.3"/>
48
+ <text x="95" y="53" text-anchor="middle" font-family="ui-monospace,monospace" font-size="13" font-weight="bold" fill="#0f172a">A</text>
49
+ <text x="55" y="123" text-anchor="middle" font-family="ui-monospace,monospace" font-size="13" font-weight="bold" fill="#0f172a">B</text>
50
+ <text x="135" y="123" text-anchor="middle" font-family="ui-monospace,monospace" font-size="13" font-weight="bold" fill="#0f172a">C</text>
51
+ <text x="95" y="93" text-anchor="middle" font-family="ui-monospace,monospace" font-size="13" font-weight="bold" fill="#0f172a">&#9733;</text>
52
+ </svg>`;
53
+
54
+ const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="25 25 140 120">
55
+ <defs>
56
+ <linearGradient id="node1" x1="0%" y1="0%" x2="100%" y2="100%">
57
+ <stop offset="0%" stop-color="#f472b6"/><stop offset="100%" stop-color="#ec4899"/>
58
+ </linearGradient>
59
+ <linearGradient id="node2" x1="0%" y1="0%" x2="100%" y2="100%">
60
+ <stop offset="0%" stop-color="#38bdf8"/><stop offset="100%" stop-color="#0ea5e9"/>
61
+ </linearGradient>
62
+ <linearGradient id="node3" x1="0%" y1="0%" x2="100%" y2="100%">
63
+ <stop offset="0%" stop-color="#a78bfa"/><stop offset="100%" stop-color="#8b5cf6"/>
64
+ </linearGradient>
65
+ <linearGradient id="center" x1="0%" y1="0%" x2="100%" y2="100%">
66
+ <stop offset="0%" stop-color="#fbbf24"/><stop offset="100%" stop-color="#f59e0b"/>
67
+ </linearGradient>
68
+ </defs>
69
+ <line x1="95" y1="48" x2="55" y2="118" stroke="#f472b6" stroke-width="1.5" opacity="0.3"/>
70
+ <line x1="95" y1="48" x2="135" y2="118" stroke="#a78bfa" stroke-width="1.5" opacity="0.3"/>
71
+ <line x1="55" y1="118" x2="135" y2="118" stroke="#38bdf8" stroke-width="1.5" opacity="0.3"/>
72
+ <line x1="95" y1="48" x2="95" y2="88" stroke="#fbbf24" stroke-width="1.5" opacity="0.4"/>
73
+ <line x1="55" y1="118" x2="95" y2="88" stroke="#fbbf24" stroke-width="1.5" opacity="0.4"/>
74
+ <line x1="135" y1="118" x2="95" y2="88" stroke="#fbbf24" stroke-width="1.5" opacity="0.4"/>
75
+ <circle cx="95" cy="48" r="16" fill="url(#node1)"/>
76
+ <circle cx="55" cy="118" r="16" fill="url(#node2)"/>
77
+ <circle cx="135" cy="118" r="16" fill="url(#node3)"/>
78
+ <circle cx="95" cy="48" r="20" fill="none" stroke="#f472b6" stroke-width="1" opacity="0.25"/>
79
+ <circle cx="55" cy="118" r="20" fill="none" stroke="#38bdf8" stroke-width="1" opacity="0.25"/>
80
+ <circle cx="135" cy="118" r="20" fill="none" stroke="#a78bfa" stroke-width="1" opacity="0.25"/>
81
+ <circle cx="95" cy="88" r="12" fill="url(#center)"/>
82
+ <circle cx="95" cy="88" r="16" fill="none" stroke="#fbbf24" stroke-width="1" opacity="0.3"/>
83
+ <text x="95" y="53" text-anchor="middle" font-family="ui-monospace,monospace" font-size="13" font-weight="bold" fill="#0f172a">A</text>
84
+ <text x="55" y="123" text-anchor="middle" font-family="ui-monospace,monospace" font-size="13" font-weight="bold" fill="#0f172a">B</text>
85
+ <text x="135" y="123" text-anchor="middle" font-family="ui-monospace,monospace" font-size="13" font-weight="bold" fill="#0f172a">C</text>
86
+ <text x="95" y="93" text-anchor="middle" font-family="ui-monospace,monospace" font-size="13" font-weight="bold" fill="#0f172a">&#9733;</text>
87
+ </svg>`;
88
+
89
+ let root = null;
90
+ let renderQueued = false;
91
+ let lastScrolledText = "";
92
+ let lastRenderedLogCount = 0;
93
+
94
+ export function escapeHtml(value) {
95
+ return String(value ?? "")
96
+ .replace(/&/g, "&amp;")
97
+ .replace(/</g, "&lt;")
98
+ .replace(/>/g, "&gt;")
99
+ .replace(/"/g, "&quot;")
100
+ .replace(/'/g, "&#39;");
101
+ }
102
+
103
+ function boolAttr(value, name) {
104
+ return value ? ` ${name}` : "";
105
+ }
106
+
107
+ export function safeLinkHref(value) {
108
+ try {
109
+ const url = new URL(String(value ?? "").trim());
110
+ if (url.protocol === "http:" || url.protocol === "https:") return url.toString();
111
+ } catch {}
112
+ return null;
113
+ }
114
+
115
+ export function shouldSubmitFromKeyDown(event) {
116
+ if (!event) return false;
117
+ if (event.isComposing || event.keyCode === 229) return false;
118
+ if (event.key !== "Enter" || event.shiftKey) return false;
119
+ return true;
120
+ }
121
+
122
+ function tokenStore() {
123
+ const tokens = [];
124
+ return {
125
+ stash(html) {
126
+ const key = `\u0000${tokens.length}\u0000`;
127
+ tokens.push(html);
128
+ return key;
129
+ },
130
+ restore(text) {
131
+ return text.replace(/\u0000(\d+)\u0000/g, (_match, index) => tokens[Number(index)] ?? "");
132
+ },
133
+ };
134
+ }
135
+
136
+ function renderInline(text) {
137
+ const store = tokenStore();
138
+ let html = String(text ?? "");
139
+
140
+ html = html.replace(/`([^`]+)`/g, (_match, code) => store.stash(`<code>${escapeHtml(code)}</code>`));
141
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
142
+ const href = safeLinkHref(url);
143
+ if (!href) return match;
144
+ return store.stash(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`);
145
+ });
146
+
147
+ html = escapeHtml(html);
148
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
149
+ html = html.replace(/(^|[^\*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>");
150
+
151
+ return store.restore(html);
152
+ }
153
+
154
+ function renderCodeBlock(lines, language) {
155
+ const cls = language ? ` class="language-${escapeHtml(language)}"` : "";
156
+ return `<pre><code${cls}>${escapeHtml(lines.join("\n"))}</code></pre>`;
157
+ }
158
+
159
+ export function renderMarkdown(text) {
160
+ if (!text) return "";
161
+
162
+ const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
163
+ const blocks = [];
164
+ let paragraph = [];
165
+ let list = null;
166
+ let codeBlock = null;
167
+
168
+ const flushParagraph = () => {
169
+ if (!paragraph.length) return;
170
+ blocks.push(`<p>${paragraph.map(renderInline).join("<br>")}</p>`);
171
+ paragraph = [];
172
+ };
173
+
174
+ const flushList = () => {
175
+ if (!list) return;
176
+ blocks.push(`<${list.type}>${list.items.map((item) => `<li>${renderInline(item)}</li>`).join("")}</${list.type}>`);
177
+ list = null;
178
+ };
179
+
180
+ const flushCodeBlock = () => {
181
+ if (!codeBlock) return;
182
+ blocks.push(renderCodeBlock(codeBlock.lines, codeBlock.language));
183
+ codeBlock = null;
184
+ };
185
+
186
+ for (const line of lines) {
187
+ if (codeBlock) {
188
+ if (/^```/.test(line)) {
189
+ flushCodeBlock();
190
+ } else {
191
+ codeBlock.lines.push(line);
192
+ }
193
+ continue;
194
+ }
195
+
196
+ const codeFence = line.match(/^```([\w-]*)\s*$/);
197
+ if (codeFence) {
198
+ flushParagraph();
199
+ flushList();
200
+ codeBlock = { language: codeFence[1], lines: [] };
201
+ continue;
202
+ }
203
+
204
+ if (!line.trim()) {
205
+ flushParagraph();
206
+ flushList();
207
+ continue;
208
+ }
209
+
210
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
211
+ if (heading) {
212
+ flushParagraph();
213
+ flushList();
214
+ const level = heading[1].length;
215
+ blocks.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
216
+ continue;
217
+ }
218
+
219
+ const unordered = line.match(/^[-*+]\s+(.*)$/);
220
+ if (unordered) {
221
+ flushParagraph();
222
+ if (!list || list.type !== "ul") {
223
+ flushList();
224
+ list = { type: "ul", items: [] };
225
+ }
226
+ list.items.push(unordered[1]);
227
+ continue;
228
+ }
229
+
230
+ const ordered = line.match(/^\d+\.\s+(.*)$/);
231
+ if (ordered) {
232
+ flushParagraph();
233
+ if (!list || list.type !== "ol") {
234
+ flushList();
235
+ list = { type: "ol", items: [] };
236
+ }
237
+ list.items.push(ordered[1]);
238
+ continue;
239
+ }
240
+
241
+ paragraph.push(line);
242
+ }
243
+
244
+ flushParagraph();
245
+ flushList();
246
+ flushCodeBlock();
247
+
248
+ return blocks.join("");
249
+ }
250
+
251
+ function renderMarkdownBlock(text, cls = "") {
252
+ const classes = cls ? `md-content ${cls}` : "md-content";
253
+ return `<div class="${classes}">${renderMarkdown(text)}</div>`;
254
+ }
255
+
256
+ async function runCouncil(query, preset, web, history, callbacks) {
257
+ const { onLog, onStream, onResult, onError, onDone } = callbacks;
258
+ const res = await fetch("/api/council", {
259
+ method: "POST",
260
+ headers: { "Content-Type": "application/json" },
261
+ body: JSON.stringify({ query, preset, web, history }),
262
+ });
263
+
264
+ const contentType = res.headers.get("content-type") || "";
265
+ if (!res.ok) {
266
+ let message = `${res.status} ${res.statusText}`.trim();
267
+ if (contentType.includes("application/json")) {
268
+ try {
269
+ const data = await res.json();
270
+ message = data.error || data.message || message;
271
+ } catch {}
272
+ } else {
273
+ const raw = await res.text();
274
+ if (raw.trim()) message = raw.trim();
275
+ }
276
+ throw new Error(message);
277
+ }
278
+ if (!contentType.includes("text/event-stream")) {
279
+ throw new Error(`Unexpected response type: ${contentType || "unknown"}`);
280
+ }
281
+ if (!res.body) {
282
+ throw new Error("Missing response body");
283
+ }
284
+
285
+ const reader = res.body.getReader();
286
+ const decoder = new TextDecoder();
287
+ let buffer = "";
288
+
289
+ while (true) {
290
+ const { done, value } = await reader.read();
291
+ if (done) break;
292
+ buffer += decoder.decode(value, { stream: true });
293
+
294
+ const lines = buffer.split("\n");
295
+ buffer = lines.pop() || "";
296
+
297
+ let currentEvent = null;
298
+ for (const line of lines) {
299
+ if (line.startsWith("event: ")) {
300
+ currentEvent = line.slice(7);
301
+ } else if (line.startsWith("data: ") && currentEvent) {
302
+ try {
303
+ const data = JSON.parse(line.slice(6));
304
+ if (currentEvent === "log") onLog(data.message);
305
+ else if (currentEvent === "stream") onStream(data.text);
306
+ else if (currentEvent === "result") onResult(data);
307
+ else if (currentEvent === "error") onError(data.message);
308
+ } catch {}
309
+ currentEvent = null;
310
+ } else if (line === "") {
311
+ currentEvent = null;
312
+ }
313
+ }
314
+ }
315
+ onDone();
316
+ }
317
+
318
+ function parseLog(logs) {
319
+ let activeStage = 0;
320
+ const stages = [
321
+ { title: "Opinions", models: [], done: false },
322
+ { title: "Peer Review", models: [], done: false },
323
+ { title: "Synthesis", models: [], done: false },
324
+ ];
325
+
326
+ for (const msg of logs) {
327
+ if (msg.includes("Stage 1")) {
328
+ activeStage = 1;
329
+ stages[0].done = false;
330
+ } else if (msg.includes("Stage 2")) {
331
+ activeStage = 2;
332
+ stages[0].done = true;
333
+ } else if (msg.includes("Stage 3")) {
334
+ activeStage = 3;
335
+ stages[1].done = true;
336
+ } else if (msg.includes("Synthesis complete")) {
337
+ stages[2].done = true;
338
+ }
339
+
340
+ const okMatch = msg.match(/✓\s+(.+?)\s+(?:responded|reviewed|complete)/);
341
+ if (okMatch) {
342
+ const name = okMatch[1];
343
+ const stageIdx = activeStage - 1;
344
+ if (stageIdx >= 0 && stageIdx < 3) {
345
+ const existing = stages[stageIdx].models.find((model) => model.name === name);
346
+ if (existing) existing.status = "ok";
347
+ else stages[stageIdx].models.push({ name, status: "ok" });
348
+ }
349
+ }
350
+
351
+ const failMatch = msg.match(/✗\s+(.+?):/);
352
+ if (failMatch) {
353
+ const name = failMatch[1];
354
+ const stageIdx = activeStage - 1;
355
+ if (stageIdx >= 0 && stageIdx < 3) {
356
+ stages[stageIdx].models.push({ name, status: "fail" });
357
+ }
358
+ }
359
+ }
360
+
361
+ return { stages, activeStage };
362
+ }
363
+
364
+ function fmtMs(ms) {
365
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
366
+ }
367
+
368
+ function renderStageBar() {
369
+ const { stages, activeStage } = parseLog(state.logs);
370
+ if (activeStage === 0) return "";
371
+
372
+ return `
373
+ <div class="stages">
374
+ ${stages.map((stage, index) => {
375
+ const cls = stage.done ? "stage done" : (index + 1 === activeStage ? "stage active" : "stage");
376
+ return `
377
+ <div class="${cls}">
378
+ <div class="title">${index + 1}. ${escapeHtml(stage.title)}</div>
379
+ <div class="models">
380
+ ${stage.models.map((model) => `
381
+ <span class="model-status">
382
+ <span class="dot ${model.status}"></span>${escapeHtml(model.name)}
383
+ </span>
384
+ `).join("")}
385
+ ${!stage.done && index + 1 === activeStage && stage.models.length === 0
386
+ ? '<span class="model-status"><span class="dot pending"></span>waiting...</span>'
387
+ : ""}
388
+ </div>
389
+ </div>
390
+ `;
391
+ }).join("")}
392
+ </div>
393
+ `;
394
+ }
395
+
396
+ function renderResultMeta(result) {
397
+ if (!result) return "";
398
+ return `
399
+ <div class="result-meta">
400
+ <div class="rankings">
401
+ ${(result.scores || []).map((score, index) => `
402
+ <span class="rank-item"><span class="rank-num">${index + 1}.</span> ${escapeHtml(score.label)} (${score.avgRank.toFixed(1)})</span>
403
+ `).join("")}
404
+ </div>
405
+ <div>${fmtMs(result.totalLatencyMs)} · ~${escapeHtml(result.tokenEstimate)} tokens</div>
406
+ </div>
407
+ `;
408
+ }
409
+
410
+ function renderOpinions() {
411
+ if (!(state.showOpinions || state.verbose) || !state.result?.opinions?.length) return "";
412
+
413
+ return `
414
+ <div>
415
+ ${state.result.opinions.map((opinion) => `
416
+ <div class="opinion">
417
+ <div class="opinion-header">${escapeHtml(opinion.model.label)}</div>
418
+ <div class="opinion-body">${renderMarkdownBlock(opinion.content)}</div>
419
+ </div>
420
+ `).join("")}
421
+ </div>
422
+ `;
423
+ }
424
+
425
+ function renderPastRuns() {
426
+ return state.pastRuns.map((run) => `
427
+ <div class="history-entry">
428
+ <div class="user-query">${escapeHtml(run.query)}</div>
429
+ <div class="synthesis">${renderMarkdownBlock(run.synthesis)}</div>
430
+ <div class="result-meta">
431
+ <div class="rankings">
432
+ ${(run.scores || []).map((score, index) => `
433
+ <span class="rank-item"><span class="rank-num">${index + 1}.</span> ${escapeHtml(score.label)} (${score.avgRank.toFixed(1)})</span>
434
+ `).join("")}
435
+ </div>
436
+ <div>${fmtMs(run.totalLatencyMs)} · ~${escapeHtml(run.tokenEstimate)} tokens</div>
437
+ </div>
438
+ </div>
439
+ `).join("");
440
+ }
441
+
442
+ function renderSynthesis() {
443
+ if (!state.synthText && !state.streaming) return "";
444
+
445
+ return `
446
+ <div class="synthesis">
447
+ <div id="synth-content" class="md-content">${renderMarkdown(state.synthText)}</div>
448
+ ${state.streaming ? '<span class="cursor"></span>' : ""}
449
+ </div>
450
+ `;
451
+ }
452
+
453
+ function hasContent() {
454
+ return state.pastRuns.length > 0 || state.activeQuery || state.running || state.result || state.error;
455
+ }
456
+
457
+ function renderWelcome() {
458
+ return `
459
+ <div class="welcome">
460
+ ${LOGO_SVG_LARGE}
461
+ <div class="welcome-title">Konsul</div>
462
+ <div class="welcome-subtitle">Multi-LLM council. Ask a question and multiple AI models will debate, review, and synthesize the best answer.</div>
463
+ </div>
464
+ `;
465
+ }
466
+
467
+ function renderApp() {
468
+ const presetMeta = state.presets?.[state.preset];
469
+ const teamInfo = presetMeta
470
+ ? `${presetMeta.members.join(", ")} · chair: ${presetMeta.chair} · ${presetMeta.rounds} round${presetMeta.rounds > 1 ? "s" : ""}`
471
+ : "";
472
+
473
+ return `
474
+ <header>
475
+ <div class="logo">${LOGO_SVG}<span>Konsul</span></div>
476
+ <div class="controls">
477
+ <select id="preset-select">
478
+ <option value="free"${state.preset === "free" ? " selected" : ""}>Free</option>
479
+ <option value="budget"${state.preset === "budget" ? " selected" : ""}>Budget</option>
480
+ <option value="balanced"${state.preset === "balanced" ? " selected" : ""}>Balanced</option>
481
+ <option value="premium"${state.preset === "premium" ? " selected" : ""}>Premium</option>
482
+ </select>
483
+ <label class="toggle">
484
+ <input id="web-toggle" type="checkbox"${boolAttr(state.web, "checked")}>
485
+ Web
486
+ </label>
487
+ <label class="toggle">
488
+ <input id="verbose-toggle" type="checkbox"${boolAttr(state.verbose, "checked")}>
489
+ Verbose
490
+ </label>
491
+ </div>
492
+ </header>
493
+
494
+ ${teamInfo ? `<div class="team-info">${escapeHtml(teamInfo)}</div>` : ""}
495
+
496
+ <div class="messages" id="messages">
497
+ ${hasContent() ? `
498
+ ${renderPastRuns()}
499
+ ${state.activeQuery ? `<div class="user-query">${escapeHtml(state.activeQuery)}</div>` : ""}
500
+ ${renderStageBar()}
501
+ ${state.error ? `<div class="error-msg">${escapeHtml(state.error)}</div>` : ""}
502
+ ${renderSynthesis()}
503
+ ${renderResultMeta(state.result)}
504
+ ${state.result ? `
505
+ <button class="opinions-toggle" data-action="toggle-opinions">
506
+ ${state.showOpinions ? "Hide" : "Show"} individual opinions (${state.result.opinions?.length || 0})
507
+ </button>
508
+ ${renderOpinions()}
509
+ ` : ""}
510
+ ` : renderWelcome()}
511
+ </div>
512
+
513
+ <div class="query-box">
514
+ <div class="input-wrapper">
515
+ <textarea
516
+ id="query-input"
517
+ placeholder="Ask the council..."
518
+ ${boolAttr(state.running, "disabled")}
519
+ >${escapeHtml(state.query)}</textarea>
520
+ <button
521
+ class="send-btn"
522
+ data-action="submit"
523
+ aria-label="Send"
524
+ ${boolAttr(state.running || !state.query.trim(), "disabled")}
525
+ >${state.running ? "\u2026" : "\u2191"}</button>
526
+ </div>
527
+ </div>
528
+ `;
529
+ }
530
+
531
+ function autoResizeTextarea(textarea) {
532
+ textarea.style.height = "0px";
533
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
534
+ }
535
+
536
+ function scrollMessages() {
537
+ const messagesEl = root?.querySelector("#messages");
538
+ if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
539
+ }
540
+
541
+ export function captureMessagesScrollState(messagesEl, options = {}) {
542
+ if (options.running) return { top: 0, stickToBottom: true };
543
+ if (!messagesEl) return { top: 0, stickToBottom: true };
544
+ const maxScrollTop = Math.max(messagesEl.scrollHeight - messagesEl.clientHeight, 0);
545
+ return {
546
+ top: messagesEl.scrollTop,
547
+ stickToBottom: maxScrollTop - messagesEl.scrollTop <= 8,
548
+ };
549
+ }
550
+
551
+ export function restoreMessagesScrollState(messagesEl, scrollState) {
552
+ if (!messagesEl || !scrollState) return;
553
+ if (scrollState.stickToBottom) {
554
+ messagesEl.scrollTop = messagesEl.scrollHeight;
555
+ return;
556
+ }
557
+ const maxScrollTop = Math.max(messagesEl.scrollHeight - messagesEl.clientHeight, 0);
558
+ messagesEl.scrollTop = Math.min(scrollState.top, maxScrollTop);
559
+ }
560
+
561
+ function render() {
562
+ if (!root) return;
563
+ const scrollState = captureMessagesScrollState(root.querySelector("#messages"), { running: state.running });
564
+ const hadFocus = document.activeElement?.id === "query-input";
565
+ const selStart = hadFocus ? document.activeElement.selectionStart : null;
566
+ const selEnd = hadFocus ? document.activeElement.selectionEnd : null;
567
+
568
+ root.innerHTML = renderApp();
569
+
570
+ const textarea = root.querySelector("#query-input");
571
+ if (textarea) {
572
+ autoResizeTextarea(textarea);
573
+ if (hadFocus) {
574
+ textarea.focus();
575
+ if (selStart !== null) textarea.setSelectionRange(selStart, selEnd);
576
+ }
577
+ }
578
+
579
+ lastRenderedLogCount = state.logs.length;
580
+ restoreMessagesScrollState(root.querySelector("#messages"), scrollState);
581
+ if (state.synthText !== lastScrolledText && scrollState.stickToBottom) {
582
+ scrollMessages();
583
+ lastScrolledText = state.synthText;
584
+ }
585
+ }
586
+
587
+ function renderStreamUpdate() {
588
+ if (!root) return;
589
+ const synthEl = root.querySelector("#synth-content");
590
+ if (!synthEl) {
591
+ render();
592
+ return;
593
+ }
594
+ const messagesEl = root.querySelector("#messages");
595
+ const scrollState = captureMessagesScrollState(messagesEl);
596
+ synthEl.innerHTML = renderMarkdown(state.synthText);
597
+
598
+ if (state.synthText !== lastScrolledText && scrollState.stickToBottom) {
599
+ scrollMessages();
600
+ lastScrolledText = state.synthText;
601
+ }
602
+ }
603
+
604
+ function scheduleRender() {
605
+ if (renderQueued) return;
606
+ renderQueued = true;
607
+ queueMicrotask(() => {
608
+ renderQueued = false;
609
+ if (state.streaming && root?.querySelector("#synth-content") && state.logs.length === lastRenderedLogCount) {
610
+ renderStreamUpdate();
611
+ } else {
612
+ render();
613
+ }
614
+ });
615
+ }
616
+
617
+ async function submit() {
618
+ const q = state.query.trim();
619
+ if (!q || state.running) return;
620
+
621
+ if (state.result) {
622
+ state.pastRuns = [
623
+ ...state.pastRuns,
624
+ {
625
+ query: state.result.query,
626
+ synthesis: state.result.synthesis,
627
+ scores: state.result.scores,
628
+ totalLatencyMs: state.result.totalLatencyMs,
629
+ tokenEstimate: state.result.tokenEstimate,
630
+ },
631
+ ];
632
+ }
633
+
634
+ state.running = true;
635
+ state.activeQuery = q;
636
+ state.logs = [];
637
+ state.synthText = "";
638
+ state.streaming = false;
639
+ state.result = null;
640
+ state.error = null;
641
+ state.showOpinions = false;
642
+ state.query = "";
643
+ render();
644
+
645
+ const synthBuf = { text: "" };
646
+ try {
647
+ await runCouncil(q, state.preset, state.web, state.history, {
648
+ onLog(message) {
649
+ state.logs = [...state.logs, message];
650
+ scheduleRender();
651
+ },
652
+ onStream(text) {
653
+ synthBuf.text += text;
654
+ state.synthText = synthBuf.text;
655
+ state.streaming = true;
656
+ scheduleRender();
657
+ },
658
+ onResult(data) {
659
+ state.result = data;
660
+ state.synthText = data.synthesis;
661
+ state.streaming = false;
662
+ state.activeQuery = "";
663
+ state.history = [
664
+ ...state.history,
665
+ { role: "user", content: q },
666
+ { role: "assistant", content: data.synthesis },
667
+ ];
668
+ scheduleRender();
669
+ },
670
+ onError(message) {
671
+ state.error = message;
672
+ state.streaming = false;
673
+ scheduleRender();
674
+ },
675
+ onDone() {
676
+ state.running = false;
677
+ state.streaming = false;
678
+ state.activeQuery = "";
679
+ scheduleRender();
680
+ },
681
+ });
682
+ } catch (err) {
683
+ state.error = err instanceof Error ? err.message : "Network error";
684
+ state.running = false;
685
+ state.streaming = false;
686
+ state.activeQuery = "";
687
+ render();
688
+ }
689
+ }
690
+
691
+ async function loadPresets() {
692
+ try {
693
+ const res = await fetch("/api/presets");
694
+ state.presets = await res.json();
695
+ render();
696
+ } catch {}
697
+ }
698
+
699
+ function onChange(event) {
700
+ const target = event.target;
701
+ if (!(target instanceof HTMLElement)) return;
702
+
703
+ if (target.id === "preset-select") {
704
+ state.preset = target.value;
705
+ render();
706
+ } else if (target.id === "web-toggle") {
707
+ state.web = target.checked;
708
+ render();
709
+ } else if (target.id === "verbose-toggle") {
710
+ state.verbose = target.checked;
711
+ render();
712
+ }
713
+ }
714
+
715
+ function onInput(event) {
716
+ const target = event.target;
717
+ if (!(target instanceof HTMLElement) || target.id !== "query-input") return;
718
+ state.query = target.value;
719
+ autoResizeTextarea(target);
720
+ const submitButton = root?.querySelector('[data-action="submit"]');
721
+ if (submitButton) submitButton.disabled = state.running || !state.query.trim();
722
+ }
723
+
724
+ function onKeyDown(event) {
725
+ const target = event.target;
726
+ if (!(target instanceof HTMLElement) || target.id !== "query-input") return;
727
+ if (!shouldSubmitFromKeyDown(event)) return;
728
+ event.preventDefault();
729
+ void submit();
730
+ }
731
+
732
+ function onClick(event) {
733
+ const element = event.target instanceof HTMLElement ? event.target.closest("[data-action]") : null;
734
+ if (!element) return;
735
+
736
+ const action = element.getAttribute("data-action");
737
+ if (action === "submit") {
738
+ void submit();
739
+ } else if (action === "toggle-opinions") {
740
+ state.showOpinions = !state.showOpinions;
741
+ render();
742
+ }
743
+ }
744
+
745
+ function bootstrap() {
746
+ root = document.getElementById("app");
747
+ if (!root) return;
748
+
749
+ root.addEventListener("change", onChange);
750
+ root.addEventListener("input", onInput);
751
+ root.addEventListener("keydown", onKeyDown);
752
+ root.addEventListener("click", onClick);
753
+
754
+ render();
755
+ void loadPresets();
756
+ }
757
+
758
+ if (typeof document !== "undefined") {
759
+ bootstrap();
760
+ }