Spaces:
Running on Zero
Running on Zero
Download api/static/index.html from lothnic/precedentai: direct link, hf CLI and curl.
- Browser
- Download file 26.5 kB
-
https://huggingface.co/spaces/lothnic/precedentai/resolve/main/api/static/index.html
- Command line
-
hf download hf://spaces/lothnic/precedentai/api/static/index.html
-
curl -L -o index.html https://huggingface.co/spaces/lothnic/precedentai/resolve/main/api/static/index.html
26.5 kB
| <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 & 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> | |