AI_Photographer/Web/gallery.js
2026-04-12 18:52:37 +04:00

1655 lines
69 KiB
JavaScript

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 [
'<option value="">Preset</option>',
...RESOLUTION_PRESETS.map(([w, h, fps]) => `<option value="${w}x${h}@${fps}">${w}x${h} @ ${fps}</option>`),
].join("");
}
function renderSourceOptions(cameraSources) {
const options = (cameraSources && cameraSources.options) || [];
if (!options.length) {
return '<option value="realsense">Preferred RealSense (default)</option>';
}
return options
.map((item) => `<option value="${escapeHtml(item.value)}">${escapeHtml(item.label || item.value)}</option>`)
.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 '<div class="status-box"><i>No people enrolled yet.</i></div>';
}
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 `
<article class="person-card">
<div class="person-card-head">
<img src="${personImageUrl(personId, "face")}" alt="${title} face">
<img src="${personImageUrl(personId, "scene")}" alt="${title} scene">
</div>
<div class="person-card-body">
<div class="person-title">${title}</div>
<div class="person-meta">${meta}</div>
<div class="person-actions">
<a class="btn ghost" href="/api/download_person?id=${encodeURIComponent(personId)}">Download</a>
<button class="btn ghost person-add-photo" data-id="${escapeHtml(personId)}">Add Photo</button>
<button class="btn ghost danger person-delete" data-id="${escapeHtml(personId)}">Delete</button>
</div>
</div>
</article>
`;
}).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) => `<option value="${escapeHtml(item.key)}">${escapeHtml(formatPromptKeyLabel(item.key))}</option>`)
.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 '<div class="status-box"><i>No audio prompt keys available.</i></div>';
}
return items.map((item) => {
const exists = !!item.exists;
const key = String(item.key || "");
const filename = String(item.filename || "");
return `
<article class="audio-prompt-card ${exists ? "ready" : "missing"}">
<div class="audio-prompt-head">
<div>
<div class="audio-prompt-title">${escapeHtml(formatPromptKeyLabel(key))}</div>
<div class="audio-prompt-key">${escapeHtml(key)}</div>
</div>
<div class="replay-tags">
<span class="replay-tag ${exists ? "active" : "warn"}">${exists ? "uploaded" : "missing"}</span>
</div>
</div>
<div class="audio-prompt-text">${escapeHtml(item.text || "")}</div>
<div class="audio-prompt-meta">
File: ${escapeHtml(filename || "-")}<br>
Size: ${formatSize(item.size || 0)}<br>
Raw: ${item.raw_exists ? escapeHtml(item.raw_filename || "-") : "missing"}<br>
Updated: ${formatTime(item.mtime || 0)}
</div>
<div class="audio-prompt-actions">
<button class="btn ghost audio-prompt-upload" data-key="${escapeHtml(key)}" data-filename="${escapeHtml(filename)}">${exists ? "Replace" : "Upload"}</button>
<button class="btn ghost audio-prompt-record" data-key="${escapeHtml(key)}">Record</button>
${exists ? `<a class="btn ghost" href="/api/download_audio_prompt?key=${encodeURIComponent(key)}">Download</a>` : ""}
<button class="btn ghost danger audio-prompt-delete" data-key="${escapeHtml(key)}" ${exists ? "" : "disabled"}>Delete</button>
</div>
</article>
`;
}).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 = "<i>Autonomous state unavailable</i>";
return;
}
const s = r.state || {};
panel.innerHTML = `
<div class="info-grid">
<div class="stat-line"><span>State</span><b>${escapeHtml(s.state || "IDLE")}</b></div>
<div class="stat-line"><span>Session</span><b>${s.session_id ?? 0}</b></div>
<div class="stat-line"><span>Detector</span><b>${escapeHtml((s.detector_backend || "normal").toUpperCase())}</b></div>
<div class="stat-line"><span>YOLO Runtime</span><b>${escapeHtml((s.yolo_runtime || "-").toUpperCase())}</b></div>
<div class="stat-line"><span>AI Blocked</span><b>${s.ai_blocked ? "Yes" : "No"}</b></div>
<div class="stat-line"><span>Block Reason</span><b>${escapeHtml(s.ai_block_reason || "-")}</b></div>
<div class="stat-line"><span>People</span><b>${s.person_count ?? 0}</b></div>
<div class="stat-line"><span>Faces</span><b>${s.face_count ?? 0}</b></div>
<div class="stat-line"><span>Group</span><b>${s.group_detected ? `Yes (${s.group_size ?? 0})` : "No"}</b></div>
<div class="stat-line"><span>Intent</span><b>${s.intent_detected ? "Yes" : "No"}</b></div>
<div class="stat-line"><span>Depth</span><b>${s.depth_m == null ? "-" : fmtNum(s.depth_m, 2)}</b></div>
<div class="stat-line"><span>Approach</span><b>${fmtNum(s.approach_speed_mps || 0, 2)} m/s</b></div>
<div class="stat-line"><span>Guest</span><b>${escapeHtml(s.recognized_person_label || "-")}</b></div>
<div class="stat-line"><span>Known Guest</span><b>${s.recognized_person_known ? "Yes" : "No"}</b></div>
<div class="stat-line"><span>Match</span><b>${s.recognized_person_match_score ? fmtNum(s.recognized_person_match_score, 2) : "-"}</b></div>
</div>
`;
} catch (_e) {
panel.innerHTML = "<i>Autonomous state unavailable</i>";
}
}
async function refreshAiReadinessPanel() {
const panel = document.getElementById("ai_readiness_panel");
if (!panel) return;
try {
const data = await apiGet("/api/ai_readiness");
panel.innerHTML = `
<div class="info-grid">
<div class="stat-line"><span>Ready</span><b>${data.ok ? "Yes" : "No"}</b></div>
<div class="stat-line"><span>Backend</span><b>${escapeHtml((data.backend || "normal").toUpperCase())}</b></div>
<div class="stat-line"><span>Runtime</span><b>${escapeHtml((data.yolo_runtime || "-").toUpperCase())}</b></div>
<div class="stat-line"><span>YOLO Loaded</span><b>${data.yolo_loaded ? "Yes" : "No"}</b></div>
<div class="stat-line"><span>Person Model</span><b>${data.person_model_ok ? "Yes" : "No"}</b></div>
<div class="stat-line"><span>Face Model</span><b>${data.face_model_ok ? "Yes" : "No"}</b></div>
<div class="stat-line"><span>Strict Required</span><b>${data.strict_required ? "Yes" : "No"}</b></div>
<div class="stat-line"><span>Block Reason</span><b>${escapeHtml(data.block_reason || "-")}</b></div>
</div>
`;
} catch (_e) {
panel.innerHTML = "<i>AI readiness unavailable</i>";
}
}
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 = "<i>Runtime health unavailable</i>";
return;
}
const h = r.health || {};
panel.innerHTML = `
<div class="info-grid">
<div class="stat-line"><span>WS</span><b>${h.ws_connected ? "Connected" : "Down"}</b></div>
<div class="stat-line"><span>WS State</span><b>${escapeHtml(h.ws_state || "-")}</b></div>
<div class="stat-line"><span>Mic Enabled</span><b>${h.mic_enabled ? "Yes" : "No"}</b></div>
<div class="stat-line"><span>Mic</span><b>${escapeHtml(h.mic_state || "-")}</b></div>
<div class="stat-line"><span>Speaker</span><b>${escapeHtml(h.speaker_state || "-")}</b></div>
<div class="stat-line"><span>Gate</span><b>${h.audio_gate_open ? "Open" : "Closed"}</b></div>
<div class="stat-line"><span>Mic Restarts</span><b>${h.mic_restarts ?? 0}</b></div>
<div class="stat-line"><span>Speaker Restarts</span><b>${h.speaker_restarts ?? 0}</b></div>
<div class="stat-line"><span>WS Restarts</span><b>${h.ws_restarts ?? 0}</b></div>
<div class="stat-line"><span>Last WS Error</span><b>${escapeHtml(h.ws_last_error || "-")}</b></div>
</div>
`;
} catch (_e) {
panel.innerHTML = "<i>Runtime health unavailable</i>";
}
}
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 = `
<div class="chip"><span>Requested</span><strong>${escapeHtml(camera.requested_source || camera.source || "-")}</strong></div>
<div class="chip"><span>Backend</span><strong>${escapeHtml(camera.backend || "-")}</strong></div>
<div class="chip"><span>Active Profile</span><strong>${escapeHtml(camera.profile || "-")}</strong></div>
<div class="chip"><span>Frame Time</span><strong>${escapeHtml(formatTime(camera.frame_time))}</strong></div>
`;
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 '<div class="status-box"><i>No replay files found.</i></div>';
}
return items.map((item) => {
const tags = [];
if (item.active) tags.push('<span class="replay-tag active">active</span>');
if (item.greeting) tags.push('<span class="replay-tag">greeting</span>');
if (!item.trigger_ok) tags.push('<span class="replay-tag warn">triggerless</span>');
return `
<article class="replay-row ${item.active ? "active" : ""}">
<div class="replay-main">
<div class="replay-title">${escapeHtml(item.name)}</div>
<div class="replay-meta">${formatSize(item.size || 0)} · ${formatTime(item.mtime || 0)}</div>
</div>
<div class="replay-tags">${tags.join("")}</div>
<div class="replay-row-actions">
<button class="btn ghost replay-test" data-name="${escapeHtml(item.name)}" ${manualMode && !testRunning ? "" : "disabled"}>Test</button>
<button class="btn ghost replay-use" data-name="${escapeHtml(item.name)}">Use</button>
<button class="btn ghost replay-rename" data-name="${escapeHtml(item.name)}">Rename</button>
<a class="btn ghost" href="/api/download_replay?name=${encodeURIComponent(item.name)}">Download</a>
<button class="btn ghost danger replay-delete" data-name="${escapeHtml(item.name)}" ${(item.active || item.greeting) ? "disabled" : ""}>Delete</button>
</div>
</article>
`;
}).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) => `<option value="${escapeHtml(item.name)}">${escapeHtml(item.name)}</option>`).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) => `<option value="${escapeHtml(item.name)}">${escapeHtml(item.name)}</option>`).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 = `
<section class="panel strong">
<div class="panel-header">
<div>
<div class="panel-eyebrow">AI Vision</div>
<h3>Detection Backend</h3>
</div>
</div>
<div class="selector-row">
<select id="det_backend_select">
<option value="normal">Normal</option>
<option value="yolo">YOLO</option>
</select>
<button id="det_backend_set" class="btn ghost">Apply Backend</button>
</div>
${strictLocked ? '<div class="meta" style="margin-top:10px;color:#8b3120;"><b>Strict AI profile active:</b> backend is locked to YOLO in AI mode.</div>' : ""}
<div id="ai_readiness_panel" class="info-box"><i>Loading AI readiness...</i></div>
</section>
`;
aiOptionsMarkup = `
<section class="panel">
<div class="panel-header">
<div>
<div class="panel-eyebrow">AI Session</div>
<h3>Session Options</h3>
</div>
</div>
<div class="inline-actions">
<label><input type="checkbox" id="opt_hard_lock" ${aiOptions.hard_target_lock_enabled ? "checked" : ""}> Hard Target Lock</label>
<label><input type="checkbox" id="opt_retake_prompt" ${aiOptions.retake_prompt_enabled ? "checked" : ""}> Retake Prompt</label>
<label><input type="checkbox" id="opt_capture_replay" ${aiOptions.autonomous_capture_replay_enabled ? "checked" : ""}> Use Active Replay During AI Photo</label>
<label><input type="checkbox" id="opt_greeting_replay" ${aiOptions.autonomous_greeting_replay_enabled ? "checked" : ""}> Greeting Replay</label>
<label><input type="checkbox" id="opt_face_recognition" ${aiOptions.face_recognition_enabled ? "checked" : ""}> Returning Guest Recognition</label>
</div>
<div class="selector-row" style="margin-top:12px;">
<select id="opt_greeting_replay_file"></select>
<input id="opt_face_threshold" type="number" min="0.50" max="0.995" step="0.01" value="${Number(aiOptions.face_recognition_threshold || 0.88).toFixed(2)}" placeholder="face threshold">
<button id="opt_ai_save" class="btn ghost">Save AI Options</button>
</div>
<div class="status-box">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 <code>photos/people/</code>. Active replay: ${escapeHtml(aiOptions.active_replay || "-")}.</div>
</section>
`;
}
const recorderMarkup = mode === "manual"
? `
<section class="panel">
<div class="panel-header">
<div>
<div class="panel-eyebrow">Recorder</div>
<h3>Record New Replay</h3>
</div>
</div>
<div class="selector-row">
<input id="replay_record_name" type="text" placeholder="photo_wave">
<input id="replay_record_seconds" type="number" min="1" step="1" value="15" placeholder="seconds">
<button id="replay_record_start" class="btn primary">Start Recording</button>
</div>
<div class="meta" style="margin-top:10px;">Recorded files are saved directly in <code>Data/G1</code> and become available in the active replay selector.</div>
<div id="replay_record_status" class="status-box">Recorder idle.</div>
</section>
`
: `
<section class="panel">
<div class="panel-header">
<div>
<div class="panel-eyebrow">Recorder</div>
<h3>Manual Mode Only</h3>
</div>
</div>
<div class="status-box">Replay recording and replay test are disabled outside manual mode. Replay selection, rename, upload, download, and delete remain available.</div>
<div id="replay_record_status" class="status-box">Recorder idle.</div>
</section>
`;
ctrl.innerHTML = `
<div class="dashboard-stack">
<div class="control-grid">
<section class="panel strong">
<div class="panel-header">
<div>
<div class="panel-eyebrow">Runtime Mode</div>
<h3>${escapeHtml(mode.toUpperCase())}</h3>
</div>
</div>
<div class="selector-row">
<select id="mode_select">
<option value="manual">Manual</option>
<option value="ai">AI</option>
</select>
<button id="mode_set" class="btn primary">Set Mode</button>
</div>
<div class="status-box">${escapeHtml(modePolicy.description || "")}</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<div class="panel-eyebrow">Gemini Mic</div>
<h3>${micEnabled ? "Microphone ON" : "Microphone OFF"}</h3>
</div>
</div>
<div class="action-row">
<button id="mic_toggle" class="btn ${micEnabled ? "ghost danger" : "primary"}">${micEnabled ? "Turn Mic Off" : "Turn Mic On"}</button>
</div>
<div class="status-box">Microphone audio can stay on in both modes. Manual mode still disables mapped photo commands and AI detection.</div>
</section>
</div>
<section class="panel strong">
<div class="panel-header">
<div>
<div class="panel-eyebrow">Camera Console</div>
<h2>Live Camera</h2>
</div>
</div>
<div class="camera-grid">
<div>
<div class="preview-shell">
<div id="preview_placeholder" class="preview-placeholder">Live preview is off.<br>Use the toggle below when you need a live camera feed.</div>
<img id="live_preview" class="camera-preview" alt="live preview">
</div>
<div class="preview-toolbar" style="margin-top:12px;">
<button id="toggle_preview" class="btn ghost">Show Live Camera</button>
<button id="capture_now_btn" class="btn primary">Capture Now</button>
<button id="camera_refresh_btn" class="btn ghost">Refresh Camera</button>
</div>
<div id="preview_hint" class="meta" style="margin-top:10px;">Live preview is off. Turn it on only when needed.</div>
<div id="camera_chip_row" class="chip-row"></div>
<div id="camera_status_box" class="status-box">Loading camera status...</div>
</div>
<div>
<div class="selector-row">
<select id="camera_source_select">${renderSourceOptions(cameraSources)}</select>
<button id="camera_apply_btn" class="btn ghost">Switch Camera</button>
<button id="camera_save_default_btn" class="btn ghost">Save As Default</button>
</div>
<div id="camera_source_meta" class="meta" style="margin-top:10px;">Loading available camera sources...</div>
<pre id="camera_inventory_box" class="status-box" style="margin-top:10px;">Loading camera inventory...</pre>
<div class="resolution-row" style="margin-top:14px;">
<select id="resolution_select">${renderResolutionOptions()}</select>
<input id="width_input" type="number" min="1" step="1" placeholder="width">
<input id="height_input" type="number" min="1" step="1" placeholder="height">
<input id="fps_input" type="number" min="1" step="1" placeholder="fps">
<button id="apply_resolution_btn" class="btn ghost">Apply Resolution</button>
</div>
<div class="meta" style="margin-top:10px;">Use a preset or enter width, height, and fps manually. The direct camera service will reopen with the requested settings.</div>
<pre id="camera_info_panel" class="raw-box">Checking camera...</pre>
</div>
</div>
</section>
<div class="control-grid">
<section class="panel">
<div class="panel-header">
<div>
<div class="panel-eyebrow">Replay</div>
<h3>Capture Motion</h3>
</div>
</div>
<div class="selector-row">
<select id="replay_select"></select>
<button id="replay_set" class="btn ghost">Set Active Replay</button>
</div>
<div id="replay_active_note" class="status-box">Loading replay status...</div>
</section>
${recorderMarkup}
</div>
<section class="panel">
<div class="panel-header">
<div>
<div class="panel-eyebrow">Replay Files</div>
<h3>Replay Inventory</h3>
</div>
</div>
<div class="action-row">
<button id="replay_upload_btn" class="btn ghost">Upload Replay</button>
<input id="replay_upload_file" type="file" accept=".jsonl" style="display:none;">
</div>
<div id="replay_test_status" class="status-box">Replay test idle.</div>
<div id="replay_manager_list" class="replay-list"></div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<div class="panel-eyebrow">AI Voice Assets</div>
<h3>Audio Prompt Library</h3>
</div>
<div class="action-row">
<button id="audio_prompt_mode_toggle" class="btn ghost">Situation Speech</button>
<button id="audio_prompt_fallback_toggle" class="btn ghost">Gemini Fallback</button>
</div>
</div>
<div class="status-box">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.</div>
<div id="audio_prompt_status_box" class="status-box">Loading audio prompt library...</div>
<div class="audio-prompt-recorder">
<div class="selector-row">
<select id="audio_prompt_record_key"></select>
<input id="audio_prompt_record_filename" type="text" placeholder="filename.wav">
<button id="audio_prompt_record_start" class="btn primary">Record From Text</button>
</div>
<textarea id="audio_prompt_record_text" class="prompt-editor" rows="4" placeholder="Prompt text to speak exactly..."></textarea>
<div id="audio_prompt_record_status_box" class="status-box">Prompt recorder idle.</div>
</div>
<input id="audio_prompt_upload_file" type="file" accept=".wav" style="display:none;">
<div id="audio_prompt_list" class="audio-prompt-grid"></div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<div class="panel-eyebrow">Actions</div>
<h3>Operator Actions</h3>
</div>
</div>
<div class="action-row">
<button id="req_photo" class="btn ghost">Request Photo</button>
<button id="clear_photos" class="btn ghost danger">Clear Photos</button>
<button id="download_zip" class="btn ghost">Download All</button>
<button id="upload_now" class="btn ghost">Upload Now</button>
<button id="scripts_fix_check" class="btn ghost">Check RealSense</button>
<button id="scripts_fix_run" class="btn ghost danger">Fix RealSense</button>
</div>
</section>
${aiReadinessMarkup}
${aiOptionsMarkup}
<div class="status-grid">
<section class="panel">
<div class="panel-header">
<div>
<div class="panel-eyebrow">AI State</div>
<h3>Autonomous State</h3>
</div>
</div>
<div id="auto_state_panel" class="info-box"><i>Loading...</i></div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<div class="panel-eyebrow">Runtime</div>
<h3>Runtime Health</h3>
</div>
</div>
<div id="runtime_health_panel" class="info-box"><i>Loading...</i></div>
</section>
</div>
</div>
`;
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 = '<div class="panel"><i>No photos yet.</i></div>';
return;
}
for (const photo of photos) {
const card = el("article", "card");
card.innerHTML = `
<img class="thumb" src="/${encodeURIComponent(photo.name)}?t=${Date.now()}" alt="${escapeHtml(photo.name)}">
<div class="card-body">
<div class="name">${escapeHtml(photo.name)}</div>
<div class="photo-meta">${formatSize(photo.size || 0)}<br>${formatTime(photo.mtime || 0)}</div>
<div class="actions">
<a href="/${encodeURIComponent(photo.name)}" target="_blank">Open</a>
<a href="#" data-name="${encodeURIComponent(photo.name)}" class="del">Delete</a>
<a href="/api/reupload?name=${encodeURIComponent(photo.name)}">Reupload</a>
</div>
</div>
`;
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 = `
<a href="/${encodeURIComponent(name)}" target="_blank">Open / Download</a>
<a href="/api/reupload?name=${encodeURIComponent(name)}">Reupload</a>
<a href="#" id="modal_delete">Delete</a>
`;
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 = `<div class="panel"><div class="status-box">Dashboard load failed\n${escapeHtml(e.message)}</div></div>`;
});
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 = "<i>No scripts</i>";
} else {
list.innerHTML = "";
r.scripts.forEach((s) => {
const row = document.createElement("div");
row.className = "script-row";
row.innerHTML = `<b>${escapeHtml(s)}</b> &nbsp; <button class="btn-secondary btn-small" data-name="${escapeHtml(s)}" data-act="set">Set</button> <button class="btn-secondary btn-small" data-name="${escapeHtml(s)}" data-act="edit">Edit</button> <button class="btn-danger btn-small" data-name="${escapeHtml(s)}" data-act="del">Delete</button>`;
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 = "<i>Error</i>";
}
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";
}