/* ──────────────────────────────────────────────────────────────
Kirana AI — client glue + flagship motion + voice capture
Talks to gr.Server (FastAPI) over plain fetch(). UI state is
client-owned and round-trips with every dispatch.
────────────────────────────────────────────────────────────── */
(function () {
function $(sel, root) { return (root || document).querySelector(sel); }
function $all(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
function waitFor(selector, callback, timeout) {
timeout = timeout || 15000;
const found = $(selector);
if (found) { callback(found); return; }
const t0 = Date.now();
const iv = setInterval(function () {
const el = $(selector);
if (el) { clearInterval(iv); callback(el); return; }
if (Date.now() - t0 > timeout) {
clearInterval(iv);
console.warn("[kirana] timeout waiting for", selector);
}
}, 80);
}
/* Swap the page-host inner HTML with what the server rendered and
persist the new client-owned state. The MutationObserver in init()
will fire processAfterRender + wireDynamic on the new DOM. */
function applyResponse(resp) {
if (!resp) return;
if (resp.state) window.kirana._state = resp.state;
if (typeof resp.html === "string") {
const host = $(".page-host");
if (host) host.innerHTML = resp.html;
}
}
function dispatch(action, params) {
params = params || {};
const body = JSON.stringify({
action: action,
params: params,
state: window.kirana._state || null,
});
return fetch("/api/dispatch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body,
})
.then(function (r) { return r.json(); })
.then(applyResponse)
.catch(function (e) {
console.error("[kirana] dispatch failed", e);
toast("Network error", "danger");
});
}
function readForm(container) {
const root = (typeof container === "string") ? $(container) : container;
if (!root) return {};
const data = {};
root.querySelectorAll("[name]").forEach(function (el) {
if (el.type === "checkbox") data[el.name] = el.checked;
else if (el.type === "radio") { if (el.checked) data[el.name] = el.value; }
else data[el.name] = el.value;
});
return data;
}
function submitForm(formId, action, extra) {
const data = readForm("#" + formId);
if (extra) Object.assign(data, extra);
dispatch(action, data);
}
function quickAction(action, params) {
// Optimistic UI for slow async actions
if (action === "refresh_insights" || action === "run_analysis") {
const strip = $(".insight-strip");
if (strip) strip.classList.add("is-processing");
const btn = document.activeElement;
if (btn && btn.tagName === "BUTTON") btn.classList.add("refresh-spinning");
}
dispatch(action, params || {});
}
function navigate(page) { dispatch("navigate", { to: page }); }
function switchTab(group, name) {
document.querySelectorAll('[data-tab-group="' + group + '"]').forEach(function (b) {
b.classList.toggle("active", b.dataset.tab === name);
if (b.getAttribute("role") === "tab") {
b.setAttribute("aria-selected", b.dataset.tab === name ? "true" : "false");
b.tabIndex = b.dataset.tab === name ? 0 : -1;
}
});
document.querySelectorAll('[data-tab-pane="' + group + '"]').forEach(function (p) {
p.style.display = (p.dataset.pane === name) ? "" : "none";
});
}
function ensureToastHost() {
let host = $(".toast-host");
if (!host) {
host = document.createElement("div");
host.className = "toast-host";
document.body.appendChild(host);
}
return host;
}
function toast(message, type) {
type = type || "info";
const host = ensureToastHost();
const el = document.createElement("div");
el.className = "toast " + type;
el.setAttribute("role", type === "danger" ? "alert" : "status");
el.innerHTML = '
' + message + '
';
host.appendChild(el);
setTimeout(function () {
el.classList.add("is-leaving");
setTimeout(function () { el.remove(); }, 260);
}, 3200);
}
function applySidebarState() {
const root = $(".k-app");
if (!root) return;
const collapsed = localStorage.getItem("kirana.sidebar") === "collapsed";
root.classList.toggle("collapsed", collapsed);
const btn = $(".k-collapse-btn");
if (btn) {
btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
btn.setAttribute("aria-label", collapsed ? "Expand sidebar" : "Collapse sidebar");
btn.setAttribute("title", collapsed ? "Expand sidebar" : "Collapse sidebar");
}
}
/* ══════════════════════════════════════════════════════════
Theme: dark default; light opt-in. Persisted to
localStorage; first load respects prefers-color-scheme.
══════════════════════════════════════════════════════════ */
function getStoredTheme() {
const v = localStorage.getItem("kirana.theme");
if (v === "light" || v === "dark") return v;
const prefersLight = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: light)").matches;
return prefersLight ? "light" : "dark";
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
const btn = $("#theme-toggle");
if (btn) {
const next = theme === "light" ? "dark" : "light";
btn.setAttribute("aria-pressed", theme === "light" ? "true" : "false");
btn.setAttribute("title", "Switch to " + next + " theme");
btn.setAttribute("aria-label", "Switch to " + next + " theme");
}
}
function toggleTheme() {
const cur = document.documentElement.getAttribute("data-theme") || getStoredTheme();
const next = cur === "light" ? "dark" : "light";
localStorage.setItem("kirana.theme", next);
applyTheme(next);
}
function toggleSidebar() {
const root = $(".k-app");
if (!root) return;
const nowCollapsed = !root.classList.contains("collapsed");
root.classList.toggle("collapsed", nowCollapsed);
localStorage.setItem("kirana.sidebar", nowCollapsed ? "collapsed" : "expanded");
const btn = $(".k-collapse-btn");
if (btn) {
btn.setAttribute("aria-expanded", nowCollapsed ? "false" : "true");
btn.setAttribute("aria-label", nowCollapsed ? "Expand sidebar" : "Collapse sidebar");
btn.setAttribute("title", nowCollapsed ? "Expand sidebar" : "Collapse sidebar");
}
}
function openPhoto() {
const input = $("#kirana-photo-input");
if (input) input.click();
else toast("Photo uploader not ready", "warn");
}
function uploadPhoto(file) {
if (!file) return;
const fd = new FormData();
fd.append("image", file);
fd.append("state", JSON.stringify(window.kirana._state || {}));
toast("Sending photo to Modal. Cold starts can take 10-30 seconds…", "info");
fetch("/api/photo", { method: "POST", body: fd })
.then(function (r) { return r.json(); })
.then(applyResponse)
.catch(function (e) {
console.error("[kirana] photo upload failed", e);
toast("Photo upload failed", "danger");
});
}
function transcribeAudio() {
const input = $("#modal-speech-audio");
if (!input || !input.files || !input.files[0]) {
toast("Choose an audio file first.", "warn");
return;
}
const fd = new FormData();
fd.append("audio", input.files[0]);
fd.append("state", JSON.stringify(window.kirana._state || {}));
toast("Sending audio to Modal. Cold starts can take 10-30 seconds…", "info");
fetch("/api/speech", { method: "POST", body: fd })
.then(function (r) { return r.json(); })
.then(function (payload) {
applyResponse(payload);
const transcript =
payload &&
payload.state &&
payload.state.voice_result &&
payload.state.voice_result.transcript;
if (transcript) {
const inputText = $("#voice-text");
const transcriptBox = $("#voice-transcript");
if (inputText) inputText.value = transcript;
if (transcriptBox) transcriptBox.textContent = transcript;
}
toast("Speech transcribed", "success");
})
.catch(function (e) {
console.error("[kirana] speech upload failed", e);
toast("Speech transcription failed", "danger");
});
}
/* ══════════════════════════════════════════════════════════
Number count-up: scans elements with [data-count] and
tweens from 0 → target. Honors reduced motion.
══════════════════════════════════════════════════════════ */
function easeOutQuart(t) { return 1 - Math.pow(1 - t, 4); }
function countUp(el) {
if (el.dataset.counted === "1") return;
el.dataset.counted = "1";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const finalText = el.textContent.trim();
// Parse: leading prefix (₹, etc.), digits/commas/dots, trailing suffix (%, etc.)
const match = finalText.match(/^([^\d\-]*)(-?[\d,.]+)(.*)$/);
if (!match) return;
const prefix = match[1];
const target = parseFloat(match[2].replace(/,/g, ""));
const suffix = match[3];
if (!isFinite(target) || reduce || target === 0) return;
const decimals = (match[2].split(".")[1] || "").length;
const dur = Math.min(900, 360 + Math.abs(target) * 0.6);
const t0 = performance.now();
function step(now) {
const t = Math.min(1, (now - t0) / dur);
const v = target * easeOutQuart(t);
const display = decimals > 0
? v.toFixed(decimals)
: Math.round(v).toLocaleString("en-IN");
el.textContent = prefix + display + suffix;
if (t < 1) requestAnimationFrame(step);
else el.textContent = finalText;
}
requestAnimationFrame(step);
}
/* ══════════════════════════════════════════════════════════
Health-bar reveal: read flex from inline style, replace
with target custom-prop, then add .is-revealed.
══════════════════════════════════════════════════════════ */
function revealHealthBar(bar) {
if (bar.dataset.revealed === "1") return;
bar.dataset.revealed = "1";
$all(".health-seg", bar).forEach(function (seg) {
const style = seg.getAttribute("style") || "";
const m = style.match(/flex\s*:\s*([\d.]+)/);
const target = m ? parseFloat(m[1]) : 1;
seg.style.flex = "";
seg.style.setProperty("--hb-target", target);
seg.style.setProperty("--hb-grow", 0);
});
// force layout then flip
void bar.offsetWidth;
requestAnimationFrame(function () {
bar.classList.add("is-revealed");
});
}
/* ══════════════════════════════════════════════════════════
Decorate dashboard: count-up KPIs, index list items for
stagger, kick health-bar reveal once visible.
══════════════════════════════════════════════════════════ */
function decorateDashboard() {
// Index KPIs and cards for staggered entrance
$all(".kpi-grid .kpi").forEach(function (el, i) { el.style.setProperty("--i", i); });
$all(".reorder-list").forEach(function (list) {
list.classList.add("stagger");
$all(".reorder-row", list).forEach(function (el, i) { el.style.setProperty("--i", i); });
});
$all(".fest-strip").forEach(function (list) {
list.classList.add("stagger");
$all(".fest-step", list).forEach(function (el, i) { el.style.setProperty("--i", i); });
});
$all(".liquidation-list").forEach(function (list) {
list.classList.add("stagger");
$all(".liquidation-row", list).forEach(function (el, i) { el.style.setProperty("--i", i); });
});
$all(".alert-list, .alert-list-compact").forEach(function (list) {
list.classList.add("stagger");
$all(".alert", list).forEach(function (el, i) { el.style.setProperty("--i", i); });
});
$all(".seller-list").forEach(function (list) {
list.classList.add("stagger");
$all(".seller-row-v2", list).forEach(function (el, i) { el.style.setProperty("--i", i); });
});
$all(".chart-list").forEach(function (list) {
list.classList.add("stagger");
$all(".chart-row", list).forEach(function (el, i) { el.style.setProperty("--i", i); });
});
// KPI numbers + chip numbers
$all(".kpi-value, .alert-chip-val").forEach(countUp);
// Health bar reveal — observe in-view, but most dashboards have it above the fold
$all(".health-bar").forEach(function (bar) {
if ("IntersectionObserver" in window) {
const io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) { revealHealthBar(bar); io.disconnect(); }
});
}, { threshold: 0.2 });
io.observe(bar);
// Safety: reveal after 600ms regardless (handles tabs hidden / no IO trigger)
setTimeout(function () { revealHealthBar(bar); }, 600);
} else {
revealHealthBar(bar);
}
});
// Chart bars: animate width from 0 to target
$all(".chart-bar-fill").forEach(function (fill) {
if (fill.dataset.anim === "1") return;
fill.dataset.anim = "1";
const target = fill.style.width;
fill.style.width = "0%";
requestAnimationFrame(function () {
fill.style.transition = "width 720ms var(--ease-out-quint, cubic-bezier(0.22,1,0.36,1))";
fill.style.width = target;
});
});
// Seller bars (background fill)
$all(".seller-bar-bg").forEach(function (bar) {
if (bar.dataset.anim === "1") return;
bar.dataset.anim = "1";
const target = bar.style.width;
bar.style.width = "0%";
requestAnimationFrame(function () {
bar.style.transition = "width 720ms var(--ease-out-quint, cubic-bezier(0.22,1,0.36,1))";
bar.style.width = target;
});
});
}
/* ══════════════════════════════════════════════════════════
VOICE CAPTURE
Web Speech API + getUserMedia analyser for mic level bars.
══════════════════════════════════════════════════════════ */
const Voice = (function () {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const state = {
rec: null,
listening: false,
lang: "te-IN",
audioCtx: null,
analyser: null,
stream: null,
rafId: null,
final: "",
interim: "",
};
const BAR_COUNT = 18;
function supported() { return !!SR; }
function setStatus(msg, kind) {
const el = $("#voice-status");
if (!el) return;
el.textContent = msg;
el.classList.toggle("is-error", kind === "error");
}
function renderTranscript() {
const el = $("#voice-transcript");
if (!el) return;
const finalPart = state.final;
const interimPart = state.interim
? '' + escapeHtml(state.interim) + ''
: "";
el.innerHTML = escapeHtml(finalPart) +
(finalPart && interimPart ? " " : "") +
interimPart;
el.classList.toggle("is-active", !!(finalPart || interimPart));
}
function escapeHtml(s) {
return (s || "").replace(/[&<>"']/g, function (c) {
return ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c];
});
}
function setLang(lang) {
state.lang = lang;
$all(".voice-lang button").forEach(function (b) {
const on = b.dataset.lang === lang;
b.setAttribute("aria-pressed", on ? "true" : "false");
});
const tr = $("#voice-transcript");
if (tr) tr.setAttribute("lang", lang.split("-")[0]);
if (state.rec) {
try { state.rec.lang = lang; } catch (e) {}
}
if (state.listening) {
stop();
setTimeout(start, 120);
}
}
async function startAnalyser() {
try {
if (!state.stream) {
state.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
}
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!state.audioCtx) state.audioCtx = new Ctx();
if (!state.analyser) {
state.analyser = state.audioCtx.createAnalyser();
state.analyser.fftSize = 64;
state.analyser.smoothingTimeConstant = 0.78;
const src = state.audioCtx.createMediaStreamSource(state.stream);
src.connect(state.analyser);
}
if (state.audioCtx.state === "suspended") await state.audioCtx.resume();
tickAnalyser();
$("#voice-wave")?.classList.add("is-live");
} catch (e) {
// No mic permission for analyser — still allow speech recognition
console.warn("[voice] analyser unavailable", e);
}
}
function stopAnalyser() {
if (state.rafId) cancelAnimationFrame(state.rafId);
state.rafId = null;
$("#voice-wave")?.classList.remove("is-live");
// Drain bars to rest
const bars = $all("#voice-wave span");
bars.forEach(function (b) { b.style.setProperty("--lvl", 0); });
}
function tickAnalyser() {
const bars = $all("#voice-wave span");
if (!state.analyser || !bars.length) return;
const data = new Uint8Array(state.analyser.frequencyBinCount);
function loop() {
state.analyser.getByteFrequencyData(data);
// Map low → high frequencies to bars, mirrored for symmetry
const half = Math.ceil(bars.length / 2);
for (let i = 0; i < half; i++) {
const idx = Math.floor((i / half) * (data.length * 0.7));
const lvl = Math.min(1, (data[idx] || 0) / 200);
// Mirror around center
bars[half - 1 - i]?.style.setProperty("--lvl", lvl.toFixed(3));
bars[half + i]?.style.setProperty("--lvl", lvl.toFixed(3));
}
state.rafId = requestAnimationFrame(loop);
}
loop();
}
function start() {
if (!supported()) {
setStatus("Speech recognition not supported in this browser. Type the command instead.", "error");
return;
}
if (state.listening) return;
try {
state.rec = new SR();
} catch (e) {
setStatus("Could not start microphone: " + e.message, "error");
return;
}
state.rec.lang = state.lang;
state.rec.interimResults = true;
state.rec.continuous = false;
state.rec.maxAlternatives = 1;
state.rec.onstart = function () {
state.listening = true;
state.final = "";
state.interim = "";
renderTranscript();
$(".voice-mic")?.classList.add("is-listening");
const langLabel = state.lang === "te-IN" ? "Telugu" : "English";
setStatus("Listening in " + langLabel + "…");
startAnalyser();
};
state.rec.onresult = function (ev) {
let interim = "";
let final = state.final;
for (let i = ev.resultIndex; i < ev.results.length; i++) {
const r = ev.results[i];
if (r.isFinal) final += r[0].transcript;
else interim += r[0].transcript;
}
state.final = final;
state.interim = interim;
renderTranscript();
};
state.rec.onerror = function (ev) {
if (ev.error === "no-speech") {
setStatus("Didn't catch that. Tap the mic and try again.");
} else if (ev.error === "not-allowed" || ev.error === "service-not-allowed") {
setStatus("Microphone permission denied. Type the command below.", "error");
} else if (ev.error === "language-not-supported") {
setStatus("This browser doesn't support " + state.lang + ". Try English.", "error");
} else {
setStatus("Microphone error: " + ev.error, "error");
}
};
state.rec.onend = function () {
state.listening = false;
$(".voice-mic")?.classList.remove("is-listening");
stopAnalyser();
const text = (state.final || state.interim || "").trim();
if (text) {
const input = $("#voice-text");
if (input) input.value = text;
setStatus('Heard: "' + text + '". Submitting…');
setTimeout(function () { submitVoice(); }, 250);
} else {
setStatus("Tap the mic and speak, or type below.");
}
};
try { state.rec.start(); }
catch (e) { setStatus("Could not start: " + e.message, "error"); }
}
function stop() {
if (state.rec && state.listening) {
try { state.rec.stop(); } catch (e) {}
}
}
function toggle() {
state.listening ? stop() : start();
}
function submitVoice() {
const input = $("#voice-text");
const text = input ? input.value.trim() : (state.final || "").trim();
if (!text) {
setStatus("Nothing to submit yet.");
return;
}
submitForm("form-voice", "voice_command", { text: text, lang: state.lang });
}
function fillExample(text) {
const input = $("#voice-text");
const tr = $("#voice-transcript");
if (input) input.value = text;
if (tr) { tr.textContent = text; tr.classList.add("is-active"); }
state.final = text; state.interim = "";
setStatus('Loaded example. Parsing for approval…');
setTimeout(function () { submitVoice(); }, 200);
}
function init() {
const stack = $(".voice-mic-stack");
if (!stack || stack.dataset.wired === "1") return;
stack.dataset.wired = "1";
// Build the level bars
const wave = $("#voice-wave");
if (wave && !wave.children.length) {
for (let i = 0; i < BAR_COUNT; i++) {
const s = document.createElement("span");
s.style.setProperty("--lvl", 0);
wave.appendChild(s);
}
}
// Wire mic button
const mic = $(".voice-mic");
if (mic) mic.addEventListener("click", toggle);
// Wire language pills
$all(".voice-lang button").forEach(function (b) {
b.addEventListener("click", function () { setLang(b.dataset.lang); });
});
// Wire examples
$all(".voice-chip").forEach(function (c) {
c.addEventListener("click", function () { fillExample(c.dataset.example || c.textContent.trim()); });
});
// Initial status
if (!supported()) {
setStatus("Speech recognition not available in this browser. Type the command below.", "error");
mic && mic.setAttribute("disabled", "true");
} else {
setStatus("Tap the mic and speak in Telugu or English.");
}
// Default language: Telugu
setLang("te-IN");
}
// Keyboard shortcut: hold Space when voice pane is visible
document.addEventListener("keydown", function (e) {
const pane = $("#pane-voice");
if (!pane || pane.style.display === "none") return;
if (e.code !== "Space" || e.repeat) return;
if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
e.preventDefault();
if (window.kirana && window.kirana.voice && window.kirana.voice.start) {
window.kirana.voice.start();
}
});
return { init: init, start: start, stop: stop, toggle: toggle, submit: submitVoice, fillExample: fillExample, setLang: setLang };
})();
function processAfterRender() {
const root = $(".k-app");
if (!root) return;
applySidebarState();
const t = root.getAttribute("data-toast");
if (t) {
root.removeAttribute("data-toast");
const idx = t.indexOf("|");
if (idx >= 0) toast(t.slice(idx + 1), t.slice(0, idx));
else toast(t, "info");
}
decorateDashboard();
if (window.kirana && window.kirana.voice && window.kirana.voice.init) window.kirana.voice.init();
}
function wirePulseChart() {
const chart = $(".pulse-chart");
if (!chart || chart.dataset.wired === "1") return;
let points;
try { points = JSON.parse(chart.dataset.pulsePoints || "[]"); }
catch (e) { return; }
if (!points.length) return;
chart.dataset.wired = "1";
const cursor = chart.querySelector(".pulse-cursor");
const tip = chart.querySelector(".pulse-tip");
const tipDay = tip && tip.querySelector(".pulse-tip-day");
const tipVal = tip && tip.querySelector(".pulse-tip-val");
if (!cursor || !tip) return;
const fmtINR = function (n) { return "₹" + Math.round(n).toLocaleString("en-IN"); };
const fmtDate = function (iso) {
const d = new Date(iso + "T00:00:00");
return d.toLocaleDateString("en-IN", { weekday: "short", day: "numeric", month: "short" });
};
function onMove(ev) {
const rect = chart.getBoundingClientRect();
const x = ev.clientX - rect.left;
const ratio = Math.max(0, Math.min(1, x / rect.width));
// viewBox is 0..100 with PAD=2 — map ratio back to nearest point
const vx = 2 + ratio * 96;
let best = points[0], bestD = Infinity;
for (let i = 0; i < points.length; i++) {
const d = Math.abs(points[i].x - vx);
if (d < bestD) { bestD = d; best = points[i]; }
}
const px = (best.x / 100) * rect.width;
const py = (best.y / 36) * rect.height;
cursor.style.transform = "translateX(" + px.toFixed(1) + "px)";
cursor.classList.add("is-visible");
tipDay.textContent = fmtDate(best.date);
tipVal.textContent = fmtINR(best.rev);
// Clamp tip within chart bounds (tip is ~120px wide)
const tipW = 132;
let tipX = px - tipW / 2;
tipX = Math.max(4, Math.min(rect.width - tipW - 4, tipX));
tip.style.transform = "translate(" + tipX.toFixed(1) + "px, " + Math.max(0, py - 56).toFixed(1) + "px)";
tip.classList.add("is-visible");
}
function onLeave() {
cursor.classList.remove("is-visible");
tip.classList.remove("is-visible");
}
chart.addEventListener("mousemove", onMove);
chart.addEventListener("mouseleave", onLeave);
chart.addEventListener("touchstart", function (e) {
if (e.touches[0]) onMove(e.touches[0]);
}, { passive: true });
chart.addEventListener("touchmove", function (e) {
if (e.touches[0]) onMove(e.touches[0]);
}, { passive: true });
chart.addEventListener("touchend", onLeave);
}
function wireDynamic() {
const trigger = $("#photo-trigger");
if (trigger && !trigger.dataset.wired) {
trigger.dataset.wired = "1";
trigger.addEventListener("click", openPhoto);
}
wirePulseChart();
}
function wirePhotoInput() {
const input = $("#kirana-photo-input");
if (!input || input.dataset.wired === "1") return;
input.dataset.wired = "1";
input.addEventListener("change", function () {
const file = input.files && input.files[0];
if (file) uploadPhoto(file);
input.value = "";
});
}
function init() {
if (window.__KIRANA_STATE__) {
window.kirana._state = window.__KIRANA_STATE__;
}
wirePhotoInput();
applyTheme(getStoredTheme());
waitFor(".page-host", function (host) {
applyTheme(getStoredTheme());
processAfterRender();
wireDynamic();
let scheduled = false;
let processing = false;
function schedule() {
if (scheduled || processing) return;
scheduled = true;
requestAnimationFrame(function () {
scheduled = false;
processing = true;
try {
wireDynamic();
processAfterRender();
applyTheme(getStoredTheme());
} finally {
// Drain any mutations our own processing produced before re-enabling
setTimeout(function () { processing = false; }, 0);
}
});
}
const obs = new MutationObserver(function (mutations) {
if (processing) return;
// Only react to real content swaps (childList), not our own attribute writes
for (let i = 0; i < mutations.length; i++) {
if (mutations[i].type === "childList" &&
(mutations[i].addedNodes.length || mutations[i].removedNodes.length)) {
schedule();
return;
}
}
});
obs.observe(host, { childList: true, subtree: true });
});
}
function createModalVoice() {
let stream = null;
let recorder = null;
let chunks = [];
let isRecording = false;
function setStatus(msg) {
const el = $("#voice-status");
if (el) el.textContent = msg;
}
function setTranscript(msg) {
const el = $("#voice-transcript");
if (el) el.textContent = msg || "";
}
function setListening(on) {
const mic = $("#voice-mic") || $(".voice-mic");
if (mic) {
mic.classList.toggle("is-listening", !!on);
mic.setAttribute("aria-pressed", on ? "true" : "false");
mic.setAttribute("aria-label", on ? "Stop recording" : "Start recording");
}
$("#voice-wave")?.classList.toggle("is-live", !!on);
}
async function start() {
if (isRecording) {
stop();
return;
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
toast("Browser microphone recording is not available. Use audio upload.", "warn");
setStatus("Mic recording unavailable. Use audio upload.");
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];
let mimeType = "";
if (window.MediaRecorder && MediaRecorder.isTypeSupported("audio/webm")) {
mimeType = "audio/webm";
} else if (window.MediaRecorder && MediaRecorder.isTypeSupported("audio/mp4")) {
mimeType = "audio/mp4";
}
recorder = mimeType
? new MediaRecorder(stream, { mimeType: mimeType })
: new MediaRecorder(stream);
recorder.ondataavailable = function (event) {
if (event.data && event.data.size > 0) chunks.push(event.data);
};
recorder.onstop = function () {
const type = recorder && recorder.mimeType ? recorder.mimeType : "audio/webm";
const ext = type.includes("mp4") ? "m4a" : "webm";
const blob = new Blob(chunks, { type: type });
cleanup();
sendBlob(blob, "voice-recording." + ext);
};
recorder.start();
isRecording = true;
setListening(true);
setTranscript("");
setStatus("Recording… click the mic again to stop.");
toast("Recording audio…", "info");
} catch (e) {
console.error("[voice] mic recording failed", e);
cleanup();
setListening(false);
setStatus("Microphone permission failed. Use audio upload.");
toast("Microphone permission failed. Use audio upload.", "danger");
}
}
function stop() {
if (recorder && isRecording) {
setStatus("Sending audio to Modal. Cold starts can take 10-30 seconds…");
toast("Transcribing audio with Modal…", "info");
recorder.stop();
} else {
cleanup();
}
}
function cleanup() {
isRecording = false;
setListening(false);
if (stream) {
stream.getTracks().forEach(function (track) { track.stop(); });
}
stream = null;
recorder = null;
}
function sendBlob(blob, filename) {
if (!blob || blob.size === 0) {
toast("No audio recorded.", "warn");
setStatus("No audio recorded.");
return;
}
const fd = new FormData();
fd.append("state", JSON.stringify(window.kirana._state || {}));
fd.append("audio", blob, filename || "voice-recording.webm");
fetch("/api/speech", { method: "POST", body: fd })
.then(function (r) {
if (!r.ok) throw new Error("Speech API failed: " + r.status);
return r.json();
})
.then(function (payload) {
applyResponse(payload);
const transcript =
payload &&
payload.state &&
payload.state.voice_result &&
payload.state.voice_result.transcript;
const inputText = $("#voice-text");
const transcriptBox = $("#voice-transcript");
if (transcript && inputText) inputText.value = transcript;
if (transcript && transcriptBox) transcriptBox.textContent = transcript;
setStatus(transcript ? "Transcribed with Modal." : "No transcript returned.");
toast(transcript ? "Speech transcribed" : "No transcript returned", transcript ? "success" : "warn");
})
.catch(function (e) {
console.error("[voice] Modal transcription failed", e);
setStatus("Speech transcription failed.");
toast("Speech transcription failed", "danger");
});
}
function init() {
const mic = $("#voice-mic") || $(".voice-mic");
if (mic) {
mic.removeAttribute("disabled");
mic.onclick = function (event) {
event.preventDefault();
start();
};
mic.title = "Record audio and transcribe with Modal";
}
setStatus("Press the mic to record with Modal, then press again to stop.");
}
return {
init: init,
start: start,
stop: stop,
};
}
window.kirana = {
_state: null,
dispatch: dispatch,
navigate: navigate,
submitForm: submitForm,
transcribeAudio: transcribeAudio,
quickAction: quickAction,
switchTab: switchTab,
readForm: readForm,
toast: toast,
openPhoto: openPhoto,
toggleSidebar: toggleSidebar,
toggleTheme: toggleTheme,
voice: createModalVoice(),
};
fetch("/api/warm").catch(function () {});
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();