262 lines
7.5 KiB
JavaScript
262 lines
7.5 KiB
JavaScript
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;
|
||
}
|
||
});
|
||
}
|