feat(rag): global tuning manager — /tuning.html + PUT /api/steering/{id}: create, edit, list, delete steering notes without a chat
This commit is contained in:
@@ -6,17 +6,18 @@
|
||||
*
|
||||
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
|
||||
* decided by /api/whoami at load);
|
||||
* • the "Sources" nav link (#nav-sources) — phase 19 UX revision
|
||||
* (owner permission 2026-08-23): hidden for anonymous on every page
|
||||
* that has a nav (chat, sources, login), revealed for admin. The
|
||||
* link SHIPS hidden in the HTML (anonymous-safe default — the
|
||||
* phase-16 "absent, not hidden" spirit), so no anonymous user ever
|
||||
* sees it for a frame;
|
||||
* • the admin-only nav links — "Sources" (#nav-sources) and "Tuning"
|
||||
* (#nav-tuning, on the Global Tuning page, phase 27) — phase 19 UX
|
||||
* revision (owner permission 2026-08-23): hidden for anonymous on
|
||||
* every page that has a nav (chat, sources, tuning, login),
|
||||
* revealed for admin. The links SHIP hidden in the HTML
|
||||
* (anonymous-safe default — the phase-16 "absent, not hidden"
|
||||
* spirit), so no anonymous user ever sees one for a frame;
|
||||
* • the sign-out click binding (POST /api/logout → reload) — moved
|
||||
* here from app.js so there is exactly one implementation;
|
||||
* • clearChatStorage() — the phase-14 conversation key, for the
|
||||
* New Chat buttons on the NON-CHAT pages (sources / document
|
||||
* viewer): a new chat means going to the chat, fresh.
|
||||
* viewer / tuning): a new chat means going to the chat, fresh.
|
||||
*
|
||||
* Every page loads this module (type="module", before its page script)
|
||||
* and its page script calls initSharedHeader() once at boot. init…
|
||||
@@ -63,6 +64,10 @@ export async function initSharedHeader() {
|
||||
if (signOut) signOut.hidden = !admin;
|
||||
const navSources = document.querySelector("#nav-sources");
|
||||
if (navSources) navSources.hidden = !admin;
|
||||
// Phase 27: the Global Tuning page's own nav link — admin-only, the
|
||||
// same ship-hidden / reveal-for-admin contract as the Sources link.
|
||||
const navTuning = document.querySelector("#nav-tuning");
|
||||
if (navTuning) navTuning.hidden = !admin;
|
||||
return admin;
|
||||
}
|
||||
|
||||
|
||||
@@ -714,6 +714,194 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.steering-delete:disabled { opacity: 0.5; cursor: wait; }
|
||||
.steering-empty { margin: 0.65rem 0 0; color: var(--ink-soft); font-size: 0.88rem; }
|
||||
|
||||
/* ---------- Global tuning page (phase 27) ---------- */
|
||||
/* /tuning.html: create / edit / delete steering notes without a chat
|
||||
conversation. Same width discipline as the chat column — a centered,
|
||||
capped column on the 72rem frame; the form and the note list span its
|
||||
FULL width (no skinny lists). Every interactive target is >=44px;
|
||||
text pairs reuse the Phase-08 AA palette (dark ink on brand 5.2:1,
|
||||
brand-ink/brand-soft 6.9:1, ok 10.6:1, err 9.1:1, ink-soft >=6.9:1).
|
||||
No filter: blur, no CDN, system font stack. */
|
||||
.tuning-shell {
|
||||
max-width: 46rem;
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Create form — the composer's surface as a vertical card: labeled
|
||||
textarea (visually-hidden label; the placeholder carries the visible
|
||||
hint) + the brand "Add note" button (dark ink on brand: 5.2:1 —
|
||||
never white on brand, 3.7:1, fails). */
|
||||
#tune-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
}
|
||||
#tune-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
||||
#tune-note {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0.2rem 0.1rem;
|
||||
resize: vertical;
|
||||
min-height: 4.6rem;
|
||||
}
|
||||
#tune-note::placeholder { color: var(--ink-soft); }
|
||||
#tune-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1.2rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
#tune-save:hover:not(:disabled) { background: #7d88f5; }
|
||||
#tune-save:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
/* Notes list — the phase-15 steering panel's language at full column
|
||||
width: rows are flex (text flexes + ellipsizes, actions shrink-0)
|
||||
with a bottom divider (the last row keeps none). */
|
||||
.tuning-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--brand-soft);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.9rem 1.1rem 1rem;
|
||||
}
|
||||
.tuning-panel-title { margin: 0; font-size: 1rem; font-weight: 700; color: var(--ink); }
|
||||
.tuning-list {
|
||||
list-style: none;
|
||||
margin: 0.65rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tuning-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tuning-note:last-child { border-bottom: 0; }
|
||||
/* The note text is the row's flex citizen: it takes every spare pixel
|
||||
and ellipsizes (the full text is one Edit away — the inline form). */
|
||||
.tuning-note-text {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* Row actions: icon + label, >=44px, shrink-0. Edit = brand pair on
|
||||
hover (brand-ink/brand-soft 6.9:1); Delete = err pair on hover
|
||||
(9.1:1), consistent with .steering-delete. */
|
||||
.tuning-edit,
|
||||
.tuning-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tuning-edit svg, .tuning-delete svg { width: 14px; height: 14px; display: block; }
|
||||
.tuning-edit:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand-soft); }
|
||||
.tuning-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.tuning-edit:disabled, .tuning-delete:disabled { opacity: 0.5; cursor: wait; }
|
||||
|
||||
/* Inline edit: the row swaps to a vertical card — the text span is
|
||||
hidden and replaced by the .tuning-edit-form (textarea pre-filled +
|
||||
Save/Cancel, reusing the phase-15 .tune-save/.tune-cancel styles);
|
||||
the row-level Edit/Delete buttons step aside for the form's own. */
|
||||
.tuning-note.is-editing {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
background: #0d1120;
|
||||
border: 1px solid var(--brand-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.6rem 0.7rem;
|
||||
}
|
||||
.tuning-note.is-editing .tuning-note-text,
|
||||
.tuning-note.is-editing .tuning-edit,
|
||||
.tuning-note.is-editing .tuning-delete { display: none; }
|
||||
.tuning-edit-form { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
|
||||
.tuning-edit-input {
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem 0.6rem;
|
||||
resize: vertical;
|
||||
min-height: 2.6rem;
|
||||
}
|
||||
.tuning-edit-input::placeholder { color: var(--ink-soft); }
|
||||
.tuning-edit-form-actions { display: flex; gap: 0.5rem; }
|
||||
|
||||
/* Per-action status pills (role=status / role=alert), the phase-15
|
||||
pairs: ok 10.6:1, err 9.1:1. */
|
||||
.tuning-saved {
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok-ink);
|
||||
border: 1px solid rgb(110 231 168 / 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tuning-error {
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#tune-empty {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* typing indicator */
|
||||
.typing { display: inline-flex; gap: 5px; padding: 0.9rem 1rem; }
|
||||
.typing span {
|
||||
@@ -1416,6 +1604,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
|
||||
.tune-btn { min-height: 44px; }
|
||||
/* Phase 27: the tuning page squeezes like the other cards — the row
|
||||
padding tightens; the icon+label actions keep their 44px floor and
|
||||
the note text ellipsizes (min-width: 0). */
|
||||
.tuning-note { gap: 0.4rem; padding: 0.3rem; }
|
||||
.tuning-edit, .tuning-delete { padding: 0.35rem 0.5rem; }
|
||||
.msg-body { max-width: 92%; }
|
||||
.empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; }
|
||||
.empty-state-title { font-size: 1.25rem; }
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/* Brain of Reese — Global Tuning page (phase 27, task 03).
|
||||
*
|
||||
* The standalone manager for steering notes: create / list / edit /
|
||||
* delete WITHOUT a chat conversation. This module is the single owner
|
||||
* of the page's behaviour:
|
||||
*
|
||||
* • loadNotes() — GET /api/steering → the newest-first note list
|
||||
* (#tune-list) + the empty state. A failed fetch (API down, or the
|
||||
* anonymous direct-URL 403) keeps the LAST RENDERED list —
|
||||
* progressive enhancement, never a blanked panel.
|
||||
* • create — #tune-form submit → POST /api/steering. 201 clears the
|
||||
* textarea, announces through the live region, and reloads the
|
||||
* list; any failure keeps the form (the instruction survives) and
|
||||
* shows the API detail inline under the button (role=alert).
|
||||
* #tune-save is disabled while the request is out.
|
||||
* • edit — a row's Edit button swaps the text for an inline
|
||||
* .tuning-edit-form: a prefilled textarea (maxlength 2000) +
|
||||
* Save / Cancel. Save → PUT /api/steering/{id}; 200 replaces the
|
||||
* form with the .tuning-saved status (role=status) and announces;
|
||||
* a failure keeps the form + an inline error; Cancel reverts to
|
||||
* the text span. The note id rides on the form (data attribute).
|
||||
* • delete — DELETE /api/steering/{id}. 204 removes the row
|
||||
* immediately (optimistic) and announces; 404 also drops the row
|
||||
* and reloads to resync; other failures re-enable the button and
|
||||
* announce a retry. The empty state is re-checked on every removal.
|
||||
* • announce(msg) — #tune-announcer (role=status, aria-live=polite),
|
||||
* the screen-reader confirmation for create / edit / delete.
|
||||
* • header boot (task 02) — initSharedHeader(): Sign in / Sign out,
|
||||
* the admin-only Sources link, and this page's own admin-only
|
||||
* "Tuning" nav link (#nav-tuning), all decided by the module's
|
||||
* cached whoami promise (exactly one /api/whoami request per
|
||||
* page); plus the non-chat New chat binding — "new chat" means
|
||||
* going to the chat, fresh (clear the phase-14 conversation key,
|
||||
* then navigate to "/"), the same contract as sources.js /
|
||||
* document.js.
|
||||
*
|
||||
* Anonymous-safe (task 03): the header already hides the "Tuning" nav
|
||||
* link for anonymous visitors; a DIRECT anonymous URL still gets a safe
|
||||
* page — loadNotes() only runs when the cached whoami says admin (the
|
||||
* Sources page gate pattern), the list stays on its empty state, and
|
||||
* the create form 403s gracefully on submit (the inline error carries
|
||||
* the API detail). Note text is always rendered with textContent —
|
||||
* never innerHTML (XSS-safe, like app.js's steering panel).
|
||||
*
|
||||
* The shared header module loads through this script's own relative
|
||||
* import ("./header.js") — a hoisted import evaluated before this body
|
||||
* runs (single-evaluation design: no direct <script> tag; esbuild
|
||||
* inlines it into the page bundle in the image build).
|
||||
*/
|
||||
|
||||
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
|
||||
/* ---------- page elements (tuning.html, task 02) ---------- */
|
||||
const tuneForm = document.querySelector("#tune-form");
|
||||
const tuneNote = document.querySelector("#tune-note");
|
||||
const tuneSave = document.querySelector("#tune-save");
|
||||
const tuneList = document.querySelector("#tune-list");
|
||||
const tuneEmpty = document.querySelector("#tune-empty");
|
||||
const tuneAnnouncer = document.querySelector("#tune-announcer");
|
||||
|
||||
/* The create form's inline error (role=alert) — created once, hidden
|
||||
by default, and kept between attempts: a failed POST keeps the form
|
||||
AND its message until the next submit. */
|
||||
const createError = document.createElement("p");
|
||||
createError.className = "tuning-error";
|
||||
createError.setAttribute("role", "alert");
|
||||
createError.hidden = true;
|
||||
if (tuneForm) tuneForm.appendChild(createError);
|
||||
|
||||
/* Polite live region: the screen-reader confirmation for create /
|
||||
edit / delete (task 03). */
|
||||
function announce(message) {
|
||||
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
|
||||
}
|
||||
|
||||
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
|
||||
carry their own labels), the same marks as app.js's steering panel. */
|
||||
const EDIT_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.2-4.2L16.7 4.3a2.1 2.1 0 0 1 3 3L8.2 18.8 4 20Z"/><path d="M14.7 6.3l3 3"/></svg>';
|
||||
const DELETE_ICON =
|
||||
'<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>';
|
||||
|
||||
/* FastAPI error bodies: a string detail or the validation-error array
|
||||
(the first entry's msg is the human line). Same extraction as app.js. */
|
||||
async function apiDetail(r, fallback) {
|
||||
try {
|
||||
const data = await r.json();
|
||||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||
return String(data.detail[0].msg);
|
||||
}
|
||||
if (typeof data.detail === "string" && data.detail) return data.detail;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/* ---------- load / render (newest first — the API's list order) ---------- */
|
||||
|
||||
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
|
||||
an anonymous direct-URL visit) keeps the last rendered list —
|
||||
progressive enhancement, never a blanked panel. */
|
||||
async function loadNotes() {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/steering");
|
||||
} catch {
|
||||
return; // API unreachable: keep the last rendered list
|
||||
}
|
||||
if (!r.ok) return; // e.g. anonymous 403: keep the last rendered list
|
||||
let notes;
|
||||
try {
|
||||
notes = (await r.json()).notes || [];
|
||||
} catch {
|
||||
return; // corrupt body: keep the last rendered list
|
||||
}
|
||||
renderNotes(notes);
|
||||
}
|
||||
|
||||
function renderNotes(notes) {
|
||||
if (!tuneList) return;
|
||||
tuneList.textContent = "";
|
||||
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
|
||||
syncEmptyState(notes.length);
|
||||
}
|
||||
|
||||
/* The empty state tracks the list's rendered rows (the HTML ships on
|
||||
the "No tuning notes yet" text; it hides as soon as one row shows). */
|
||||
function syncEmptyState(count) {
|
||||
if (!tuneEmpty || !tuneList) return;
|
||||
const rows = typeof count === "number" ? count : tuneList.children.length;
|
||||
tuneEmpty.hidden = rows > 0;
|
||||
}
|
||||
|
||||
/* One list row: the note text (textContent — XSS-safe, never
|
||||
innerHTML) + the Edit and Delete buttons. */
|
||||
function makeNoteRow(n) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "tuning-note";
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "tuning-note-text";
|
||||
text.textContent = n.note; // rendered as text, never as HTML
|
||||
li.appendChild(text);
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.type = "button";
|
||||
editBtn.className = "tuning-edit";
|
||||
editBtn.innerHTML = EDIT_ICON + "<span>Edit</span>";
|
||||
editBtn.addEventListener("click", () => openEditForm(li, n));
|
||||
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.type = "button";
|
||||
delBtn.className = "tuning-delete";
|
||||
delBtn.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
|
||||
delBtn.innerHTML = DELETE_ICON + "<span>Delete</span>";
|
||||
delBtn.addEventListener("click", () => deleteNote(n.id, delBtn, li));
|
||||
|
||||
li.append(editBtn, delBtn);
|
||||
return li;
|
||||
}
|
||||
|
||||
/* ---------- create (POST /api/steering) ---------- */
|
||||
|
||||
if (tuneForm) {
|
||||
tuneForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (tuneSave) tuneSave.disabled = true; // one note per click
|
||||
createError.hidden = true;
|
||||
try {
|
||||
const r = await fetch("/api/steering", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note: tuneNote ? tuneNote.value : "" }),
|
||||
});
|
||||
if (r.ok) {
|
||||
if (tuneNote) tuneNote.value = ""; // 201: the note is stored
|
||||
announce("Tuning note added. Future answers will follow it.");
|
||||
await loadNotes(); // the new note lands in the list, newest first
|
||||
} else {
|
||||
createError.textContent = await apiDetail(r, "Could not add the note — try again.");
|
||||
createError.hidden = false; // form kept — the instruction survives
|
||||
}
|
||||
} catch {
|
||||
createError.textContent = "Could not add the note — is the app reachable?";
|
||||
createError.hidden = false;
|
||||
} finally {
|
||||
if (tuneSave) tuneSave.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
|
||||
* The row swaps to the inline form — the is-editing class does the
|
||||
* visual swap (styles.css hides the text + the row buttons). One open
|
||||
* form page-wide: opening a new one reverts the others. Cancel reverts
|
||||
* to the text span; a failed save keeps the form + the inline error.
|
||||
*/
|
||||
let editSeq = 0; // unique ids for the edit forms' labeled textareas
|
||||
|
||||
function openEditForm(li, n) {
|
||||
if (li.classList.contains("is-editing")) return; // one per row
|
||||
// One open form page-wide: close any other row's first.
|
||||
document.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
|
||||
other.classList.remove("is-editing");
|
||||
other.querySelector(".tuning-edit-form")?.remove();
|
||||
});
|
||||
li.querySelector(".tuning-saved")?.remove(); // a stale "Saved" pill
|
||||
li.classList.add("is-editing");
|
||||
|
||||
editSeq += 1;
|
||||
const inputId = `tuning-edit-input-${editSeq}`;
|
||||
const form = document.createElement("form");
|
||||
form.className = "tuning-edit-form";
|
||||
form.dataset.noteId = n.id; // the note id rides on the form
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "visually-hidden";
|
||||
label.htmlFor = inputId;
|
||||
label.textContent = `Edit tuning note: ${n.note}`;
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.id = inputId;
|
||||
textarea.className = "tuning-edit-input";
|
||||
textarea.rows = 2;
|
||||
textarea.maxLength = 2000; // client-side 1–2000 contract (server re-validates)
|
||||
textarea.required = true;
|
||||
textarea.value = n.note; // prefilled with the current text
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tuning-edit-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);
|
||||
|
||||
const error = document.createElement("p");
|
||||
error.className = "tuning-error";
|
||||
error.setAttribute("role", "alert");
|
||||
error.hidden = true;
|
||||
|
||||
form.append(label, textarea, actions, error);
|
||||
form.addEventListener("submit", (e) => handleEditSave(e, li, form, textarea, saveBtn, error));
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
li.classList.remove("is-editing"); // revert to the text span
|
||||
form.remove();
|
||||
li.querySelector(".tuning-edit")?.focus();
|
||||
});
|
||||
|
||||
li.insertBefore(form, li.querySelector(".tuning-edit"));
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
|
||||
e.preventDefault();
|
||||
saveBtn.disabled = true;
|
||||
error.hidden = true;
|
||||
const id = form.dataset.noteId;
|
||||
try {
|
||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note: textarea.value }),
|
||||
});
|
||||
if (r.ok) {
|
||||
let saved = textarea.value.trim();
|
||||
try {
|
||||
saved = (await r.json()).note ?? saved;
|
||||
} catch {
|
||||
/* keep the trimmed local text */
|
||||
}
|
||||
const textEl = li.querySelector(".tuning-note-text");
|
||||
if (textEl) textEl.textContent = saved; // the list shows the stored text
|
||||
const savedPill = document.createElement("p");
|
||||
savedPill.className = "tuning-saved";
|
||||
savedPill.setAttribute("role", "status");
|
||||
savedPill.textContent = "Saved";
|
||||
form.replaceWith(savedPill);
|
||||
li.classList.remove("is-editing"); // updated text + row buttons come back
|
||||
announce("Tuning note updated.");
|
||||
return;
|
||||
}
|
||||
error.textContent = await apiDetail(r, "Could not update the note — try again.");
|
||||
error.hidden = false; // form kept — the edit survives the failure
|
||||
saveBtn.disabled = false;
|
||||
} catch {
|
||||
error.textContent = "Could not update the note — is the app reachable?";
|
||||
error.hidden = false;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
|
||||
* The row leaves the DOM the moment the server agrees (204); a 404
|
||||
* (already gone) also drops the row and reloads to resync; any other
|
||||
* failure re-enables the button and says to retry. */
|
||||
async function deleteNote(id, btn, li) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (r.status === 404) {
|
||||
li.remove(); // already gone on the server — drop it and resync
|
||||
syncEmptyState();
|
||||
announce("That note was already removed.");
|
||||
await loadNotes();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce("Could not delete the note — try again.");
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
li.remove(); // 204: the server confirmed — the row goes now
|
||||
syncEmptyState();
|
||||
announce("Tuning note deleted.");
|
||||
} catch {
|
||||
announce("Could not delete the note — is the app reachable?");
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- non-chat New chat + header boot (task 02) ---------- */
|
||||
|
||||
/* Phase 19 contract: New chat on a non-chat page means "go to the
|
||||
* chat, fresh": clear the phase-14 conversation key, then land on the
|
||||
* chat page — its empty state, since the conversation is gone from
|
||||
* storage (the same contract as sources.js / document.js). */
|
||||
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||
if (newChatBtn) {
|
||||
newChatBtn.addEventListener("click", () => {
|
||||
clearChatStorage();
|
||||
window.location.href = "/";
|
||||
});
|
||||
}
|
||||
|
||||
/* Boot: the shared header FIRST (Sign in/out + the admin-only nav
|
||||
links — one cached whoami), then the note list — admin data only
|
||||
(the Sources page gate pattern): an anonymous visitor gets the page
|
||||
frame with the empty state, and the create form 403s gracefully on
|
||||
submit if one tries. */
|
||||
(async () => {
|
||||
await initSharedHeader(); // phase 19: whoami + Sign in/out + nav links
|
||||
if (await fetchIsAdmin()) loadNotes(); // phase 27: the list is admin-only
|
||||
})();
|
||||
@@ -0,0 +1,116 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Manage the global tuning notes that steer every Brain of Reese answer.">
|
||||
<title>Global Tuning · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19: the Sources link is admin-only (owner permission
|
||||
2026-08-23) — hidden by default, header.js reveals it once
|
||||
whoami says admin. The soft-gated page itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 27: this page's own nav link. Admin-only, exactly like
|
||||
the Sources link: it SHIPS hidden (anonymous-safe default —
|
||||
the phase-16 "absent, not hidden" spirit) and header.js
|
||||
reveals it once whoami says admin, so no anonymous user ever
|
||||
sees it for a frame. -->
|
||||
<a href="/tuning.html" class="nav-link is-active" id="nav-tuning" aria-current="page" hidden>Tuning</a>
|
||||
</nav>
|
||||
<!-- Phase 19: the shared header controls reach the tuning page —
|
||||
same markup, ids, and aria as the other pages (one consistent
|
||||
bar on every page). header.js (assets/header.js) reveals
|
||||
exactly one of Sign in / Sign out after whoami; the New chat
|
||||
button here means "go to the chat, fresh" (tuning.js). -->
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign out
|
||||
is visible; /api/whoami decides at load (header.js). Icon-only
|
||||
below 640px (aria-labels keep the accessible names). -->
|
||||
<a href="/login.html?next=/tuning.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<div class="container tuning-shell">
|
||||
<div class="page-head">
|
||||
<h1>Global Tuning</h1>
|
||||
<p class="page-sub">
|
||||
Every note below is read into the system prompt of
|
||||
<strong>every</strong> chat turn. Add, edit, or remove them here —
|
||||
no conversation required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Phase 27: create a note without a chat. The label is
|
||||
visually-hidden (the heading + placeholder carry the visible
|
||||
context); the 1–2000-char contract mirrors the chat-page tune
|
||||
form — the server re-validates (422). -->
|
||||
<form id="tune-form">
|
||||
<label class="visually-hidden" for="tune-note">Add a global tuning note</label>
|
||||
<textarea
|
||||
id="tune-note"
|
||||
name="note"
|
||||
rows="3"
|
||||
maxlength="2000"
|
||||
placeholder="e.g. be more concise — or: assume I'm on NixOS"
|
||||
required
|
||||
></textarea>
|
||||
<button type="submit" id="tune-save">Add note</button>
|
||||
</form>
|
||||
|
||||
<!-- Live announcer for create / edit / delete — tuning.js (phase 27,
|
||||
task 03) owns the message text. -->
|
||||
<p class="visually-hidden" id="tune-announcer" role="status" aria-live="polite"></p>
|
||||
|
||||
<!-- Phase 27: the note list — the phase-15 steering panel's
|
||||
language, full column width. tuning.js fills it newest-first;
|
||||
each row is an <li class="tuning-note"> with a
|
||||
.tuning-note-text span + an Edit and a Delete button (styles:
|
||||
styles.css "Global tuning page"). The empty state toggles with
|
||||
the list. -->
|
||||
<section class="tuning-panel" aria-labelledby="tuning-panel-title">
|
||||
<h2 id="tuning-panel-title" class="tuning-panel-title">Tuning notes</h2>
|
||||
<ul id="tune-list" class="tuning-list" role="list"></ul>
|
||||
<p id="tune-empty">No tuning notes yet — add one above.</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span>Powered by Reese's self-hosted models</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 27: markdown.js (the classic global renderMarkdown) loads
|
||||
BEFORE the module script; the shared header module loads through
|
||||
tuning.js's own `import "./header.js"` — a hoisted import that is
|
||||
evaluated before the page script body calls initSharedHeader() at
|
||||
boot. No direct header.js <script> tag (single-evaluation design). -->
|
||||
<script src="assets/markdown.js"></script>
|
||||
<script type="module" src="/assets/tuning.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user