feat(rag): steering notes — tune how Brain answers, stored in Postgres and injected into every system prompt
This commit is contained in:
+212
-1
@@ -28,6 +28,16 @@
|
||||
* "New chat" (#new-chat-btn) clears the key + the list back to the empty
|
||||
* state.
|
||||
*
|
||||
* Steering notes (phase 15) let the owner tune how Brain answers: a
|
||||
* "Tune" button under every completed brain bubble (deflected included)
|
||||
* opens an inline form → POST /api/steering → the note is stored in
|
||||
* Postgres and injected into the system prompt of every subsequent turn
|
||||
* (the <tuning> section). Notes are listed newest-first in the header
|
||||
* "Tuning" panel (#steering-panel), where each can be deleted. Note text
|
||||
* is always rendered with textContent (XSS-safe), save/delete are
|
||||
* announced through a polite live region (#steering-announcer), and the
|
||||
* panel + count badge update on every change.
|
||||
*
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
@@ -90,6 +100,203 @@ export function documentUrl(source, path, back = "/") {
|
||||
return url;
|
||||
}
|
||||
|
||||
/* ---------- steering notes (phase 15) ----------
|
||||
*
|
||||
* The owner's tuning notes steer every future answer: they live in
|
||||
* Postgres (stateless API, A10) and the chat turn reads them into the
|
||||
* system prompt. UI contract: Tune button → inline form → save →
|
||||
* confirmation (or inline error, form kept); the header panel lists the
|
||||
* notes (newest first) with per-note delete.
|
||||
*/
|
||||
const steeringToggle = document.querySelector("#steering-toggle");
|
||||
const steeringCount = document.querySelector("#steering-count");
|
||||
const steeringPanel = document.querySelector("#steering-panel");
|
||||
const steeringList = document.querySelector("#steering-list");
|
||||
const steeringEmpty = document.querySelector("#steering-empty");
|
||||
const steeringAnnouncer = document.querySelector("#steering-announcer");
|
||||
|
||||
const TUNE_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>';
|
||||
|
||||
let tuneSeq = 0; // unique ids for one open tune form's inputs
|
||||
|
||||
function announceSteering(message) {
|
||||
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
|
||||
}
|
||||
|
||||
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
|
||||
sources' .msg-meta row when it exists (role=list → the button joins as
|
||||
a listitem so ARIA stays valid); otherwise creates a plain meta row. */
|
||||
function appendTuneButton(wrap) {
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
let meta = body.querySelector(".msg-meta");
|
||||
if (!meta) {
|
||||
meta = document.createElement("div");
|
||||
meta.className = "msg-meta";
|
||||
body.appendChild(meta);
|
||||
}
|
||||
if (meta.querySelector(".tune-btn")) return; // one per bubble
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tune-btn";
|
||||
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
|
||||
btn.innerHTML = TUNE_ICON + "<span>Tune</span>";
|
||||
btn.addEventListener("click", () => openTuneForm(wrap, btn));
|
||||
meta.appendChild(btn);
|
||||
}
|
||||
|
||||
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
|
||||
+ Save / Cancel. Success replaces the form with the .tune-saved status
|
||||
(role=status); failure keeps the form and shows an inline error
|
||||
(role=alert) — the note is never lost on a failed save. */
|
||||
function openTuneForm(wrap, toggleBtn) {
|
||||
document.querySelectorAll(".tune-form").forEach((f) => f.remove()); // one at a time
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
tuneSeq += 1;
|
||||
const inputId = `tune-input-${tuneSeq}`;
|
||||
const form = document.createElement("form");
|
||||
form.className = "tune-form";
|
||||
form.noValidate = true;
|
||||
form.innerHTML =
|
||||
`<label for="${inputId}">Tuning note — how should Brain answer from now on?</label>` +
|
||||
`<textarea id="${inputId}" name="note" rows="2" maxlength="2000"
|
||||
placeholder="e.g. be more concise — or: assume I'm on NixOS"></textarea>`;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tune-form-actions";
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "submit";
|
||||
saveBtn.className = "tune-save";
|
||||
saveBtn.textContent = "Save";
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "tune-cancel";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
actions.append(saveBtn, cancelBtn);
|
||||
form.appendChild(actions);
|
||||
const status = document.createElement("p");
|
||||
status.className = "tune-error";
|
||||
status.setAttribute("role", "alert");
|
||||
status.hidden = true;
|
||||
form.appendChild(status);
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
saveBtn.disabled = true;
|
||||
status.hidden = true;
|
||||
try {
|
||||
const r = await fetch("/api/steering", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note: form.querySelector("textarea").value }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
let detail = "Could not save the note — try again.";
|
||||
try {
|
||||
const data = await r.json();
|
||||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||
detail = String(data.detail[0].msg);
|
||||
} else if (typeof data.detail === "string" && data.detail) {
|
||||
detail = data.detail;
|
||||
}
|
||||
} catch { /* non-JSON error body */ }
|
||||
status.textContent = detail;
|
||||
status.hidden = false;
|
||||
saveBtn.disabled = false;
|
||||
return; // form kept on failure — the instruction survives
|
||||
}
|
||||
const saved = document.createElement("p");
|
||||
saved.className = "tune-saved";
|
||||
saved.setAttribute("role", "status");
|
||||
saved.textContent = "Saved — future answers will follow this.";
|
||||
form.replaceWith(saved);
|
||||
announceSteering("Tuning note saved. Future answers will follow it.");
|
||||
await loadSteering(); // panel + count badge update
|
||||
} catch {
|
||||
status.textContent = "Could not save the note — is the app reachable?";
|
||||
status.hidden = false;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
form.remove();
|
||||
toggleBtn.focus();
|
||||
});
|
||||
body.appendChild(form);
|
||||
form.querySelector("textarea").focus();
|
||||
}
|
||||
|
||||
/* Panel: newest-first list (textContent — XSS-safe), per-note delete,
|
||||
empty text, and the header count badge. */
|
||||
async function loadSteering() {
|
||||
let notes = [];
|
||||
try {
|
||||
const r = await fetch("/api/steering");
|
||||
if (r.ok) notes = (await r.json()).notes || [];
|
||||
} catch { /* API unreachable: keep the last rendered list */ }
|
||||
renderSteeringPanel(notes);
|
||||
return notes;
|
||||
}
|
||||
|
||||
function renderSteeringPanel(notes) {
|
||||
if (!steeringList) return;
|
||||
steeringList.textContent = "";
|
||||
for (const n of notes) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "steering-note";
|
||||
const text = document.createElement("span");
|
||||
text.className = "steering-note-text";
|
||||
text.textContent = n.note; // rendered as text, never as HTML
|
||||
li.appendChild(text);
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "steering-delete";
|
||||
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
|
||||
del.innerHTML =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
||||
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
|
||||
li.appendChild(del);
|
||||
steeringList.appendChild(li);
|
||||
}
|
||||
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
|
||||
if (steeringCount) steeringCount.textContent = String(notes.length);
|
||||
}
|
||||
|
||||
async function deleteSteeringNote(id, btn) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (r.status === 404) {
|
||||
announceSteering("That note was already removed.");
|
||||
await loadSteering();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announceSteering("Could not delete the note — try again.");
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await loadSteering();
|
||||
announceSteering("Tuning note deleted.");
|
||||
} catch {
|
||||
announceSteering("Could not delete the note — is the app reachable?");
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setSteeringPanel(open) {
|
||||
if (!steeringPanel || !steeringToggle) return;
|
||||
steeringPanel.hidden = !open;
|
||||
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
if (steeringToggle && steeringPanel) {
|
||||
steeringToggle.addEventListener("click", () => {
|
||||
setSteeringPanel(steeringPanel.hidden);
|
||||
if (!steeringPanel.hidden) loadSteering(); // refresh when (re)opened
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
|
||||
* Inline SVG as string constants so the message renderer and the typing
|
||||
* indicator share exactly the same marks. currentColor lets the CSS theme
|
||||
@@ -426,6 +633,7 @@ function renderStoredMessage(m) {
|
||||
appendMaybeTry(wrap, m.suggestions);
|
||||
}
|
||||
appendSources(wrap, m.sources);
|
||||
appendTuneButton(wrap); // restored brain answers are tunable too
|
||||
}
|
||||
|
||||
/* On load: re-render the stored conversation (markdown, source chips,
|
||||
@@ -545,6 +753,7 @@ async function handleSend(e) {
|
||||
appendMaybeTry(wrap, ev.suggestions);
|
||||
}
|
||||
appendSources(wrap, ev.sources);
|
||||
appendTuneButton(wrap); // every completed brain bubble is tunable
|
||||
// Persistence save point 2: the answer lands only when the turn is
|
||||
// complete (raw text + the done metadata).
|
||||
rememberBrainTurn(acc, {
|
||||
@@ -558,7 +767,8 @@ async function handleSend(e) {
|
||||
});
|
||||
if (!aborted && !wrap) {
|
||||
const fallback = "Hmm — that came back empty. Ask me again?";
|
||||
addMessage("brain", fallback);
|
||||
const fwrap = addMessage("brain", fallback);
|
||||
appendTuneButton(fwrap);
|
||||
rememberBrainTurn(fallback, {}); // persist what the user actually saw
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -592,3 +802,4 @@ composer.addEventListener("submit", handleSend);
|
||||
restoreConversation(); // phase 14: the conversation comes back as left
|
||||
loadSuggestions();
|
||||
loadHealth();
|
||||
loadSteering(); // phase 15: tuning notes (panel + count badge)
|
||||
|
||||
Reference in New Issue
Block a user