Spaces:
Sleeping
Sleeping
| <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 ; width:0 ; min-width:0 ; padding:0 ; border:none ; overflow:hidden ; } | |
| #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> | |