// Page logic: wire the Forge button, render the estimate, handle events.
import {
forgeEstimateStream,
resumeEstimateStream,
uploadImage,
recalc,
downloadPdf,
downloadJson,
sendEstimate,
translateEstimate,
chatAboutEstimate,
transcribeNote,
parseDocument,
modelInfo,
saveEstimate,
loadEstimate,
deleteEstimate,
createPairing,
pollPairing,
} from "./client.js";
import { resetTrace, addStep } from "./trace.js";
import { escapeHtml } from "./util.js";
const $ = (id) => document.getElementById(id);
const TAX_RATE = 0.13;
const JOB_TITLE = "AC Unit Repair — 123 Maple St";
// Run state, in one place: the label text + the live-dot color (data-state).
function setState(state, label) {
$("forge-state").textContent = label;
document.querySelector(".live").dataset.state = state;
}
// Photos picked for this job: server-side paths (after upload).
let imagePaths = [];
// Current estimate rows (editable). [{description, quantity, unit, rate, subtotal}]
let rows = [];
// English source descriptions, so language switches re-translate from English.
let sourceDescriptions = [];
// ADR-0013: the sanitized post-forge chat turns, and this estimate's id in the store
// (null until it has been forged/saved). Thread + id travel together on save/reopen.
let refinementThread = [];
let savedId = null;
// A rate change awaiting a scope answer ("this estimate"/"the catalog") from the next turn.
let pendingChange = null;
// Model mode from /api/model_info (stub | local | modal | mixed) + whether we've forged
// once this session — used to warn about the GPU cold-start on the first real forge.
let modelMode = "stub";
let firstForgeDone = false;
let warmupPending = false; // a "waking the models" card is showing, awaiting the first step
function readAsDataURL(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(file);
});
}
// --- Voice note: record via mic, transcribe (Cohere Transcribe), fill the note ---
let mediaRecorder = null;
let recordedChunks = [];
async function toggleRecording() {
const btn = $("mic-btn");
// Stop if already recording.
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
return;
}
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
btn.title = "Mic permission denied";
return;
}
recordedChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => e.data.size && recordedChunks.push(e.data);
mediaRecorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
btn.classList.remove("recording");
btn.innerHTML = 'hourglass_top';
const blob = new Blob(recordedChunks, { type: "audio/webm" });
const dataUrl = await readAsDataURL(blob);
const text = await transcribeNote(dataUrl, "note.webm");
if (text) $("transcript").value = text;
btn.innerHTML = 'mic';
};
mediaRecorder.start();
btn.classList.add("recording");
btn.innerHTML = 'stop';
}
async function onPhotos(e) {
const files = Array.from(e.target.files || []);
for (const file of files) {
const dataUrl = await readAsDataURL(file);
const img = document.createElement("img");
img.src = dataUrl;
$("thumbs").appendChild(img);
const path = await uploadImage(dataUrl, file.name);
imagePaths.push(path);
}
}
function money(n) {
return `$${Number(n).toFixed(2)}`;
}
// --- Document Capture (ADR-0011): a handed-over document -> Proposed Line Items ---
// The document is the *source*, but every price it carries is proposed, never a
// fact — the human confirms (or edits) each row before it enters the estimate.
async function onDocument(e) {
const file = (e.target.files || [])[0];
e.target.value = ""; // allow re-picking the same file
if (!file) return;
setState("working", "Reading document…");
const dataUrl = await readAsDataURL(file);
const out = await parseDocument(dataUrl, file.name);
if (rows.length) setState("done", "Done");
else setState("idle", "Idle");
const title = (out.observations[0] || {}).text || file.name;
addStep($("log"), {
action: "document",
model: out.model,
detail: `“${title}” — ${out.proposed_items.length} priced row(s) proposed`,
status: "ok",
});
showProposedItems(out.proposed_items);
}
// Render the Proposed-Line-Items confirm card on the Agent-Pause surface.
function showProposedItems(items) {
const card = $("pause");
if (!items.length) {
card.innerHTML = `
document_scanner
I couldn't find any priced rows in that document.
`;
} else {
card.innerHTML = `
document_scanner
I read ${items.length} priced row(s) off that document. Confirm what enters the estimate:
${items
.map(
(p, i) => `
`,
)
.join("")}
`;
}
card.style.display = "flex";
const hide = () => {
card.style.display = "none";
card.innerHTML = "";
};
$("doc-dismiss").addEventListener("click", hide);
const add = $("doc-add");
if (add)
add.addEventListener("click", () => {
card.querySelectorAll(".doc-item").forEach((row) => {
const check = row.querySelector("input[type=checkbox]");
if (!check.checked) return;
const p = items[Number(check.dataset.i)];
rows.push({
description: p.description,
quantity: parseFloat(row.querySelector(".doc-qty").value) || 1,
unit: p.unit,
rate: parseFloat(row.querySelector(".doc-rate").value) || 0,
subtotal: 0,
price_source: "document",
});
});
hide();
recalcFromRows();
setChatEnabled(rows.length > 0);
});
}
// Render the editable estimate from `rows`. qty/rate cells edit -> recalc.
// `animate` staggers the rows in (used when a fresh estimate lands, not on edits).
function renderRows(animate = false) {
const tbody = $("est-rows");
if (!rows.length) {
tbody.innerHTML =
'
`,
)
.join("");
}
// Pulse the rate cell of the row matching `description` — used when the apprentice
// applies a user-confirmed rate via chat, so the eye lands on what just changed.
function pulseRateCell(description) {
const i = rows.findIndex((r) => r.description === description);
if (i < 0) return;
const cell = document.querySelector(`#est-rows tr[data-i="${i}"] td[data-field="rate"]`);
if (!cell) return;
cell.classList.remove("cell-pulse");
void cell.offsetWidth; // restart the animation if it was already applied
cell.classList.add("cell-pulse");
}
// Flash the total when it changes (subtle "the number moved" cue).
let lastTotal = null;
function bumpTotal() {
const el = $("sum-total");
el.classList.remove("bump");
void el.offsetWidth; // restart the animation
el.classList.add("bump");
}
function renderTotals(est) {
$("sum-subtotal").textContent = money(est.subtotal);
$("sum-tax-rate").textContent = `${Math.round(est.tax_rate * 100)}%`;
$("sum-tax").textContent = money(est.tax);
$("sum-total").textContent = money(est.total);
if (lastTotal !== null && est.total !== lastTotal) bumpTotal();
lastTotal = est.total;
}
// Adopt a server estimate as the editable working copy.
// `animate` staggers the rows in (true when a forge/chat just produced it).
function setEstimate(est, animate = false) {
if (!est) {
rows = [];
lastTotal = null;
renderRows();
renderTotals({ subtotal: 0, tax_rate: 0, tax: 0, total: 0 });
setChatEnabled(false);
return;
}
rows = est.line_items.map((li) => ({ ...li }));
sourceDescriptions = rows.map((li) => li.description);
$("lang").value = "English";
renderRows(animate);
renderTotals(est);
setChatEnabled(rows.length > 0);
}
// Re-render the customer copy in the selected language (descriptions only).
async function onLanguageChange() {
const language = $("lang").value;
// translate from the English source each time, not from a prior translation
const src = rows.map((li, i) => ({
...li,
description: sourceDescriptions[i] ?? li.description,
}));
const est = await translateEstimate(src, JOB_TITLE, TAX_RATE, language);
rows = est.line_items.map((li) => ({ ...li }));
renderRows();
renderTotals(est);
}
// Recompute totals server-side after an edit (Facts-from-Tools).
async function recalcFromRows() {
const est = await recalc(rows, JOB_TITLE, TAX_RATE);
rows = est.line_items.map((li) => ({ ...li }));
renderRows();
renderTotals(est);
}
// Read an edited cell back into `rows`.
function onCellEdit(e) {
const td = e.target.closest("td[data-field]");
if (!td) return;
const tr = td.closest("tr");
const i = Number(tr.dataset.i);
const field = td.dataset.field;
let val = td.textContent.trim();
if (field === "quantity" || field === "rate") val = parseFloat(val.replace(/[^0-9.]/g, "")) || 0;
rows[i][field] = val;
recalcFromRows();
}
// The single event handler used by both the initial run and the resume.
function handleEvent(event) {
// The first real signal means the models are warm — mark the warm-up card done.
if (warmupPending && (event.type === "trace" || event.type === "estimate")) {
warmupPending = false;
addStep($("log"), {
action: "warmup",
model: "",
detail: "Models are warm.",
status: "ok",
});
setState("working", "Working…");
}
if (event.type === "trace") {
addStep($("log"), event.step);
} else if (event.type === "pause") {
showPause(event);
} else if (event.type === "estimate") {
setEstimate(event.estimate, true);
setState("done", "Done");
}
}
// Render the Agent-Pause question card; answering resumes the run.
function showPause(event) {
setState("needs-you", "Needs you");
const card = $("pause");
card.innerHTML = `
smart_toy
${event.reason}. What should I charge for it?
`;
card.style.display = "flex";
const input = $("pause-price");
input.focus();
const submit = async () => {
const value = parseFloat(input.value);
if (Number.isNaN(value)) return;
card.style.display = "none";
card.innerHTML = "";
setState("working", "Working…");
await withForgeLocked(() => resumeEstimateStream(value, handleEvent));
};
$("pause-submit").addEventListener("click", submit);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit();
});
}
// Hold the Forge button down while a run streams (no double-forge); always release.
async function withForgeLocked(run) {
$("forge-btn").disabled = true;
try {
await run();
} finally {
$("forge-btn").disabled = false;
}
}
async function forge() {
const transcript = $("transcript").value;
resetTrace($("log"));
setEstimate(null);
$("pause").style.display = "none";
setState("working", "Working…");
// First real forge pays a GPU cold-start (Modal apps scale to zero; the 30B brain can
// take a minute or two to wake). Say so honestly so a slow first run reads as warming,
// not hung. Stub mode is instant, so only warn when real models are actually in play.
if (!firstForgeDone && modelMode !== "stub") {
setState("working", "Warming models…");
warmupPending = true;
addStep($("log"), {
action: "warmup",
model: modelMode === "modal" || modelMode === "mixed" ? "Modal GPU" : "local models",
detail:
"First run wakes the models — this can take a minute or two on a cold start. " +
"It's working, not stuck.",
status: "active", // pulses until the first real step lands (then marked done)
});
}
firstForgeDone = true;
await withForgeLocked(() => forgeEstimateStream(transcript, "hvac", imagePaths, handleEvent));
}
function newEstimate() {
resetTrace($("log"));
setEstimate(null);
$("pause").style.display = "none";
setState("idle", "Idle");
$("transcript").value = "";
$("thumbs").innerHTML = "";
imagePaths = [];
chatStarted = false;
refinementThread = [];
savedId = null;
pendingChange = null;
$("transcript").focus();
}
// Discard the current draft: delete its saved row (if any), then clear the workspace.
async function discardDraft() {
if (savedId) {
try {
await deleteEstimate(savedId);
} catch {
/* best-effort */
}
}
newEstimate();
}
// --- Chat: refine the estimate, in the SAME stream as the trace ---
// The input docks at the bottom of the Apprentice pane and only enables once an
// estimate exists. The first chat turn drops a divider after the trace steps.
let chatStarted = false;
function setChatEnabled(on) {
$("chat-text").disabled = !on;
$("chat-send").disabled = !on;
$("chat-text").placeholder = on
? "Ask the apprentice to refine the estimate…"
: "Forge an estimate, then refine it here…";
}
function appendToStream(node) {
const log = $("log");
log.appendChild(node);
log.scrollTo({ top: log.scrollHeight, behavior: "smooth" });
}
function ensureChatStarted() {
if (chatStarted) return;
chatStarted = true;
const divider = document.createElement("div");
divider.className = "chat-divider";
divider.textContent = "Refine";
$("log").appendChild(divider);
}
function appendMsg(role, text) {
ensureChatStarted();
const el = document.createElement("div");
el.className = `chat-msg ${role}`;
el.innerHTML = `