Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s

This commit is contained in:
snowspeeder committed 2026-09-06 18:43:33 -04:00
1 parent 1fd53091ac
commit 8c21750356
28 files changed
+3848 -384

No files matched your search

+246 -40
View File
@@ -1,55 +1,261 @@
// Chat-with-the-LLM drawer on the planner: stateless per page load, grounded server-side in
// the current plan + learned profiles. The whole visible conversation is resent each turn.
import { api } from "./api.js?v=__ASSET_VERSION__";
export function initPlanChat({ getPlan }) {
const thread = document.getElementById("chat-thread");
const form = document.getElementById("chat-form");
const input = document.getElementById("chat-input");
const send = document.getElementById("chat-send");
if (!thread || !form) return;
const messages = [];
function bubble(role, content, pending = false) {
const div = document.createElement("div");
div.className = `chat-msg ${role}${pending ? " pending" : ""}`;
div.textContent = content;
thread.append(div);
thread.scrollTop = thread.scrollHeight;
return div;
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);
}
bubble(
"assistant",
"Ask me anything about this plan — why a number is what it is, what to change for a different cup, or how your machine's history should shape it.",
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,
);
form.addEventListener("submit", async (event) => {
event.preventDefault();
const content = input.value.trim();
if (!content || send.disabled) return;
input.value = "";
messages.push({ role: "user", content });
bubble("user", content);
const pending = bubble("assistant", "Thinking…", true);
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 body = await api("/api/plan-chat", {
const result = await api("/api/plan-chat", {
method: "POST",
body: JSON.stringify({ plan: getPlan(), messages }),
body: JSON.stringify({ plan: getPlan(), messages: memory().messages }),
});
messages.push({ role: "assistant", content: body.reply });
pending.classList.remove("pending");
pending.textContent = body.reply;
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) {
messages.pop(); // keep history consistent with what's on screen
pending.classList.remove("pending");
pending.textContent =
error.code === "no_model"
? "No LLM model is configured on the server — ask an admin to set one on the Admin page."
: `That didn't work: ${error.message}`;
? "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;
thread.scrollTop = thread.scrollHeight;
}
});
}