precedentai / api /static /index.html
lothnic's picture
Release code against the existing immutable corpus
b60ee0b verified
Raw History Blame Contribute Delete
26.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PrecedentAI — legal research, gated and verified</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230F0E0C' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z'/%3E%3Cpath d='m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z'/%3E%3Cpath d='M7 21h10'/%3E%3Cpath d='M12 3v18'/%3E%3Cpath d='M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* ---- Clean design tokens: white ground, warm neutrals, one accent ---- */
:root {
--bg: #FFFFFF;
--bg-subtle: #F7F6F2;
--border: #E8E6E1;
--border-strong: #DBD8D1;
--text: #0F0E0C;
--dim: #6B6760;
--faint: #A8A49F;
--accent: #4F46E5;
--accent-soft: rgba(79, 70, 229, .07);
--ok: #2E7D57;
--warn: #9A6700;
--bad: #B3261E;
--display: "Manrope", "Helvetica Neue", sans-serif;
--sans: "DM Sans", "Helvetica Neue", sans-serif;
--mono: "JetBrains Mono", ui-monospace, monospace;
--shadow: 0 1px 3px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.04);
--shadow-lift: 0 6px 20px rgba(0,0,0,.08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { height: 100%; }
body {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 400 15.5px/1.65 var(--sans);
display: flex; justify-content: center;
-webkit-font-smoothing: antialiased;
}
.app { width: min(880px, 100%); display: flex; flex-direction: column; min-height: 100vh; padding: 0 24px; }
/* ---- header ---- */
header {
display: flex; align-items: center; gap: 13px;
padding: 22px 2px 18px; border-bottom: 1px solid var(--border);
}
.mark { color: var(--text); display: inline-flex; flex: none; }
.mark svg { width: 26px; height: 26px; }
header h1 { font: 700 19px/1.2 var(--display); letter-spacing: -.01em; }
header .sub { color: var(--dim); font-size: 13px; letter-spacing: .005em; }
header .right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.pill {
font: 500 11px/1 var(--mono); color: var(--dim);
border: 1px solid var(--border); border-radius: 100px; padding: 6px 12px;
background: var(--bg-subtle); white-space: nowrap;
}
.pill button {
all: unset; cursor: pointer; color: var(--faint); margin-left: 7px;
padding: 0 2px; transition: color .18s ease;
}
.pill button:hover { color: var(--text); }
.pill button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
/* ---- conversation ---- */
#log { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; padding: 28px 2px; }
.msg { animation: rise .45s cubic-bezier(.2,.7,.2,1) both; }
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .msg { animation: none; } }
.msg.user {
align-self: flex-end; max-width: 76%;
background: var(--bg-subtle); border: 1px solid var(--border);
border-radius: 12px; padding: 11px 16px; color: var(--text);
}
.msg.agent {
border: 1px solid var(--border); border-radius: 12px; background: var(--bg);
box-shadow: var(--shadow); overflow: hidden;
}
.msg .who {
font: 500 10.5px/1 var(--mono); text-transform: uppercase; letter-spacing: .12em;
color: var(--faint); margin-bottom: 10px;
}
.msg.agent .head {
display: flex; align-items: center; gap: 10px;
padding: 12px 18px; border-bottom: 1px solid var(--border); background: var(--bg-subtle);
}
.msg.agent .head .who { margin: 0; }
.msg.agent .body { padding: 16px 18px; }
.msg pre { white-space: pre-wrap; font: inherit; }
/* ---- pipeline ---- */
.pipe {
display: flex; flex-wrap: wrap; gap: 7px; align-items: center;
padding: 12px 18px; border-bottom: 1px solid var(--border); background: var(--bg-subtle);
}
.step {
font: 500 11px/1 var(--mono); color: var(--faint);
border: 1px solid var(--border); border-radius: 100px; padding: 5px 11px; background: var(--bg);
display: inline-flex; gap: 6px; align-items: center;
transition: color .25s ease, border-color .25s ease, background .25s ease;
}
.step .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--faint); opacity: .5; transition: background .25s ease; }
.step.active { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, transparent); background: var(--accent-soft); }
.step.active .dot { background: var(--accent); opacity: 1; animation: pulse 1s ease-in-out infinite; }
.step.done { color: var(--dim); }
.step.done .dot { background: var(--ok); opacity: 1; animation: none; }
@keyframes pulse { 50% { opacity: .3; } }
.arrow { color: var(--faint); font-size: 11px; opacity: .6; }
/* ---- badges ---- */
.badges { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0 2px; }
.badge {
font: 500 11px/1 var(--mono); border-radius: 100px; padding: 4px 11px;
border: 1px solid var(--border); color: var(--dim); background: var(--bg-subtle);
}
.badge.brass { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, transparent); background: var(--accent-soft); }
.badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 32%, transparent); background: color-mix(in srgb, var(--ok) 6%, #fff); }
.badge.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 34%, transparent); background: color-mix(in srgb, var(--warn) 6%, #fff); }
.badge.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 32%, transparent); background: color-mix(in srgb, var(--bad) 5%, #fff); }
/* ---- key/value detail ---- */
.kv { margin-bottom: 12px; }
.kv .k {
font: 500 10.5px/1 var(--mono); text-transform: uppercase; letter-spacing: .11em;
color: var(--faint); margin-bottom: 5px;
}
.kv .v { font: 400 15px/1.65 var(--sans); color: var(--text); }
.kv .v.serif { font: 600 16.5px/1.5 var(--display); letter-spacing: -.005em; }
.prov { font: 400 11.5px/1.5 var(--mono); color: var(--faint); margin: 4px 0 6px; }
/* ---- sources ---- */
.sources { display: grid; gap: 10px; margin-top: 14px; }
.source {
border: 1px solid var(--border); border-radius: 12px; padding: 12px 15px; background: var(--bg);
display: grid; grid-template-columns: auto 1fr; gap: 3px 14px;
transition: transform .2s ease, box-shadow .2s ease;
}
.source:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); }
.source .rank {
font: 500 11px/1.9 var(--mono); color: var(--accent);
grid-row: span 2; padding-right: 6px; border-right: 1px solid var(--border);
}
.source .t { font: 600 15px/1.4 var(--display); letter-spacing: -.005em; }
.source .m { font: 400 11.5px/1.5 var(--mono); color: var(--faint); }
.source .x { grid-column: 2; color: var(--dim); font-size: 13px; margin-top: 5px; }
/* ---- verbatim quotes ---- */
.quotes { margin-top: 12px; display: grid; gap: 8px; }
.quote {
border-left: 2px solid var(--accent); padding: 8px 14px;
font: italic 400 15px/1.6 var(--sans); color: var(--text);
background: var(--bg-subtle); border-radius: 0 8px 8px 0;
}
.quote .off { font: 400 10.5px/1 var(--mono); font-style: normal; color: var(--faint); margin-left: 8px; }
/* ---- human review ---- */
.review { border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--warn) 4%, #fff); overflow: hidden; }
.review .head {
padding: 12px 18px; border-bottom: 1px solid color-mix(in srgb, var(--warn) 26%, transparent);
font: 500 10.5px/1 var(--mono); text-transform: uppercase; letter-spacing: .12em; color: var(--warn);
}
.review .body { padding: 16px 18px; }
.review .note { font: 600 16px/1.5 var(--display); color: var(--text); margin-bottom: 10px; }
.review .row { display: flex; gap: 10px; margin-top: 8px; }
/* ---- controls ---- */
button {
font: 500 14px/1 var(--sans); cursor: pointer; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg); color: var(--text); padding: 10px 18px;
transition: background .18s ease, border-color .18s ease, transform .08s ease, opacity .18s ease;
}
button:hover { background: var(--bg-subtle); }
button:active { transform: scale(.98); }
button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary { background: var(--text); border-color: var(--text); color: #fff; }
button.primary:hover { background: #333; border-color: #333; }
button.ok { background: var(--ok); border-color: var(--ok); color: #fff; }
button.ok:hover { background: #276a49; border-color: #276a49; }
button.bad { background: transparent; border-color: color-mix(in srgb, var(--bad) 45%, transparent); color: var(--bad); }
button.bad:hover { background: color-mix(in srgb, var(--bad) 6%, #fff); }
button:disabled { opacity: .45; cursor: wait; }
form { display: flex; gap: 10px; border-top: 1px solid var(--border); padding: 16px 0 22px; }
input {
flex: 1; font: 400 15.5px/1.4 var(--sans); color: var(--text); background: var(--bg);
border: 1px solid var(--border); border-radius: 8px; padding: 13px 16px; outline: none;
transition: border-color .18s ease, box-shadow .18s ease;
}
input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
input::placeholder { color: var(--faint); }
.typing { color: var(--dim); font: 400 12.5px/1 var(--mono); padding: 4px 6px; }
.typing span { animation: blink 1.2s infinite; }
@keyframes blink { 50% { opacity: .2; } }
.foot { color: var(--faint); font: 400 11px/1.6 var(--mono); text-align: center; padding: 0 0 20px; }
.foot b { color: var(--dim); font-weight: 500; }
@media (max-width: 640px) {
.app { padding: 0 16px; }
header { flex-wrap: wrap; row-gap: 10px; }
header .right { margin-left: 0; width: 100%; }
#log { gap: 16px; padding: 20px 0; }
.msg.user { max-width: 88%; }
form { flex-wrap: wrap; }
input { flex-basis: 100%; }
button.primary { flex: 1; }
}
</style>
</head>
<body>
<div class="app">
<header>
<span class="mark" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/>
<path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/>
<path d="M7 21h10"/>
<path d="M12 3v18"/>
<path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/>
</svg>
</span>
<div>
<h1>PrecedentAI</h1>
<div class="sub">Indian Supreme Court research — answers linked to source passages</div>
</div>
<div class="right">
<span class="pill" id="thread" title="conversation thread"></span>
<span class="pill" id="health">checking…</span>
</div>
</header>
<div id="log" role="log" aria-live="polite" aria-label="conversation"></div>
<form id="f">
<input id="q" placeholder='Ask the law… "is privacy a fundamental right?" · "summarize Joseph Shine" · "draft grounds under Art 21"' aria-label="your question" autocomplete="off">
<button class="primary" id="send">Send</button>
</form>
<div class="foot"><b>gated, not trusted</b> · every quote verbatim &amp; offset-verified · drafts require human approval · <span id="usage"></span></div>
</div>
<script>
const log = document.getElementById('log');
const form = document.getElementById('f');
const input = document.getElementById('q');
const send = document.getElementById('send');
const health = document.getElementById('health');
const threadPill = document.getElementById('thread');
const usageEl = document.getElementById('usage');
const API = location.origin;
let THREAD = 'ui-' + Math.random().toString(36).slice(2, 8);
function el(tag, cls, text) {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined) n.textContent = text;
return n;
}
function scroll() { log.scrollTop = log.scrollHeight; }
function refreshThreadPill() {
threadPill.textContent = 'thread: ' + THREAD;
threadPill.title = 'click for a new thread';
}
threadPill.onclick = () => { THREAD = 'ui-' + Math.random().toString(36).slice(2, 8); refreshThreadPill(); log.appendChild(el('div', 'typing', '— new thread —')); scroll(); };
refreshThreadPill();
/* ---------- result rendering ---------- */
function badges(r) {
const b = el('div', 'badges');
const ev = r.evaluation || {};
if (ev.passed === true) b.appendChild(el('span', 'badge ok', 'eval passed'));
if (ev.passed === false) b.appendChild(el('span', 'badge bad', 'eval failed'));
(ev.notes || []).slice(0, 3).forEach(n => b.appendChild(el('span', 'badge', n)));
const s = r.result && r.result.summary;
if (s && s.llm_status) {
const cls = s.llm_status === 'verified' ? 'badge brass' : 'badge warn';
b.appendChild(el('span', cls, 'llm: ' + s.llm_status));
}
return b;
}
function sourcesBlock(sources) {
if (!sources || !sources.length) return null;
const wrap = el('div', 'sources');
sources.slice(0, 5).forEach((s, i) => {
const card = el('div', 'source');
card.appendChild(el('div', 'rank', '#' + (i + 1)));
card.appendChild(el('div', 't', s.title || s.source_id));
card.appendChild(el('div', 'm', [s.court, s.year, s.source_id].filter(Boolean).join(' · ')));
if (s.text) card.appendChild(el('div', 'x', String(s.text).slice(0, 260) + (String(s.text).length > 260 ? '…' : '')));
wrap.appendChild(card);
});
return wrap;
}
function summaryBlock(result) {
const s = result && result.summary;
if (!s) return null;
const wrap = el('div');
const kv = (label, v, serif) => {
if (!v) return;
const row = el('div', 'kv');
row.appendChild(el('div', 'k', label));
row.appendChild(el('div', 'v' + (serif ? ' serif' : ''), v));
wrap.appendChild(row);
};
if (s.variant === 'hindi') {
Object.entries(s.sections || {}).forEach(([k, v]) => kv(k, v));
} else {
kv('case', s.case_name, true);
if (s.facts_llm) kv('facts · llm verified', s.facts_llm);
else kv('facts', s.facts);
kv('issues', s.issues);
kv('holding', s.holding, true);
}
if (s.provenance && s.provenance.summarized_from) {
wrap.appendChild(el('div', 'prov', 'source: ' + s.provenance.summarized_from +
(s.provenance.parent_id ? ' · ' + s.provenance.parent_id : '') +
(s.llm_windows ? ' · ' + s.llm_windows + ' llm windows' : '')));
}
const quotes = s.key_quotes || [];
if (quotes.length) {
const q = el('div', 'quotes');
quotes.slice(0, 4).forEach(x => {
const line = el('div', 'quote');
line.appendChild(document.createTextNode('“' + (x.quote || x.text || '') + '”'));
if (x.offset !== undefined) line.appendChild(el('span', 'off', '[offset ' + x.offset + ']'));
q.appendChild(line);
});
if (quotes.length > 4) q.appendChild(el('div', 'prov', '+' + (quotes.length - 4) + ' more verified quotes'));
wrap.appendChild(q);
}
return wrap;
}
function outcomeBlock(result) {
const o = result && result.outcome;
if (!o) return null;
const wrap = el('div');
if (o.action === 'clarification_requested') wrap.appendChild(el('pre', null, o.question || ''));
else if (o.document) wrap.appendChild(el('pre', null, o.document));
else if (o.note) wrap.appendChild(el('pre', null, o.note));
if (o.review_notice) wrap.appendChild(el('div', 'prov', o.review_notice));
return wrap;
}
function appendSourceDetails(container, source) {
if (!source) return;
const locations = [];
for (const [label, start, end] of [
['PDF page', source.page_start, source.page_end],
['paragraph', source.paragraph_start, source.paragraph_end]
]) {
if (Number.isInteger(start) && start > 0) {
locations.push(label + ' ' + start + (Number.isInteger(end) && end > start ? '–' + end : ''));
}
}
if (locations.length) container.appendChild(el('p', 'prov', locations.join(' · ')));
if (source.metadata_date_status === 'source_metadata_conflict') {
container.appendChild(el('p', 'note', 'Date discrepancy: metadata says ' + source.decision_date +
'; PDF header says ' + source.pdf_header_date + '.'));
}
let href = null;
if (/^\/document\/(?:[A-Za-z0-9][A-Za-z0-9._@-]{0,127}|@[a-f0-9]{16})\/pdf$/.test(source.source_pdf_path || '')) {
href = source.source_pdf_path;
if (Number.isInteger(source.page_start) && source.page_start > 0) href += '#page=' + source.page_start;
} else {
for (const value of [source.original_pdf_url, source.source_url]) {
try {
const url = new URL(value);
if (url.protocol === 'https:' || url.protocol === 'http:') { href = url.href; break; }
} catch {}
}
}
if (href) {
const link = el('a', 'prov', 'Read original source');
link.href = href; link.target = '_blank'; link.rel = 'noopener noreferrer';
container.appendChild(link);
}
}
function answerBlock(answer) {
if (!answer) return null;
const wrap = el('div');
for (const paragraph of answer.paragraphs || []) {
wrap.appendChild(el('p', null, paragraph.text));
for (const citation of paragraph.citations || []) {
const source = (answer.evidence || []).find(item => item.id === citation.id);
const detail = el('details');
detail.appendChild(el('summary', null, '[' + citation.id + '] ' + (source?.title || 'Supporting passage')));
detail.appendChild(el('blockquote', null, citation.quote));
appendSourceDetails(detail, source);
wrap.appendChild(detail);
}
}
if (answer.mode === 'excerpts') {
wrap.appendChild(el('h3', null, 'Relevant passages'));
wrap.appendChild(el('p', null, answer.llm_status === 'deterministic'
? 'Answer generation is off. These are source excerpts, not a synthesized answer.'
: 'A cited answer could not be generated. Retrieved passages are shown below.'));
for (const source of (answer.evidence || []).slice(0, 4)) {
const detail = el('details');
detail.appendChild(el('summary', null, '[' + source.id + '] ' + source.title));
detail.appendChild(el('blockquote', null, source.text));
appendSourceDetails(detail, source);
wrap.appendChild(detail);
}
}
if (answer.treatment && !['not_applicable', 'disabled'].includes(answer.treatment.status)) {
const detail = el('details');
detail.appendChild(el('summary', null, 'Later decisions in the local corpus'));
detail.appendChild(el('p', 'note', answer.treatment.note));
for (const check of answer.treatment.checks || []) {
detail.appendChild(el('p', null, check.target_title));
for (const finding of check.findings || []) {
detail.appendChild(el('p', null, 'Explicit later-decision statement: ' + finding.statement +
' · ' + (finding.source.decision_date || finding.source.year) + ' (source-reported date)'));
detail.appendChild(el('blockquote', null, finding.quote));
appendSourceDetails(detail, finding.source);
}
}
wrap.appendChild(detail);
}
for (const gap of answer.gaps || []) wrap.appendChild(el('p', 'note', gap));
return wrap;
}
function agentCard(r) {
const msg = el('div', 'msg agent');
const head = el('div', 'head');
head.appendChild(el('div', 'who', 'precedent' + (r.routed_to ? ' · ' + r.routed_to : '')));
msg.appendChild(head);
const body = el('div', 'body');
const ab = answerBlock(r.result && r.result.answer);
const sb = summaryBlock(r.result);
const ob = outcomeBlock(r.result);
if (ab) body.appendChild(ab);
if (sb && ab) {
const detail = el('details');
detail.appendChild(el('summary', null, 'Judgment extract'));
detail.appendChild(sb);
body.appendChild(detail);
} else if (sb) body.appendChild(sb);
if (ob) body.appendChild(ob);
if (!ab && !sb && !ob) body.appendChild(el('pre', null, 'No result payload.'));
if (!ab) body.appendChild(badges(r));
const srcs = sourcesBlock(r.sources);
if (srcs) body.appendChild(srcs);
msg.appendChild(body);
return msg;
}
function reviewCard(review, threadId) {
const card = el('div', 'msg agent review');
const head = el('div', 'head');
head.appendChild(el('div', 'who', 'human review required'));
card.appendChild(head);
const body = el('div', 'body');
body.appendChild(el('div', 'note', review.note || review.task || 'Drafting paused for your approval.'));
if (review.task) body.appendChild(el('pre', null, 'Task: ' + review.task));
const row = el('div', 'row');
const ok = el('button', 'ok', 'Approve & draft');
const no = el('button', 'bad', 'Reject');
ok.onclick = () => decide(true);
no.onclick = () => decide(false);
row.append(no, ok);
body.appendChild(row);
card.appendChild(body);
async function decide(approved) {
ok.disabled = no.disabled = true;
ok.textContent = approved ? 'drafting…' : 'rejected';
try {
const r = await fetch(API + '/review/' + encodeURIComponent(threadId), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved, approval_id: review.approval_id, note: approved ? 'approved in UI' : 'rejected in UI' }),
}).then(async x => { if (!x.ok) throw new Error((await x.json()).detail); return x.json(); });
card.remove();
log.appendChild(agentCard(r)); scroll();
} catch (e) {
ok.textContent = 'Request a new draft';
body.appendChild(el('p', 'muted', 'The review outcome could not be confirmed. Request a new draft to continue.'));
}
}
return card;
}
function renderResult(r, threadId) {
if (r.interrupted) { log.appendChild(reviewCard(r.review || {}, threadId)); scroll(); return; }
log.appendChild(agentCard(r)); scroll();
}
/* ---------- live pipeline via /stream, /chat fallback ---------- */
function pipelineCard() {
const msg = el('div', 'msg agent');
const pipe = el('div', 'pipe');
const body = el('div', 'body');
body.style.display = 'none';
msg.append(pipe, body);
return { msg, pipe, body };
}
function setStep(pipe, name, state) {
let step = pipe.querySelector('[data-step="' + name + '"]');
if (!step) {
if (pipe.children.length) pipe.appendChild(el('span', 'arrow', '→'));
step = el('span', 'step');
step.dataset.step = name;
step.appendChild(el('span', 'dot'));
step.appendChild(document.createTextNode(name));
pipe.appendChild(step);
}
step.classList.remove('active'); step.classList.add(state);
}
async function askStream(text) {
const { msg, pipe, body } = pipelineCard();
log.appendChild(msg); scroll();
const res = await fetch(API + '/stream', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, thread_id: THREAD }),
});
if (!res.ok || !res.body) throw new Error('stream unavailable');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let donePayload = null;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
if (!raw.startsWith('data: ')) continue;
let evt;
try { evt = JSON.parse(raw.slice(6)); } catch { continue; }
if (evt.event === 'node') {
setStep(pipe, evt.name, 'done');
} else if (evt.event === 'done') {
donePayload = evt;
}
}
}
if (donePayload && donePayload.interrupted) {
pipe.remove(); body.remove();
renderResult({ interrupted: true, review: donePayload.review }, THREAD);
} else if (donePayload && donePayload.interrupted === false) {
pipe.querySelectorAll('.step').forEach(s => s.classList.add('done'));
// The done event carries the finished state, so render it directly.
// Re-POSTing /chat here used to re-run the entire graph (and its LLM
// calls) just to fetch a payload the stream already had.
pipe.remove(); body.remove();
renderResult(donePayload, THREAD);
} else {
throw new Error('stream ended without done event');
}
}
async function askPlain(text, note) {
const typing = el('div', 'typing', note || 'consulting the corpus ');
for (let i = 0; i < 3; i++) typing.appendChild(el('span', null, '·'));
log.appendChild(typing); scroll();
try {
const r = await fetch(API + '/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, thread_id: THREAD }),
}).then(x => x.json());
typing.remove();
renderResult(r, THREAD);
} catch (e) {
typing.remove();
renderResult({ result: { outcome: { note: 'Request failed: ' + e } } }, THREAD);
}
}
form.addEventListener('submit', e => {
e.preventDefault();
const v = input.value.trim();
if (!v) return;
input.value = '';
send.disabled = true;
log.appendChild(el('div', 'msg user', v)); scroll();
askStream(v)
.catch(() => askPlain(v))
.finally(() => { send.disabled = false; refreshUsage(); });
});
function refreshUsage() {
fetch(API + '/usage').then(r => r.json()).then(u => {
if (!u.calls) return;
usageEl.textContent = u.total_tokens.toLocaleString() + ' tokens · ' + u.calls + ' calls';
}).catch(() => {});
}
fetch(API + '/health').then(r => r.json()).then(h => {
health.textContent = 'llm: ' + h.llm + ' · ' + h.backend;
health.style.color = String(h.llm) === 'True' ? 'var(--ok)' : 'var(--warn)';
}).catch(() => { health.textContent = 'API unreachable'; health.style.color = 'var(--bad)'; });
refreshUsage();
</script>
</body>
</html>