Spaces:
Running
Running
| /* LifeOS frontend — vanilla JS, raw fetch to Gradio Server SSE API. No deps. */ | |
| ; | |
| const $ = (sel) => document.querySelector(sel); | |
| const $$ = (sel) => Array.from(document.querySelectorAll(sel)); | |
| /* ---------------- status indicator ---------------- */ | |
| let busyCount = 0; | |
| let modelState = "loading"; // loading | ready | error (from /status) | |
| function renderStatus() { | |
| const dot = $("#status-dot"); | |
| const text = $("#status-text"); | |
| if (!dot || !text) return; | |
| const thinking = busyCount > 0; | |
| dot.classList.toggle("thinking", thinking); | |
| dot.classList.toggle("error", modelState === "error" && !thinking); | |
| if (thinking) text.textContent = "model: thinking"; | |
| else if (modelState === "loading") text.textContent = "model: loading…"; | |
| else if (modelState === "error") text.textContent = "model: unavailable"; | |
| else text.textContent = "model: ready"; | |
| } | |
| function setThinking(on) { | |
| busyCount += on ? 1 : -1; | |
| if (busyCount < 0) busyCount = 0; | |
| renderStatus(); | |
| } | |
| /* Poll /status until the model is ready (first run also downloads it). */ | |
| async function pollModelStatus() { | |
| try { | |
| const res = await fetch("/status"); | |
| if (res.ok) { | |
| const s = await res.json(); | |
| modelState = s.state === "ready" ? "ready" : s.state === "error" ? "error" : "loading"; | |
| renderStatus(); | |
| if (modelState === "ready" || modelState === "error") return; | |
| } | |
| } catch { /* server not up yet — keep polling */ } | |
| setTimeout(pollModelStatus, 2000); | |
| } | |
| /* ---------------- toasts ---------------- */ | |
| function toast(msg, ok = false) { | |
| const el = document.createElement("div"); | |
| el.className = "toast" + (ok ? " ok" : ""); | |
| el.textContent = msg; | |
| $("#toast-stack").appendChild(el); | |
| setTimeout(() => el.remove(), 4200); | |
| } | |
| /* ---------------- button busy state ---------------- */ | |
| function setBusy(btn, busy) { | |
| btn.disabled = busy; | |
| if (busy) { | |
| const sp = document.createElement("span"); | |
| sp.className = "spinner"; | |
| btn.prepend(sp); | |
| } else { | |
| const sp = btn.querySelector(".spinner"); | |
| if (sp) sp.remove(); | |
| } | |
| } | |
| /* ---------------- Gradio SSE helper ---------------- | |
| POST /gradio_api/call/<name> {data:[...]} -> {event_id} | |
| GET /gradio_api/call/<name>/<event_id> -> SSE stream | |
| onUpdate(cumulativeText) called on each "generating"/"complete" event. | |
| Resolves with the final output[0]. Throws Error with .status on HTTP fail. */ | |
| async function callGradio(name, args, onUpdate) { | |
| const post = await fetch(`/gradio_api/call/${name}`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ data: args }), | |
| }); | |
| if (!post.ok) { | |
| const err = new Error(`${name}: HTTP ${post.status}`); | |
| err.status = post.status; | |
| throw err; | |
| } | |
| const { event_id } = await post.json(); | |
| const res = await fetch(`/gradio_api/call/${name}/${event_id}`); | |
| if (!res.ok || !res.body) throw new Error(`${name}: stream HTTP ${res.status}`); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buf = "", eventType = "", final = null; | |
| for (;;) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buf += decoder.decode(value, { stream: true }); | |
| let nl; | |
| while ((nl = buf.indexOf("\n")) >= 0) { | |
| const line = buf.slice(0, nl).replace(/\r$/, ""); | |
| buf = buf.slice(nl + 1); | |
| if (line.startsWith("event:")) { | |
| eventType = line.slice(6).trim(); | |
| } else if (line.startsWith("data:")) { | |
| const payload = line.slice(5).trim(); | |
| if (!payload || payload === "null") continue; | |
| if (eventType === "error") throw new Error(`${name}: ${payload}`); | |
| let arr; | |
| try { arr = JSON.parse(payload); } catch { continue; } | |
| if (!Array.isArray(arr)) continue; | |
| if (eventType === "generating" || eventType === "complete") { | |
| final = arr[0]; | |
| if (onUpdate) onUpdate(arr[0]); | |
| } | |
| if (eventType === "complete") return final; | |
| } | |
| } | |
| } | |
| if (final === null) throw new Error(`${name}: stream ended without output`); | |
| return final; | |
| } | |
| /* Friendly handling of endpoints the backend may not ship yet. */ | |
| function isMissing(e) { return e && (e.status === 404 || e.status === 422 || /HTTP 404/.test(e.message || "")); } | |
| function featureUnavailable(el, label) { | |
| if (el) el.innerHTML = `<div class="feature-banner">${escapeHtml(label)} isn't available on this backend build yet — try again after the next update.</div>`; | |
| else toast(`${label}: not available on this backend yet`); | |
| } | |
| /* ---------------- tiny markdown renderer (XSS-safe) ---------------- */ | |
| function escapeHtml(s) { | |
| return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") | |
| .replace(/"/g, """).replace(/'/g, "'"); | |
| } | |
| function mdInline(s) { | |
| return s | |
| .replace(/`([^`]+)`/g, "<code>$1</code>") | |
| .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") | |
| .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>"); | |
| } | |
| function renderMarkdown(raw) { | |
| const lines = escapeHtml(raw).split("\n"); | |
| let html = "", i = 0, list = null, para = []; | |
| const flushPara = () => { if (para.length) { html += `<p>${mdInline(para.join(" "))}</p>`; para = []; } }; | |
| const closeList = () => { if (list) { html += `</${list}>`; list = null; } }; | |
| while (i < lines.length) { | |
| const line = lines[i]; | |
| const t = line.trim(); | |
| // fenced code block | |
| if (t.startsWith("```")) { | |
| flushPara(); closeList(); | |
| const code = []; | |
| i++; | |
| while (i < lines.length && !lines[i].trim().startsWith("```")) { code.push(lines[i]); i++; } | |
| html += `<pre class="codeblock"><code>${code.join("\n")}</code></pre>`; | |
| i++; // skip closing fence | |
| continue; | |
| } | |
| // table: header row + separator row | |
| if (t.startsWith("|") && i + 1 < lines.length && /^\|?[\s:|-]+\|?$/.test(lines[i + 1].trim()) && lines[i + 1].includes("-")) { | |
| flushPara(); closeList(); | |
| const cells = (r) => r.trim().replace(/^\||\|$/g, "").split("|").map((c) => mdInline(c.trim())); | |
| html += "<table><thead><tr>" + cells(t).map((c) => `<th>${c}</th>`).join("") + "</tr></thead><tbody>"; | |
| i += 2; | |
| while (i < lines.length && lines[i].trim().startsWith("|")) { | |
| html += "<tr>" + cells(lines[i]).map((c) => `<td>${c}</td>`).join("") + "</tr>"; | |
| i++; | |
| } | |
| html += "</tbody></table>"; | |
| continue; | |
| } | |
| let m; | |
| if ((m = t.match(/^>\s?(.*)/))) { | |
| flushPara(); closeList(); | |
| const quote = [m[1]]; | |
| while (i + 1 < lines.length && (m = lines[i + 1].trim().match(/^>\s?(.*)/))) { quote.push(m[1]); i++; } | |
| html += `<blockquote>${mdInline(quote.join(" "))}</blockquote>`; | |
| } else if ((m = t.match(/^(#{1,3})\s+(.*)/))) { | |
| flushPara(); closeList(); | |
| html += `<h${m[1].length}>${mdInline(m[2])}</h${m[1].length}>`; | |
| } else if ((m = t.match(/^\*\*([^*]+?)\*\*:?\s*$/))) { | |
| // A line that's only a bold span (e.g. "**Identified**") is a section | |
| // title — the model's usual way of heading sections. Render as a heading. | |
| flushPara(); closeList(); | |
| html += `<h4 class="md-section">${mdInline(m[1])}</h4>`; | |
| } else if ((m = t.match(/^[-*]\s+(.*)/))) { | |
| flushPara(); | |
| if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; } | |
| html += `<li>${mdInline(m[1])}</li>`; | |
| } else if ((m = t.match(/^\d+[.)]\s+(.*)/))) { | |
| flushPara(); | |
| if (list !== "ol") { closeList(); html += "<ol>"; list = "ol"; } | |
| html += `<li>${mdInline(m[1])}</li>`; | |
| } else if (/^([-_*])\s*\1\s*\1[\s\-_*]*$/.test(t)) { | |
| flushPara(); closeList(); | |
| html += "<hr>"; | |
| } else if (t === "") { | |
| flushPara(); closeList(); | |
| } else { | |
| closeList(); | |
| para.push(t); | |
| } | |
| i++; | |
| } | |
| flushPara(); closeList(); | |
| return html; | |
| } | |
| /* Strip <think> + markdown syntax for speech. */ | |
| function stripForSpeech(raw) { | |
| let text = raw.replace(/<think>[\s\S]*?(<\/think>|$)/g, ""); | |
| return text | |
| .replace(/```[\s\S]*?```/g, "") | |
| .replace(/`([^`]+)`/g, "$1") | |
| .replace(/\*\*?([^*]+)\*\*?/g, "$1") | |
| .replace(/^#{1,6}\s+/gm, "") | |
| .replace(/^\s*[-*]\s+/gm, "") | |
| .replace(/\|/g, " ") | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| } | |
| /* Handle <think>...</think> before MD: fold into a collapsed details block. */ | |
| function renderResponse(raw) { | |
| let html = ""; | |
| let text = raw; | |
| const open = text.indexOf("<think>"); | |
| if (open >= 0) { | |
| const close = text.indexOf("</think>"); | |
| const inner = close >= 0 ? text.slice(open + 7, close) : text.slice(open + 7); | |
| text = close >= 0 ? text.slice(0, open) + text.slice(close + 8) : text.slice(0, open); | |
| html += `<details><summary>reasoning</summary><p>${escapeHtml(inner.trim()).replace(/\n/g, "<br>")}</p></details>`; | |
| } | |
| return html + renderMarkdown(text.trim()); | |
| } | |
| /* Stream a generator endpoint into a result element. */ | |
| async function streamInto(name, args, el, btn, label) { | |
| setBusy(btn, true); setThinking(true); | |
| el.classList.add("streaming"); | |
| el.innerHTML = "<p class='empty-hint'>thinking...</p>"; | |
| try { | |
| await callGradio(name, args, (text) => { | |
| if (typeof text === "string") el.innerHTML = renderResponse(text); | |
| }); | |
| } catch (e) { | |
| if (isMissing(e)) featureUnavailable(el, label || name); | |
| else { | |
| toast(`error: ${e.message}`); | |
| el.innerHTML = "<p class='empty-hint'>something went wrong — is the backend running?</p>"; | |
| } | |
| } finally { | |
| el.classList.remove("streaming"); | |
| setBusy(btn, false); setThinking(false); | |
| } | |
| } | |
| /* ---------------- sidebar navigation ---------------- */ | |
| function navTo(name) { | |
| $$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.nav === name)); | |
| $$(".panel").forEach((p) => p.classList.toggle("active", p.id === `panel-${name}`)); | |
| if (name === "memory") { renderMemRows(); loadNotes(); } | |
| } | |
| document.querySelector(".sidebar").addEventListener("click", (e) => { | |
| const item = e.target.closest(".nav-item"); | |
| if (item) navTo(item.dataset.nav); | |
| }); | |
| $("#money-to-goals").addEventListener("click", () => navTo("goals")); | |
| /* ================= KITCHEN ================= */ | |
| let deals = []; | |
| function renderDeals() { | |
| const wrap = $("#deal-chips"); | |
| if (!deals.length) { | |
| wrap.innerHTML = "<p class='empty-hint'>no deals yet — upload a flyer or paste its text</p>"; | |
| } else { | |
| wrap.innerHTML = deals.map((d) => | |
| `<span class="deal-chip">${escapeHtml(d.item)} <b>${escapeHtml(d.price_text)}</b></span>`).join(""); | |
| } | |
| $("#recipe-btn").disabled = !deals.length; | |
| } | |
| async function uploadFile(url, file) { | |
| const fd = new FormData(); | |
| fd.append("file", file); | |
| const res = await fetch(url, { method: "POST", body: fd }); | |
| if (!res.ok) { | |
| const err = new Error(`upload failed: HTTP ${res.status}`); | |
| err.status = res.status; | |
| throw err; | |
| } | |
| return res.json(); | |
| } | |
| function wireDropzone(dropEl, inputEl, handler) { | |
| dropEl.addEventListener("click", () => inputEl.click()); | |
| inputEl.addEventListener("change", () => { if (inputEl.files[0]) handler(inputEl.files[0]); inputEl.value = ""; }); | |
| dropEl.addEventListener("dragover", (e) => { e.preventDefault(); dropEl.classList.add("dragover"); }); | |
| dropEl.addEventListener("dragleave", () => dropEl.classList.remove("dragover")); | |
| dropEl.addEventListener("drop", (e) => { | |
| e.preventDefault(); dropEl.classList.remove("dragover"); | |
| if (e.dataTransfer.files[0]) handler(e.dataTransfer.files[0]); | |
| }); | |
| } | |
| wireDropzone($("#flyer-drop"), $("#flyer-file"), async (file) => { | |
| setThinking(true); | |
| try { | |
| const out = await uploadFile("/upload/flyer", file); | |
| deals = out.deals || []; | |
| renderDeals(); | |
| toast(`extracted ${deals.length} deals`, true); | |
| } catch (e) { toast(`flyer upload: ${e.message}`); } | |
| finally { setThinking(false); } | |
| }); | |
| $("#flyer-text-btn").addEventListener("click", async () => { | |
| const text = $("#flyer-text").value.trim(); | |
| if (!text) { toast("paste some flyer text first"); return; } | |
| const btn = $("#flyer-text-btn"); | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| const res = await fetch("/upload/flyer_text", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ text }), | |
| }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const out = await res.json(); | |
| deals = out.deals || []; | |
| renderDeals(); | |
| toast(`extracted ${deals.length} deals`, true); | |
| } catch (e) { toast(`text extract: ${e.message}`); } | |
| finally { setBusy(btn, false); setThinking(false); } | |
| }); | |
| $("#recipe-btn").addEventListener("click", () => { | |
| streamInto("food_recommend", [JSON.stringify(deals)], $("#food-result"), $("#recipe-btn"), "recipe picks"); | |
| }); | |
| /* ---- meal photo ---- */ | |
| let mealOcrText = ""; | |
| wireDropzone($("#meal-drop"), $("#meal-file"), async (file) => { | |
| setThinking(true); | |
| $("#meal-ocr-wrap").hidden = true; | |
| $("#meal-result").innerHTML = "<p class='empty-hint'>🔍 identifying food items on your machine — hang tight, this runs fully offline…</p>"; | |
| try { | |
| const out = await uploadFile("/upload/meal_photo", file); | |
| if (out.error) { | |
| $("#meal-ocr-wrap").hidden = true; | |
| $("#meal-result").innerHTML = `<div class="feature-banner">${escapeHtml(out.error)}</div>`; | |
| return; | |
| } | |
| mealOcrText = out.text || ""; | |
| if (!mealOcrText.trim()) { | |
| $("#meal-result").innerHTML = "<div class='feature-banner'>Couldn't make out any food items in that photo — try a clearer, well-lit shot.</div>"; | |
| $("#meal-ocr-wrap").hidden = true; | |
| return; | |
| } | |
| const fromVision = out.source !== "ocr"; | |
| $("#meal-ocr-title").textContent = fromVision ? "identified items" : "text read from photo"; | |
| // The vision model returns a markdown bullet list; render it formatted. | |
| $("#meal-ocr").innerHTML = fromVision | |
| ? renderMarkdown(mealOcrText) | |
| : escapeHtml(mealOcrText.slice(0, 600) + (mealOcrText.length > 600 ? " ..." : "")); | |
| $("#meal-ocr-wrap").hidden = false; | |
| $("#meal-result").innerHTML = "<p class='empty-hint'>pick a label and hit Analyze</p>"; | |
| toast(fromVision ? "food items identified" : "photo text read", true); | |
| } catch (e) { | |
| if (isMissing(e)) featureUnavailable($("#meal-result"), "meal photo analysis"); | |
| else toast(`meal photo: ${e.message}`); | |
| } finally { setThinking(false); } | |
| }); | |
| $("#meal-analyze-btn").addEventListener("click", () => { | |
| if (!mealOcrText.trim()) { toast("upload a meal photo first"); return; } | |
| streamInto("meal_analyze", [mealOcrText, $("#meal-label").value], | |
| $("#meal-result"), $("#meal-analyze-btn"), "meal analysis"); | |
| }); | |
| /* ================= HEALTH ================= */ | |
| const TYPE_ABBR = { | |
| run: "RUN", "push (chest/triceps)": "PSH", "pull (back/biceps)": "PLL", | |
| legs: "LEG", cycling: "CYC", swim: "SWM", yoga: "YOG", other: "OTH", | |
| }; | |
| const isoDate = (d) => { | |
| const p = (n) => String(n).padStart(2, "0"); | |
| return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; | |
| }; | |
| const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; | |
| function renderWeekStrip(mem) { | |
| const strip = $("#week-strip"); | |
| const days = []; | |
| const today = new Date(); | |
| for (let i = 6; i >= 0; i--) { | |
| const d = new Date(today); | |
| d.setDate(today.getDate() - i); | |
| days.push(d); | |
| } | |
| const byDate = {}; | |
| (mem.workouts || []).forEach((w) => { byDate[w.date] = w; }); | |
| strip.innerHTML = days.map((d, idx) => { | |
| const w = byDate[isoDate(d)]; | |
| const isToday = idx === 6; | |
| const abbr = w ? (TYPE_ABBR[w.type] || w.type.slice(0, 3).toUpperCase()) : "·"; | |
| return `<div class="day-box ${w ? "filled" : ""} ${isToday ? "today" : ""}" title="${w ? escapeHtml(w.type) + " " + w.duration_min + "min" : "rest"}"> | |
| <span>${DAY_NAMES[d.getDay()]}</span><span class="abbr">${abbr}</span>${w ? `<span>${w.duration_min}m</span>` : ""} | |
| </div>`; | |
| }).join(""); | |
| } | |
| const WORKOUT_LOG_RECENT = 7; | |
| let showAllWorkouts = false; | |
| let allWorkouts = []; | |
| function renderWorkoutLog(mem) { | |
| const box = $("#workout-log"); | |
| if (!box) return; | |
| allWorkouts = (mem.workouts || []).slice().sort((a, b) => (a.date < b.date ? 1 : -1)); | |
| paintWorkoutLog(); | |
| } | |
| function paintWorkoutLog() { | |
| const box = $("#workout-log"); | |
| const btn = $("#workout-history-btn"); | |
| if (!allWorkouts.length) { | |
| box.innerHTML = "<p class='empty-hint'>no workouts logged yet</p>"; | |
| if (btn) btn.hidden = true; | |
| return; | |
| } | |
| const shown = showAllWorkouts ? allWorkouts : allWorkouts.slice(0, WORKOUT_LOG_RECENT); | |
| box.classList.toggle("scroll", showAllWorkouts); | |
| const header = | |
| `<div class="workout-row head"> | |
| <span class="wl-date" title="the day you logged this workout">date</span> | |
| <span class="wl-type" title="what kind of training it was">workout</span> | |
| <span class="wl-dur" title="how long you trained, in minutes">duration</span> | |
| </div>`; | |
| box.innerHTML = header + shown.map((w) => | |
| `<div class="workout-row"> | |
| <span class="wl-date">${escapeHtml(w.date)}</span> | |
| <span class="wl-type">${escapeHtml(w.type)}</span> | |
| <span class="wl-dur">${w.duration_min} min</span> | |
| </div>`).join(""); | |
| if (btn) { | |
| if (allWorkouts.length > WORKOUT_LOG_RECENT) { | |
| btn.hidden = false; | |
| btn.textContent = showAllWorkouts | |
| ? "show recent only" | |
| : `view full history (${allWorkouts.length})`; | |
| } else { | |
| btn.hidden = true; | |
| } | |
| } | |
| } | |
| $("#workout-history-btn").addEventListener("click", () => { | |
| showAllWorkouts = !showAllWorkouts; | |
| paintWorkoutLog(); | |
| }); | |
| /* ---- schedule editor ---- */ | |
| $("#sched-days").addEventListener("click", (e) => { | |
| const chip = e.target.closest(".day-chip"); | |
| if (chip) chip.classList.toggle("on"); | |
| }); | |
| function selectedDays() { | |
| return $$("#sched-days .day-chip.on").map((c) => c.dataset.day); | |
| } | |
| function renderSchedule(mem) { | |
| const ws = (mem && mem.workout_schedule) || {}; | |
| const days = ws.days || []; | |
| $$("#sched-days .day-chip").forEach((c) => c.classList.toggle("on", days.includes(c.dataset.day))); | |
| if (ws.time) $("#sched-time").value = ws.time; | |
| $("#sched-hint").textContent = days.length | |
| ? `scheduled: ${days.join(", ")} at ${ws.time || "07:00"}` | |
| : "pick training days and a time"; | |
| } | |
| $("#sched-save-btn").addEventListener("click", async () => { | |
| const btn = $("#sched-save-btn"); | |
| const days = selectedDays(); | |
| if (!days.length) { toast("pick at least one day"); return; } | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| await callGradio("set_schedule", [JSON.stringify(days), $("#sched-time").value || "07:00"]); | |
| toast("schedule saved", true); | |
| refreshMemoryViews(); | |
| } catch (e) { | |
| if (isMissing(e)) featureUnavailable(null, "workout schedule"); | |
| else toast(`schedule: ${e.message}`); | |
| } finally { setBusy(btn, false); setThinking(false); } | |
| }); | |
| /* ---- browser reminder ---- */ | |
| function nextWorkoutSlot() { | |
| const days = selectedDays(); | |
| const time = $("#sched-time").value || "07:00"; | |
| if (!days.length) return null; | |
| const [hh, mm] = time.split(":").map(Number); | |
| const dayIdx = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }; | |
| const now = new Date(); | |
| for (let i = 0; i < 8; i++) { | |
| const d = new Date(now); | |
| d.setDate(now.getDate() + i); | |
| d.setHours(hh, mm, 0, 0); | |
| const key = Object.keys(dayIdx).find((k) => dayIdx[k] === d.getDay()); | |
| if (days.includes(key) && d > now) return d; | |
| } | |
| return null; | |
| } | |
| let reminderTimer = null; | |
| $("#remind-btn").addEventListener("click", async () => { | |
| if (!("Notification" in window)) { toast("notifications not supported in this browser"); return; } | |
| const slot = nextWorkoutSlot(); | |
| if (!slot) { toast("save a schedule first — no upcoming slot found"); return; } | |
| let perm = Notification.permission; | |
| if (perm === "default") perm = await Notification.requestPermission(); | |
| if (perm !== "granted") { toast("notification permission denied"); return; } | |
| if (reminderTimer) clearTimeout(reminderTimer); | |
| const ms = slot.getTime() - Date.now(); | |
| reminderTimer = setTimeout(() => { | |
| new Notification("LifeOS — time to train", { body: `Scheduled workout at ${$("#sched-time").value}. Go get it.` }); | |
| }, ms); | |
| toast(`reminder set for ${DAY_NAMES[slot.getDay()]} ${slot.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} (while this page stays open)`, true); | |
| }); | |
| /* ---- calendar ---- */ | |
| function next7(mem) { | |
| const events = (mem && mem.calendar) || []; | |
| const out = []; | |
| const today = new Date(); | |
| for (let i = 0; i < 7; i++) { | |
| const d = new Date(today); | |
| d.setDate(today.getDate() + i); | |
| const key = isoDate(d); | |
| out.push({ date: d, key, events: events.filter((ev) => ev.date === key) }); | |
| } | |
| return out; | |
| } | |
| function renderCalendar(mem) { | |
| const days = next7(mem); | |
| $("#cal-strip").innerHTML = days.map((d, i) => | |
| `<div class="cal-day ${i === 0 ? "today" : ""} ${d.events.length ? "has-events" : ""}"> | |
| ${DAY_NAMES[d.date.getDay()]}<span class="cal-count">${d.events.length || "·"}</span> | |
| </div>`).join(""); | |
| const all = days.flatMap((d) => d.events.map((ev) => ({ ...ev, _day: DAY_NAMES[d.date.getDay()] }))); | |
| const list = $("#event-list"); | |
| if (!all.length) { | |
| list.innerHTML = "<p class='empty-hint'>no events in the next 7 days</p>"; | |
| } else { | |
| list.innerHTML = all.map((ev) => | |
| `<div class="event-row"> | |
| <span class="ev-when">${ev._day} ${escapeHtml(ev.start || "")}–${escapeHtml(ev.end || "")}</span> | |
| <span class="ev-title">${escapeHtml(ev.title || "(untitled)")}</span> | |
| <span class="ev-kind">${escapeHtml(ev.kind || "other")}</span> | |
| <button class="btn btn-ghost ev-del" data-id="${escapeHtml(String(ev.id))}" title="delete event"><svg class="icon"><use href="#i-trash"/></svg></button> | |
| </div>`).join(""); | |
| } | |
| } | |
| $("#event-form").addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const btn = $("#ev-add-btn"); | |
| let kind = $("#ev-kind").value; | |
| if (kind === "other") { | |
| const desc = $("#ev-other").value.trim(); | |
| if (!desc) { toast("describe what \"other\" is"); return; } | |
| kind = desc; | |
| } | |
| const ev = { | |
| title: $("#ev-title").value.trim(), | |
| date: $("#ev-date").value, | |
| start: $("#ev-start").value, | |
| end: $("#ev-end").value, | |
| kind, | |
| }; | |
| if (!ev.title || !ev.date) { toast("event needs a title and a date"); return; } | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| await callGradio("add_event", [JSON.stringify(ev)]); | |
| toast(`added "${ev.title}"`, true); | |
| $("#ev-title").value = ""; | |
| $("#ev-kind").value = "appointment"; | |
| $("#ev-other-wrap").hidden = true; $("#ev-other").value = ""; | |
| refreshMemoryViews(); | |
| } catch (err) { | |
| if (isMissing(err)) featureUnavailable(null, "calendar events"); | |
| else toast(`add event: ${err.message}`); | |
| } finally { setBusy(btn, false); setThinking(false); } | |
| }); | |
| $("#event-list").addEventListener("click", async (e) => { | |
| const btn = e.target.closest(".ev-del"); | |
| if (!btn) return; | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| await callGradio("delete_event", [btn.dataset.id]); | |
| toast("event deleted", true); | |
| refreshMemoryViews(); | |
| } catch (err) { | |
| setBusy(btn, false); | |
| if (isMissing(err)) featureUnavailable(null, "calendar events"); | |
| else toast(`delete event: ${err.message}`); | |
| } finally { setThinking(false); } | |
| }); | |
| /* ---- "other" describe toggles ---- */ | |
| function wireOtherDescribe(selectId, wrapId, inputId) { | |
| const sel = $(selectId), wrap = $(wrapId), input = $(inputId); | |
| if (!sel || !wrap) return; | |
| const sync = () => { | |
| const on = sel.value === "other"; | |
| wrap.hidden = !on; | |
| if (on) input.focus(); else input.value = ""; | |
| }; | |
| sel.addEventListener("change", sync); | |
| } | |
| wireOtherDescribe("#workout-type", "#workout-other-wrap", "#workout-other"); | |
| wireOtherDescribe("#ev-kind", "#ev-other-wrap", "#ev-other"); | |
| /* ---- calendar day view (modal) ---- */ | |
| const CAL_DAY_START = 6, CAL_DAY_END = 23; // visible hour band | |
| const KIND_COLORS = { | |
| class: "#6366f1", work: "#0ea5e9", social: "#ec4899", | |
| workout: "#22c55e", appointment: "#f59e0b", other: "#94a3b8", | |
| }; | |
| const toMin = (t) => { const [h, m] = String(t || "0:0").split(":").map(Number); return (h || 0) * 60 + (m || 0); }; | |
| function renderCalGrid(mem) { | |
| const grid = $("#cal-grid"); | |
| if (!grid) return; | |
| const days = next7(mem); | |
| const hours = []; | |
| for (let h = CAL_DAY_START; h <= CAL_DAY_END; h++) hours.push(h); | |
| const pxPerMin = 0.9, top0 = CAL_DAY_START * 60; | |
| const header = `<div class="calg-corner"></div>` + | |
| days.map((d) => `<div class="calg-dayhead ${d.key === isoDate(new Date()) ? "today" : ""}"> | |
| <span>${DAY_NAMES[d.date.getDay()]}</span><b>${d.date.getDate()}</b></div>`).join(""); | |
| const gutter = `<div class="calg-gutter">` + | |
| hours.map((h) => `<div class="calg-hour" style="height:${60 * pxPerMin}px">${String(h).padStart(2, "0")}:00</div>`).join("") + | |
| `</div>`; | |
| const cols = days.map((d) => { | |
| const evs = d.events.map((ev) => { | |
| const s = Math.max(toMin(ev.start), top0), e = Math.min(toMin(ev.end || ev.start), CAL_DAY_END * 60 + 60); | |
| const top = (s - top0) * pxPerMin, h = Math.max((e - s) * pxPerMin, 18); | |
| const c = KIND_COLORS[ev.kind] || KIND_COLORS.other; | |
| return `<div class="calg-event" style="top:${top}px;height:${h}px;border-left-color:${c};background:${c}22"> | |
| <b>${escapeHtml(ev.title || "(untitled)")}</b> | |
| <span>${escapeHtml(ev.start || "")}–${escapeHtml(ev.end || "")} · ${escapeHtml(ev.kind || "")}</span> | |
| </div>`; | |
| }).join(""); | |
| return `<div class="calg-col" style="height:${(CAL_DAY_END - CAL_DAY_START + 1) * 60 * pxPerMin}px"> | |
| ${hours.map((h) => `<div class="calg-slot" style="height:${60 * pxPerMin}px"></div>`).join("")}${evs}</div>`; | |
| }).join(""); | |
| grid.innerHTML = `<div class="calg-head">${header}</div><div class="calg-body">${gutter}<div class="calg-cols">${cols}</div></div>`; | |
| } | |
| $("#cal-view-btn").addEventListener("click", () => { | |
| if (lastMem) renderCalGrid(lastMem); | |
| $("#cal-modal").hidden = false; | |
| }); | |
| const closeCalModal = () => { $("#cal-modal").hidden = true; }; | |
| $("#cal-modal-close").addEventListener("click", closeCalModal); | |
| $("#cal-modal").addEventListener("click", (e) => { if (e.target.id === "cal-modal") closeCalModal(); }); | |
| document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#cal-modal").hidden) closeCalModal(); }); | |
| /* ---- workout log + coach ---- */ | |
| $("#workout-form").addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const btn = $("#workout-btn"); | |
| let type = $("#workout-type").value; | |
| if (type === "other") { | |
| const desc = $("#workout-other").value.trim(); | |
| if (!desc) { toast("describe what \"other\" is"); return; } | |
| type = desc; | |
| } | |
| const dur = Number($("#workout-duration").value); | |
| if (!dur || dur < 1) { toast("enter a valid duration"); return; } | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| const out = await callGradio("log_workout", [type, dur]); | |
| const mem = typeof out === "string" ? JSON.parse(out) : out; | |
| applyMemory(mem); | |
| $("#workout-type").value = "run"; | |
| $("#workout-other-wrap").hidden = true; $("#workout-other").value = ""; | |
| toast(`logged ${type} · ${dur} min`, true); | |
| } catch (err) { toast(`log workout: ${err.message}`); } | |
| finally { setBusy(btn, false); setThinking(false); } | |
| }); | |
| $("#health-btn").addEventListener("click", () => { | |
| $("#free-slots").hidden = true; | |
| streamInto("health_recommend", [], $("#health-result"), $("#health-btn"), "coach"); | |
| }); | |
| $("#plan-week-btn").addEventListener("click", async () => { | |
| const btn = $("#plan-week-btn"); | |
| const slotsEl = $("#free-slots"); | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| const out = await callGradio("health_plan", []); | |
| let plan = null; | |
| try { plan = typeof out === "string" ? JSON.parse(out) : out; } catch { /* not JSON */ } | |
| const slots = plan && (plan.recommendation_slots || plan.free_slots || plan.slots || (Array.isArray(plan) ? plan : null)); | |
| if (slots && slots.length) { | |
| slotsEl.innerHTML = "<div class='legend muted'>free slots this week</div><div class='slot-chips'>" + | |
| slots.map((s) => `<span class="slot-chip">${escapeHtml(typeof s === "string" ? s : `${s.day || s.date || ""} ${s.start || s.time || ""}${s.end ? "–" + s.end : ""}`.trim())}</span>`).join("") + | |
| "</div>"; | |
| } else { | |
| slotsEl.innerHTML = "<div class='legend muted'>no free slots returned — week looks packed</div>"; | |
| } | |
| slotsEl.hidden = false; | |
| } catch (e) { | |
| setBusy(btn, false); setThinking(false); | |
| if (isMissing(e)) { featureUnavailable($("#health-result"), "weekly training plan"); return; } | |
| toast(`plan: ${e.message}`); | |
| return; | |
| } | |
| setBusy(btn, false); setThinking(false); | |
| streamInto("health_recommend", [], $("#health-result"), $("#plan-week-btn"), "coach"); | |
| }); | |
| /* ================= MONEY ================= */ | |
| let subscriptions = []; | |
| function renderSubs() { | |
| const wrap = $("#subs-table-wrap"); | |
| if (!subscriptions.length) { | |
| wrap.innerHTML = "<p class='empty-hint'>no subscriptions detected yet — upload a transactions CSV</p>"; | |
| } else { | |
| const rows = subscriptions.map((s) => | |
| `<tr><td>${escapeHtml(s.name)}</td><td class="num">$${Number(s.cost).toFixed(2)}</td><td>${escapeHtml(s.last_charged || "—")}</td><td class="num">${s.cadence_days != null ? s.cadence_days + "d" : "—"}</td></tr>`).join(""); | |
| wrap.innerHTML = `<table class="subs-table"> | |
| <thead><tr> | |
| <th title="the merchant this recurring charge is from">name</th> | |
| <th title="how much it costs you each month">$ / mo</th> | |
| <th title="the date this subscription was most recently billed">last charged</th> | |
| <th title="how many days between charges — e.g. 30d means billed monthly">cadence</th> | |
| </tr></thead> | |
| <tbody>${rows}</tbody></table> | |
| <p class="table-legend muted">name = merchant · $/mo = monthly cost · last charged = most recent bill · cadence = days between charges</p>`; | |
| } | |
| $("#money-btn").disabled = !subscriptions.length; | |
| } | |
| wireDropzone($("#csv-drop"), $("#csv-file"), async (file) => { | |
| setThinking(true); | |
| try { | |
| const out = await uploadFile("/upload/transactions", file); | |
| subscriptions = out.subscriptions || []; | |
| renderSubs(); | |
| toast(`detected ${subscriptions.length} subscriptions`, true); | |
| } catch (e) { toast(`csv upload: ${e.message}`); } | |
| finally { setThinking(false); } | |
| }); | |
| $("#money-btn").addEventListener("click", () => { | |
| streamInto("money_review", [JSON.stringify(subscriptions)], $("#money-result"), $("#money-btn"), "subscription audit"); | |
| }); | |
| /* ---- monthly payments editor ---- */ | |
| function payRowEl(p = {}) { | |
| const row = document.createElement("div"); | |
| row.className = "pay-row"; | |
| row.innerHTML = ` | |
| <input type="text" class="pay-name" placeholder="rent" value="${escapeHtml(p.name || "")}"> | |
| <input type="number" class="pay-amount" placeholder="$" min="0" step="0.01" value="${p.amount != null ? escapeHtml(String(p.amount)) : ""}"> | |
| <input type="number" class="pay-due" placeholder="due day" min="1" max="31" value="${p.due_day != null ? escapeHtml(String(p.due_day)) : ""}"> | |
| <button type="button" class="btn btn-ghost pay-del" title="remove row"><svg class="icon"><use href="#i-trash"/></svg></button>`; | |
| return row; | |
| } | |
| function renderPayments(list) { | |
| const wrap = $("#pay-rows"); | |
| wrap.innerHTML = ""; | |
| (list && list.length ? list : [{}]).forEach((p) => wrap.appendChild(payRowEl(p))); | |
| } | |
| $("#pay-add-btn").addEventListener("click", () => $("#pay-rows").appendChild(payRowEl())); | |
| $("#pay-rows").addEventListener("click", (e) => { | |
| const del = e.target.closest(".pay-del"); | |
| if (del) del.closest(".pay-row").remove(); | |
| }); | |
| $("#pay-save-btn").addEventListener("click", async () => { | |
| const btn = $("#pay-save-btn"); | |
| const payments = $$("#pay-rows .pay-row").map((r) => ({ | |
| name: r.querySelector(".pay-name").value.trim(), | |
| amount: Number(r.querySelector(".pay-amount").value) || 0, | |
| due_day: Number(r.querySelector(".pay-due").value) || 1, | |
| })).filter((p) => p.name); | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| await callGradio("set_payments", [JSON.stringify(payments)]); | |
| toast(`saved ${payments.length} monthly payments`, true); | |
| refreshMemoryViews(); | |
| // Let the model explain how these payments affect the user's goals. | |
| const impact = $("#pay-impact"); | |
| if (impact) { | |
| impact.hidden = false; | |
| impact.innerHTML = "<p class='empty-hint'>checking how this affects your goals…</p>"; | |
| try { | |
| await callGradio("payment_impact", [], (text) => { | |
| if (typeof text === "string") impact.innerHTML = renderResponse(text); | |
| }); | |
| } catch { impact.hidden = true; } | |
| } | |
| } catch (e) { | |
| if (isMissing(e)) featureUnavailable(null, "monthly payments"); | |
| else toast(`payments: ${e.message}`); | |
| } finally { setBusy(btn, false); setThinking(false); } | |
| }); | |
| /* ================= GOALS ================= */ | |
| let goalHistory = []; | |
| let activeGoal = null; // goal object passed to the coach | |
| let goalBusy = false; | |
| let currentGoals = []; | |
| function goalPct(g) { | |
| return g.target_amount ? Math.min(100, Math.round((Number(g.saved) || 0) / Number(g.target_amount) * 100)) : 0; | |
| } | |
| function renderGoals(mem) { | |
| currentGoals = (mem && mem.goals) || []; | |
| const grid = $("#goals-grid"); | |
| if (!currentGoals.length) { | |
| grid.innerHTML = "<p class='empty-hint'>no goals yet — add one below</p>"; | |
| } else { | |
| grid.innerHTML = currentGoals.map((g) => ` | |
| <div class="goal-card" data-id="${escapeHtml(String(g.id || ""))}"> | |
| <div class="goal-title">${escapeHtml(g.title || "goal")}</div> | |
| <div class="goal-bar-wrap"><div class="goal-bar" style="width:${goalPct(g)}%"></div></div> | |
| <div class="goal-meta"> | |
| <span>$${Number(g.saved || 0).toFixed(0)} / $${Number(g.target_amount || 0).toFixed(0)} (${goalPct(g)}%)</span> | |
| <span>deadline: ${escapeHtml(g.deadline || "—")}</span> | |
| </div> | |
| <div class="goal-actions"> | |
| <button class="btn btn-primary goal-coach-btn"><svg class="icon"><use href="#i-chat"/></svg> Coach me</button> | |
| <button class="btn goal-edit-btn"><svg class="icon"><use href="#i-edit"/></svg> Edit</button> | |
| <button class="btn btn-ghost goal-del-btn" title="delete goal"><svg class="icon"><use href="#i-trash"/></svg></button> | |
| </div> | |
| </div>`).join(""); | |
| } | |
| // keep coach target in sync | |
| if (activeGoal) activeGoal = currentGoals.find((g) => g.id === activeGoal.id) || null; | |
| if (!activeGoal) activeGoal = currentGoals[0] || null; | |
| renderGoalSummary(); | |
| } | |
| function renderGoalSummary() { | |
| const el = $("#goal-summary"); | |
| if (!activeGoal) { | |
| el.innerHTML = "<p class='empty-hint'>pick a goal with \"Coach me\" — or just start typing</p>"; | |
| return; | |
| } | |
| const g = activeGoal; | |
| el.innerHTML = ` | |
| <div class="goal-title">${escapeHtml(g.title || "goal")}</div> | |
| <div class="goal-bar-wrap"><div class="goal-bar" style="width:${goalPct(g)}%"></div></div> | |
| <div class="goal-meta"> | |
| <span>$${Number(g.saved || 0).toFixed(0)} / $${Number(g.target_amount || 0).toFixed(0)} (${goalPct(g)}%)</span> | |
| <span>deadline: ${escapeHtml(g.deadline || "—")}</span> | |
| </div>`; | |
| } | |
| function fillGoalForm(g) { | |
| $("#g-id").value = g && g.id ? g.id : ""; | |
| $("#g-title").value = g ? g.title || "" : ""; | |
| $("#g-target").value = g && g.target_amount != null ? g.target_amount : ""; | |
| $("#g-saved").value = g && g.saved != null ? g.saved : ""; | |
| $("#g-deadline").value = g && g.deadline ? String(g.deadline).slice(0, 7) : ""; | |
| $("#goal-form-title").textContent = g ? "// edit goal" : "// new goal"; | |
| $("#g-cancel-btn").hidden = !g; | |
| } | |
| $("#g-cancel-btn").addEventListener("click", () => fillGoalForm(null)); | |
| $("#goals-grid").addEventListener("click", async (e) => { | |
| const card = e.target.closest(".goal-card"); | |
| if (!card) return; | |
| const goal = currentGoals.find((g) => String(g.id) === card.dataset.id); | |
| if (e.target.closest(".goal-edit-btn")) { | |
| fillGoalForm(goal); | |
| $("#g-title").focus(); | |
| } else if (e.target.closest(".goal-coach-btn")) { | |
| activeGoal = goal || null; | |
| goalHistory = []; | |
| $("#goal-list").innerHTML = "<p class='empty-hint'>talk it through — the coach asks questions, you find the plan</p>"; | |
| renderGoalSummary(); | |
| $("#goal-input").focus(); | |
| } else if (e.target.closest(".goal-del-btn")) { | |
| const btn = e.target.closest(".goal-del-btn"); | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| const out = await callGradio("delete_goal", [String(goal && goal.id || "")]); | |
| const mem = typeof out === "string" ? JSON.parse(out) : out; | |
| applyMemory(mem); | |
| toast("goal deleted", true); | |
| } catch (err) { | |
| setBusy(btn, false); | |
| if (isMissing(err)) featureUnavailable(null, "goal delete"); | |
| else toast(`delete goal: ${err.message}`); | |
| } finally { setThinking(false); } | |
| } | |
| }); | |
| $("#goal-edit-form").addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const btn = $("#g-save-btn"); | |
| const goal = { | |
| title: $("#g-title").value.trim(), | |
| target_amount: Number($("#g-target").value) || 0, | |
| saved: Number($("#g-saved").value) || 0, | |
| deadline: $("#g-deadline").value, | |
| }; | |
| if ($("#g-id").value) goal.id = $("#g-id").value; | |
| if (!goal.title) { toast("goal needs a title"); return; } | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| const out = await callGradio("upsert_goal", [JSON.stringify(goal)]); | |
| const mem = typeof out === "string" ? JSON.parse(out) : out; | |
| applyMemory(mem); | |
| toast(`saved goal "${goal.title}"`, true); | |
| fillGoalForm(null); | |
| } catch (err) { | |
| if (isMissing(err)) featureUnavailable(null, "goal save"); | |
| else toast(`save goal: ${err.message}`); | |
| } finally { setBusy(btn, false); setThinking(false); } | |
| }); | |
| /* ---- goal coach (Socratic) ---- */ | |
| function addGoalBubble(role) { | |
| const hint = $("#goal-list .empty-hint"); | |
| if (hint) hint.remove(); | |
| return addMsg($("#goal-list"), role); | |
| } | |
| $("#goal-form").addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const input = $("#goal-input"); | |
| const message = input.value.trim(); | |
| if (!message || goalBusy) return; | |
| input.value = ""; | |
| goalBusy = true; | |
| const btn = $("#goal-send"); | |
| setBusy(btn, true); setThinking(true); | |
| const userEl = addGoalBubble("user"); | |
| userEl.textContent = message; | |
| const asstEl = addGoalBubble("assistant"); | |
| asstEl.classList.add("streaming"); | |
| asstEl.innerHTML = "<p class='empty-hint'>thinking...</p>"; | |
| $("#goal-list").scrollTop = $("#goal-list").scrollHeight; | |
| try { | |
| const finalText = await callGradio("goal_chat", | |
| [message, JSON.stringify(goalHistory), JSON.stringify(activeGoal || {})], | |
| (text) => { | |
| if (typeof text === "string") { | |
| asstEl.innerHTML = renderResponse(text); | |
| $("#goal-list").scrollTop = $("#goal-list").scrollHeight; | |
| } | |
| }); | |
| goalHistory.push({ role: "user", content: message }); | |
| goalHistory.push({ role: "assistant", content: finalText }); | |
| } catch (err) { | |
| if (isMissing(err)) featureUnavailable(asstEl, "goal coach"); | |
| else { toast(`goal coach: ${err.message}`); asstEl.innerHTML = "<p class='empty-hint'>no response — is the backend running?</p>"; } | |
| } finally { | |
| asstEl.classList.remove("streaming"); | |
| setBusy(btn, false); setThinking(false); | |
| goalBusy = false; | |
| } | |
| }); | |
| /* ================= CHAT ================= */ | |
| let history = []; // [{role, content}] | |
| let chatBusy = false; | |
| let chatArity = 4; // preferred arg count for chat(); degrades on 404/422 | |
| /* ---- toggles ---- */ | |
| function setToggle(btn, on) { | |
| btn.setAttribute("aria-pressed", String(on)); | |
| const st = btn.querySelector(".toggle-state"); | |
| if (st) st.textContent = on ? "on" : "off"; | |
| } | |
| function wireToggle(btn, onChange) { | |
| btn.addEventListener("click", () => { | |
| const on = btn.getAttribute("aria-pressed") !== "true"; | |
| setToggle(btn, on); | |
| if (onChange) onChange(on); | |
| }); | |
| } | |
| wireToggle($("#tts-toggle"), (on) => { if (!on && "speechSynthesis" in window) speechSynthesis.cancel(); }); | |
| wireToggle($("#web-toggle"), (on) => { if (on) toast("web search ON — requests will use the internet"); }); | |
| const ttsOn = () => $("#tts-toggle").getAttribute("aria-pressed") === "true"; | |
| const webOn = () => $("#web-toggle").getAttribute("aria-pressed") === "true"; | |
| function speak(text) { | |
| if (!("speechSynthesis" in window)) return; | |
| speechSynthesis.cancel(); | |
| const clean = stripForSpeech(text); | |
| if (!clean) return; | |
| const u = new SpeechSynthesisUtterance(clean.slice(0, 1500)); | |
| u.rate = 1.05; | |
| speechSynthesis.speak(u); | |
| } | |
| if (!("speechSynthesis" in window)) $("#tts-toggle").hidden = true; | |
| /* ---- domain refs (@Kitchen / @Health / @Money) ---- */ | |
| $("#ref-row").addEventListener("click", (e) => { | |
| const chip = e.target.closest(".ref-chip"); | |
| if (!chip) return; | |
| chip.setAttribute("aria-pressed", chip.getAttribute("aria-pressed") !== "true" ? "true" : "false"); | |
| }); | |
| function selectedRefs() { | |
| return $$(".ref-chip[aria-pressed='true']").map((c) => c.dataset.ref); | |
| } | |
| /* ---- mic (speech-to-text) ---- */ | |
| const SR = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| if (SR) { | |
| const micBtn = $("#mic-btn"); | |
| micBtn.hidden = false; | |
| let rec = null; | |
| micBtn.addEventListener("click", () => { | |
| if (rec) { rec.stop(); return; } | |
| rec = new SR(); | |
| rec.lang = "en-US"; | |
| rec.interimResults = true; | |
| micBtn.classList.add("listening"); | |
| let finalTranscript = ""; | |
| rec.onresult = (e) => { | |
| let interim = ""; | |
| for (const r of e.results) { | |
| if (r.isFinal) finalTranscript += r[0].transcript; | |
| else interim += r[0].transcript; | |
| } | |
| $("#chat-input").value = (finalTranscript + interim).trim(); | |
| }; | |
| rec.onerror = (e) => { if (e.error !== "aborted") toast(`mic: ${e.error}`); }; | |
| rec.onend = () => { | |
| micBtn.classList.remove("listening"); | |
| rec = null; | |
| const msg = $("#chat-input").value.trim(); | |
| if (msg) { $("#chat-input").value = ""; sendChat(msg); } | |
| }; | |
| try { rec.start(); } catch { micBtn.classList.remove("listening"); rec = null; } | |
| }); | |
| } | |
| /* message DOM: assistant rows get an avatar; returns the bubble element */ | |
| function addMsg(listEl, role) { | |
| const row = document.createElement("div"); | |
| row.className = `msg ${role}`; | |
| if (role === "assistant") { | |
| const av = document.createElement("div"); | |
| av.className = "avatar"; | |
| av.innerHTML = '<svg class="icon"><use href="#i-bolt"/></svg>'; | |
| row.appendChild(av); | |
| } | |
| const el = document.createElement("div"); | |
| el.className = `bubble ${role} md`; | |
| row.appendChild(el); | |
| listEl.appendChild(row); | |
| return el; | |
| } | |
| function addBubble(role) { | |
| const greet = $("#chat-greeting"); | |
| if (greet) greet.remove(); | |
| return addMsg($("#chat-list"), role); | |
| } | |
| function scrollChat() { | |
| const list = $("#chat-list"); | |
| list.scrollTop = list.scrollHeight; | |
| } | |
| function restoreGreeting() { | |
| $("#chat-list").innerHTML = ` | |
| <div class="chat-greeting" id="chat-greeting"> | |
| <div class="greet-hello" id="greet-hello">Hello</div> | |
| <div class="greet-sub">your local-first copilot for meals, training and money — ask anything</div> | |
| </div>`; | |
| applyGreeting(); | |
| } | |
| let firstName = ""; | |
| function applyGreeting() { | |
| const el = $("#greet-hello"); | |
| if (el) el.textContent = firstName ? `Hello, ${firstName}` : "Hello"; | |
| } | |
| async function sendChat(message) { | |
| if (chatBusy || !message.trim()) return; | |
| chatBusy = true; | |
| const btn = $("#chat-send"); | |
| setBusy(btn, true); setThinking(true); | |
| const refs = selectedRefs(); | |
| const userEl = addBubble("user"); | |
| userEl.textContent = message; | |
| if (refs.length) { | |
| const tags = document.createElement("div"); | |
| tags.className = "msg-refs"; | |
| tags.innerHTML = refs.map((r) => `<span class="msg-ref">@${escapeHtml(r)}</span>`).join(""); | |
| userEl.prepend(tags); | |
| } | |
| const asstEl = addBubble("assistant"); | |
| asstEl.classList.add("streaming"); | |
| asstEl.innerHTML = "<p class='empty-hint'>thinking...</p>"; | |
| scrollChat(); | |
| const onUpdate = (text) => { | |
| if (typeof text === "string") { asstEl.innerHTML = renderResponse(text); scrollChat(); } | |
| }; | |
| let finalText = ""; | |
| try { | |
| const histJson = JSON.stringify(history); | |
| const argSets = { | |
| 4: [message, histJson, webOn() ? "1" : "", JSON.stringify(refs)], | |
| 3: [message, histJson, webOn() ? "1" : ""], | |
| 2: [message, histJson], | |
| }; | |
| for (;;) { | |
| try { | |
| finalText = await callGradio("chat", argSets[chatArity], onUpdate); | |
| break; | |
| } catch (e) { | |
| if (isMissing(e) && chatArity > 2) { | |
| if (chatArity === 4 && refs.length) toast("context refs not supported by this backend build yet — sent without them"); | |
| chatArity--; | |
| } else throw e; | |
| } | |
| } | |
| history.push({ role: "user", content: message }); | |
| history.push({ role: "assistant", content: finalText }); | |
| if (ttsOn()) speak(finalText); | |
| } catch (e) { | |
| toast(`chat: ${e.message}`); | |
| asstEl.innerHTML = "<p class='empty-hint'>no response — is the backend running?</p>"; | |
| } finally { | |
| asstEl.classList.remove("streaming"); | |
| setBusy(btn, false); setThinking(false); | |
| chatBusy = false; | |
| } | |
| } | |
| $("#chat-form").addEventListener("submit", (e) => { | |
| e.preventDefault(); | |
| const input = $("#chat-input"); | |
| const msg = input.value; | |
| input.value = ""; | |
| sendChat(msg); | |
| }); | |
| $("#example-chips").addEventListener("click", (e) => { | |
| const chip = e.target.closest(".chip-btn"); | |
| if (chip) sendChat(chip.textContent.trim()); | |
| }); | |
| /* ================= PROFILE ================= */ | |
| function renderProfile(mem) { | |
| const p = (mem && mem.user_profile) || {}; | |
| const f = (mem && mem.finances) || {}; | |
| firstName = p.first_name || (p.name ? String(p.name).split(" ")[0] : ""); | |
| applyGreeting(); | |
| if (document.activeElement && document.activeElement.closest && document.activeElement.closest("#profile-form")) return; // don't clobber while editing | |
| let first = p.first_name || "", last = p.last_name || ""; | |
| if (!first && !last && p.name) { | |
| const parts = String(p.name).split(" "); | |
| first = parts.shift() || ""; | |
| last = parts.join(" "); | |
| } | |
| $("#pf-first").value = first; | |
| $("#pf-last").value = last; | |
| $("#pf-city").value = p.city || ""; | |
| $("#pf-address").value = p.address || ""; | |
| $("#pf-postal").value = p.postal_code || ""; | |
| $("#pf-country").value = p.country || ""; | |
| $("#pf-income").value = f.income_monthly != null ? f.income_monthly : ""; | |
| $("#pf-budget").value = p.budget_weekly != null ? p.budget_weekly : ""; | |
| $("#pf-diet").value = Array.isArray(p.dietary_prefs) ? p.dietary_prefs.join(", ") : (p.dietary_prefs || ""); | |
| $("#pf-fitness").value = p.fitness_goal || ""; | |
| const shown = [first || p.name, p.city].filter(Boolean).join(" · "); | |
| $("#pf-saved-state").textContent = shown ? `saved: ${shown}` : ""; | |
| updateLocalDealsBtn(); | |
| } | |
| function updateLocalDealsBtn() { | |
| const btn = $("#local-deals-btn"); | |
| const hasCity = !!$("#pf-city").value.trim(); | |
| btn.disabled = !hasCity; | |
| btn.title = hasCity ? "look up grocery deals near you (online)" : "set your city first"; | |
| } | |
| $("#pf-city").addEventListener("input", updateLocalDealsBtn); | |
| $("#profile-form").addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const btn = $("#pf-save-btn"); | |
| const first = $("#pf-first").value.trim(); | |
| const last = $("#pf-last").value.trim(); | |
| const profile = { | |
| first_name: first, | |
| last_name: last, | |
| name: [first, last].filter(Boolean).join(" "), | |
| address: $("#pf-address").value.trim(), | |
| city: $("#pf-city").value.trim(), | |
| postal_code: $("#pf-postal").value.trim(), | |
| country: $("#pf-country").value.trim(), | |
| income_monthly: Number($("#pf-income").value) || 0, | |
| budget_weekly: Number($("#pf-budget").value) || 0, | |
| dietary_prefs: $("#pf-diet").value.split(",").map((s) => s.trim()).filter(Boolean), | |
| fitness_goal: $("#pf-fitness").value.trim(), | |
| }; | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| await callGradio("set_profile", [JSON.stringify(profile)]); | |
| toast("profile saved", true); | |
| $("#pf-saved-state").textContent = `saved: ${[profile.name, profile.city].filter(Boolean).join(" · ")}`; | |
| firstName = first; | |
| applyGreeting(); | |
| refreshMemoryViews(); | |
| } catch (err) { | |
| if (isMissing(err)) featureUnavailable(null, "profile"); | |
| else toast(`profile: ${err.message}`); | |
| } finally { setBusy(btn, false); setThinking(false); } | |
| }); | |
| $("#local-deals-btn").addEventListener("click", () => { | |
| const city = $("#pf-city").value.trim(); | |
| if (!city) { toast("set your city first"); return; } | |
| streamInto("local_deals", [city], $("#local-deals-result"), $("#local-deals-btn"), "local flyer deals"); | |
| }); | |
| /* ================= MEMORY SECTION ================= */ | |
| let lastMem = null; | |
| /* field definitions per editable short-term section */ | |
| const MEM_FIELDS = { | |
| meals: [ | |
| { key: "date", label: "date", type: "date" }, | |
| { key: "dish", label: "dish", type: "text", grow: true }, | |
| { key: "ingredients", label: "ingredients (comma-separated)", type: "list", grow: true }, | |
| ], | |
| workouts: [ | |
| { key: "date", label: "date", type: "date" }, | |
| { key: "type", label: "type", type: "text", grow: true }, | |
| { key: "duration_min", label: "minutes", type: "number" }, | |
| ], | |
| calendar: [ | |
| { key: "id", label: "id", type: "hidden" }, | |
| { key: "title", label: "title", type: "text", grow: true }, | |
| { key: "date", label: "date", type: "date" }, | |
| { key: "start", label: "start", type: "time" }, | |
| { key: "end", label: "end", type: "time" }, | |
| { key: "kind", label: "kind", type: "text" }, | |
| ], | |
| subscriptions: [ | |
| { key: "name", label: "name", type: "text", grow: true }, | |
| { key: "cost", label: "cost ($/mo)", type: "number" }, | |
| { key: "last_used", label: "last used", type: "date" }, | |
| ], | |
| monthly_payments: [ | |
| { key: "name", label: "name", type: "text", grow: true }, | |
| { key: "amount", label: "amount ($)", type: "number" }, | |
| { key: "due_day", label: "due day", type: "number" }, | |
| ], | |
| }; | |
| function memSectionList(mem, section) { | |
| if (!mem) return []; | |
| if (section === "subscriptions" || section === "monthly_payments") { | |
| return (mem.finances && mem.finances[section]) || []; | |
| } | |
| return mem[section] || []; | |
| } | |
| function memRowEl(section, item = {}) { | |
| const row = document.createElement("div"); | |
| row.className = "mem-row"; | |
| MEM_FIELDS[section].forEach((f) => { | |
| const val = item[f.key]; | |
| const str = f.type === "list" && Array.isArray(val) ? val.join(", ") : (val != null ? String(val) : ""); | |
| const input = document.createElement("input"); | |
| input.type = f.type === "list" ? "text" : (f.type === "hidden" ? "hidden" : f.type); | |
| input.dataset.key = f.key; | |
| input.placeholder = f.label; | |
| input.title = f.label; | |
| input.value = str; | |
| if (f.grow) input.classList.add("grow"); | |
| row.appendChild(input); | |
| }); | |
| const del = document.createElement("button"); | |
| del.type = "button"; | |
| del.className = "btn btn-ghost mem-del"; | |
| del.title = "remove row"; | |
| del.innerHTML = '<svg class="icon"><use href="#i-trash"/></svg>'; | |
| row.appendChild(del); | |
| return row; | |
| } | |
| function renderMemRows() { | |
| const section = $("#mem-section").value; | |
| const list = memSectionList(lastMem, section); | |
| const wrap = $("#mem-rows"); | |
| wrap.innerHTML = ""; | |
| list.forEach((item) => wrap.appendChild(memRowEl(section, item))); | |
| $("#mem-short-hint").textContent = list.length | |
| ? `${list.length} ${section.replace("_", " ")} rows — edit, add or delete, then Save section` | |
| : `no ${section.replace("_", " ")} rows yet — add one`; | |
| } | |
| $("#mem-section").addEventListener("change", renderMemRows); | |
| $("#mem-add-row").addEventListener("click", () => { | |
| $("#mem-rows").appendChild(memRowEl($("#mem-section").value)); | |
| }); | |
| $("#mem-rows").addEventListener("click", (e) => { | |
| const del = e.target.closest(".mem-del"); | |
| if (del) del.closest(".mem-row").remove(); | |
| }); | |
| function collectMemRows(section) { | |
| return $$("#mem-rows .mem-row").map((row) => { | |
| const item = {}; | |
| MEM_FIELDS[section].forEach((f) => { | |
| const v = row.querySelector(`input[data-key="${f.key}"]`).value.trim(); | |
| if (f.type === "hidden" && !v) return; // let backend assign ids | |
| if (f.type === "number") item[f.key] = Number(v) || 0; | |
| else if (f.type === "list") item[f.key] = v.split(",").map((s) => s.trim()).filter(Boolean); | |
| else item[f.key] = v; | |
| }); | |
| return item; | |
| }).filter((item) => Object.values(item).some((v) => Array.isArray(v) ? v.length : v !== "" && v !== 0)); | |
| } | |
| $("#mem-save-btn").addEventListener("click", async () => { | |
| const btn = $("#mem-save-btn"); | |
| const section = $("#mem-section").value; | |
| const items = collectMemRows(section); | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| const out = await callGradio("edit_memory", [section, JSON.stringify(items)]); | |
| const mem = typeof out === "string" ? JSON.parse(out) : out; | |
| if (mem && mem.error) { toast(`save: ${mem.error}`); return; } | |
| applyMemory(mem); | |
| renderMemRows(); | |
| toast(`saved ${items.length} ${section.replace("_", " ")} rows`, true); | |
| } catch (e) { | |
| if (isMissing(e)) featureUnavailable($("#mem-rows"), "manual memory editing"); | |
| else toast(`memory save: ${e.message}`); | |
| } finally { setBusy(btn, false); setThinking(false); } | |
| }); | |
| /* ---- segments ---- */ | |
| $("#mem-segs").addEventListener("click", (e) => { | |
| const seg = e.target.closest(".seg"); | |
| if (!seg) return; | |
| $$("#mem-segs .seg").forEach((s) => s.classList.toggle("active", s === seg)); | |
| $("#mem-short").hidden = seg.dataset.seg !== "short"; | |
| $("#mem-long").hidden = seg.dataset.seg !== "long"; | |
| if (seg.dataset.seg === "long") loadNotes(); | |
| }); | |
| /* ---- long-term notes ---- */ | |
| let notesLoading = false; | |
| async function loadNotes() { | |
| if (notesLoading) return; | |
| notesLoading = true; | |
| const list = $("#notes-list"); | |
| try { | |
| const out = await callGradio("list_notes", []); | |
| const notes = typeof out === "string" ? JSON.parse(out) : out; | |
| if (!Array.isArray(notes) || !notes.length) { | |
| list.innerHTML = "<p class='empty-hint'>no long-term notes yet — say \"remember ...\" in chat</p>"; | |
| return; | |
| } | |
| list.innerHTML = notes.map((n) => ` | |
| <div class="note-row" data-id="${escapeHtml(String(n.id))}"> | |
| <span class="note-kind kind-${escapeHtml(String(n.kind || "fact"))}">${escapeHtml(String(n.kind || "fact"))}</span> | |
| <div class="note-body"> | |
| <div class="note-text">${escapeHtml(String(n.text || ""))}</div> | |
| <textarea class="note-edit" rows="2" hidden>${escapeHtml(String(n.text || ""))}</textarea> | |
| </div> | |
| <div class="note-actions"> | |
| <button class="btn btn-ghost note-edit-btn" title="edit note"><svg class="icon"><use href="#i-edit"/></svg></button> | |
| <button class="btn btn-primary note-save-btn" hidden title="save note"><svg class="icon"><use href="#i-check"/></svg></button> | |
| <button class="btn btn-ghost note-del-btn" title="delete note"><svg class="icon"><use href="#i-trash"/></svg></button> | |
| </div> | |
| </div>`).join(""); | |
| } catch (e) { | |
| if (isMissing(e)) featureUnavailable(list, "long-term notes"); | |
| else list.innerHTML = "<p class='empty-hint'>couldn't load notes — is the backend running?</p>"; | |
| } finally { notesLoading = false; } | |
| } | |
| $("#notes-list").addEventListener("click", async (e) => { | |
| const row = e.target.closest(".note-row"); | |
| if (!row) return; | |
| const id = row.dataset.id; | |
| if (e.target.closest(".note-edit-btn")) { | |
| row.querySelector(".note-text").hidden = true; | |
| row.querySelector(".note-edit").hidden = false; | |
| row.querySelector(".note-edit-btn").hidden = true; | |
| row.querySelector(".note-save-btn").hidden = false; | |
| row.querySelector(".note-edit").focus(); | |
| } else if (e.target.closest(".note-save-btn")) { | |
| const btn = e.target.closest(".note-save-btn"); | |
| const text = row.querySelector(".note-edit").value.trim(); | |
| if (!text) { toast("note can't be empty"); return; } | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| await callGradio("update_note", [id, text]); | |
| toast("note updated", true); | |
| await loadNotes(); | |
| } catch (err) { | |
| setBusy(btn, false); | |
| if (isMissing(err)) featureUnavailable(null, "note editing"); | |
| else toast(`update note: ${err.message}`); | |
| } finally { setThinking(false); } | |
| } else if (e.target.closest(".note-del-btn")) { | |
| const btn = e.target.closest(".note-del-btn"); | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| await callGradio("delete_note", [id]); | |
| toast("note deleted", true); | |
| await loadNotes(); | |
| } catch (err) { | |
| setBusy(btn, false); | |
| if (isMissing(err)) featureUnavailable(null, "note deletion"); | |
| else toast(`delete note: ${err.message}`); | |
| } finally { setThinking(false); } | |
| } | |
| }); | |
| /* ================= MEMORY (shared state) ================= */ | |
| function applyMemory(mem) { | |
| if (!mem) return; | |
| lastMem = mem; | |
| renderWeekStrip(mem); | |
| renderWorkoutLog(mem); | |
| renderSchedule(mem); | |
| renderCalendar(mem); | |
| if (!$("#cal-modal").hidden) renderCalGrid(mem); | |
| renderGoals(mem); | |
| renderProfile(mem); | |
| renderPayments((mem.finances && mem.finances.monthly_payments) || []); | |
| } | |
| async function refreshMemoryViews() { | |
| try { | |
| const out = await callGradio("get_memory", []); | |
| const mem = typeof out === "string" ? JSON.parse(out) : out; | |
| applyMemory(mem); | |
| return mem; | |
| } catch (e) { | |
| $("#week-strip").innerHTML = "<p class='empty-hint'>couldn't load memory — is the backend running?</p>"; | |
| return null; | |
| } | |
| } | |
| /* ================= SETTINGS ================= */ | |
| const LS_TTS = "lifeos.tts_default"; | |
| const LS_WEB = "lifeos.web_default"; | |
| function initSettings() { | |
| const ttsDef = localStorage.getItem(LS_TTS) === "1"; | |
| const webDef = localStorage.getItem(LS_WEB) === "1"; | |
| setToggle($("#set-tts"), ttsDef); | |
| setToggle($("#set-web"), webDef); | |
| setToggle($("#tts-toggle"), ttsDef); | |
| setToggle($("#web-toggle"), webDef); | |
| } | |
| wireToggle($("#set-tts"), (on) => { localStorage.setItem(LS_TTS, on ? "1" : "0"); setToggle($("#tts-toggle"), on); }); | |
| wireToggle($("#set-web"), (on) => { localStorage.setItem(LS_WEB, on ? "1" : "0"); setToggle($("#web-toggle"), on); }); | |
| /* ================= THEME TOGGLE ================= */ | |
| const LS_THEME = "lifeos.theme"; | |
| function setTheme(theme) { | |
| if (theme === "light") { | |
| document.body.classList.add("light-mode"); | |
| $("#theme-icon-sun").style.display = "none"; | |
| $("#theme-icon-moon").style.display = "inline-block"; | |
| $("#theme-text").textContent = "dark"; | |
| localStorage.setItem(LS_THEME, "light"); | |
| } else { | |
| document.body.classList.remove("light-mode"); | |
| $("#theme-icon-sun").style.display = "inline-block"; | |
| $("#theme-icon-moon").style.display = "none"; | |
| $("#theme-text").textContent = "light"; | |
| localStorage.setItem(LS_THEME, "dark"); | |
| } | |
| } | |
| function initTheme() { | |
| const saved = localStorage.getItem(LS_THEME) || "light"; | |
| setTheme(saved); | |
| } | |
| $("#theme-toggle").addEventListener("click", () => { | |
| const isLight = document.body.classList.contains("light-mode"); | |
| setTheme(isLight ? "dark" : "light"); | |
| }); | |
| $$(".reset-btn").forEach((btn) => btn.addEventListener("click", async () => { | |
| // Wipe all stored data back to a blank slate. (In demo builds, the demo | |
| // persona can be reloaded server-side via LIFEOS_DEMO + reset_demo.) | |
| if (!confirm("Erase all your LifeOS data and start fresh? This can't be undone.")) return; | |
| setBusy(btn, true); setThinking(true); | |
| try { | |
| const out = await callGradio("reset_account", []); | |
| const mem = typeof out === "string" ? JSON.parse(out) : out; | |
| if (mem.error) { toast(mem.error); return; } | |
| applyMemory(mem); | |
| deals = []; renderDeals(); | |
| subscriptions = []; renderSubs(); | |
| history = []; goalHistory = []; activeGoal = null; | |
| restoreGreeting(); | |
| $("#goal-list").innerHTML = "<p class='empty-hint'>talk it through — the coach asks questions, you find the plan</p>"; | |
| renderMemRows(); | |
| loadNotes(); | |
| toast("data cleared", true); | |
| } catch (e) { toast(`reset: ${e.message}`); } | |
| finally { setBusy(btn, false); setThinking(false); } | |
| })); | |
| /* ================= init ================= */ | |
| $("#ev-date").value = isoDate(new Date()); | |
| renderPayments([]); | |
| initSettings(); | |
| initTheme(); | |
| refreshMemoryViews(); | |
| renderStatus(); | |
| pollModelStatus(); | |