RGMC98's picture
fix responsive
67a9983 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Monaco Cultural Agent</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0a0a;
--bg-secondary: #111111;
--bg-tertiary: #1a1a1a;
--accent-red: #e8002d;
--accent-red-dim:rgba(232,0,45,0.12);
--accent-gold: #c9a84c;
--accent-gold-l: #e8c870;
--text-primary: #f0f0f0;
--text-secondary:#555555;
--border: #1f1f1f;
}
body.light-mode {
--bg-primary: #ffffff;
--bg-secondary: #f4f4f4;
--bg-tertiary: #eaeaea;
--text-primary: #111111;
--text-secondary:#777777;
--border: #d4d4d4;
}
body.light-mode .msg.agent li { color: #555; }
body.light-mode .s-source { color: #888; }
#theme-btn { background:transparent; border:1px solid var(--border); border-radius:3px; color:var(--text-secondary); font-size:14px; cursor:pointer; padding:4px 10px; transition:all 120ms; line-height:1; }
#theme-btn:hover { border-color:var(--accent-gold); color:var(--accent-gold); }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', sans-serif;
height: 100vh;
overflow: hidden;
position: relative;
}
body::before {
content:'';
position:fixed;
inset:0;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events:none;
z-index:0;
}
body { z-index:1; }
#header, #app { position:relative; z-index:1; }
#header {
display:flex; align-items:center;
padding: 0 28px; height: 58px;
border-bottom: 2px solid var(--accent-red);
background: var(--bg-primary);
gap: 16px; flex-shrink: 0;
}
.logo { font-family:'Rajdhani',sans-serif; font-size:20px; font-weight:700; letter-spacing:4px; text-transform:uppercase; color:var(--text-primary); }
.logo em { color:var(--accent-red); font-style:normal; }
.sep { width:1px; height:18px; background:var(--border); }
.tagline { font-size:10px; color:var(--text-secondary); letter-spacing:2.5px; text-transform:uppercase; font-weight:300; }
.header-right { display:flex; align-items:center; gap:12px; margin-left:auto; }
.badge { font-family:'JetBrains Mono',monospace; font-size:9px; color:var(--accent-gold); border:1px solid rgba(201,168,76,0.35); padding:3px 10px; letter-spacing:2px; text-transform:uppercase; }
#app { display:flex; height:calc(100vh - 58px); overflow:hidden; }
#chat-area { flex:1; display:flex; flex-direction:column; border-right:1px solid var(--border); overflow:hidden; }
#messages { flex:1; overflow-y:auto; padding:28px 32px; display:flex; flex-direction:column; gap:14px; scroll-behavior:smooth; }
#messages::-webkit-scrollbar { width:3px; }
#messages::-webkit-scrollbar-thumb { background:var(--accent-red); border-radius:2px; }
#messages::-webkit-scrollbar-track { background:transparent; }
.msg-wrap { display:flex; flex-direction:column; gap:4px; animation:fadeUp 0.2s ease-out; }
@keyframes fadeUp { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
.msg-label { font-size:9px; letter-spacing:2px; text-transform:uppercase; font-family:'JetBrains Mono',monospace; padding:0 4px; }
.msg-label.user-label { color:rgba(232,0,45,0.6); text-align:right; }
.msg-label.agent-label { color:var(--accent-gold); text-align:left; }
.msg { padding:11px 16px; border-radius:4px; font-size:14px; line-height:1.65; max-width:78%; }
.msg.user { align-self:flex-end; background:var(--bg-tertiary); border:1px solid var(--border); border-right:3px solid var(--accent-red); box-shadow:2px 2px 16px rgba(232,0,45,0.06); }
.msg.agent { align-self:flex-start; background:var(--bg-tertiary); border:1px solid var(--border); border-right:3px solid var(--accent-gold); box-shadow:2px 2px 16px rgba(232, 217, 0, 0.06); max-width:78%; }
.msg.agent ul { padding-left:18px; margin-top:6px; }
.msg.agent li { margin-bottom:6px; color:#ccc; }
.agent-intro { color:var(--text-primary); font-weight:500; margin-bottom:12px; }
.event-list { list-style:none; padding-left:0; margin:0; }
.event-item { margin-bottom:14px; padding-left:0; color:var(--text-secondary); font-size:13px; }
.event-item::before { content:''; display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--text-secondary); margin-right:10px; vertical-align:middle; }
.event-title { color:var(--accent-gold-l); font-weight:500; display:inline; margin-bottom:0; }
.event-meta { color:var(--text-secondary); font-size:12px; margin-top:2px; margin-bottom:4px; }
.agent-body.event-meta { margin-top:8px; }
.event-link { color:var(--accent-red); text-decoration:none; font-size:12px; display:inline-block; margin-top:2px; }
.event-link:hover { text-decoration:underline; }
.msg.agent a { color:var(--accent-red); text-decoration:none; font-size:12px; }
.msg.agent a:hover { text-decoration:underline; }
.msg-audio { display:flex; align-items:center; gap:8px; margin-top:10px; padding-top:8px; border-top:1px solid var(--border); }
.play-btn { width:28px; height:28px; border-radius:50%; background:transparent; border:1px solid var(--accent-gold); color:var(--accent-gold); font-size:10px; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:all 120ms; flex-shrink:0; }
.play-btn:hover { background:rgba(201,168,76,0.12); }
.play-btn.loading { border-color:transparent; border-top-color:var(--accent-gold); animation:spin 0.7s linear infinite; cursor:default; }
@keyframes spin { to { transform:rotate(360deg); } }
.audio-bar { flex:1; height:2px; background:var(--border); border-radius:1px; cursor:pointer; position:relative; }
.audio-progress { height:100%; background:var(--accent-gold); border-radius:1px; width:0%; transition:width 0.1s; }
.audio-time { font-family:'JetBrains Mono',monospace; font-size:9px; color:var(--text-secondary); white-space:nowrap; }
.typing-indicator { display:flex; align-items:center; gap:10px; padding:10px 16px; background:var(--bg-secondary); border:1px solid var(--border); border-left:3px solid var(--accent-gold); border-radius:4px; align-self:flex-start; width:fit-content; }
.dots { display:flex; gap:4px; align-items:center; }
.dot { width:5px; height:5px; border-radius:50%; background:var(--accent-gold); animation:blink 1.2s ease-in-out infinite; }
.dot:nth-child(2){animation-delay:0.2s} .dot:nth-child(3){animation-delay:0.4s}
.typing-label { font-size:11px; color:var(--text-secondary); font-style:italic; }
.typing-fun { font-size:10px; color:#e8002d; font-family:'JetBrains Mono',monospace; margin-top:6px; font-style:italic; opacity:0.9; }
@keyframes blink { 0%,80%,100%{opacity:0.2;transform:scale(0.8)} 40%{opacity:1;transform:scale(1.1)} }
#suggestions { display:flex; flex-wrap:wrap; gap:8px; padding:12px 28px; border-top:1px solid var(--border); background:var(--bg-primary); transition:opacity 0.3s,max-height 0.3s; overflow:hidden; max-height:60px; }
#suggestions.hidden { opacity:0; max-height:0; padding:0 28px; }
.sugg { background:transparent; border:1px solid var(--border); color:var(--text-secondary); font-size:12px; font-family:'Inter',sans-serif; padding:5px 12px; border-radius:3px; cursor:pointer; transition:all 120ms; white-space:nowrap; }
.sugg:hover { border-color:var(--accent-gold); color:var(--accent-gold-l); background:rgba(201,168,76,0.05); }
#input-bar { display:flex; align-items:center; gap:8px; padding:12px 20px; background:var(--bg-secondary); border-top:1px solid var(--border); }
#mic-btn { width:44px; height:44px; border-radius:50%; background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-secondary); font-size:18px; cursor:pointer; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:all 120ms; position:relative; }
#mic-btn:hover { border-color:var(--accent-red); color:var(--accent-red); }
#mic-btn.active { background:var(--accent-red); border-color:var(--accent-red); color:white; animation:mic-pulse 1.3s ease-in-out infinite; }
#mic-btn.active::after { content:''; position:absolute; inset:-7px; border-radius:50%; border:2px solid var(--accent-red); animation:ring-out 1.3s ease-out infinite; }
@keyframes mic-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(232,0,45,0.5)} 50%{box-shadow:0 0 0 6px rgba(232,0,45,0)} }
@keyframes ring-out { 0%{transform:scale(1);opacity:0.7} 100%{transform:scale(1.7);opacity:0} }
#text-input { flex:1; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:4px; color:var(--text-primary); font-family:'Inter',sans-serif; font-size:14px; padding:10px 14px; outline:none; transition:border-color 120ms; height:44px; }
#text-input:focus { border-color:var(--accent-red); box-shadow:0 0 0 2px rgba(232,0,45,0.1); }
#text-input::placeholder { color:var(--text-secondary); font-size:13px; }
#send-btn { height:44px; background:var(--accent-red); color:white; border:none; border-radius:4px; font-family:'Rajdhani',sans-serif; font-size:13px; font-weight:600; letter-spacing:2px; text-transform:uppercase; padding:0 22px; cursor:pointer; transition:all 120ms; white-space:nowrap; flex-shrink:0; }
#send-btn:hover { background:#ff1a42; box-shadow:0 2px 14px rgba(232,0,45,0.3); transform:translateY(-1px); }
#sidebar { width:240px; min-width:240px; background:var(--bg-secondary); padding:20px 16px; display:flex; flex-direction:column; gap:18px; overflow-y:auto; border-left:1px solid var(--border); }
#sidebar::-webkit-scrollbar{width:2px} #sidebar::-webkit-scrollbar-thumb{background:var(--border)}
.s-status { display:flex; align-items:center; gap:7px; font-family:'JetBrains Mono',monospace; font-size:9px; color:var(--text-secondary); letter-spacing:1.5px; text-transform:uppercase; }
.dot-live { width:5px; height:5px; border-radius:50%; background:#00c851; box-shadow:0 0 6px rgba(0,200,81,0.8); flex-shrink:0; }
.s-divider { height:1px; background:var(--border); }
.s-title { font-family:'Rajdhani',sans-serif; font-size:10px; font-weight:600; letter-spacing:2.5px; text-transform:uppercase; color:var(--text-secondary); margin-bottom:8px; }
.s-select { width:100%; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:3px; color:var(--text-primary); font-size:13px; font-family:'Inter',sans-serif; padding:8px 10px; outline:none; cursor:pointer; margin-bottom:8px; appearance:none; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 10px center; }
.s-select:focus { border-color:var(--accent-gold); }
.s-label { font-size:10px; color:var(--text-secondary); letter-spacing:1px; text-transform:uppercase; display:block; margin-bottom:5px; }
.s-checkbox-row { display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:8px; }
.s-checkbox-row input[type="checkbox"] { accent-color:var(--accent-gold); width:14px; height:14px; cursor:pointer; }
.s-checkbox-row span { font-size:12px; color:var(--text-primary); }
.s-about { background:var(--bg-tertiary); border:1px solid var(--border); border-radius:4px; padding:14px; font-size:11px; color:var(--text-secondary); line-height:1.8; flex:1; }
.s-about-title { font-family:'JetBrains Mono',monospace; font-size:9px; color:var(--accent-gold); letter-spacing:2px; display:block; margin-bottom:10px; }
.s-source { color: var(--accent-gold); font-size:10px; font-family:'JetBrains Mono',monospace; letter-spacing:0.5px; text-decoration:none; }
.s-source:hover { text-decoration:underline; }
#rec-overlay { position:fixed; bottom:80px; left:50%; transform:translateX(-50%) translateY(10px); background:var(--bg-secondary); border:1px solid var(--accent-red); border-radius:4px; padding:10px 20px; display:flex; align-items:center; gap:10px; font-size:12px; color:var(--text-secondary); letter-spacing:1px; opacity:0; pointer-events:none; transition:all 200ms ease; z-index:100; box-shadow:0 4px 20px rgba(232,0,45,0.15); }
#rec-overlay.visible { opacity:1; transform:translateX(-50%) translateY(0); pointer-events:auto; }
.rec-dot { width:7px; height:7px; border-radius:50%; background:var(--accent-red); animation:blink-dot 0.8s ease-in-out infinite alternate; }
@keyframes blink-dot { from{opacity:1} to{opacity:0.2} }
@media (max-width: 768px) {
#sidebar { display:none !important; width:0 !important; min-width:0 !important; padding:0 !important; border:none !important; overflow:hidden !important; }
#chat-area { border-right:none; flex:1; }
#messages { padding:16px; }
#input-area { padding:10px 12px; }
.msg { max-width:92%; }
.msg.agent { max-width:92%; }
}
#transcription-toast { position:fixed; top:70px; right:28px; background:var(--bg-secondary); border:1px solid var(--accent-gold); border-radius:4px; padding:10px 16px; font-size:12px; color:var(--text-secondary); letter-spacing:0.5px; opacity:0; transform:translateX(10px); transition:all 250ms ease; z-index:100; max-width:280px; }
#transcription-toast.visible { opacity:1; transform:translateX(0); }
.toast-label { font-family:'JetBrains Mono',monospace; font-size:9px; color:var(--accent-gold); letter-spacing:2px; text-transform:uppercase; display:block; margin-bottom:4px; }
</style>
</head>
<body>
<div id="header">
<div class="logo">🇲🇨 🎬 🎭 🖼️ <em>Monaco</em> Cultural Agent</div>
<div class="sep"></div>
<div class="tagline" id="last-scraped-line">Last updated: …</div>
<div class="header-right">
<div class="badge">Mistral Hackathon 2026</div>
<button type="button" id="theme-btn" title="Toggle light/dark theme">🌙</button>
</div>
</div>
<div id="app">
<div id="chat-area">
<div id="messages"></div>
<div id="suggestions">
<button type="button" class="sugg" data-sugg="What films are showing in Monaco this weekend?">🎬 Films this weekend</button>
<button type="button" class="sugg" data-sugg="What exhibitions are currently on in Monaco?">🖼️ Current exhibitions</button>
<button type="button" class="sugg" data-sugg="What is the Grimaldi Forum schedule?">🎤 Grimaldi Forum schedule</button>
<button type="button" class="sugg" data-sugg="What is the programme at the Théâtre Princesse Grace?">🎭 Théâtre Princesse Grace</button>
</div>
<div id="input-bar">
<button type="button" id="mic-btn" title="Voice recording">🎙</button>
<input type="text" id="text-input" placeholder="Ask your question about Monaco…" />
<button type="button" id="send-btn">Send</button>
</div>
</div>
<div id="sidebar">
<div class="s-status"><div class="dot-live"></div>Agent active</div>
<div class="s-divider"></div>
<div>
<div class="s-title">Model</div>
<span class="s-label">Provider</span>
<select id="provider-select" class="s-select"></select>
<span class="s-label">Model</span>
<select id="model-select" class="s-select"></select>
</div>
<div class="s-divider"></div>
<div>
<div class="s-title">ElevenLabs Voice</div>
<label class="s-checkbox-row">
<input type="checkbox" id="speaker-chk" />
<span>Auto voice response</span>
</label>
<span class="s-label">Voice</span>
<select id="voice-select" class="s-select"></select>
</div>
<div class="s-divider"></div>
<div class="s-about">
<span class="s-about-title">// ABOUT</span>
Specialized agent for cultural events in the Principality of Monaco. Areas of expertise: cultural events, exhibitions, shows, theatre, cinema, museums, opera, exotic garden, etc.<br><br>
<span style="color:#333;font-size:10px;font-family:'JetBrains Mono',monospace">Sources:</span><br>
<a class="s-source" href="https://www.oceano.mc" target="_blank">oceano.mc</a><br>
<a class="s-source" href="https://www.grimaldiforum.com" target="_blank">grimaldiforum.com</a><br>
<a class="s-source" href="https://www.cinemas2monaco.com" target="_blank">cinemas2monaco.com</a><br>
<a class="s-source" href="https://www.mediatheque.mc" target="_blank">mediatheque.mc</a><br>
<a class="s-source" href="https://www.letheatredesmuses.com" target="_blank">letheatredesmuses.com</a><br>
<a class="s-source" href="https://www.tpgmonaco.mc" target="_blank">tpgmonaco.mc</a>
</div>
</div>
</div>
<div id="rec-overlay"><div class="rec-dot"></div>Recording in progress… Click ⏹ to send</div>
<div id="transcription-toast"><span class="toast-label">// Voxtral</span><span id="toast-text"></span></div>
<script>
(function() {
const MODELS = {
"mistral": ["ministral-8b-latest", "mistral-large-latest", "mistral-small-latest"],
"vllm": [],
"nvidia": ["mistralai/ministral-14b-instruct-2512", "mistralai/mistral-large-3-675b-instruct-2512"],
"lmstudio": ["mistralai/ministral-14b-instruct-2512"],
};
const PROVIDERS = ["mistral", "nvidia", "lmstudio"];
const state = {
history: [],
provider: "mistral",
model: "ministral-8b-latest",
voiceId: "",
speakerAuto: false,
recording: false,
mediaRecorder: null,
audioChunks: [],
};
const messagesEl = document.getElementById("messages");
const suggestionsEl = document.getElementById("suggestions");
const textInput = document.getElementById("text-input");
const sendBtn = document.getElementById("send-btn");
const micBtn = document.getElementById("mic-btn");
const providerSelect = document.getElementById("provider-select");
const modelSelect = document.getElementById("model-select");
const voiceSelect = document.getElementById("voice-select");
const speakerChk = document.getElementById("speaker-chk");
const recOverlay = document.getElementById("rec-overlay");
const toastEl = document.getElementById("transcription-toast");
const toastText = document.getElementById("toast-text");
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
function stripUrlsForTTS(txt) {
if (!txt || !txt.trim()) return "";
return txt.replace(/\s*https?:\/\/[^\s]+\s*/gi, " ").replace(/\s+/g, " ").trim();
}
function getDomainFromUrl(url) {
try {
return url.replace(/^https?:\/\/(?:www\.)?/, "").split(/[/?#]/)[0] || url;
} catch (err) { return url; }
}
function renderMarkup(txt) {
if (!txt) return "";
return escapeHtml(txt)
.replace(/\n/g, "<br>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^- (.+)$/gm, "<li>$1</li>")
.replace(/(<li>.*<\/li>)/s, "<ul>$1</ul>");
}
function formatIntroHtml(intro) {
if (!intro || !intro.trim()) return "";
var escaped = escapeHtml(intro);
escaped = escaped.replace(/\*\*(.+?)\*\*/g, '<span class="event-title">$1</span>');
escaped = escaped.replace(/\[([^\]]*)\]\((https?:\S+)\)/g, function(_, __, url) {
var domain = getDomainFromUrl(url);
return '<a class="event-link" href="' + escapeHtml(url) + '" target="_blank" rel="noopener">' + escapeHtml(domain) + " →</a>";
});
return escaped;
}
function formatItemHtml(line) {
var escaped = escapeHtml(line);
escaped = escaped.replace(/\*\*(.+?)\*\*/g, '<span class="event-title">$1</span>');
escaped = escaped.replace(/\[([^\]]*)\]\((https?:\S+)\)/g, function(_, __, url) {
var domain = getDomainFromUrl(url);
return '<a class="event-link" href="' + escapeHtml(url) + '" target="_blank" rel="noopener">' + escapeHtml(domain) + " →</a>";
});
return escaped;
}
function parseEventLine(line) {
line = line.replace(/\s+/g, " ").trim();
var title = "", date = "", lieu = "", tarif = "", url = "";
var lieuM = line.match(/\b(?:Lieu|Venue)\s*:\s*([^.]*?)(?=\.\s*(?:Tarif|Price|Lien|Link|Horaires|Schedule)|\.\s*$|$)/i);
var tarifM = line.match(/\b(?:Tarif|Price)\s*:\s*([^.]*?)(?=\.\s*(?:Lien|Link)|\.\s*$|$)/i);
if (!tarifM && /(?:Gratuit|Free)/i.test(line)) tarifM = [null, (line.match(/(?:Gratuit|Free)[^.]*\.?/) || [])[0] || "Free"];
var lienMd = line.match(/\b(?:Lien|Link)\s*:\s*\[[^\]]*\]\((https?:[^)]+)\)/i);
var lienRaw = line.match(/\b(?:Lien|Link)\s*:\s*(https?:\S+)/i);
var lienBare = line.match(/\b(?:Lien|Link)\s*:\s*(\S+)/i);
var duM = line.match(/(?:Du|From)\s+(.+?)\s+(?:au|to)\s+([^.]*?)(?=\.|$)/i);
if (lieuM) lieu = lieuM[1].trim();
if (tarifM) tarif = tarifM[1].trim();
if (lienMd) url = lienMd[1].trim();
else if (lienRaw) url = lienRaw[1].trim();
else if (lienBare) { var u = lienBare[1].trim().replace(/^\[|\]\.?$/g, ""); if (u) url = u.indexOf("http") === 0 ? u : "https://" + u; }
if (duM) date = (duM[1].trim() + " – " + duM[2].trim()).replace(/\s*\.\s*$/, "");
var beforeLieu = lieuM ? line.substring(0, line.indexOf(lieuM[0])).trim() : line;
title = beforeLieu.split(". ")[0].trim();
var duIdx = Math.max(beforeLieu.indexOf("Du "), beforeLieu.indexOf("From "));
if (duM && duIdx >= 0 && title.length > duIdx) {
var avantDu = beforeLieu.substring(0, duIdx).trim();
if (avantDu) title = avantDu.replace(/\s*\.\s*$/, "");
}
if (!title) title = beforeLieu.split(". ")[0] || beforeLieu;
return { title: title, date: date, lieu: lieu, tarif: tarif || "Price not available", url: url };
}
function looksLikeEventList(intro, firstItem) {
if (!firstItem || !intro) return false;
var lower = (intro + " " + firstItem).toLowerCase();
if (lower.indexOf("lieu") >= 0 || lower.indexOf("venue") >= 0 || lower.indexOf("du ") >= 0 || lower.indexOf("from ") >= 0) return true;
if (/^\d+\.\s*.{10,}/.test(firstItem) && (firstItem.indexOf("Lieu") >= 0 || firstItem.indexOf("Venue") >= 0 || firstItem.indexOf("Tarif") >= 0 || firstItem.indexOf("Price") >= 0)) return true;
return false;
}
function eventEmojiFromIntro(intro) {
var i = (intro || "").toLowerCase();
if (i.indexOf("concert") >= 0) return "🎵";
if (i.indexOf("exposition") >= 0 || i.indexOf("exhibition") >= 0 || i.indexOf("expo") >= 0) return "🖼️";
if (i.indexOf("humour") >= 0 || i.indexOf("comedy") >= 0) return "🎤";
if (i.indexOf("atelier") >= 0 || i.indexOf("workshop") >= 0) return "📚";
if (i.indexOf("théâtre") >= 0 || i.indexOf("theatre") >= 0 || i.indexOf("theater") >= 0 || i.indexOf("spectacle") >= 0 || i.indexOf("show") >= 0) return "🎭";
return "🖼️";
}
function buildShortTTS(data) {
var response = (data && data.response) || "";
var events = data && data.events && Array.isArray(data.events) ? data.events : null;
if (data && data.tts_text && (data.tts_text + "").trim()) return (data.tts_text + "").trim();
if (events && events.length > 0) {
var intro = (response || "").split("\n\n")[0] || "";
var titres = events.map(function(e) { return (e.titre || "").trim(); }).filter(Boolean);
return (intro.trim() + (titres.length ? " " + titres.join(", ") : "")).trim() || response;
}
var parts = (response || "").trim().split(/\n\n+/);
var intro = parts[0] || "";
var body = parts.slice(1).join("\n\n").trim();
var items = body ? body.split(/(?=^\d+\.\s)/m).filter(function(s) { return /^\d+\.\s/.test(s.trim()); }) : [];
if (items.length > 0) {
var firstLine = items[0].replace(/^\d+\.\s*/, "").trim();
if (looksLikeEventList(intro, firstLine)) {
var titres = items.map(function(it) {
var line = it.replace(/^\d+\.\s*/, "").trim();
var p = parseEventLine(line);
return p.title || line.split(". ")[0] || line;
}).filter(Boolean);
return (intro.trim() + (titres.length ? " " + titres.join(", ") : "")).trim() || stripUrlsForTTS(response);
}
}
return stripUrlsForTTS(response);
}
function renderAgentResponse(txt) {
if (!txt || !txt.trim()) return "";
var parts = txt.trim().split(/\n\n+/);
var intro = parts[0] || "";
var body = parts.slice(1).join("\n\n").trim();
var introHtml = "<p class=\"agent-intro\">" + formatIntroHtml(intro) + "</p>";
if (!body.trim()) return introHtml;
var items = body.split(/(?=^\d+\.\s)/m).filter(function(s) { return /^\d+\.\s/.test(s.trim()); });
var listHtml = "";
if (items.length > 0) {
var firstLine = items[0].replace(/^\d+\.\s*/, "").trim();
if (looksLikeEventList(intro, firstLine)) {
var emoji = eventEmojiFromIntro(intro);
listHtml = '<ul class="event-list">';
items.forEach(function(it) {
var line = it.replace(/^\d+\.\s*/, "").trim();
var p = parseEventLine(line);
if (!p.title) p.title = line.split(". ")[0] || line;
listHtml += '<li class="event-item">';
listHtml += '<span class="event-title">' + emoji + " " + escapeHtml(p.title) + "</span>";
listHtml += '<div class="event-meta">';
listHtml += (p.date ? "📅 " + escapeHtml(p.date) + " . " : "") + "📍 " + escapeHtml(p.lieu);
if (p.tarif && p.tarif !== "Price not available") listHtml += " . 🎟️ " + escapeHtml(p.tarif);
listHtml += "</div>";
if (p.url) listHtml += '<a class="event-link" href="' + escapeHtml(p.url) + '" target="_blank" rel="noopener">' + escapeHtml(getDomainFromUrl(p.url)) + " →</a>";
listHtml += "</li>";
});
listHtml += "</ul>";
} else {
listHtml = '<ul class="event-list">';
items.forEach(function(it) {
var line = it.replace(/^\d+\.\s*/, "").trim();
listHtml += '<li class="event-item">' + formatItemHtml(line).replace(/\n/g, "<br>") + "</li>";
});
listHtml += "</ul>";
}
} else {
listHtml = '<div class="agent-body event-meta">' + formatItemHtml(body).replace(/\n/g, "<br>") + "</div>";
}
return introHtml + listHtml;
}
function appendMessage(role, content, isVocal, events) {
const wrap = document.createElement("div");
wrap.className = "msg-wrap";
const isUser = role === "user";
const label = document.createElement("div");
label.className = "msg-label " + (isUser ? "user-label" : "agent-label");
label.textContent = isUser ? (isVocal ? "YOU · 🎙 VOICE" : "YOU") : "AGENT";
const msg = document.createElement("div");
msg.className = "msg " + (isUser ? "user" : "agent");
if (!isUser && events && events.length > 0) {
var intro = (content || "").split("\n\n")[0] || "";
msg.innerHTML = renderEventsBlock(intro, events);
} else if (!isUser && content) {
msg.innerHTML = renderAgentResponse(content);
} else {
msg.innerHTML = renderMarkup(content);
}
wrap.appendChild(label);
wrap.appendChild(msg);
messagesEl.appendChild(wrap);
messagesEl.scrollTop = messagesEl.scrollHeight;
return wrap;
}
function eventKindFromTags(tags) {
var t = (tags || []).map(function(x) { return (x || "").toLowerCase(); });
if (t.some(function(x) { return x.indexOf("concert") >= 0 || x.indexOf("musique") >= 0; })) return "concert";
if (t.some(function(x) { return x.indexOf("film") >= 0 || x.indexOf("cinéma") >= 0 || x.indexOf("cinema") >= 0 || x.indexOf("projection") >= 0; })) return "film";
if (t.some(function(x) { return x.indexOf("expo") >= 0 || x.indexOf("exposition") >= 0; })) return "exposition";
if (t.some(function(x) { return x.indexOf("humour") >= 0; })) return "humour";
if (t.some(function(x) { return x.indexOf("atelier") >= 0; })) return "atelier";
if (t.some(function(x) { return x.indexOf("théâtre") >= 0 || x.indexOf("theatre") >= 0 || x.indexOf("spectacle") >= 0; })) return "theatre";
return "default";
}
var MOIS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
function formatDateShort(startStr, endStr) {
if (!startStr) return "";
var d = startStr.split("-");
var j = d[2] ? parseInt(d[2], 10) : "";
var m = d[1] ? MOIS[parseInt(d[1], 10) - 1] : "";
if (!endStr || endStr === startStr) return (j && m) ? j + " " + m : startStr;
var e = endStr.split("-");
var j2 = e[2] ? parseInt(e[2], 10) : "";
var m2 = e[1] ? MOIS[parseInt(e[1], 10) - 1] : "";
if (m2 === m) return (j && j2 && m) ? j + "-" + j2 + " " + m : startStr + "–" + endStr;
return (j && m && j2 && m2) ? j + " " + m + " – " + j2 + " " + m2 : startStr + "–" + endStr;
}
function renderEventsBlock(intro, events) {
var html = "";
if (intro) html += '<p class="agent-intro">' + escapeHtml(intro.trim()) + "</p>";
html += '<ul class="event-list">';
events.forEach(function(e) {
var titre = e.titre || "";
var lieu = e.lieu_nom || "";
var start = e.date_start || "";
var end = e.date_end || e.date_start || "";
var heure = e.heure_debut || "";
var description = (e.description || "").trim();
var kind = eventKindFromTags(e.tags);
var tarif = e.tarif ? (e.tarif + "").trim() : "";
if (!tarif && e.gratuit) tarif = "Free admission";
if (!tarif) tarif = "Price not available";
var linkUrl = (e.url != null && e.url !== "") ? String(e.url).trim() : "";
if (!linkUrl && e.source) linkUrl = (e.source != null && e.source !== "") ? String(e.source).trim() : "";
var domain = linkUrl ? getDomainFromUrl(linkUrl) : "";
var emoji = "🎭";
if (kind === "concert") emoji = "🎵";
else if (kind === "film") emoji = "🎬";
else if (kind === "exposition") emoji = "🖼️";
else if (kind === "humour") emoji = "🎤";
else if (kind === "atelier") emoji = "📚";
else if (kind === "theatre") emoji = "🎭";
var metaHtml = "";
if (kind === "film") {
var metaParts = [];
if (heure || (description && (description.indexOf("h") >= 0 || description.indexOf(":") >= 0))) {
if (description && description.length < 80) metaParts.push("🕐 " + escapeHtml(description));
else if (heure) metaParts.push("🕐 From " + escapeHtml(heure));
}
metaParts.push("🗓️ " + escapeHtml(start + (end && end !== start ? "–" + end : "")));
metaParts.push("📍 " + escapeHtml(lieu));
if (tarif !== "Price not available") metaParts.push("💰 " + escapeHtml(tarif));
metaHtml = metaParts.join(" • ");
} else {
var dateLabel = formatDateShort(start, end);
metaHtml = "📅 " + escapeHtml(dateLabel) + " . 📍 " + escapeHtml(lieu);
if (tarif !== "Price not available") metaHtml += " . 🎟️ " + escapeHtml(tarif);
}
html += '<li class="event-item">';
html += '<span class="event-title">' + emoji + " " + escapeHtml(titre) + "</span>";
html += '<div class="event-meta">' + metaHtml + "</div>";
if (linkUrl && domain) html += '<a class="event-link" href="' + escapeHtml(linkUrl) + '" target="_blank" rel="noopener">' + escapeHtml(domain) + " →</a>";
html += "</li>";
});
html += "</ul>";
return html;
}
function formatTime(sec) {
if (!isFinite(sec) || sec < 0) return "0:00";
var m = Math.floor(sec / 60);
var s = Math.floor(sec % 60);
return m + ":" + (s < 10 ? "0" : "") + s;
}
function attachAudioFromBlob(audioDiv, blob) {
var playBtn = audioDiv.querySelector(".play-btn");
var progress = audioDiv.querySelector(".audio-progress");
var timeEl = audioDiv.querySelector(".audio-time");
var url = URL.createObjectURL(blob);
var audio = new Audio(url);
audioDiv._audio = audio;
audio.onended = function() {
playBtn.innerHTML = "▶";
progress.style.width = "0%";
timeEl.textContent = "0:00 / " + formatTime(audio.duration);
playBtn.disabled = false;
URL.revokeObjectURL(url);
};
audio.onloadedmetadata = function() {
timeEl.textContent = "0:00 / " + formatTime(audio.duration);
};
audio.ontimeupdate = function() {
var p = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
progress.style.width = p + "%";
timeEl.textContent = formatTime(audio.currentTime) + " / " + formatTime(audio.duration);
};
}
function addAudioPlayer(wrap, text, preloadedBlob) {
var msg = wrap.querySelector(".msg");
var audioDiv = msg.querySelector(".msg-audio");
if (audioDiv) return audioDiv;
audioDiv = document.createElement("div");
audioDiv.className = "msg-audio";
var playBtn = document.createElement("button");
playBtn.type = "button";
playBtn.className = "play-btn";
playBtn.innerHTML = "▶";
playBtn.title = "Listen";
var barWrap = document.createElement("div");
barWrap.className = "audio-bar";
var progress = document.createElement("div");
progress.className = "audio-progress";
barWrap.appendChild(progress);
var timeEl = document.createElement("span");
timeEl.className = "audio-time";
timeEl.textContent = "0:00 / 0:00";
audioDiv.appendChild(playBtn);
audioDiv.appendChild(barWrap);
audioDiv.appendChild(timeEl);
msg.appendChild(audioDiv);
if (preloadedBlob) attachAudioFromBlob(audioDiv, preloadedBlob);
playBtn.addEventListener("click", function() {
if (audioDiv._audio) {
if (audioDiv._audio.paused) {
audioDiv._audio.play();
playBtn.innerHTML = "⏸";
} else {
audioDiv._audio.pause();
playBtn.innerHTML = "▶";
}
return;
}
if (!(text || "").trim()) return;
playBtn.disabled = true;
playBtn.innerHTML = "";
playBtn.classList.add("loading");
fetch("/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), voice_id: state.voiceId || null }),
})
.then(function(r) {
if (!r.ok) throw new Error("TTS failed");
return r.blob();
})
.then(function(blob) {
playBtn.classList.remove("loading");
attachAudioFromBlob(audioDiv, blob);
audioDiv._audio.play().then(function() {
playBtn.innerHTML = "⏸";
playBtn.disabled = false;
}).catch(function() {
playBtn.innerHTML = "▶";
playBtn.disabled = false;
});
})
.catch(function() {
playBtn.classList.remove("loading");
playBtn.innerHTML = "▶";
playBtn.disabled = false;
});
});
return audioDiv;
}
function playTTSForMessage(wrap, text) {
if (!(text || "").trim()) return;
addAudioPlayer(wrap, text);
var audioDiv = wrap.querySelector(".msg-audio");
var playBtn = audioDiv.querySelector(".play-btn");
if (audioDiv._audio) {
audioDiv._audio.currentTime = 0;
audioDiv._audio.play();
playBtn.innerHTML = "⏸";
return;
}
playBtn.disabled = true;
playBtn.innerHTML = "";
playBtn.classList.add("loading");
fetch("/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), voice_id: state.voiceId || null }),
})
.then(function(r) {
if (!r.ok) throw new Error("TTS failed");
return r.blob();
})
.then(function(blob) {
playBtn.classList.remove("loading");
var url = URL.createObjectURL(blob);
var audio = new Audio(url);
audioDiv._audio = audio;
var progress = audioDiv.querySelector(".audio-progress");
var timeEl = audioDiv.querySelector(".audio-time");
audio.onended = function() {
playBtn.innerHTML = "▶";
progress.style.width = "0%";
timeEl.textContent = "0:00 / " + formatTime(audio.duration);
playBtn.disabled = false;
URL.revokeObjectURL(url);
};
audio.onloadedmetadata = function() {
timeEl.textContent = "0:00 / " + formatTime(audio.duration);
};
audio.ontimeupdate = function() {
var p = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
progress.style.width = p + "%";
timeEl.textContent = formatTime(audio.currentTime) + " / " + formatTime(audio.duration);
};
audio.play().then(function() {
playBtn.innerHTML = "⏸";
playBtn.disabled = false;
}).catch(function() {
playBtn.innerHTML = "▶";
playBtn.disabled = false;
});
})
.catch(function() {
playBtn.classList.remove("loading");
playBtn.innerHTML = "▶";
playBtn.disabled = false;
});
}
function guessFrontLang(text) {
var t = (text || "").toLowerCase();
if (/\b(what|where|when|how|show|is|are|the|and|this|week)\b/.test(t)) return "en";
if (/\b(cosa|dove|quando|come|spettacolo|mostra|questa)\b/.test(t)) return "it";
if (/\b(qué|dónde|cuándo|cómo|espectáculo|esta|semana)\b/.test(t)) return "es";
if (/[а-яёА-ЯЁ]/.test(t)) return "ru";
return "fr";
}
var FUN_FACTS = {
fr: [
"Monaco compte environ 38 000 habitants pour seulement 2,02 km² — la ville la plus dense du monde.",
"Monaco est le 2ème plus petit État souverain du monde, après le Vatican.",
"Le théâtre se dit « teatru » en langue monégasque. 🎭",
],
en: [
"Monaco has around 38,000 inhabitants in just 2.02 km² — the world's most densely populated country.",
"Monaco is the 2nd smallest sovereign state in the world, after Vatican City.",
"The word for theatre in Monégasque, the local language, is « teatru ». 🎭",
],
it: [
"Monaco ha circa 38.000 abitanti in soli 2,02 km² — il paese più densamente popolato al mondo.",
"Monaco è il 2° stato sovrano più piccolo del mondo, dopo il Vaticano.",
"In monegasco, il teatro si dice « teatru ». 🎭",
],
es: [
"Mónaco tiene unos 38.000 habitantes en apenas 2,02 km² — el país más densamente poblado del mundo.",
"Mónaco es el 2º estado soberano más pequeño del mundo, después del Vaticano.",
"En monegasco, teatro se dice « teatru ». 🎭",
],
ru: [
"В Монако около 38 000 жителей на площади всего 2,02 км² — самое густонаселённое государство мира.",
"Монако — 2-е по величине государство в мире после Ватикана.",
"На монегасском языке театр называется « teatru ». 🎭",
],
};
var DID_YOU_KNOW = {
fr: "Le saviez-vous ?",
en: "Did you know?",
it: "Lo sapevi?",
es: "¿Sabías que?",
ru: "Знаете ли вы?",
};
function getDidYouKnow(lang) {
return DID_YOU_KNOW[lang] || DID_YOU_KNOW["fr"];
}
function getRandomFact(lang) {
var list = FUN_FACTS[lang] || FUN_FACTS["fr"];
return list[Math.floor(Math.random() * list.length)];
}
function showTyping(lang) {
var fact = getRandomFact(lang || state.lastLang || "fr");
var wrap = document.createElement("div");
wrap.className = "msg-wrap typing-wrap";
wrap.innerHTML = '<div class="typing-indicator" style="flex-direction:column;align-items:flex-start;">'
+ '<div style="display:flex;align-items:center;gap:10px"><div class="dots"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div><span class="typing-label">Agent searching…</span></div>'
+ '<div class="typing-fun">💡 ' + getDidYouKnow(lang) + ' ' + fact + '</div>'
+ '</div>';
messagesEl.appendChild(wrap);
messagesEl.scrollTop = messagesEl.scrollHeight;
return wrap;
}
function removeTyping(wrap) {
if (wrap && wrap.parentNode) wrap.parentNode.removeChild(wrap);
}
function showToast(text) {
toastText.textContent = text || "";
toastEl.classList.add("visible");
setTimeout(function() {
toastEl.classList.remove("visible");
}, 3500);
}
function sendMessage(message, isVocal) {
var text = (message || "").trim();
if (!text) return;
state.history.push({ role: "user", content: text });
appendMessage("user", text, !!isVocal);
suggestionsEl.classList.add("hidden");
var typingWrap = showTyping(guessFrontLang(text));
fetch("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: text,
history: state.history.slice(0, -1),
provider: state.provider,
model: state.model || null,
}),
})
.then(function(r) { return r.json(); })
.then(function(data) {
var response = (data && data.response) || "";
var events = data && data.events && Array.isArray(data.events) ? data.events : null;
var ttsText = buildShortTTS(data);
state.history.push({ role: "assistant", content: response });
function showMessage(blob) {
removeTyping(typingWrap);
var agentWrap = appendMessage("assistant", response, false, events);
addAudioPlayer(agentWrap, ttsText, blob || null);
if (state.speakerAuto || isVocal) {
var ad = agentWrap.querySelector(".msg-audio");
if (ad && ad._audio) {
ad._audio.play();
ad.querySelector(".play-btn").innerHTML = "⏸";
} else if (ad && ttsText) playTTSForMessage(agentWrap, ttsText);
}
}
if (!(ttsText && ttsText.trim()) || !state.speakerAuto) {
showMessage(null);
return;
}
fetch("/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: ttsText.trim(), voice_id: state.voiceId || null }),
})
.then(function(r) {
if (!r.ok) throw new Error("TTS failed");
return r.blob();
})
.then(function(blob) { showMessage(blob); })
.catch(function() { showMessage(null); });
})
.catch(function() {
removeTyping(typingWrap);
state.history.push({ role: "assistant", content: "Sorry, an error occurred." });
appendMessage("assistant", "Sorry, an error occurred.", false);
});
}
function populateProviders() {
providerSelect.innerHTML = "";
PROVIDERS.forEach(function(p) {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = p;
if (p === state.provider) opt.selected = true;
providerSelect.appendChild(opt);
});
}
function populateModels() {
const list = MODELS[state.provider] || [];
const prev = state.model;
modelSelect.innerHTML = "";
list.forEach(function(m) {
const opt = document.createElement("option");
opt.value = m;
opt.textContent = m;
if (m === prev || (!prev && list.length)) opt.selected = true;
modelSelect.appendChild(opt);
});
state.model = modelSelect.value || (list[0] || "");
}
function populateVoices() {
fetch("/voices")
.then(function(r) { return r.json(); })
.then(function(voices) {
voiceSelect.innerHTML = "";
const defOpt = document.createElement("option");
defOpt.value = "";
defOpt.textContent = "Default";
voiceSelect.appendChild(defOpt);
(voices || []).forEach(function(v) {
const opt = document.createElement("option");
opt.value = v.voice_id || "";
opt.textContent = v.name || v.voice_id || "";
voiceSelect.appendChild(opt);
});
if (voices && voices.length && !state.voiceId) state.voiceId = voices[0].voice_id || "";
})
.catch(function() {});
}
providerSelect.addEventListener("change", function() {
state.provider = providerSelect.value;
populateModels();
});
modelSelect.addEventListener("change", function() {
state.model = modelSelect.value;
});
voiceSelect.addEventListener("change", function() {
state.voiceId = voiceSelect.value;
});
speakerChk.addEventListener("change", function() {
state.speakerAuto = speakerChk.checked;
});
sendBtn.addEventListener("click", function() {
sendMessage(textInput.value, false);
textInput.value = "";
});
textInput.addEventListener("keydown", function(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage(textInput.value, false);
textInput.value = "";
}
});
document.querySelectorAll(".sugg").forEach(function(btn) {
btn.addEventListener("click", function() {
const text = btn.getAttribute("data-sugg") || btn.textContent;
sendMessage(text, false);
});
});
micBtn.addEventListener("click", function() {
if (state.recording) {
state.recording = false;
micBtn.classList.remove("active");
micBtn.textContent = "🎙";
recOverlay.classList.remove("visible");
if (state.mediaRecorder && state.mediaRecorder.state !== "inactive") {
state.mediaRecorder.stop();
}
return;
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return;
}
navigator.mediaDevices.getUserMedia({ audio: true })
.then(function(stream) {
state.audioChunks = [];
const mr = new MediaRecorder(stream);
state.mediaRecorder = mr;
mr.ondataavailable = function(e) {
if (e.data.size) state.audioChunks.push(e.data);
};
mr.onstop = function() {
stream.getTracks().forEach(function(t) { t.stop(); });
const blob = new Blob(state.audioChunks, { type: "audio/webm" });
const fd = new FormData();
fd.append("file", blob, "recording.webm");
fetch("/transcribe", { method: "POST", body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
const text = (data && data.text) || "";
if (!text) return;
showToast(text);
state.history.push({ role: "user", content: text });
appendMessage("user", text, true);
suggestionsEl.classList.add("hidden");
const typingWrap = showTyping(guessFrontLang(text));
fetch("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: text,
history: state.history.slice(0, -1),
provider: state.provider,
model: state.model || null,
}),
})
.then(function(res) { return res.json(); })
.then(function(chatData) {
var response = (chatData && chatData.response) || "";
var events = chatData && chatData.events && Array.isArray(chatData.events) ? chatData.events : null;
var ttsText = buildShortTTS(chatData);
state.history.push({ role: "assistant", content: response });
function showMessage(blob) {
removeTyping(typingWrap);
var agentWrap = appendMessage("assistant", response, false, events);
addAudioPlayer(agentWrap, ttsText, blob || null);
var ad = agentWrap.querySelector(".msg-audio");
if (ad && ad._audio) {
ad._audio.play();
ad.querySelector(".play-btn").innerHTML = "⏸";
} else if (ad && ttsText) playTTSForMessage(agentWrap, ttsText);
}
if (!(ttsText && ttsText.trim()) || !state.speakerAuto) {
showMessage(null);
return;
}
fetch("/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: ttsText.trim(), voice_id: state.voiceId || null }),
})
.then(function(r) {
if (!r.ok) throw new Error("TTS failed");
return r.blob();
})
.then(function(blob) { showMessage(blob); })
.catch(function() { showMessage(null); });
})
.catch(function() {
removeTyping(typingWrap);
state.history.push({ role: "assistant", content: "Sorry, an error occurred." });
appendMessage("assistant", "Sorry, an error occurred.", false);
});
})
.catch(function() {});
};
mr.start();
state.recording = true;
micBtn.classList.add("active");
micBtn.textContent = "⏹";
recOverlay.classList.add("visible");
})
.catch(function() {});
});
// Theme toggle
const themeBtn = document.getElementById("theme-btn");
const savedTheme = localStorage.getItem("theme");
if (savedTheme !== "dark") {
document.body.classList.add("light-mode");
themeBtn.textContent = "🌙";
} else {
themeBtn.textContent = "☀️";
}
themeBtn.addEventListener("click", function() {
const isLight = document.body.classList.toggle("light-mode");
themeBtn.textContent = isLight ? "🌙" : "☀️";
localStorage.setItem("theme", isLight ? "light" : "dark");
});
appendMessage("assistant", "Hello, I am the Monaco City Cultural Agent. Ask me about events, exhibitions, or shows in the Principality.", false);
populateProviders();
populateModels();
populateVoices();
fetch("/last-scraped").then(function(r) { return r.json(); }).then(function(d) {
var el = document.getElementById("last-scraped-line");
if (el && d && d.last_scraped_at) el.textContent = "Last updated: " + d.last_scraped_at;
}).catch(function() {});
})();
</script>
</body>
</html>