Files
roast_command_center/public/js/plan-chat-ui.js
T
2026-09-07 06:38:16 -04:00

275 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api, streamPlanChat } 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);
memory().suggestions = [];
showSuggestions([]);
recompute();
thread.setAttribute("aria-busy", "true");
let partial = "";
try {
const result = await streamPlanChat(
{ plan: getPlan(), messages: memory().messages },
(text) => {
const nearBottom = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80;
partial = text;
pending.textContent = text;
pending.classList.remove("pending");
if (nearBottom) thread.scrollTop = thread.scrollHeight;
},
);
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.classList.remove("pending");
pending.textContent = (partial ? partial + "\n\nResponse interrupted. " : "") +
(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 {
thread.setAttribute("aria-busy", "false");
busy = false;
send.disabled = false;
}
});
}