| |
| |
| |
| |
|
|
| const heroEl = document.getElementById("hero"); |
| const threadEl = document.getElementById("thread"); |
| const chatEl = document.getElementById("chat"); |
| const form = document.getElementById("composer"); |
| const input = document.getElementById("input"); |
| const sendBtn = document.getElementById("send"); |
| const statusEl = document.getElementById("agent-status"); |
| const statusDot = statusEl.querySelector(".status-dot"); |
| const statusText = statusEl.querySelector(".status-text"); |
| const msgTpl = document.getElementById("msg-tpl"); |
| const runTpl = document.getElementById("run-tpl"); |
| const apiKeyInput = document.getElementById("api-key"); |
|
|
| const API_KEY_STORE = "aprover_anthropic_key"; |
|
|
| |
| |
| |
| if (apiKeyInput) { |
| apiKeyInput.value = localStorage.getItem(API_KEY_STORE) || ""; |
| apiKeyInput.addEventListener("input", () => { |
| localStorage.setItem(API_KEY_STORE, apiKeyInput.value.trim()); |
| }); |
| } |
|
|
| function getApiKey() { |
| return (apiKeyInput && apiKeyInput.value.trim()) || ""; |
| } |
|
|
| const history = []; |
| let busy = false; |
| let activeRun = null; |
|
|
| const PHASES = ["spec", "bmc", "classify", "report"]; |
|
|
| function setStatus(state, label) { |
| statusDot.className = "status-dot " + state; |
| statusText.textContent = label; |
| } |
|
|
| function setBusy(b, label) { |
| busy = b; |
| sendBtn.disabled = b; |
| if (b) setStatus("thinking", label || "thinking"); |
| else setStatus("idle", "idle"); |
| } |
|
|
| function hideHero() { |
| if (heroEl && !heroEl.classList.contains("hidden")) heroEl.classList.add("hidden"); |
| } |
|
|
| function escapeHtml(s) { |
| return String(s) |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">"); |
| } |
|
|
| function renderMarkdown(text) { |
| const blocks = []; |
| let i = 0; |
| text = String(text).replace(/```([^\n]*)\n([\s\S]*?)```/g, (_m, lang, body) => { |
| blocks.push({ lang: lang.trim(), body }); |
| return ` BLOCK${i++} `; |
| }); |
| text = escapeHtml(text); |
| text = text.replace(/`([^`\n]+)`/g, (_m, c) => `<code>${escapeHtml(c)}</code>`.replace(/&(lt|gt|amp);/g, "&$1;")); |
| text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"); |
| text = text.replace(/\*([^*\n]+)\*/g, "<em>$1</em>"); |
| const paragraphs = text |
| .split(/\n{2,}/) |
| .map((p) => `<p>${p.replace(/\n/g, "<br />")}</p>`) |
| .join(""); |
| return paragraphs.replace(/ BLOCK(\d+) /g, (_m, idx) => { |
| const b = blocks[+idx]; |
| return `<pre><code class="lang-${escapeHtml(b.lang)}">${escapeHtml(b.body)}</code></pre>`; |
| }); |
| } |
|
|
| function appendMsg(role, html, opts = {}) { |
| hideHero(); |
| const node = msgTpl.content.firstElementChild.cloneNode(true); |
| node.classList.add(role); |
| node.querySelector(".role").textContent = opts.label || role; |
| node.querySelector(".body").innerHTML = html; |
| threadEl.appendChild(node); |
| scrollToBottom(); |
| return node; |
| } |
|
|
| function appendRunCard() { |
| hideHero(); |
| const node = runTpl.content.firstElementChild.cloneNode(true); |
| threadEl.appendChild(node); |
| scrollToBottom(); |
| const logDisc = node.querySelector(".log-disc"); |
| |
| logDisc.open = true; |
| activeRun = { |
| rootEl: node, |
| bodyEl: node.querySelector(".body"), |
| logEl: node.querySelector(".run-log"), |
| logDiscEl: logDisc, |
| phaseEls: Array.from(node.querySelectorAll(".phase")), |
| phaseLineEls: Array.from(node.querySelectorAll(".phase-line")), |
| nowLineEl: node.querySelector(".now-line"), |
| nowTextEl: node.querySelector(".now-text"), |
| elapsedEl: node.querySelector(".run-elapsed"), |
| elapsedNumEl: node.querySelector(".run-elapsed-num"), |
| startedAt: performance.now(), |
| timerId: null, |
| currentPhase: null, |
| completedPhases: new Set(), |
| }; |
| |
| activeRun.timerId = setInterval(() => { |
| if (!activeRun) return; |
| const t = (performance.now() - activeRun.startedAt) / 1000; |
| activeRun.elapsedNumEl.textContent = t.toFixed(1); |
| }, 100); |
| return activeRun; |
| } |
|
|
| function stopRunTimer(state) { |
| if (!activeRun) return; |
| if (activeRun.timerId) { |
| clearInterval(activeRun.timerId); |
| activeRun.timerId = null; |
| } |
| if (state) activeRun.elapsedEl.classList.add(state); |
| } |
|
|
| function scrollToBottom() { |
| chatEl.scrollTop = chatEl.scrollHeight; |
| } |
|
|
| function setPhase(phaseName) { |
| if (!activeRun) return; |
| const order = PHASES.indexOf(phaseName); |
| if (order < 0) return; |
| activeRun.phaseEls.forEach((el) => { |
| const p = el.getAttribute("data-phase"); |
| const idx = PHASES.indexOf(p); |
| el.classList.remove("active", "done"); |
| if (idx < order) el.classList.add("done"); |
| else if (idx === order) el.classList.add("active"); |
| }); |
| |
| activeRun.phaseLineEls.forEach((el, i) => { |
| el.classList.remove("flowing", "done"); |
| if (i < order - 1) el.classList.add("done"); |
| else if (i === order - 1) el.classList.add("flowing"); |
| }); |
| activeRun.currentPhase = phaseName; |
| } |
|
|
| function completePhase(phaseName) { |
| if (!activeRun) return; |
| const el = activeRun.phaseEls.find((e) => e.getAttribute("data-phase") === phaseName); |
| if (!el) return; |
| el.classList.remove("active"); |
| el.classList.add("done"); |
| activeRun.completedPhases.add(phaseName); |
| |
| |
| const idx = PHASES.indexOf(phaseName); |
| const nextLine = activeRun.phaseLineEls[idx]; |
| if (nextLine && !nextLine.classList.contains("done")) { |
| nextLine.classList.add("flowing"); |
| } |
| } |
|
|
| |
| |
| function extractActivity(message) { |
| const m = String(message); |
| if (/Parsing source file/.test(m)) return "parsing source"; |
| if (/Phase 1: Generating specs/.test(m)) return "generating function specs"; |
| let mm = m.match(/Processing layer (\d+): \[?([^\]\n]+)/); |
| if (mm) return `spec layer ${mm[1]} · ${mm[2].split(",")[0].trim()}`; |
| if (/Phase 1 complete/.test(m)) return "specs generated"; |
| if (/Phase 1\.5: Selecting per-function CBMC flags/.test(m)) |
| return "selecting CBMC flags per function"; |
| if (/Phase 2: Running BMC on (\d+)/.test(m)) { |
| const [, n] = m.match(/Phase 2: Running BMC on (\d+)/); |
| return `running CBMC on ${n} function${n === "1" ? "" : "s"}`; |
| } |
| if (/Phase 2 complete/.test(m)) return "bounded model checking done"; |
| if (/Phase 3: Validating/.test(m)) return "classifying counterexamples"; |
| if (/Phase 3c:/.test(m)) return "refining specs"; |
| mm = m.match(/Checking function '([^']+)'/); |
| if (mm) return `bmc · ${mm[1]}`; |
| mm = m.match(/Generating spec for '([^']+)'/i); |
| if (mm) return `spec · ${mm[1]}`; |
| mm = m.match(/CBMC verdict for '([^']+)':\s*verified=(\w+)/); |
| if (mm) return `verdict · ${mm[1]} · ${mm[2] === "True" ? "verified" : "counterexample"}`; |
| mm = m.match(/Recheck: validating counterexample for '([^']+)'/); |
| if (mm) return `recheck · ${mm[1]}`; |
| mm = m.match(/Validating counterexample for '([^']+)'/); |
| if (mm) return `classifying · ${mm[1]}`; |
| if (/REAL BUG \(Phase 3c\) confirmed in '([^']+)'/.test(m)) { |
| const [, fn] = m.match(/REAL BUG \(Phase 3c\) confirmed in '([^']+)'/); |
| return `real bug (refined) confirmed in ${fn}`; |
| } |
| if (/REAL BUG (?:\(recheck\) )?confirmed in '([^']+)'/.test(m)) { |
| const [, fn] = m.match(/REAL BUG (?:\(recheck\) )?confirmed in '([^']+)'/); |
| return `real bug confirmed in ${fn}`; |
| } |
| if (/AMC Pipeline END/.test(m)) return "wrapping up"; |
| return null; |
| } |
|
|
| function setNowLine(text) { |
| if (!activeRun) return; |
| activeRun.nowTextEl.textContent = text; |
| } |
|
|
| function inspectLogLine(message) { |
| const m = String(message); |
| if (/Phase 1: Generating specs/.test(m)) setPhase("spec"); |
| else if (/Phase 1 complete/.test(m)) completePhase("spec"); |
| |
| |
| else if (/Phase 2: Running BMC/.test(m)) setPhase("bmc"); |
| else if (/Phase 2 complete/.test(m)) completePhase("bmc"); |
| else if (/Phase 3: Validating/.test(m)) setPhase("classify"); |
| |
| |
| else if (/=== AMC Pipeline END/.test(m)) { |
| completePhase("classify"); |
| setPhase("report"); |
| } |
| const headline = extractActivity(m); |
| if (headline) setNowLine(headline); |
| } |
|
|
| function logRunLine(level, message) { |
| if (!activeRun) return; |
| const li = document.createElement("li"); |
| li.className = level || "info"; |
| li.textContent = message; |
| activeRun.logEl.appendChild(li); |
| activeRun.logEl.scrollTop = activeRun.logEl.scrollHeight; |
| inspectLogLine(message); |
| } |
|
|
| function renderResult(result) { |
| if (!activeRun) return; |
| if (!result.ok) { |
| setStatus("error", "failed"); |
| stopRunTimer(); |
| activeRun.phaseEls.forEach((e) => e.classList.remove("active")); |
| activeRun.phaseLineEls.forEach((e) => e.classList.remove("flowing")); |
| activeRun.nowLineEl.classList.remove("done"); |
| activeRun.nowLineEl.classList.add("idle"); |
| setNowLine("pipeline failed"); |
| const err = document.createElement("div"); |
| err.className = "bug-summary"; |
| err.innerHTML = `<div class="verdict bad">▲ pipeline failed</div><pre>${escapeHtml(result.error || "unknown error")}</pre>`; |
| activeRun.bodyEl.appendChild(err); |
| return; |
| } |
| completePhase("report"); |
| activeRun.phaseEls.forEach((e) => e.classList.remove("active")); |
| activeRun.phaseLineEls.forEach((e) => { |
| e.classList.remove("flowing"); |
| e.classList.add("done"); |
| }); |
| stopRunTimer("done"); |
| activeRun.nowLineEl.classList.add("done"); |
| setNowLine( |
| !result.bugs || result.bugs.length === 0 |
| ? "no bugs confirmed" |
| : `${result.bugs.length} bug${result.bugs.length === 1 ? "" : "s"} confirmed` |
| ); |
| |
| if (activeRun.logDiscEl) activeRun.logDiscEl.open = false; |
|
|
| const wrap = document.createElement("div"); |
| wrap.className = "bug-summary"; |
| if (!result.bugs || result.bugs.length === 0) { |
| wrap.innerHTML = `<div class="verdict ok">✓ no bugs confirmed</div>`; |
| } else { |
| const v = document.createElement("div"); |
| v.className = "verdict bad"; |
| v.textContent = `▲ ${result.bugs.length} bug${result.bugs.length === 1 ? "" : "s"} confirmed`; |
| wrap.appendChild(v); |
| for (const b of result.bugs) { |
| const card = document.createElement("div"); |
| card.className = "bug-card"; |
| const chain = (b.call_chain || []).join(" → "); |
| const reasoning = (b.reasoning || "").trim(); |
| card.innerHTML = ` |
| <div class="row"> |
| <span class="fn-name">${escapeHtml(b.function || "?")}</span> |
| <span class="badge tier-${escapeHtml(b.confidence || "")}">${escapeHtml(b.confidence || "")}</span> |
| <span class="badge">${escapeHtml(b.bug_type || "")}</span> |
| </div> |
| <div class="prop">${escapeHtml(b.violated_property || "")}</div> |
| ${chain ? `<div class="chain">via ${escapeHtml(chain)}</div>` : ""} |
| ${reasoning ? ` |
| <details class="bug-reason"> |
| <summary>why this is a bug</summary> |
| <div class="reason-body">${escapeHtml(reasoning)}</div> |
| </details>` : ""} |
| `; |
| wrap.appendChild(card); |
| } |
| } |
| activeRun.bodyEl.appendChild(wrap); |
| scrollToBottom(); |
| } |
|
|
| async function streamChat() { |
| setBusy(true, "thinking"); |
|
|
| const res = await fetch("/chat", { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| "X-Anthropic-Key": getApiKey(), |
| }, |
| body: JSON.stringify({ messages: history }), |
| }); |
| if (!res.ok || !res.body) { |
| appendMsg("system", `network error: ${res.status} ${res.statusText}`); |
| setBusy(false); |
| return; |
| } |
|
|
| const reader = res.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ""; |
|
|
| while (true) { |
| const { value, done } = await reader.read(); |
| if (done) break; |
| buffer += decoder.decode(value, { stream: true }); |
|
|
| let idx; |
| while ((idx = buffer.indexOf("\n\n")) >= 0) { |
| const chunk = buffer.slice(0, idx); |
| buffer = buffer.slice(idx + 2); |
| const evt = parseSSE(chunk); |
| if (evt) handleEvent(evt); |
| } |
| } |
|
|
| setBusy(false); |
| } |
|
|
| function parseSSE(chunk) { |
| const lines = chunk.split("\n"); |
| let event = "message"; |
| const dataLines = []; |
| for (const line of lines) { |
| if (line.startsWith("event:")) event = line.slice(6).trim(); |
| else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()); |
| } |
| if (!dataLines.length) return null; |
| try { |
| return { event, data: JSON.parse(dataLines.join("\n")) }; |
| } catch { |
| return { event, data: { raw: dataLines.join("\n") } }; |
| } |
| } |
|
|
| function handleEvent(evt) { |
| switch (evt.event) { |
| case "assistant_text": { |
| appendMsg("assistant", renderMarkdown(evt.data.text || ""), { label: "aprover" }); |
| history.push({ role: "assistant", content: evt.data.text || "" }); |
| break; |
| } |
| case "tool_call": |
| if (evt.data.name === "fetch_source") { |
| const url = (evt.data.input || {}).url || ""; |
| appendMsg("system", `→ fetching <code>${escapeHtml(url)}</code>`, { label: "tool" }); |
| setStatus("working", "fetching"); |
| } else if (evt.data.name === "run_aprover") { |
| appendRunCard(); |
| setStatus("working", "verifying"); |
| } |
| break; |
| case "tool_progress": { |
| const d = evt.data || {}; |
| if (d.type === "fetch_result") { |
| const note = d.ok |
| ? `← fetched ${d.bytes} bytes` |
| : `× fetch failed: ${escapeHtml(d.error || "")}`; |
| appendMsg("system", note, { label: "tool" }); |
| } else if (d.type === "started") { |
| if (activeRun) logRunLine("info", "pipeline started"); |
| } else if (d.type === "log") { |
| logRunLine(d.level, d.message); |
| } else if (d.type === "error") { |
| logRunLine("error", d.message); |
| } else if (d.type === "result") { |
| renderResult(d.result || {}); |
| setStatus("thinking", "summarizing"); |
| } |
| break; |
| } |
| case "error": |
| appendMsg("system", `server error: ${escapeHtml(evt.data.message || "unknown")}`); |
| setStatus("error", "error"); |
| break; |
| case "done": |
| break; |
| } |
| } |
|
|
| |
|
|
| function autoGrow() { |
| input.style.height = "auto"; |
| input.style.height = Math.min(input.scrollHeight, 220) + "px"; |
| } |
|
|
| input.addEventListener("input", autoGrow); |
|
|
| input.addEventListener("keydown", (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| form.requestSubmit(); |
| } |
| }); |
|
|
| form.addEventListener("submit", (e) => { |
| e.preventDefault(); |
| if (busy) return; |
| const text = input.value.trim(); |
| if (!text) return; |
| if (!getApiKey()) { |
| appendMsg("system", "Add your Anthropic API key below to run AProver — it stays in your browser."); |
| if (apiKeyInput) { |
| apiKeyInput.focus(); |
| apiKeyInput.classList.add("key-needed"); |
| setTimeout(() => apiKeyInput.classList.remove("key-needed"), 1500); |
| } |
| return; |
| } |
| appendMsg("user", renderMarkdown(text), { label: "you" }); |
| history.push({ role: "user", content: text }); |
| input.value = ""; |
| autoGrow(); |
| streamChat().catch((err) => { |
| appendMsg("system", `client error: ${escapeHtml(String(err))}`); |
| setBusy(false); |
| }); |
| }); |
|
|
| |
| |
| document.querySelectorAll(".chip").forEach((chip) => { |
| chip.addEventListener("click", () => { |
| const prompt = chip.getAttribute("data-prompt") || ""; |
| input.value = prompt; |
| autoGrow(); |
| input.focus(); |
| |
| if (!/[:?\s]$/.test(prompt)) { |
| form.requestSubmit(); |
| } |
| }); |
| }); |
|
|
| |
| |
| |
| |
|
|
| const DEMO_SOURCE = `#include <stdint.h> |
| int add(int a, int b) { |
| return a + b; |
| }`; |
|
|
| function startHeroDemo() { |
| const demoEl = document.getElementById("hero-demo"); |
| if (!demoEl) return; |
|
|
| const PHASES = ["spec", "bmc", "classify", "report"]; |
| const phaseEls = Array.from(demoEl.querySelectorAll(".demo-phase")); |
| const lineEls = Array.from(demoEl.querySelectorAll(".phase-line")); |
| const nowEl = demoEl.querySelector(".demo-now"); |
| const nowText = demoEl.querySelector(".demo-now-text"); |
| const resultEl = demoEl.querySelector(".demo-result"); |
| const caretEl = demoEl.querySelector(".demo-caret"); |
|
|
| |
| |
| |
| |
| const CARET_TOP_BASE = 56; |
| const LINE_H = 21.25; |
|
|
| const script = [ |
| { phase: "spec", text: "parsing source…", ms: 1100, line: 0 }, |
| { phase: "spec", text: "generating spec for add()", ms: 1500, line: 1 }, |
| { phase: "spec", text: "spec ready · int, int → bounded add", ms: 1000, line: 1, done: ["spec"] }, |
| { phase: "bmc", text: "running CBMC on add()", ms: 1500, line: 2 }, |
| { phase: "bmc", text: "counterexample · a = INT_MAX, b = 1", ms: 1700, line: 2, done: ["spec"] }, |
| { phase: "classify", text: "validating counterexample", ms: 1500, line: 2, done: ["spec","bmc"] }, |
| { phase: "classify", text: "real bug · signed integer overflow", ms: 1400, line: 2, done: ["spec","bmc"] }, |
| { phase: "report", text: "bug report written", ms: 2600, line: 2, done: ["spec","bmc","classify"], showResult: true, finished: true }, |
| { reset: true, ms: 1400 }, |
| ]; |
|
|
| function applyStep(step) { |
| if (step.reset) { |
| phaseEls.forEach((e) => e.classList.remove("active", "done")); |
| lineEls.forEach((e) => e.classList.remove("flowing", "done")); |
| resultEl.hidden = true; |
| nowEl.classList.remove("done"); |
| nowText.textContent = "ready · paste C to run for real"; |
| if (caretEl) caretEl.classList.remove("visible"); |
| return; |
| } |
| const order = PHASES.indexOf(step.phase); |
| const doneSet = new Set(step.done || []); |
| phaseEls.forEach((el) => { |
| const p = el.getAttribute("data-phase"); |
| el.classList.remove("active", "done"); |
| if (doneSet.has(p)) el.classList.add("done"); |
| else if (p === step.phase && !step.finished) el.classList.add("active"); |
| else if (p === step.phase && step.finished) el.classList.add("done"); |
| }); |
| lineEls.forEach((el, i) => { |
| el.classList.remove("flowing", "done"); |
| const prevPhase = PHASES[i]; |
| if (doneSet.has(prevPhase) && doneSet.has(PHASES[i + 1])) el.classList.add("done"); |
| else if (doneSet.has(prevPhase)) el.classList.add("flowing"); |
| }); |
| nowText.textContent = step.text; |
| nowEl.classList.toggle("done", !!step.finished); |
| resultEl.hidden = !step.showResult; |
| if (caretEl) { |
| if (typeof step.line === "number") { |
| caretEl.style.top = CARET_TOP_BASE + step.line * LINE_H + "px"; |
| caretEl.classList.add("visible"); |
| } else { |
| caretEl.classList.remove("visible"); |
| } |
| } |
| } |
|
|
| let idx = 0; |
| let timerId = null; |
| let paused = false; |
|
|
| function tick() { |
| if (!demoEl.isConnected) return; |
| if (document.getElementById("hero")?.classList.contains("hidden")) return; |
| if (paused) { |
| timerId = setTimeout(tick, 300); |
| return; |
| } |
| const step = script[idx]; |
| applyStep(step); |
| idx = (idx + 1) % script.length; |
| timerId = setTimeout(tick, step.ms); |
| } |
| tick(); |
|
|
| demoEl.addEventListener("mouseenter", () => { |
| paused = true; |
| demoEl.classList.add("paused"); |
| }); |
| demoEl.addEventListener("mouseleave", () => { |
| paused = false; |
| demoEl.classList.remove("paused"); |
| }); |
|
|
| function loadDemoIntoComposer() { |
| input.value = `verify this:\n\n\`\`\`c\n${DEMO_SOURCE}\n\`\`\``; |
| autoGrow(); |
| input.focus(); |
| } |
| demoEl.addEventListener("click", loadDemoIntoComposer); |
| demoEl.addEventListener("keydown", (e) => { |
| if (e.key === "Enter" || e.key === " ") { |
| e.preventDefault(); |
| loadDemoIntoComposer(); |
| } |
| }); |
| } |
|
|
| startHeroDemo(); |
|
|
| |
| setStatus("idle", "idle"); |
|
|