window.__previewEnabled = false; window.__dashboardPoll = null; window.__cameraSourcesCache = null; window.__runtimeMode = "manual"; window.__peoplePollCounter = 0; window.__audioPromptLibrary = []; const RESOLUTION_PRESETS = [ [1920, 1080, 30], [1920, 1080, 15], [1280, 720, 30], [1280, 720, 15], [960, 540, 30], [960, 540, 15], [848, 480, 60], [848, 480, 30], [640, 480, 60], [640, 480, 30], [640, 480, 15], [424, 240, 60], [424, 240, 30], ]; async function apiGet(path) { const response = await fetch(path); const raw = await response.text(); let data = null; try { data = raw ? JSON.parse(raw) : null; } catch (_e) { if (!response.ok) { throw new Error(raw || `HTTP ${response.status}`); } return raw; } if (!response.ok) { throw new Error((data && (data.error || data.result)) || raw || `HTTP ${response.status}`); } return data; } async function apiPostFormData(path, formData) { const response = await fetch(path, { method: "POST", body: formData }); const raw = await response.text(); let data = null; try { data = raw ? JSON.parse(raw) : null; } catch (_e) { if (!response.ok) { throw new Error(raw || `HTTP ${response.status}`); } return raw; } if (!response.ok) { throw new Error((data && (data.error || data.result)) || raw || `HTTP ${response.status}`); } return data; } async function apiPostJson(path, payload) { const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload || {}), }); const raw = await response.text(); let data = null; try { data = raw ? JSON.parse(raw) : null; } catch (_e) { if (!response.ok) { throw new Error(raw || `HTTP ${response.status}`); } return raw; } if (!response.ok) { throw new Error((data && (data.error || data.result)) || raw || `HTTP ${response.status}`); } return data; } function el(tag, cls, html) { const node = document.createElement(tag); if (cls) node.className = cls; if (html !== undefined) node.innerHTML = html; return node; } function escapeHtml(text) { return String(text || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function formatSize(n) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } function fmtNum(v, digits = 2) { const n = Number(v); if (Number.isNaN(n)) return "-"; return n.toFixed(digits); } function formatTime(ts) { const n = Number(ts); if (!Number.isFinite(n) || n <= 0) return "-"; try { return new Date(n * 1000).toLocaleString(); } catch (_e) { return "-"; } } function cameraStatusLines(camera) { return [ `Camera OK`, `Source: ${camera.source || "-"}`, `Backend: ${camera.backend || "-"}`, `Active: ${camera.profile || "-"}`, `Requested: ${camera.requested_profile || "-"}`, `Preferred RS Serial: ${camera.preferred_realsense_serial || "-"}`, `Active RS Serial: ${camera.realsense_serial || "-"}`, ].join("\n"); } function setPreviewEnabled(enabled) { window.__previewEnabled = !!enabled; const btn = document.getElementById("toggle_preview"); const img = document.getElementById("live_preview"); const placeholder = document.getElementById("preview_placeholder"); const hint = document.getElementById("preview_hint"); if (btn) btn.textContent = window.__previewEnabled ? "Hide Live Camera" : "Show Live Camera"; if (!img) return; if (window.__previewEnabled) { img.style.display = "block"; img.src = `/preview.mjpg?t=${Date.now()}`; if (placeholder) placeholder.style.display = "none"; if (hint) hint.textContent = "Live preview is running through the shared direct camera service."; } else { img.removeAttribute("src"); img.src = ""; img.style.display = "none"; if (placeholder) placeholder.style.display = "grid"; if (hint) hint.textContent = "Live preview is off. Turn it on only when needed."; } } function restartPreview() { if (!window.__previewEnabled) return; const img = document.getElementById("live_preview"); if (!img) return; img.src = `/preview.mjpg?t=${Date.now()}`; } function setResolutionInputs(width, height, fps) { const widthInput = document.getElementById("width_input"); const heightInput = document.getElementById("height_input"); const fpsInput = document.getElementById("fps_input"); const presetSelect = document.getElementById("resolution_select"); if (widthInput) widthInput.value = String(width || ""); if (heightInput) heightInput.value = String(height || ""); if (fpsInput) fpsInput.value = String(fps || ""); if (presetSelect) { const preset = `${width}x${height}@${fps}`; const hasPreset = Array.from(presetSelect.options).some((opt) => opt.value === preset); presetSelect.value = hasPreset ? preset : ""; } } function renderResolutionOptions() { return [ '', ...RESOLUTION_PRESETS.map(([w, h, fps]) => ``), ].join(""); } function renderSourceOptions(cameraSources) { const options = (cameraSources && cameraSources.options) || []; if (!options.length) { return ''; } return options .map((item) => ``) .join(""); } function renderCameraInventory(cameraSources, camera) { const lines = []; const preferred = String((cameraSources && cameraSources.preferred_realsense_serial) || (camera && camera.preferred_realsense_serial) || "").trim(); const active = String((camera && camera.realsense_serial) || "").trim(); const activeSource = String((camera && camera.source) || "").trim(); if (preferred) { lines.push(`Preferred RealSense: ${preferred}`); } if (active) { lines.push(`Active RealSense: ${active}`); } const rsDevices = (cameraSources && cameraSources.realsense_devices) || []; if (rsDevices.length) { lines.push("RealSense devices:"); for (const item of rsDevices) { const serial = String(item.serial || "").trim() || "-"; const name = String(item.name || "Intel RealSense").trim(); const tags = []; if (item.is_preferred) tags.push("default"); if (item.is_active) tags.push("live"); lines.push(`- ${name} [${serial}]${tags.length ? ` (${tags.join(", ")})` : ""}`); } } const videoDevices = (cameraSources && cameraSources.video_devices) || []; if (videoDevices.length) { lines.push("Video nodes:"); for (const item of videoDevices) { const name = String(item.name || "").trim(); const label = String(item.value || item.label || "").trim(); const suffix = label === activeSource ? " (live)" : ""; lines.push(`- ${label}${name ? ` :: ${name}` : ""}${suffix}`); } } return lines.join("\n") || "No camera inventory available."; } function personImageUrl(personId, kind) { return `/api/person_image?id=${encodeURIComponent(personId)}&kind=${encodeURIComponent(kind)}&t=${Date.now()}`; } function renderPeopleCards(items) { if (!items.length) { return '
No people enrolled yet.
'; } return items.map((person) => { const personId = String(person.person_id || ""); const title = escapeHtml(person.display_name || person.short_id || personId || "guest"); const meta = [ `ID: ${escapeHtml(person.short_id || personId || "-")}`, `Created: ${escapeHtml(person.created_date || "-")}`, `Seen: ${person.times_seen ?? 0}`, `Samples: ${person.sample_count ?? 0}`, `Last Seen: ${escapeHtml(formatTime(person.last_seen_at || 0))}`, ].join("\n"); return `
${title} face ${title} scene
${title}
${meta}
Download
`; }).join(""); } function selectedCameraSerial(source, cameraSources, camera) { const src = String(source || "").trim(); if (src.startsWith("realsense:")) { return src.split(":", 2)[1] || ""; } if (src === "realsense") { return String((camera && camera.realsense_serial) || (cameraSources && cameraSources.preferred_realsense_serial) || "").trim(); } const devices = (cameraSources && cameraSources.realsense_devices) || []; const exact = devices.find((item) => item.value === src); return String((exact && exact.serial) || "").trim(); } function formatPromptKeyLabel(key) { return String(key || "") .split("_") .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } function promptFilenameForKey(key) { const item = (window.__audioPromptLibrary || []).find((entry) => entry.key === key); return item ? String(item.filename || `${key}.wav`) : `${key}.wav`; } function promptTextForKey(key) { const item = (window.__audioPromptLibrary || []).find((entry) => entry.key === key); return item ? String(item.text || "") : ""; } function populateAudioPromptRecorder(prompts) { window.__audioPromptLibrary = Array.isArray(prompts) ? prompts.slice() : []; const keySelect = document.getElementById("audio_prompt_record_key"); const filenameInput = document.getElementById("audio_prompt_record_filename"); const textArea = document.getElementById("audio_prompt_record_text"); if (!keySelect || !filenameInput || !textArea) return; const previousKey = keySelect.value; keySelect.innerHTML = (window.__audioPromptLibrary || []) .map((item) => ``) .join(""); if (previousKey && (window.__audioPromptLibrary || []).some((item) => item.key === previousKey)) { keySelect.value = previousKey; } const activeKey = keySelect.value || ((window.__audioPromptLibrary[0] && window.__audioPromptLibrary[0].key) || ""); if (activeKey) { keySelect.value = activeKey; if (!filenameInput.value || filenameInput.dataset.autofill !== "0") { filenameInput.value = promptFilenameForKey(activeKey); filenameInput.dataset.autofill = "1"; } if (!textArea.value || textArea.dataset.autofill !== "0") { textArea.value = promptTextForKey(activeKey); textArea.dataset.autofill = "1"; } } } async function refreshAudioPromptRecordStatus() { const status = document.getElementById("audio_prompt_record_status_box"); if (!status) return; try { const res = await apiGet("/api/audio_prompt_record_status"); const s = res.status || {}; if (s.running) { status.textContent = [ `Recording prompt: ${s.key || "-"}`, `Filename: ${s.filename || "-"}`, `Started: ${formatTime(s.started_at || 0)}`, "Status: generating Gemini audio and recording speaker output...", ].join("\n"); return; } if (s.finished_at) { if (s.ok) { const result = s.result || {}; status.textContent = [ `Recorded prompt: ${result.key || s.key || "-"}`, `Saved speaker file: ${result.filename || s.filename || "-"}`, `Saved raw file: ${result.raw_filename || "-"}`, `Finished: ${formatTime(s.finished_at || 0)}`, ].join("\n"); } else { status.textContent = [ `Prompt recording failed for ${s.key || "-"}`, `Finished: ${formatTime(s.finished_at || 0)}`, `${s.error || "Unknown error"}`, ].join("\n"); } return; } status.textContent = "Prompt recorder idle."; } catch (e) { status.textContent = `Prompt recorder unavailable\n${e.message}`; } } function renderAudioPromptCards(items) { if (!items.length) { return '
No audio prompt keys available.
'; } return items.map((item) => { const exists = !!item.exists; const key = String(item.key || ""); const filename = String(item.filename || ""); return `
${escapeHtml(formatPromptKeyLabel(key))}
${escapeHtml(key)}
${exists ? "uploaded" : "missing"}
${escapeHtml(item.text || "")}
File: ${escapeHtml(filename || "-")}
Size: ${formatSize(item.size || 0)}
Raw: ${item.raw_exists ? escapeHtml(item.raw_filename || "-") : "missing"}
Updated: ${formatTime(item.mtime || 0)}
${exists ? `Download` : ""}
`; }).join(""); } async function refreshAudioPromptPanel() { const list = document.getElementById("audio_prompt_list"); const status = document.getElementById("audio_prompt_status_box"); const modeToggle = document.getElementById("audio_prompt_mode_toggle"); const toggle = document.getElementById("audio_prompt_fallback_toggle"); const fileInput = document.getElementById("audio_prompt_upload_file"); if (!list || !status || !toggle || !fileInput || !modeToggle) return; try { const res = await apiGet("/api/audio_prompts"); const prompts = res.prompts || []; const promptMode = String(res.mode || "audio").trim().toLowerCase() === "gemini" ? "gemini" : "audio"; const fallbackToGemini = !!res.fallback_to_gemini; const available = prompts.filter((item) => item.exists).length; modeToggle.dataset.mode = promptMode; modeToggle.textContent = promptMode === "gemini" ? "Situation Speech: GEMINI" : "Situation Speech: AUDIO"; toggle.dataset.enabled = fallbackToGemini ? "1" : "0"; toggle.textContent = fallbackToGemini ? "Gemini Fallback: ON" : "Gemini Fallback: OFF"; populateAudioPromptRecorder(prompts); status.textContent = [ `Prompt folder: ${res.dir || "AI_Photographer/Data/Audio"}`, `Available clips: ${available} / ${prompts.length}`, `Fixed AI situation speech: ${promptMode.toUpperCase()}`, `Missing-clip Gemini fallback: ${fallbackToGemini ? "ON" : "OFF"}`, promptMode === "audio" ? "Recorded audio is the default for AI situations such as greeting on detection, photo request, countdown, refusal, retake, and thank-you. After those fixed prompts finish, normal Gemini conversation continues." : "Gemini is currently speaking the fixed AI situation prompts directly. General Gemini conversation remains available before and after the photo flow." ].join("\n"); list.innerHTML = renderAudioPromptCards(prompts); list.querySelectorAll(".audio-prompt-upload").forEach((btn) => { btn.addEventListener("click", () => { fileInput.dataset.key = btn.dataset.key || ""; fileInput.dataset.filename = btn.dataset.filename || ""; fileInput.click(); }); }); list.querySelectorAll(".audio-prompt-record").forEach((btn) => { btn.addEventListener("click", () => { const key = btn.dataset.key || ""; const keySelect = document.getElementById("audio_prompt_record_key"); const filenameInput = document.getElementById("audio_prompt_record_filename"); const textArea = document.getElementById("audio_prompt_record_text"); if (keySelect) keySelect.value = key; if (filenameInput) { filenameInput.value = promptFilenameForKey(key); filenameInput.dataset.autofill = "1"; } if (textArea) { textArea.value = promptTextForKey(key); textArea.dataset.autofill = "1"; textArea.focus(); } }); }); list.querySelectorAll(".audio-prompt-delete").forEach((btn) => { btn.addEventListener("click", async () => { const key = btn.dataset.key || ""; if (!key) return; if (!confirm(`Delete audio prompt clip for ${key}?`)) return; await apiGet(`/api/delete_audio_prompt?key=${encodeURIComponent(key)}`); await refreshAudioPromptPanel(); await refreshAudioPromptRecordStatus(); }); }); } catch (e) { status.textContent = `Audio prompt library unavailable\n${e.message}`; list.innerHTML = ""; toggle.textContent = "Gemini Fallback"; delete toggle.dataset.enabled; } } function bindTopActions() { const topStatus = document.getElementById("top_status"); const topCapture = document.getElementById("top_capture"); const topTest = document.getElementById("top_test"); const topFix = document.getElementById("top_fix"); if (topStatus) { topStatus.onclick = () => window.open("/api/status", "_blank", "noopener"); } if (topCapture) { topCapture.onclick = async () => { try { const res = await apiGet("/api/capture"); alert(`Captured:\n${res.result || res.path || "OK"}`); await renderPhotos(); } catch (e) { alert(`Capture failed: ${e.message}`); } }; } if (topTest) { topTest.onclick = async () => { try { const res = await apiGet("/api/test"); alert(JSON.stringify(res, null, 2)); } catch (e) { alert(`Camera test failed: ${e.message}`); } }; } if (topFix) { topFix.onclick = async () => { if (!confirm("Run the camera fix flow?")) return; try { const res = await apiGet("/api/fix"); alert(JSON.stringify(res, null, 2)); } catch (e) { alert(`Fix failed: ${e.message}`); } }; } } async function refreshAutonomousPanel() { const panel = document.getElementById("auto_state_panel"); if (!panel) return; try { const r = await apiGet("/api/autonomous_state"); if (!r.ok) { panel.innerHTML = "Autonomous state unavailable"; return; } const s = r.state || {}; panel.innerHTML = `
State${escapeHtml(s.state || "IDLE")}
Session${s.session_id ?? 0}
Detector${escapeHtml((s.detector_backend || "normal").toUpperCase())}
YOLO Runtime${escapeHtml((s.yolo_runtime || "-").toUpperCase())}
AI Blocked${s.ai_blocked ? "Yes" : "No"}
Block Reason${escapeHtml(s.ai_block_reason || "-")}
People${s.person_count ?? 0}
Faces${s.face_count ?? 0}
Group${s.group_detected ? `Yes (${s.group_size ?? 0})` : "No"}
Intent${s.intent_detected ? "Yes" : "No"}
Depth${s.depth_m == null ? "-" : fmtNum(s.depth_m, 2)}
Approach${fmtNum(s.approach_speed_mps || 0, 2)} m/s
Guest${escapeHtml(s.recognized_person_label || "-")}
Known Guest${s.recognized_person_known ? "Yes" : "No"}
Match${s.recognized_person_match_score ? fmtNum(s.recognized_person_match_score, 2) : "-"}
`; } catch (_e) { panel.innerHTML = "Autonomous state unavailable"; } } async function refreshAiReadinessPanel() { const panel = document.getElementById("ai_readiness_panel"); if (!panel) return; try { const data = await apiGet("/api/ai_readiness"); panel.innerHTML = `
Ready${data.ok ? "Yes" : "No"}
Backend${escapeHtml((data.backend || "normal").toUpperCase())}
Runtime${escapeHtml((data.yolo_runtime || "-").toUpperCase())}
YOLO Loaded${data.yolo_loaded ? "Yes" : "No"}
Person Model${data.person_model_ok ? "Yes" : "No"}
Face Model${data.face_model_ok ? "Yes" : "No"}
Strict Required${data.strict_required ? "Yes" : "No"}
Block Reason${escapeHtml(data.block_reason || "-")}
`; } catch (_e) { panel.innerHTML = "AI readiness unavailable"; } } async function refreshRuntimeHealthPanel() { const panel = document.getElementById("runtime_health_panel"); if (!panel) return; try { const r = await apiGet("/api/runtime_health"); if (!r.ok) { panel.innerHTML = "Runtime health unavailable"; return; } const h = r.health || {}; panel.innerHTML = `
WS${h.ws_connected ? "Connected" : "Down"}
WS State${escapeHtml(h.ws_state || "-")}
Mic Enabled${h.mic_enabled ? "Yes" : "No"}
Mic${escapeHtml(h.mic_state || "-")}
Speaker${escapeHtml(h.speaker_state || "-")}
Gate${h.audio_gate_open ? "Open" : "Closed"}
Mic Restarts${h.mic_restarts ?? 0}
Speaker Restarts${h.speaker_restarts ?? 0}
WS Restarts${h.ws_restarts ?? 0}
Last WS Error${escapeHtml(h.ws_last_error || "-")}
`; } catch (_e) { panel.innerHTML = "Runtime health unavailable"; } } async function renderPeople() { const list = document.getElementById("people_list"); const status = document.getElementById("people_status_box"); if (!list || !status) return; try { const res = await apiGet("/api/people"); const people = res.people || []; status.textContent = people.length ? `Registered guests: ${people.length}\nReturning guests are recognized from saved face samples in photos/people/.` : "No people enrolled yet. AI mode will create a guest profile the first time a single face is confirmed."; list.innerHTML = renderPeopleCards(people); list.querySelectorAll(".person-delete").forEach((btn) => { btn.addEventListener("click", async () => { const personId = btn.dataset.id || ""; if (!personId) return; if (!confirm(`Delete person ${personId}?`)) return; await apiGet(`/api/delete_person?id=${encodeURIComponent(personId)}`); await renderPeople(); }); }); list.querySelectorAll(".person-add-photo").forEach((btn) => { btn.addEventListener("click", () => { const personId = btn.dataset.id || ""; const input = document.getElementById("people_upload_file"); if (!input) return; input.dataset.personId = personId; input.click(); }); }); } catch (e) { status.textContent = `People registry unavailable\n${e.message}`; list.innerHTML = ""; } } async function refreshCameraPanel() { const statusBox = document.getElementById("camera_status_box"); const infoPanel = document.getElementById("camera_info_panel"); const chipRow = document.getElementById("camera_chip_row"); const selector = document.getElementById("camera_source_select"); const sourceMeta = document.getElementById("camera_source_meta"); const inventoryBox = document.getElementById("camera_inventory_box"); if (!statusBox || !infoPanel || !chipRow) return; try { const [health, cameraSources] = await Promise.all([ apiGet("/api/camera_health"), selector && !window.__cameraSourcesCache ? apiGet("/api/camera_sources") : Promise.resolve(window.__cameraSourcesCache || null), ]); if (cameraSources && cameraSources.ok) { window.__cameraSourcesCache = cameraSources; if (selector) { const currentValue = selector.dataset.initialized === "1" ? (selector.value || cameraSources.selected_source || "realsense") : (cameraSources.selected_source || "realsense"); selector.innerHTML = renderSourceOptions(cameraSources); selector.value = currentValue; if (!selector.value) selector.value = cameraSources.selected_source || "realsense"; selector.dataset.initialized = "1"; } if (sourceMeta) { const rsCount = (cameraSources.realsense_devices || []).length; const vidCount = (cameraSources.video_devices || []).length; const activeLabel = (cameraSources.active_device && cameraSources.active_device.label) || cameraSources.active_source || "-"; sourceMeta.textContent = `Preferred RS: ${cameraSources.preferred_realsense_serial || "-"} | Active: ${activeLabel} | RealSense devices: ${rsCount} | Video nodes: ${vidCount}`; } } const camera = (health && health.camera) || {}; statusBox.textContent = camera.ok ? cameraStatusLines(camera) : `Camera not ready\nSource: ${camera.source || "-"}\nRequested: ${camera.requested_profile || "-"}\nError: ${camera.last_error || "-"}`; chipRow.innerHTML = `
Requested${escapeHtml(camera.requested_source || camera.source || "-")}
Backend${escapeHtml(camera.backend || "-")}
Active Profile${escapeHtml(camera.profile || "-")}
Frame Time${escapeHtml(formatTime(camera.frame_time))}
`; infoPanel.textContent = JSON.stringify(health, null, 2); if (inventoryBox) { inventoryBox.textContent = renderCameraInventory(cameraSources, camera); } const widthInput = document.getElementById("width_input"); if (widthInput && widthInput.dataset.initialized !== "1") { setResolutionInputs(camera.requested_width, camera.requested_height, camera.requested_fps); widthInput.dataset.initialized = "1"; } } catch (e) { statusBox.textContent = `Camera service unavailable\n${e.message}`; infoPanel.textContent = JSON.stringify({ ok: false, error: e.message }, null, 2); chipRow.innerHTML = ""; if (inventoryBox) inventoryBox.textContent = "Camera inventory unavailable."; } } function renderReplayList(items, opts = {}) { const manualMode = !!opts.manualMode; const testRunning = !!opts.testRunning; if (!items.length) { return '
No replay files found.
'; } return items.map((item) => { const tags = []; if (item.active) tags.push('active'); if (item.greeting) tags.push('greeting'); if (!item.trigger_ok) tags.push('triggerless'); return `
${escapeHtml(item.name)}
${formatSize(item.size || 0)} ยท ${formatTime(item.mtime || 0)}
${tags.join("")}
Download
`; }).join(""); } function renderReplayRecordStatus(status) { if (!status || !status.started_at) { return "Recorder idle."; } if (status.running) { return `Recording ${status.name || "-"} for ${status.seconds || 0}s...\nOutput: ${status.output || "-"}`; } if (status.ok) { return `Recording saved\n${status.output || "-"}\nFinished: ${formatTime(status.finished_at || 0)}`; } return `Recorder error\n${status.error || "-"}${(status.output ? `\nOutput: ${status.output}` : "")}`; } function renderReplayTestStatus(status) { if (!status || !status.started_at) { return "Replay test idle."; } if (status.running) { return `Testing replay\n${status.name || "-"}`; } if (status.ok) { return `Replay test complete\n${status.result || status.name || "-"}`; } return `Replay test error\n${status.error || "-"}`; } async function refreshReplayPanel() { const selector = document.getElementById("replay_select"); const activeNote = document.getElementById("replay_active_note"); const list = document.getElementById("replay_manager_list"); const status = document.getElementById("replay_record_status"); const testStatus = document.getElementById("replay_test_status"); const greetingSelect = document.getElementById("opt_greeting_replay_file"); try { const [replayResp, activeResp, recordResp, testResp] = await Promise.all([ apiGet("/api/replays"), apiGet("/api/get_replay"), apiGet("/api/replay_record_status").catch(() => ({ ok: false, status: {} })), apiGet("/api/replay_test_status").catch(() => ({ ok: false, status: {} })), ]); const items = replayResp.items || []; const activeReplay = activeResp.replay || ""; const replayTest = testResp.status || {}; const manualMode = window.__runtimeMode === "manual"; if (selector) { selector.innerHTML = items.map((item) => ``).join(""); selector.value = activeReplay || (items[0] && items[0].name) || ""; } if (activeNote) { activeNote.textContent = activeReplay ? `Active replay: ${activeReplay}. Manual R2+X and AI photo capture use this replay when AI capture replay is enabled. Replay recording and replay test are manual-mode only.` : "No active replay selected."; } if (list) { list.innerHTML = renderReplayList(items, { manualMode, testRunning: !!replayTest.running }); list.querySelectorAll(".replay-test").forEach((btn) => { btn.addEventListener("click", async () => { const name = btn.dataset.name || ""; await apiGet(`/api/test_replay?name=${encodeURIComponent(name)}`); await refreshReplayPanel(); }); }); list.querySelectorAll(".replay-use").forEach((btn) => { btn.addEventListener("click", async () => { await apiGet(`/api/set_replay?name=${encodeURIComponent(btn.dataset.name || "")}`); await refreshReplayPanel(); }); }); list.querySelectorAll(".replay-rename").forEach((btn) => { btn.addEventListener("click", async () => { const oldName = btn.dataset.name || ""; const currentLeaf = oldName.split("/").pop() || oldName; const nextName = window.prompt("Rename replay", currentLeaf); if (!nextName || !nextName.trim()) return; await apiGet( `/api/rename_replay?old=${encodeURIComponent(oldName)}&new=${encodeURIComponent(nextName.trim())}` ); await refreshReplayPanel(); }); }); list.querySelectorAll(".replay-delete").forEach((btn) => { btn.addEventListener("click", async () => { const name = btn.dataset.name || ""; if (!confirm(`Delete replay ${name}?`)) return; await apiGet(`/api/delete_replay?name=${encodeURIComponent(name)}`); await refreshReplayPanel(); }); }); } if (status) { status.textContent = renderReplayRecordStatus(recordResp.status || {}); } if (testStatus) { testStatus.textContent = renderReplayTestStatus(replayTest); } if (greetingSelect) { const current = greetingSelect.dataset.current || greetingSelect.value; greetingSelect.innerHTML = items.map((item) => ``).join(""); if (current) greetingSelect.value = current; if (!greetingSelect.value && items.length) greetingSelect.value = items[0].name; delete greetingSelect.dataset.current; } } catch (e) { if (activeNote) activeNote.textContent = `Replay inventory unavailable: ${e.message}`; if (list) list.innerHTML = ""; if (status) status.textContent = `Recorder unavailable\n${e.message}`; if (testStatus) testStatus.textContent = `Replay test unavailable\n${e.message}`; } } async function renderControls() { const ctrl = document.getElementById("controls"); const [modeResp, micResp, cameraSources] = await Promise.all([ apiGet("/api/mode"), apiGet("/api/mic"), apiGet("/api/camera_sources").catch(() => ({ ok: false, options: [] })), ]); window.__cameraSourcesCache = cameraSources && cameraSources.ok ? cameraSources : null; const mode = modeResp.mode || "manual"; window.__runtimeMode = mode; const modePolicy = modeResp.policy || {}; const micEnabled = !!(micResp.options && micResp.options.mic_enabled); let backend = "normal"; let strictLocked = false; let aiOptions = { hard_target_lock_enabled: true, retake_prompt_enabled: true, autonomous_greeting_replay_enabled: true, autonomous_greeting_replay_file: "right_hand_up.jsonl", autonomous_capture_replay_enabled: true, face_recognition_enabled: true, face_recognition_threshold: 0.88, active_replay: "photo_G3.jsonl", }; let aiReadinessMarkup = ""; let aiOptionsMarkup = ""; if (mode === "ai") { try { const bj = await apiGet("/api/detector_backend"); backend = bj.backend || "normal"; strictLocked = !!bj.locked; } catch (_e) {} try { const oj = await apiGet("/api/ai_options"); if (oj && oj.ok && oj.options) aiOptions = oj.options; } catch (_e) {} aiReadinessMarkup = `
AI Vision

Detection Backend

${strictLocked ? '
Strict AI profile active: backend is locked to YOLO in AI mode.
' : ""}
Loading AI readiness...
`; aiOptionsMarkup = `
AI Session

Session Options

AI welcomes approaching guests, asks if they want a photo, guides them into frame, and captures using the active replay when enabled. Returning-guest recognition stores samples in photos/people/. Active replay: ${escapeHtml(aiOptions.active_replay || "-")}.
`; } const recorderMarkup = mode === "manual" ? `
Recorder

Record New Replay

Recorded files are saved directly in Data/G1 and become available in the active replay selector.
Recorder idle.
` : `
Recorder

Manual Mode Only

Replay recording and replay test are disabled outside manual mode. Replay selection, rename, upload, download, and delete remain available.
Recorder idle.
`; ctrl.innerHTML = `
Runtime Mode

${escapeHtml(mode.toUpperCase())}

${escapeHtml(modePolicy.description || "")}
Gemini Mic

${micEnabled ? "Microphone ON" : "Microphone OFF"}

Microphone audio can stay on in both modes. Manual mode still disables mapped photo commands and AI detection.
Camera Console

Live Camera

Live preview is off.
Use the toggle below when you need a live camera feed.
live preview
Live preview is off. Turn it on only when needed.
Loading camera status...
Loading available camera sources...
Loading camera inventory...
Use a preset or enter width, height, and fps manually. The direct camera service will reopen with the requested settings.
Checking camera...
Replay

Capture Motion

Loading replay status...
${recorderMarkup}
Replay Files

Replay Inventory

Replay test idle.
AI Voice Assets

Audio Prompt Library

Upload prerecorded WAV clips for fixed AI situation prompts. In audio mode, AI detection, confirmation, countdown, retake, and thank-you situations use these clips first. Gemini can be switched back on for those same situations from the dashboard.
Loading audio prompt library...
Prompt recorder idle.
Actions

Operator Actions

${aiReadinessMarkup} ${aiOptionsMarkup}
AI State

Autonomous State

Loading...
Runtime

Runtime Health

Loading...
`; if (mode === "ai") { setTimeout(() => { const bSel = document.getElementById("det_backend_select"); const bBtn = document.getElementById("det_backend_set"); if (bSel) { bSel.value = backend; if (strictLocked) { bSel.disabled = true; bSel.value = "yolo"; } } if (bBtn && strictLocked) { bBtn.disabled = true; } const greetingSel = document.getElementById("opt_greeting_replay_file"); if (greetingSel) { greetingSel.dataset.current = aiOptions.autonomous_greeting_replay_file || ""; } }, 0); } document.getElementById("mode_select").value = mode; document.getElementById("mode_set").addEventListener("click", async () => { const sel = document.getElementById("mode_select").value; await apiGet(`/api/set_mode?mode=${encodeURIComponent(sel)}`); await renderControls(); await renderPhotos(); }); document.getElementById("mic_toggle").addEventListener("click", async () => { const nextEnabled = micEnabled ? 0 : 1; await apiGet(`/api/set_mic?enabled=${nextEnabled}`); await renderControls(); }); const replaySet = document.getElementById("replay_set"); if (replaySet) { replaySet.addEventListener("click", async () => { const name = encodeURIComponent(document.getElementById("replay_select").value); await apiGet(`/api/set_replay?name=${name}`); await refreshReplayPanel(); }); } const replayRecordStart = document.getElementById("replay_record_start"); if (replayRecordStart) { replayRecordStart.addEventListener("click", async () => { const name = (document.getElementById("replay_record_name").value || "").trim(); const seconds = Number(document.getElementById("replay_record_seconds").value || "15"); if (!name) { alert("Enter a replay file name first."); return; } await apiGet(`/api/replay_record_start?name=${encodeURIComponent(name)}&seconds=${encodeURIComponent(seconds)}`); await refreshReplayPanel(); }); } const replayUploadBtn = document.getElementById("replay_upload_btn"); const replayUploadFile = document.getElementById("replay_upload_file"); if (replayUploadBtn && replayUploadFile) { replayUploadBtn.addEventListener("click", () => replayUploadFile.click()); replayUploadFile.addEventListener("change", async () => { const file = replayUploadFile.files && replayUploadFile.files[0]; if (!file) return; const requestedName = window.prompt("Replay name", file.name) || file.name; const form = new FormData(); form.append("file", file, file.name); form.append("name", requestedName); try { await apiPostFormData("/api/upload_replay", form); await refreshReplayPanel(); } catch (e) { alert(`Replay upload failed: ${e.message}`); } finally { replayUploadFile.value = ""; } }); } const audioPromptUploadFile = document.getElementById("audio_prompt_upload_file"); const audioPromptModeToggle = document.getElementById("audio_prompt_mode_toggle"); const audioPromptFallbackToggle = document.getElementById("audio_prompt_fallback_toggle"); const audioPromptRecordKey = document.getElementById("audio_prompt_record_key"); const audioPromptRecordFilename = document.getElementById("audio_prompt_record_filename"); const audioPromptRecordText = document.getElementById("audio_prompt_record_text"); const audioPromptRecordStart = document.getElementById("audio_prompt_record_start"); if (audioPromptUploadFile) { audioPromptUploadFile.addEventListener("change", async () => { const file = audioPromptUploadFile.files && audioPromptUploadFile.files[0]; const key = audioPromptUploadFile.dataset.key || ""; if (!file || !key) return; const defaultName = audioPromptUploadFile.dataset.filename || `${key}.wav`; const requestedName = window.prompt("Audio filename", defaultName) || defaultName; const form = new FormData(); form.append("key", key); form.append("file", file, file.name); form.append("filename", requestedName); try { await apiPostFormData("/api/upload_audio_prompt", form); await refreshAudioPromptPanel(); } catch (e) { alert(`Audio prompt upload failed: ${e.message}`); } finally { audioPromptUploadFile.value = ""; delete audioPromptUploadFile.dataset.key; delete audioPromptUploadFile.dataset.filename; } }); } if (audioPromptModeToggle) { audioPromptModeToggle.addEventListener("click", async () => { const current = String(audioPromptModeToggle.dataset.mode || "audio").trim().toLowerCase(); const next = current === "gemini" ? "audio" : "gemini"; await apiGet(`/api/set_audio_prompt_mode?mode=${encodeURIComponent(next)}`); await refreshAudioPromptPanel(); }); } if (audioPromptFallbackToggle) { audioPromptFallbackToggle.addEventListener("click", async () => { const current = audioPromptFallbackToggle.dataset.enabled === "1"; await apiGet(`/api/set_audio_prompt_fallback?enabled=${current ? 0 : 1}`); await refreshAudioPromptPanel(); }); } if (audioPromptRecordKey && audioPromptRecordFilename && audioPromptRecordText) { audioPromptRecordKey.addEventListener("change", () => { const key = audioPromptRecordKey.value || ""; audioPromptRecordFilename.value = promptFilenameForKey(key); audioPromptRecordFilename.dataset.autofill = "1"; audioPromptRecordText.value = promptTextForKey(key); audioPromptRecordText.dataset.autofill = "1"; }); audioPromptRecordFilename.addEventListener("input", () => { audioPromptRecordFilename.dataset.autofill = "0"; }); audioPromptRecordText.addEventListener("input", () => { audioPromptRecordText.dataset.autofill = "0"; }); } if (audioPromptRecordStart && audioPromptRecordKey && audioPromptRecordFilename && audioPromptRecordText) { audioPromptRecordStart.addEventListener("click", async () => { const key = (audioPromptRecordKey.value || "").trim(); const filename = (audioPromptRecordFilename.value || "").trim(); const text = (audioPromptRecordText.value || "").trim(); if (!key) { alert("Select a prompt key first."); return; } if (!text) { alert("Prompt text is required."); return; } try { await apiPostJson("/api/audio_prompt_record", { key, filename, text }); await refreshAudioPromptRecordStatus(); } catch (e) { alert(`Prompt recording failed: ${e.message}`); } }); } const peopleUploadBtn = document.getElementById("people_upload_btn"); const peopleUploadFile = document.getElementById("people_upload_file"); const peopleResetBtn = document.getElementById("people_reset_btn"); if (peopleUploadBtn && peopleUploadFile) { peopleUploadBtn.addEventListener("click", () => { peopleUploadFile.dataset.personId = ""; peopleUploadFile.click(); }); peopleUploadFile.addEventListener("change", async () => { const file = peopleUploadFile.files && peopleUploadFile.files[0]; if (!file) return; const form = new FormData(); form.append("file", file, file.name); if (peopleUploadFile.dataset.personId) { form.append("person_id", peopleUploadFile.dataset.personId); } try { await apiPostFormData("/api/upload_person", form); await renderPeople(); } catch (e) { alert(`People upload failed: ${e.message}`); } finally { peopleUploadFile.value = ""; delete peopleUploadFile.dataset.personId; } }); } if (peopleResetBtn) { peopleResetBtn.addEventListener("click", async () => { if (!confirm("Delete all saved people profiles?")) return; await apiGet("/api/reset_people"); await renderPeople(); }); } document.getElementById("req_photo").addEventListener("click", async () => { await apiGet("/api/request_photo"); }); document.getElementById("clear_photos").addEventListener("click", async () => { if (!confirm("Delete ALL photos?")) return; await apiGet("/api/clear_photos"); await renderPhotos(); }); document.getElementById("download_zip").addEventListener("click", async () => { const r = await apiGet("/api/download_zip"); if (r.ok) window.location = `/${r.archive}`; }); document.getElementById("upload_now").addEventListener("click", async () => { await apiGet("/api/upload_now"); }); document.getElementById("scripts_fix_check").addEventListener("click", async () => { const r = await apiGet("/api/run_scripts_fix?mode=--check"); alert(`RealSense check:\n${(r.stdout || []).join("\n") || "true"}`); }); document.getElementById("scripts_fix_run").addEventListener("click", async () => { if (!confirm("Run RealSense fix script?")) return; const r = await apiGet("/api/run_scripts_fix?mode=--fix"); alert(`Fix output:\n${(r.stdout || []).join("\n")}`); }); document.getElementById("toggle_preview").addEventListener("click", () => { setPreviewEnabled(!window.__previewEnabled); }); document.getElementById("camera_refresh_btn").addEventListener("click", async () => { window.__cameraSourcesCache = null; await refreshCameraPanel(); restartPreview(); }); document.getElementById("capture_now_btn").addEventListener("click", async () => { const statusBox = document.getElementById("camera_status_box"); statusBox.textContent = "Capturing..."; try { const resp = await apiGet("/api/capture"); statusBox.textContent = `Saved\n${resp.result || "OK"}`; await renderPhotos(); await refreshCameraPanel(); } catch (e) { statusBox.textContent = `Capture failed\n${e.message}`; } }); document.getElementById("camera_apply_btn").addEventListener("click", async () => { const selector = document.getElementById("camera_source_select"); const statusBox = document.getElementById("camera_status_box"); const source = selector.value; statusBox.textContent = `Switching camera to ${source}...`; try { await apiGet(`/api/set_camera_source?source=${encodeURIComponent(source)}`); restartPreview(); await refreshCameraPanel(); } catch (e) { statusBox.textContent = `Camera switch failed\n${e.message}`; } }); document.getElementById("camera_save_default_btn").addEventListener("click", async () => { const selector = document.getElementById("camera_source_select"); const statusBox = document.getElementById("camera_status_box"); const camera = ((await apiGet("/api/camera_health")).camera) || {}; const cameraSources = window.__cameraSourcesCache || await apiGet("/api/camera_sources"); const serial = selectedCameraSerial(selector.value, cameraSources, camera); if (!serial) { statusBox.textContent = "Default camera save failed\nSelect a RealSense source first."; return; } statusBox.textContent = `Saving preferred camera ${serial}...`; try { await apiGet(`/api/set_preferred_camera?serial=${encodeURIComponent(serial)}`); window.__cameraSourcesCache = null; await refreshCameraPanel(); statusBox.textContent = `Preferred camera saved\n${serial}`; } catch (e) { statusBox.textContent = `Default camera save failed\n${e.message}`; } }); document.getElementById("resolution_select").addEventListener("change", (e) => { const value = e.target.value; const match = value.match(/^(\d+)x(\d+)@(\d+)$/); if (!match) return; setResolutionInputs(Number(match[1]), Number(match[2]), Number(match[3])); }); document.getElementById("apply_resolution_btn").addEventListener("click", async () => { const width = Number(document.getElementById("width_input").value); const height = Number(document.getElementById("height_input").value); const fps = Number(document.getElementById("fps_input").value); const statusBox = document.getElementById("camera_status_box"); statusBox.textContent = `Applying resolution ${width}x${height}@${fps}...`; try { const resp = await apiGet(`/api/set_camera_resolution?width=${encodeURIComponent(width)}&height=${encodeURIComponent(height)}&fps=${encodeURIComponent(fps)}`); const camera = resp.camera || {}; setResolutionInputs(camera.requested_width, camera.requested_height, camera.requested_fps); restartPreview(); await refreshCameraPanel(); } catch (e) { statusBox.textContent = `Resolution change failed\n${e.message}`; } }); if (mode === "ai") { const backendButton = document.getElementById("det_backend_set"); if (backendButton) { backendButton.addEventListener("click", async () => { const v = document.getElementById("det_backend_select").value || "normal"; try { await apiGet(`/api/set_detector_backend?backend=${encodeURIComponent(v)}`); await renderControls(); } catch (e) { alert(e.message); } }); } const aiSave = document.getElementById("opt_ai_save"); if (aiSave) { aiSave.addEventListener("click", async () => { const hard = document.getElementById("opt_hard_lock").checked ? 1 : 0; const retake = document.getElementById("opt_retake_prompt").checked ? 1 : 0; const greetEnabled = document.getElementById("opt_greeting_replay").checked ? 1 : 0; const greetFile = document.getElementById("opt_greeting_replay_file").value || ""; const captureReplay = document.getElementById("opt_capture_replay").checked ? 1 : 0; const faceRecognition = document.getElementById("opt_face_recognition").checked ? 1 : 0; const faceThreshold = document.getElementById("opt_face_threshold").value || "0.88"; try { await apiGet( `/api/set_ai_options?hard_target_lock_enabled=${hard}&retake_prompt_enabled=${retake}` + `&autonomous_greeting_replay_enabled=${greetEnabled}` + `&autonomous_greeting_replay_file=${encodeURIComponent(greetFile)}` + `&autonomous_capture_replay_enabled=${captureReplay}` + `&face_recognition_enabled=${faceRecognition}` + `&face_recognition_threshold=${encodeURIComponent(faceThreshold)}` ); await renderControls(); } catch (e) { alert(e.message); } }); } } setPreviewEnabled(window.__previewEnabled); await refreshCameraPanel(); await refreshReplayPanel(); await refreshAudioPromptPanel(); await refreshAudioPromptRecordStatus(); await refreshAutonomousPanel(); await refreshAiReadinessPanel(); await refreshRuntimeHealthPanel(); await renderPeople(); if (window.__dashboardPoll) clearInterval(window.__dashboardPoll); window.__peoplePollCounter = 0; window.__dashboardPoll = setInterval(async () => { await refreshCameraPanel().catch(() => {}); await refreshReplayPanel().catch(() => {}); await refreshAutonomousPanel().catch(() => {}); await refreshAiReadinessPanel().catch(() => {}); await refreshRuntimeHealthPanel().catch(() => {}); window.__peoplePollCounter += 1; if (window.__peoplePollCounter % 4 === 0) { await renderPeople().catch(() => {}); } if (window.__peoplePollCounter % 8 === 0) { await refreshAudioPromptPanel().catch(() => {}); } await refreshAudioPromptRecordStatus().catch(() => {}); }, 1500); } async function renderPhotos() { const list = document.getElementById("list"); if (!list) return; list.innerHTML = ""; const res = await apiGet("/api/photos"); const photos = res.photos || []; if (!photos.length) { list.innerHTML = '
No photos yet.
'; return; } for (const photo of photos) { const card = el("article", "card"); card.innerHTML = ` ${escapeHtml(photo.name)}
${escapeHtml(photo.name)}
${formatSize(photo.size || 0)}
${formatTime(photo.mtime || 0)}
Open Delete Reupload
`; card.querySelector(".thumb").addEventListener("click", () => openPreview(photo.name)); list.appendChild(card); } document.querySelectorAll("a.del").forEach((anchor) => { anchor.addEventListener("click", async (ev) => { ev.preventDefault(); const name = anchor.getAttribute("data-name"); if (!confirm("Delete this photo?")) return; await apiGet(`/api/delete?name=${name}`); await renderPhotos(); }); }); } function openPreview(name) { const modal = document.getElementById("preview_modal"); const img = document.getElementById("modal_img"); const actions = document.getElementById("modal_actions"); img.src = `/${encodeURIComponent(name)}`; actions.innerHTML = ` Open / Download Reupload Delete `; document.getElementById("modal_delete").addEventListener("click", async (ev) => { ev.preventDefault(); if (!confirm("Delete this photo?")) return; await apiGet(`/api/delete?name=${encodeURIComponent(name)}`); closeModal(); await renderPhotos(); }); modal.style.display = "flex"; } function closeModal() { const modal = document.getElementById("preview_modal"); if (modal) modal.style.display = "none"; } window.addEventListener("load", () => { bindTopActions(); const modalClose = document.getElementById("modal_close"); if (modalClose) modalClose.addEventListener("click", closeModal); renderControls().catch((e) => { const ctrl = document.getElementById("controls"); if (ctrl) ctrl.innerHTML = `
Dashboard load failed\n${escapeHtml(e.message)}
`; }); renderPhotos().catch(() => {}); }); window.addEventListener("beforeunload", () => { if (window.__dashboardPoll) clearInterval(window.__dashboardPoll); if (window.__previewEnabled) setPreviewEnabled(false); }); async function openScriptsModal() { const modal = document.getElementById("scripts_modal"); const list = document.getElementById("scripts_list"); const editor = document.getElementById("script_editor"); const fname = document.getElementById("script_filename"); list.innerHTML = "Loading..."; try { const r = await apiGet("/api/scripts"); if ((r.scripts || []).length === 0) { list.innerHTML = "No scripts"; } else { list.innerHTML = ""; r.scripts.forEach((s) => { const row = document.createElement("div"); row.className = "script-row"; row.innerHTML = `${escapeHtml(s)}   `; list.appendChild(row); }); list.querySelectorAll("button").forEach((btn) => { btn.addEventListener("click", async () => { const act = btn.getAttribute("data-act"); const name = btn.getAttribute("data-name"); if (act === "set") { await fetch("/api/set_sanad_script", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); alert(`Set as active: ${name}`); } else if (act === "del") { if (!confirm(`Delete script ${name}?`)) return; await fetch("/api/delete_script", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); await openScriptsModal(); } else if (act === "edit") { const r = await apiGet(`/api/script_content?name=${encodeURIComponent(name)}`); if (r.ok) { editor.value = r.script || ""; fname.value = name; } } }); }); } } catch (_e) { list.innerHTML = "Error"; } try { const a = await apiGet("/api/sanad_script"); if (a.ok) editor.value = a.script || ""; } catch (_e) {} document.getElementById("scripts_modal_close").onclick = () => { modal.style.display = "none"; }; document.getElementById("script_upload").onclick = async () => { const name = fname.value.trim(); const script = editor.value || ""; if (!name) { alert("Enter filename"); return; } await fetch("/api/upload_script", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: name, script }) }); await openScriptsModal(); }; document.getElementById("script_save").onclick = async () => { await fetch("/api/sanad_script", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ script: editor.value || "" }) }); alert("Saved active SANAD script"); }; modal.style.display = "flex"; }