import { api } from "./api.js?v=__ASSET_VERSION__"; import { SUGGESTION_FIELDS, cleanSuggestions, } from "/shared/workspace.js?v=__ASSET_VERSION__"; import { computeLedger } from "/shared/ledger.js?v=__ASSET_VERSION__"; import { formatDuration } from "/shared/time.js?v=__ASSET_VERSION__"; const n = (tag, text, cls = "") => { const e = document.createElement(tag); e.textContent = text; e.className = cls; return e; }; export function initPlanChat({ getPlan, recompute, renderFormFromPlan, flushCurrentPlan, getRemotePlanId, }) { const thread = document.getElementById("chat-thread"), form = document.getElementById("chat-form"), input = document.getElementById("chat-input"), send = document.getElementById("chat-send"); let busy = false; const memory = () => getPlan().assistant ?? (getPlan().assistant = { messages: [] }); memory().messages ??= []; const starters = n("div", "", "chat-starters"); for (const text of [ "Review this plan", "Aim for more fruit", "Explain these timings", "Suggest one experiment using my tasting history", ]) { const b = n("button", text, "ghost-btn small"); b.type = "button"; b.onclick = () => { input.value = text; form.requestSubmit(); }; starters.append(b); } thread.before( n( "p", "Your plan, your tasting history, and one useful next experiment. Suggestions never change your plan until you apply them.", "bro-intro", ), starters, ); const suggestions = n("div", "", "bro-suggestions"); thread.after(suggestions); const draw = () => { thread.replaceChildren(); for (const m of memory().messages) thread.append(n("div", m.content, "chat-msg " + m.role)); thread.scrollTop = thread.scrollHeight; }; draw(); const showSuggestions = (raw) => { suggestions.replaceChildren(); for (const proposal of cleanSuggestions(raw)) { const panel = n("section", "", "proposal-card"); panel.append( n("h3", "A change to review"), n("p", proposal.reason), n( "p", "Expected: " + (proposal.expected || "Taste and record whether this helps."), ), ); const table = n("table", "", "data-table"); const head = n("tr"); for (const label of ["Setting", "Current", "Suggested"]) head.append(n("th", label)); table.append(head); const before = structuredClone( memory().suggestionBase || getPlan().fields, ); for (const [key, value] of Object.entries(proposal.fields)) { const row = n("tr"); row.append( n("td", SUGGESTION_FIELDS[key]), n("td", before[key] || "—"), n("td", value), ); table.append(row); } panel.append(table); const candidate = { ...getPlan(), fields: { ...getPlan().fields, ...proposal.fields }, }; const l = computeLedger(candidate); panel.append( n( "p", `Preview: first crack ${formatDuration(l.A)}, development ${formatDuration(l.C)}, total ${formatDuration(l.D)}.`, "field-note", ), ); for (const warning of l.warnings || []) panel.append( n( "p", typeof warning === "string" ? warning : JSON.stringify(warning), "field-note", ), ); for (const id of proposal.evidenceRoastIds) { const a = n("a", "View supporting roast"); a.href = "/roasts?roast=" + encodeURIComponent(id); panel.append(a); } const apply = n("button", "Apply changes", "primary-btn"), variation = n("button", "Create a variation", "ghost-btn"), undo = n("button", "Undo changes", "ghost-btn"); undo.hidden = true; const stale = () => Object.keys(before).some((k) => getPlan().fields[k] !== before[k]); apply.onclick = () => { if (stale()) { panel.append( n( "p", "The plan changed since this suggestion. Ask Bean Bro to review it again.", ), ); return; } memory().undo = { fields: structuredClone(getPlan().fields), experiment: structuredClone(getPlan().experiment || {}), }; Object.assign(getPlan().fields, proposal.fields); getPlan().experiment = { ...getPlan().experiment, change: proposal.reason, expected: proposal.expected, }; renderFormFromPlan(); recompute(); apply.disabled = true; undo.hidden = false; }; undo.onclick = () => { const previous = memory().undo; if (!previous) return; if ( Object.entries(proposal.fields).some( ([k, v]) => getPlan().fields[k] !== v, ) ) { panel.append( n( "p", "These settings have changed again. Use Plan history to review earlier versions.", ), ); return; } for (const k of Object.keys(proposal.fields)) getPlan().fields[k] = previous.fields[k]; getPlan().experiment = previous.experiment; delete memory().undo; renderFormFromPlan(); recompute(); showSuggestions([]); }; variation.onclick = async () => { if (stale()) { panel.append( n( "p", "The plan changed. Request a fresh suggestion before creating a variation.", ), ); return; } if (!(await flushCurrentPlan())) return panel.append( n("p", "Save the current plan before creating a variation."), ); variation.disabled = true; try { const copy = ( await api(`/api/plans/${getRemotePlanId()}/clone`, { method: "POST", }) ).plan; Object.assign(copy.plan.fields, proposal.fields); copy.plan.experiment = { ...copy.plan.experiment, question: "Test Bean Bro’s suggestion", change: proposal.reason, expected: proposal.expected, }; await api(`/api/plans/${copy.id}`, { method: "PUT", body: JSON.stringify({ plan: copy.plan }), }); location.assign("/app?plan=" + copy.id); } catch (e) { panel.append(n("p", e.message)); variation.disabled = false; } }; panel.append(apply, variation, undo); suggestions.append(panel); } }; showSuggestions(memory().suggestions || []); if(new URLSearchParams(location.search).get("intent")==="next-experiment") input.value="Use my tasting history to suggest one experiment for the next batch. Explain the expected cup result and how to evaluate it."; form.addEventListener("submit", async (e) => { e.preventDefault(); const text = input.value.trim(); if (!text || busy) return; busy = true; send.disabled = true; input.value = ""; const messages = memory().messages; messages.push({ role: "user", content: text.slice(0, 4000) }); memory().messages = messages.slice(-30); draw(); recompute(); const pending = n( "div", "Bean Bro is reviewing your plan…", "chat-msg assistant pending", ); thread.append(pending); const sentFields = structuredClone(getPlan().fields); try { const result = await api("/api/plan-chat", { method: "POST", body: JSON.stringify({ plan: getPlan(), messages: memory().messages }), }); memory().messages.push({ role: "assistant", content: result.reply }); memory().messages = memory().messages.slice(-30); memory().suggestionBase = sentFields; memory().suggestions = cleanSuggestions(result.suggestions); memory().model = result.model; draw(); showSuggestions(memory().suggestions); recompute(); } catch (error) { pending.textContent = error.code === "no_model" ? "Bean Bro is not connected. An admin can configure the provider in Admin → Bean Bro." : `Could not get a reply. ${error.message}`; memory().messages.pop(); input.value = text; recompute(); } finally { busy = false; send.disabled = false; } }); }