Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s
Test and deploy / test-and-deploy (push) Successful in 1m21s
This commit is contained in:
1 parent
1fd53091ac
commit
8c21750356
28 files changed
+3848
-384
No files matched your search
+246
-40
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user