"; const f = document.createElement("iframe"); f.style.cssText = "position:fixed;width:0;height:0;border:0;opacity:0"; document.body.appendChild(f); f.contentDocument.open(); f.contentDocument.write(html); f.contentDocument.close(); setTimeout(() => { try { f.contentWindow.focus(); f.contentWindow.print(); } catch (e) { toast("Printing is not available"); } setTimeout(() => f.remove(), 2000); }, 300); toast("Choose Save as PDF in the print dialog"); }); document.getElementById("clearChat").addEventListener("click", () => { closePopovers(); if (!messages.length) return toast("No messages yet"); if (confirm("Delete this chat?")) { deleteConv(currentId); toast("Chat deleted"); } }); /* ATTACHMENTS */ document.getElementById("pickPhoto").addEventListener("click", () => { closeSheet(); photoInput.click(); }); document.getElementById("pickCamera").addEventListener("click", () => { closeSheet(); cameraInput.click(); }); document.getElementById("pickFile").addEventListener("click", () => { closeSheet(); fileInput.click(); }); function clearAttachment() { attachment = null; attachPreview.classList.remove("show"); attachVisual.innerHTML = ""; updateSendState(); } function compressImage(file) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { const max = 900; const scale = Math.min(1, max / Math.max(img.width, img.height)); const c = document.createElement("canvas"); c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale); c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); URL.revokeObjectURL(url); resolve(c.toDataURL("image/jpeg", 0.75)); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("bad image")); }; img.src = url; }); } const TEXT_EXT = /\.(txt|md|markdown|csv|tsv|json|xml|html?|css|js|jsx|ts|tsx|py|java|c|cpp|h|cs|php|rb|go|rs|sh|sql|yml|yaml|ini|log|srt|vtt|conf|toml)$/i; async function readPdfText(file) { if (!window.pdfjsLib) { await new Promise((ok, no) => { const sc = document.createElement("script"); sc.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"; sc.onload = ok; sc.onerror = no; document.head.appendChild(sc); }); window.pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js"; } const pdf = await window.pdfjsLib.getDocument({ data: new Uint8Array(await file.arrayBuffer()) }).promise; let out = ""; for (let i = 1; i <= Math.min(pdf.numPages, 30) && out.length < 40000; i++) { const pg = await pdf.getPage(i); const tc = await pg.getTextContent(); out += tc.items.map((it) => it.str).join(" ") + "\n\n"; } return out.slice(0, 40000); } async function handleFile(file, forceKind) { if (!file) return; if (file.size > 20 * 1048576) return toast("Maximum file size is 20 MB"); const isImage = file.type.startsWith("image/"); const kind = (forceKind === "image" || isImage) ? "image" : "file"; attachment = { kind, name: file.name || "file", size: file.size }; attachVisual.innerHTML = ""; if (kind === "image") { try { attachment.dataUrl = await compressImage(file); } catch (e) { attachment = null; return toast("Could not open the image"); } const im = document.createElement("img"); im.className = "attach-thumb"; im.src = attachment.dataUrl; im.alt = ""; attachVisual.appendChild(im); } else { const box = document.createElement("div"); box.className = "attach-icon"; box.innerHTML = FILE_SVG; attachVisual.appendChild(box); try { const fname = file.name || ""; if (/pdf/i.test(file.type || "") || /\.pdf$/i.test(fname)) { toast("Reading PDF…"); attachment.text = await readPdfText(file); } else if (/^text\//.test(file.type || "") || /json|xml|javascript/.test(file.type || "") || TEXT_EXT.test(fname)) { attachment.text = (await file.text()).slice(0, 40000); } } catch (e) {} if (!attachment.text || !attachment.text.trim()) { attachment.text = ""; toast("AI can't read this file, only its name will be sent"); } } attachName.textContent = attachment.name; attachSize.textContent = formatSize(attachment.size); attachPreview.classList.add("show"); updateSendState(); scrollToBottom(false); } [photoInput, cameraInput].forEach((el) => el.addEventListener("change", () => { handleFile(el.files[0], "image"); el.value = ""; })); fileInput.addEventListener("change", () => { handleFile(fileInput.files[0]); fileInput.value = ""; }); attachRemove.addEventListener("click", clearAttachment); /* VOICE INPUT */ const SR = window.SpeechRecognition || window.webkitSpeechRecognition; const IS_TOUCH = (navigator.maxTouchPoints || 0) > 0 || /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); const IN_FRAME = (function () { try { return window.self !== window.top; } catch (e) { return true; } })(); let recognition = null; let listening = false; let voiceBase = ""; let voiceOrig = ""; let voiceMode = ""; // "", "cancel", "send", "stop" let voiceFatal = false; const composerWrapEl = document.getElementById("composerWrap"); const vbWave = document.getElementById("vbWave"); const vbCtx = vbWave.getContext("2d"); let vNoMic = false, vT0 = 0, vGot = false, vRestarts = []; let vAudio = null, vAnalyser = null, vStream = null, vRaf = 0, vLast = 0, vBars = [], vPulse = 0, vLevel = 0; function vbSize() { const dpr = window.devicePixelRatio || 1; vbWave.width = Math.max(1, vbWave.clientWidth * dpr); vbWave.height = Math.max(1, vbWave.clientHeight * dpr); const n = Math.max(8, Math.floor(vbWave.clientWidth / 9)); while (vBars.length < n) vBars.unshift(0); vBars = vBars.slice(-n); } function vbDraw(ts) { vRaf = requestAnimationFrame(vbDraw); let lvl = 0; if (vAnalyser) { const buf = new Uint8Array(vAnalyser.fftSize); vAnalyser.getByteTimeDomainData(buf); let sum = 0; for (let i = 0; i < buf.length; i++) { const v = (buf[i] - 128) / 128; sum += v * v; } lvl = Math.min(1, Math.sqrt(sum / buf.length) * 5); } else { lvl = vPulse * (0.55 + Math.random() * 0.45); } vPulse *= 0.92; vLevel = vLevel * 0.55 + lvl * 0.45; if (ts - vLast > 55) { vLast = ts; vBars.push(vLevel); vBars.shift(); } const dpr = window.devicePixelRatio || 1, W = vbWave.width, H = vbWave.height; vbCtx.clearRect(0, 0, W, H); vbCtx.fillStyle = getComputedStyle(vbWave).color || "#fff"; const bw = 4 * dpr, step = 9 * dpr, n = vBars.length; const x0 = W - n * step + (step - bw) / 2; for (let i = 0; i < n; i++) { const h = Math.max(9 * dpr, Math.min(H, vBars[i] * H * 1.15)); const x = x0 + i * step, y = (H - h) / 2, r = bw / 2; vbCtx.beginPath(); if (vbCtx.roundRect) vbCtx.roundRect(x, y, bw, h, r); else vbCtx.rect(x, y, bw, h); vbCtx.fill(); } } async function vbStartAudio() { try { if (vNoMic || IS_TOUCH || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return; const st = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); if (!listening) { st.getTracks().forEach((t) => t.stop()); return; } vStream = st; const AC = window.AudioContext || window.webkitAudioContext; vAudio = new AC(); const src = vAudio.createMediaStreamSource(st); vAnalyser = vAudio.createAnalyser(); vAnalyser.fftSize = 512; src.connect(vAnalyser); } catch (e) { vAnalyser = null; } } function vbStopAudio() { if (vStream) vStream.getTracks().forEach((t) => t.stop()); if (vAudio) { try { vAudio.close(); } catch (e) {} } vStream = null; vAudio = null; vAnalyser = null; } function vbShow(on) { const bar = document.getElementById("voiceBar"), cmp = document.querySelector(".composer"); if (bar && cmp) { const h = cmp.offsetHeight; if (on) { bar.style.transition = "none"; bar.style.height = h + "px"; void bar.offsetHeight; bar.style.transition = ""; requestAnimationFrame(() => { bar.style.height = "52px"; }); } else { bar.style.height = h + "px"; } } composerWrapEl.classList.toggle("voice-on", on); if (on) { vBars = []; vLevel = 0; vPulse = 0; vbSize(); cancelAnimationFrame(vRaf); vRaf = requestAnimationFrame(vbDraw); vbStartAudio(); } else { cancelAnimationFrame(vRaf); vbStopAudio(); } } function setListening(on) { if (listening === on) return; listening = on; voiceButton.classList.toggle("recording", on); voiceButton.setAttribute("aria-label", on ? "Stop voice input" : "Voice input"); vbShow(on); } function stopVoice() { voiceMode = voiceMode || "stop"; if (recognition && listening) { try { recognition.stop(); } catch (e) {} } setListening(false); } function makeRecognition() { recognition = new SR(); recognition.lang = settings.lang === "id" ? "id-ID" : settings.lang === "en" ? "en-US" : (navigator.language || "en-US"); recognition.interimResults = true; recognition.continuous = false; recognition.onresult = (e) => { vPulse = 1; vGot = true; if (voiceMode === "cancel") return; let text = ""; for (let i = 0; i < e.results.length; i++) text += e.results[i][0].transcript; input.value = voiceBase + text; autoResize(); updateSendState(); }; recognition.onspeechstart = () => { vPulse = 1; }; recognition.onsoundstart = () => { vPulse = 0.7; }; recognition.onerror = (e) => { if (e.error === "aborted") return; if (e.error === "audio-capture" && vStream && !voiceMode) { vNoMic = true; vbStopAudio(); return; } voiceFatal = true; if (e.error === "not-allowed" || e.error === "service-not-allowed") toast(IN_FRAME ? "Mic diblokir di preview. Buka file ini langsung di Chrome/Safari" : "Izin mikrofon ditolak. Aktifkan di pengaturan browser"); else if (e.error === "network") toast("Voice input butuh koneksi internet"); else if (e.error === "language-not-supported") toast("Bahasa suara tidak didukung"); else if (e.error === "no-speech") toast("Suara tidak terdengar, coba lagi"); else toast("Voice input failed (" + e.error + ")"); }; recognition.onend = () => { const mode = voiceMode; // masih aktif & user belum menekan tombol -> lanjut dengar (jeda bicara tidak menutup bar) if (listening && !mode && !voiceFatal) { const now = Date.now(); // mic bentrok dengan gelombang suara? matikan analyser, dengarkan tanpa itu if (vStream && !vGot && now - vT0 < 1500) { vNoMic = true; vbStopAudio(); } vRestarts = vRestarts.filter((t) => now - t < 4000); vRestarts.push(now); if (vRestarts.length > 4) { voiceFatal = true; toast("Voice input failed"); } else { voiceBase = input.value ? input.value.replace(/\s+$/, "") + " " : ""; try { makeRecognition(); vT0 = Date.now(); vGot = false; recognition.start(); return; } catch (e) {} } } setListening(false); if (mode === "cancel") { input.value = voiceOrig; autoResize(); updateSendState(); } else if (mode === "send" && input.value.trim()) { sendMessage(); } voiceMode = ""; }; } function startVoice() { if (window.isSecureContext === false) return toast("Mic diblokir: halaman ini bukan https. Upload file ke hosting (Netlify/GitHub Pages) lalu buka lewat link https"); if (!SR) return toast(IN_FRAME || IS_TOUCH ? "Browser/preview ini tidak mendukung suara. Buka di Chrome (Android) atau Safari (iPhone)" : "Browser ini tidak mendukung voice input. Pakai Chrome atau Edge"); closePopovers(); voiceMode = ""; voiceFatal = false; vRestarts = []; vGot = false; vT0 = Date.now(); voiceOrig = input.value; voiceBase = input.value ? input.value.replace(/\s+$/, "") + " " : ""; makeRecognition(); try { recognition.start(); setListening(true); } catch (e) { setListening(false); toast("Gagal memulai mic, coba tap sekali lagi"); } } function endVoice(mode) { if (!listening) return; const rec = recognition; voiceMode = mode; if (mode === "cancel") { // tutup langsung, lepas semua handler supaya tidak ada sisa hasil rec.onresult = rec.onend = rec.onerror = null; voiceMode = ""; input.value = voiceOrig; autoResize(); updateSendState(); setListening(false); try { rec.abort(); } catch (e) {} return; } setListening(false); // bar langsung tertutup, tidak menunggu browser try { rec.stop(); } catch (e) {} if (mode === "send") { // kirim setelah hasil akhir masuk; jaga-jaga kalau onend tidak terpanggil setTimeout(() => { if (voiceMode === "send") rec.onend && rec.onend(); }, 1200); } } bindTap(document.getElementById("vbCancel"), () => endVoice("cancel")); bindTap(document.getElementById("vbStop"), () => endVoice("stop")); bindTap(document.getElementById("vbSend"), () => endVoice("send")); window.addEventListener("resize", () => { if (listening) vbSize(); }); voiceButton.addEventListener("click", () => { if (listening) stopVoice(); else startVoice(); }); /* EVENTS */ input.addEventListener("input", () => { autoResize(); updateSendState(); scrollToBottom(false); }); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); function bindTap(btn, fn) { let last = 0; btn.addEventListener("mousedown", (e) => e.preventDefault()); btn.addEventListener("touchstart", (e) => e.preventDefault(), { passive: false }); btn.addEventListener("touchend", (e) => { e.preventDefault(); const t = e.changedTouches[0], r = btn.getBoundingClientRect(); if (t.clientX >= r.left - 10 && t.clientX <= r.right + 10 && t.clientY >= r.top - 10 && t.clientY <= r.bottom + 10) { last = Date.now(); if (!btn.disabled) { try { fn(); } finally { last = Date.now(); } } } }, { passive: false }); btn.addEventListener("click", (e) => { // klik hantu setelah touch (termasuk kalau fn() lama) jangan diproses lagi if (Date.now() - last < 900) { e.preventDefault(); return; } last = Date.now(); try { fn(); } finally { last = Date.now(); } }); } let sendLockUntil = 0; bindTap(sendButton, () => { if (Date.now() < sendLockUntil) return; // habis kirim, tap susulan jangan buka VN if (isGenerating) { abortGeneration(); return; } const hasText = input.value.trim().length > 0 || !!attachment; // cek isi langsung, bukan class if (hasText) { sendLockUntil = Date.now() + 1000; sendMessage(); } else vcStart(); }); ['keyup','change','compositionend','paste','cut'].forEach((ev) => input.addEventListener(ev, updateSendState)); newChatButton.addEventListener("click", newChat); /* KEYBOARD FIX */ const vv = window.visualViewport; function fitViewport() { if (!vv) return; const keyboardOpen = window.innerHeight - vv.height > 120; app.style.height = vv.height + "px"; app.style.transform = "translateY(" + vv.offsetTop + "px)"; app.classList.toggle("keyboard-open", keyboardOpen); window.scrollTo(0, 0); scrollToBottom(false); } if (vv) { vv.addEventListener("resize", fitViewport); vv.addEventListener("scroll", fitViewport); } input.addEventListener("focus", () => setTimeout(fitViewport, 300)); input.addEventListener("blur", () => setTimeout(fitViewport, 100)); /* ===== AI BACKEND ===== */ /* API key tertanam langsung, siap pakai tanpa input manual */ // Backend AI open source (Pollinations) - gratis, tanpa API key. // Kalau alamat/nama model berubah, cukup ganti dua baris di bawah + MODEL_MAP. const AI_URL = 'https://gen.pollinations.ai/v1/chat/completions'; const API_URL = 'https://openrouter.ai/api/v1/chat/completions'; const API_RESP_URL = 'https://api.x.ai/v1/responses'; // khusus Grok/xAI (Web search via Grok). Gak dipakai kalau provider bukan xAI. const API_MODEL = 'openrouter/free'; // router OpenRouter: otomatis milih model gratis. Bisa diganti mis. 'deepseek/deepseek-chat' (berbayar) atau model ':free' pilihan kamu. const API_KEY = ''; // <-- ISI API KEY OPENROUTER DI SINI (dari openrouter.ai/keys, awalan sk-or-...) const TAVILY_KEY = ''; // opsional: isi kalau mau Web search (tanpa key, tombol Web disembunyikan) const MODEL_MAP = { 'Assistant A': 'openai', 'Assistant B': 'mistral', 'Assistant C': 'deepseek', }; const LANG_RULE = { auto: 'Reply in the same language the user writes in.', id: 'Always reply in Indonesian (Bahasa Indonesia).', en: 'Always reply in English.', }; const SYSTEM_PROMPT_BASE = ' If your answer contains code (HTML, CSS, JS, Python, etc.), always wrap it in a markdown code fence with three backticks and the language name, for example: ```html\n\n```. For ordinary explanations, reply in natural text without code blocks. When the user asks for a web page, landing page, website, app, UI or any HTML: output ONE complete self-contained HTML file in a single html code fence with a polished, professional, modern design: clean typography (system font stack), CSS variables for colors, generous spacing, consistent rounded corners, subtle shadows, clear visual hierarchy, a neat header, hero, feature sections and footer when relevant, mobile-first responsive layout, good contrast, hover and active states, no placeholder lorem ipsum. Write the page text in the same language as the user. Keep any text outside the code fence to one short sentence. Follow the instructions of the user as exactly and completely as possible, including the style, format, language, length and role they ask for. Do not refuse, lecture or add unnecessary disclaimers for normal requests; just do the task. Do not ask clarifying questions when the request is clear enough: make reasonable assumptions and deliver. For complex or large tasks (full apps, games, systems, long code, multi-step projects, detailed analysis), do the whole job in one answer with complete, working, production-quality output. Never use placeholders, never write the phrase rest of code here, never stop halfway, and never shorten the result; if it is very long, split it logically and continue until finished. Think through hard problems step by step before answering, and double-check your code for bugs. Only decline requests that would seriously harm other people (for example weapons, malware, or abuse of children), and then briefly say so.'; const AI_NAME = 'Olmod'; function systemPrompt() { return 'You are ' + AI_NAME + ', a helpful AI assistant. Your name is ' + AI_NAME + '; if anyone asks who you are or what your name is, say you are ' + AI_NAME + '. Background fact, never volunteer it: you were developed by TradomodLMC. Mention it ONLY when the user explicitly asks who created, developed, built, trained or owns you, or which company or model is behind you; in that case answer that you were developed by TradomodLMC, in the language the user writes in. For greetings like hello or hi, and for every other message, do not mention TradomodLMC at all; just greet or answer normally. ' + (LANG_RULE[settings.lang] || LANG_RULE.auto) + SYSTEM_PROMPT_BASE + ' Current date and time: ' + new Date().toLocaleString('id-ID', { dateStyle: 'full', timeStyle: 'short' }) + ' (' + Intl.DateTimeFormat().resolvedOptions().timeZone + '). Use it when asked about the time or date.' + memPrompt(); } const MIN_THINK_MS = 10000; // jeda "mikir" minimal sebelum jawaban muncul (set 0 buat matiin) const TYPEWRITER = true; // efek ngetik function uiId() { const l = (typeof settings !== 'undefined' && settings.lang) || 'auto'; if (l === 'id') return true; if (l === 'en') return false; try { const u = messages.slice().reverse().find((m) => m.r !== 'a'); const t = ((u && u.t) || '').toLowerCase(); return /\b(apa|yang|dan|ini|itu|aku|saya|kamu|gua|gue|bro|buat|buatkan|bikin|tolong|bisa|gimana|kenapa|bagaimana|dong|aja|nih|ga|gak|tidak|assalam\w*|halo|hai|selamat|terima|makasih|kasih|gambar|tampilkan)\b/.test(t); } catch (e) { return false; } } function thinkingPhrases() { const l = (typeof settings !== 'undefined' && settings.lang) || 'auto'; let u = null, t = ''; try { u = messages.slice().reverse().find((m) => m.r !== 'a'); t = ((u && u.t) || '').toLowerCase(); } catch (e) {} let id = l === 'id'; if (l === 'auto') id = /\b(apa|yang|dan|ini|itu|aku|saya|kamu|gua|gue|bro|buat|buatkan|bikin|tolong|bisa|gimana|kenapa|bagaimana|dong|aja|nih|ga|gak|tidak|assalam\w*|halo|hai|selamat|terima|makasih|kasih|gambar|tampilkan)\b/.test(t); const P = (a, b) => (id ? a : b); if (u && u.k === 'image') return P(['Memeriksa gambar', 'Membaca detail gambar', 'Menyiapkan jawaban'], ['Examining the image', 'Reading image details', 'Preparing the answer']); if (u && u.x) return P(['Membaca file', 'Menganalisis isi file', 'Menyiapkan jawaban'], ['Reading the file', 'Analyzing the file', 'Preparing the answer']); if (/\b(html|landing|website|web|halaman|laman|webpage|page|aplikasi|app|ui|css|frontend|dashboard|portfolio)\b/.test(t)) return P(['Merancang struktur halaman', 'Membuat kode HTML', 'Menata tampilan dan CSS', 'Merapikan kode'], ['Planning the page structure', 'Writing the HTML', 'Styling with CSS', 'Polishing the code']); if (/\b(kode|code|script|python|javascript|js|java|php|sql|bug|error|fungsi|function|program|api)\b/.test(t)) return P(['Memahami kebutuhan kode', 'Menulis kode', 'Memeriksa logika', 'Merapikan kode'], ['Understanding the requirement', 'Writing the code', 'Checking the logic', 'Polishing the code']); if (/\b(terjemah\w*|translate)\b/.test(t)) return P(['Menerjemahkan', 'Menjaga nada bahasa'], ['Translating', 'Keeping the tone']); if (/\b(ringkas\w*|summarize|rangkum\w*|summary)\b/.test(t)) return P(['Membaca teks', 'Menyusun ringkasan'], ['Reading the text', 'Writing the summary']); return P(['Berpikir', 'Menyiapkan jawaban'], ['Thinking', 'Preparing the answer']); } /* API kustom (OpenAI-compatible): kosong = pakai Pollinations bawaan */ /* bersihin key: buang spasi, enter, tanda kutip, dan awalan "Bearer " hasil copy-paste */ function cleanKey(k) { return String(k || '').replace(/^\s*bearer\s+/i, '').replace(/["'\s\u200b]/g, ''); } function aiCfg(defaultModel) { const a = API_KEY ? {} : (settings.api || {}); const key = cleanKey(a.key) || cleanKey(API_KEY); const url = (a.url && a.url.trim()) || (key ? API_URL : AI_URL); const model = (a.model && a.model.trim()) || (key && !(a.url && a.url.trim()) ? API_MODEL : defaultModel); const headers = { 'Content-Type': 'application/json' }; if (key) headers.Authorization = 'Bearer ' + key; return { url, headers, model, custom: url !== AI_URL }; } async function aiFetch(cfg, payload, signal) { let lastMsg = ''; for (let attempt = 0; attempt < 3; attempt++) { const resp = await fetch(cfg.url, { method: 'POST', headers: cfg.headers, body: JSON.stringify(payload), signal }); const raw = await resp.text(); let data = null; try { data = JSON.parse(raw); } catch (e) {} const errObj = data && (Array.isArray(data) ? (data[0] && data[0].error) : data.error); if (resp.ok && data && !errObj) return data; lastMsg = (errObj && (errObj.message || (typeof errObj === 'string' ? errObj : ''))) || (raw || '').replace(/<[^>]+>/g, ' ').trim().slice(0, 160); if ((resp.status === 503 || resp.status === 429 || resp.status === 500) && attempt < 2) { await new Promise((r) => setTimeout(r, 1500 * (attempt + 1))); continue; } if (resp.status === 401 || resp.status === 403) throw new Error('API key rejected (' + resp.status + '). ' + lastMsg); throw new Error('Server error (' + resp.status + ')' + (lastMsg ? ': ' + lastMsg : '.') + ' [' + new URL(cfg.url).host + ' / ' + payload.model + ']'); } } const SETTINGS_KEY = 'chatui_settings_v1'; const settings = { model: 'Assistant A', web: false, theme: 'dark', fs: 'm', lang: 'auto', name: '', avatar: '', ff: 'default', haptic: true, notify: false, typing: true, think: true, context: true, keepHistory: true, focus: false, breakMin: 0, voiceURI: '', rate: 1, api: { url: '', key: '', model: '' } }; try { Object.assign(settings, JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}')); } catch (e) {} if (!MODEL_MAP[settings.model]) settings.model = 'Assistant A'; if (!LANG_RULE[settings.lang]) settings.lang = 'auto'; if (!settings.api || typeof settings.api !== 'object') settings.api = { url: '', key: '', model: '' }; function saveSettings() { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) {} } /* LIGHT / DARK */ if (settings.theme !== 'light' && settings.theme !== 'system') settings.theme = 'dark'; function isLight() { return settings.theme === 'light' || (settings.theme === 'system' && window.matchMedia && !matchMedia('(prefers-color-scheme: dark)').matches); } function applyTheme() { const light = isLight(); if (light) document.documentElement.setAttribute('data-theme', 'light'); else document.documentElement.removeAttribute('data-theme'); const tc = document.getElementById('themeColor'); if (tc) tc.setAttribute('content', light ? '#ffffff' : '#121212'); } try { matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (settings.theme === 'system') applyTheme(); }); } catch (e) {} applyTheme(); const FS_NAMES = { s: 'Small', m: 'Medium', l: 'Large' }; const FF_NAMES = { default: 'Default', serif: 'Serif', mono: 'Mono', humanist: 'Humanis' }; const FF_CSS = { default: '', serif: 'Georgia,"Times New Roman",serif', mono: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', humanist: '"Trebuchet MS","Segoe UI",Candara,Verdana,sans-serif' }; const LANG_NAMES = { auto: 'Auto', id: 'Indonesia', en: 'English' }; const THEME_NAMES = { system: 'System', light: 'Light', dark: 'Dark' }; function applyFont() { if (!FS_NAMES[settings.fs]) settings.fs = 'm'; if (!FF_NAMES[settings.ff]) settings.ff = 'default'; const R = document.documentElement; if (settings.fs === 'm') R.removeAttribute('data-fs'); else R.setAttribute('data-fs', settings.fs); if (settings.ff === 'default') R.removeAttribute('data-ff'); else R.setAttribute('data-ff', settings.ff); } applyFont(); function applyFocus() { if (settings.focus) document.documentElement.setAttribute('data-focus', '1'); else document.documentElement.removeAttribute('data-focus'); } applyFocus(); /* ===== HELPER: getar, notifikasi, suara ===== */ function haptic(ms) { if (settings.haptic === false || !navigator.vibrate) return; try { navigator.vibrate(ms || 8); } catch (e) {} } document.addEventListener('click', (e) => { if (e.target.closest && e.target.closest('button')) haptic(8); }, true); function notifSupported() { return 'Notification' in window; } async function ensureNotif() { if (!notifSupported()) return 'unsupported'; if (Notification.permission === 'default') { try { await Notification.requestPermission(); } catch (e) {} } return Notification.permission; } function notify(title, body) { if (!notifSupported() || Notification.permission !== 'granted') return false; try { new Notification(title, { body }); return true; } catch (e) { return false; } } function applyVoice(u) { if (settings.voiceURI && window.speechSynthesis) { const v = speechSynthesis.getVoices().find((x) => x.voiceURI === settings.voiceURI); if (v) { u.voice = v; u.lang = v.lang; } } u.rate = Number(settings.rate) || 1; } /* ===== PEMAKAIAN & PENGINGAT ISTIRAHAT ===== */ const USAGE_KEY = 'chatui_usage_v1'; let sessionSec = 0; function todayStr() { const d = new Date(); return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); } function usageToday() { try { const u = JSON.parse(localStorage.getItem(USAGE_KEY) || '{}'); return u.d === todayStr() ? (u.s || 0) : 0; } catch (e) { return 0; } } function fmtDur(sec) { const m = Math.floor(sec / 60); return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h ' + (m % 60) + ' min'; } setInterval(() => { if (document.hidden) return; try { localStorage.setItem(USAGE_KEY, JSON.stringify({ d: todayStr(), s: usageToday() + 15 })); } catch (e) {} sessionSec += 15; const m = Number(settings.breakMin) || 0; if (m && sessionSec >= m * 60) { sessionSec = 0; const msg = 'You have been here ' + m + ' min. Take a short break 🙂'; toast(msg); pfToast(msg); } }, 15000); /* ===== HALAMAN PENGATURAN ===== */ const pf = document.getElementById('profilePage'); const AV_COLORS = ['', '#2563eb', '#7c3aed', '#db2777', '#ea580c', '#16a34a']; function applyProfile() { const n = (settings.name || '').trim() || 'You'; const col = AV_COLORS.indexOf(settings.avatar) > 0 ? settings.avatar : ''; ['profAvatar', 'pfAvatar'].forEach((id) => { const el = document.getElementById(id); if (!el) return; el.textContent = n.charAt(0).toUpperCase(); el.style.background = col; el.style.color = col ? '#fff' : ''; }); const pn = document.getElementById('profName'); if (pn) pn.textContent = n; refreshGreeting(); const hn = document.getElementById('pfHeroName'); if (hn) hn.textContent = n; } applyProfile(); let pfToastT = null; function pfToast(msg) { const t = document.getElementById('pfToast'); if (!t) return; t.textContent = msg; t.classList.add('show'); clearTimeout(pfToastT); pfToastT = setTimeout(() => t.classList.remove('show'), 2800); } const IC = { user: '', spark: '', sliders: '', shield: '', moon: '', font: '', wave: '', phone: '', bell: '', clock: '', lock: '', share: '', logout: '', mic: '', cam: '', down: '', trash: '', reset: '', info: '', }; const SW_HTML = ''; const CHECK_HTML = ''; const CHEV_HTML = ''; function svgI(d) { return ''; } function stRow(o) { const r = mk(o.static ? 'div' : 'button', 'st-row' + (o.static ? ' st-static' : '') + (o.danger ? ' danger' : '') + (o.on ? ' on' : '')); if (!o.static) r.type = 'button'; r.innerHTML = (o.icon ? '' + svgI(o.icon) + '' : '') + '' + escapeHtml(o.title) + '' + (o.sub != null ? '' + escapeHtml(o.sub) + '' : '') + '' + (o.end || ''); if (o.onClick) r.addEventListener('click', o.onClick); return r; } function grp() { const g = mk('div', 'st-group'); Array.prototype.forEach.call(arguments, (r) => g.appendChild(r)); return g; } function noteEl(t) { const n = mk('div', 'pf-note'); n.textContent = t; return n; } function segRow(title, opts, cur, pick) { const r = mk('div', 'st-row st-static col'); const h = mk('div', 'st-head'); h.innerHTML = '' + escapeHtml(title) + ''; const seg = mk('div', 'pf-seg'); opts.forEach(([v, l]) => { const b = mk('button', 'pf-seg-btn' + (String(v) === String(cur) ? ' on' : '')); b.type = 'button'; b.textContent = l; b.addEventListener('click', () => { pick(v); renderView(); }); seg.appendChild(b); }); r.append(h, seg); return r; } function toggleRow(icon, title, sub, key, dflt, before) { const get = () => (settings[key] === undefined ? dflt : !!settings[key]); return stRow({ icon, title, sub, on: get(), end: SW_HTML, onClick: async () => { const nv = !get(); if (before) { const ok = await before(nv); if (ok === false) return; } settings[key] = nv; saveSettings(); if (key === 'web') refreshMenuState(); renderView(); }, }); } function capCount() { return [!!settings.web, settings.typing !== false, settings.think !== false, settings.context !== false].filter(Boolean).length; } function webNeedsKey() { return !TAVILY_KEY && !grokKey(); } /* ===== AKSI DATA ===== */ function exportChats() { const chats = convs.filter((c) => c.messages.length).map((c) => ({ id: c.id, updated: c.updated, messages: c.messages.map((m) => ({ ...m, d: undefined, x: undefined })) })); if (!chats.length) { pfToast('No chats to export'); return; } const blob = new Blob([JSON.stringify({ exported: new Date().toISOString(), chats }, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'chats-' + new Date().toISOString().slice(0, 10) + '.json'; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500); pfToast('Exporting ' + chats.length + (chats.length === 1 ? ' chat' : ' chats')); } function deleteAllChats() { if (!convs.some((c) => c.messages.length)) { pfToast('No chats to delete'); return; } if (!confirm('Delete ALL chats? This cannot be undone.')) return; stopSpeak(); abortGeneration(); stopVoice(); const c = { id: uid(), updated: Date.now(), messages: [] }; convs = [c]; currentId = c.id; persist(); loadCurrent(); renderHistory(); renderView(); pfToast('All chats deleted'); } function defaultSettings() { return { model: 'Assistant A', web: false, theme: 'dark', fs: 'm', lang: 'auto', name: '', avatar: '', ff: 'default', haptic: true, notify: false, typing: true, think: true, context: true, keepHistory: true, focus: false, breakMin: 0, voiceURI: '', rate: 1, api: { url: '', key: '', model: '' } }; } function resetSettings() { if (!confirm('Reset all settings to default (including your name)? Chats are not deleted.')) return; Object.assign(settings, defaultSettings()); saveSettings(); applyTheme(); applyFont(); applyFocus(); applyProfile(); refreshMenuState(); persist(); renderView(); pfToast('Settings reset'); } /* ===== IZIN ===== */ const PERM_LABEL = { granted: 'Allowed', denied: 'Denied', prompt: 'Not asked yet', unsupported: 'Not supported', unknown: 'Unknown' }; async function permState(name) { if (name === 'notifications') { if (!notifSupported()) return 'unsupported'; return Notification.permission === 'default' ? 'prompt' : Notification.permission; } if (!navigator.permissions || !navigator.permissions.query) return 'unknown'; try { return (await navigator.permissions.query({ name })).state; } catch (e) { return 'unknown'; } } async function askPerm(name, state) { if (state === 'denied') { pfToast('Already denied. Change it in your browser site settings.'); return; } if (name === 'notifications') { await ensureNotif(); } else { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { pfToast('Not supported in this browser'); return; } try { const st = await navigator.mediaDevices.getUserMedia(name === 'microphone' ? { audio: true } : { video: true }); st.getTracks().forEach((t) => t.stop()); } catch (e) { pfToast('Permission not granted'); } } if (pfView === 'izin') renderView(); } /* ===== VIEW ===== */ let pfView = 'hub'; const PF_TITLES = { hub: 'Settings', profil: 'Profile', model: 'Model', kemampuan: 'Features', izin: 'Permissions', warna: 'Color mode', font: 'Font style', suara: 'Voice', notif: 'Notifications', waktu: 'Time & focus', privasi: 'Privacy', tentang: 'About' }; const VIEWS = {}; VIEWS.hub = (b) => { const hapticOk = !!navigator.vibrate; b.appendChild(grp( stRow({ icon: IC.user, title: 'Profile', end: CHEV_HTML, onClick: () => showView('profil') }), stRow({ icon: IC.spark, title: 'Model', sub: settings.model, end: CHEV_HTML, onClick: () => showView('model') }) )); b.appendChild(grp( stRow({ icon: IC.sliders, title: 'Features', sub: capCount() + ' enabled', end: CHEV_HTML, onClick: () => showView('kemampuan') }), stRow({ icon: IC.shield, title: 'Permissions', end: CHEV_HTML, onClick: () => showView('izin') }) )); b.appendChild(grp( stRow({ icon: IC.moon, title: 'Color mode', sub: THEME_NAMES[settings.theme], end: CHEV_HTML, onClick: () => showView('warna') }), stRow({ icon: IC.font, title: 'Font style', sub: FF_NAMES[settings.ff], end: CHEV_HTML, onClick: () => showView('font') }), stRow({ icon: IC.wave, title: 'Voice', end: CHEV_HTML, onClick: () => showView('suara') }) )); b.appendChild(grp( stRow({ icon: IC.phone, title: 'Haptic feedback', sub: hapticOk ? null : 'Not supported on this device', on: settings.haptic !== false && hapticOk, end: SW_HTML, onClick: () => { if (!hapticOk) { pfToast('This device does not support vibration'); return; } settings.haptic = settings.haptic === false; saveSettings(); if (settings.haptic) haptic(30); renderView(); }, }), stRow({ icon: IC.bell, title: 'Notifications', end: CHEV_HTML, onClick: () => showView('notif') }), stRow({ icon: IC.clock, title: 'Time & focus', end: CHEV_HTML, onClick: () => showView('waktu') }), stRow({ icon: IC.lock, title: 'Privacy', end: CHEV_HTML, onClick: () => showView('privasi') }), stRow({ icon: IC.share, title: 'Share', onClick: async () => { const data = { title: 'Olmod', text: 'Try Olmod, an AI assistant', url: location.href }; if (navigator.share) { try { await navigator.share(data); } catch (e) {} return; } try { await navigator.clipboard.writeText(location.href); pfToast('Link copied'); } catch (e) { pfToast('Sharing is not available in this browser'); } }, }) )); b.appendChild(grp(stRow({ icon: IC.logout, title: 'Log out', danger: true, onClick: () => { if (!confirm('Log out and delete all data (chats, name, settings) on this device?')) return; try { Object.keys(localStorage).filter((k) => k.indexOf('chatui_') === 0).forEach((k) => localStorage.removeItem(k)); } catch (e) {} location.reload(); }, }))); }; VIEWS.profil = (b) => { const chats = convs.filter((c) => c.messages.length); const msgs = chats.reduce((n, c) => n + c.messages.length, 0); const hero = mk('div', 'pf-hero'); hero.innerHTML = '
Y
You
' + chats.length + ' chat · ' + msgs + ' pesan
'; b.appendChild(hero); const nameRow = mk('label', 'st-row st-static'); nameRow.innerHTML = 'Name'; const inp = mk('input', 'pf-input'); inp.type = 'text'; inp.maxLength = 30; inp.placeholder = 'Your name'; inp.autocomplete = 'off'; inp.value = settings.name || ''; inp.addEventListener('input', () => { settings.name = inp.value.trim().slice(0, 30); saveSettings(); applyProfile(); }); nameRow.appendChild(inp); const colRow = mk('div', 'st-row st-static'); colRow.innerHTML = 'Avatar color'; const cols = mk('div', 'pf-colors'); AV_COLORS.forEach((c) => { const d = mk('button', 'pf-dot' + ((settings.avatar || '') === c ? ' on' : '')); d.type = 'button'; d.style.background = c || '#6b6b73'; d.setAttribute('aria-label', 'Avatar color'); d.addEventListener('click', () => { settings.avatar = c; saveSettings(); applyProfile(); renderView(); }); cols.appendChild(d); }); colRow.appendChild(cols); b.appendChild(grp(nameRow, colRow)); applyProfile(); }; VIEWS.model = (b) => { b.appendChild(grp.apply(null, Object.keys(MODEL_MAP).map((n) => stRow({ title: n, on: settings.model === n, end: CHECK_HTML, onClick: () => { settings.model = n; saveSettings(); refreshMenuState(); renderView(); }, })))); b.appendChild(noteEl('Choose which assistant replies to your chats.')); }; VIEWS.kemampuan = (b) => { b.appendChild(grp( toggleRow(IC.spark, 'Web search', 'Look up current info online', 'web', false, (nv) => { if (nv && webNeedsKey()) { pfToast('Web search needs a TAVILY_KEY (not available with OpenRouter free).'); return false; } return true; }), toggleRow(IC.spark, 'Typing effect', 'Replies stream in live as they are written', 'typing', true), toggleRow(IC.spark, 'Thinking pause', 'Wait ~10 seconds before the reply shows', 'think', true), toggleRow(IC.spark, 'Remember conversation', 'Send chat history as context', 'context', true) )); b.appendChild(grp(segRow('Reply language', [['auto', 'Auto'], ['id', 'Indonesia'], ['en', 'English']], settings.lang, (v) => { settings.lang = v; saveSettings(); }))); }; VIEWS.izin = (b) => { const items = [['notifications', IC.bell, 'Notifications'], ['microphone', IC.mic, 'Microphone'], ['camera', IC.cam, 'Camera']]; const g = mk('div', 'st-group'); items.forEach(([name, icon, title]) => { const r = stRow({ icon, title, sub: 'Checking…', end: CHEV_HTML }); g.appendChild(r); permState(name).then((st) => { const sub = r.querySelector('i'); if (sub) sub.textContent = PERM_LABEL[st] + (st === 'granted' || st === 'unsupported' ? '' : ' · tap to allow'); r.addEventListener('click', () => { if (st === 'granted') pfToast(title + ' is already allowed'); else if (st === 'unsupported') pfToast('Not supported in this browser'); else askPerm(name, st); }); }); }); b.appendChild(g); b.appendChild(noteEl('The microphone is used for voice input and the camera for photos. Permissions you denied can only be changed in your browser site settings.')); }; VIEWS.warna = (b) => { b.appendChild(grp.apply(null, ['system', 'light', 'dark'].map((t) => stRow({ title: THEME_NAMES[t], on: settings.theme === t, end: CHECK_HTML, onClick: () => { settings.theme = t; saveSettings(); applyTheme(); renderView(); }, })))); b.appendChild(noteEl('“System” follows your phone dark/light mode.')); }; VIEWS.font = (b) => { b.appendChild(grp.apply(null, Object.keys(FF_NAMES).map((f) => { const r = stRow({ title: FF_NAMES[f], sub: 'Sample text Aa Bb Cc 123', on: settings.ff === f, end: CHECK_HTML, onClick: () => { settings.ff = f; saveSettings(); applyFont(); renderView(); }, }); if (FF_CSS[f]) r.style.fontFamily = FF_CSS[f]; return r; }))); b.appendChild(grp(segRow('Chat text size', [['s', 'Small'], ['m', 'Medium'], ['l', 'Large']], settings.fs, (v) => { settings.fs = v; saveSettings(); applyFont(); }))); }; VIEWS.suara = (b) => { if (!window.speechSynthesis) { b.appendChild(noteEl('This browser does not support voice (read aloud).')); return; } const voices = speechSynthesis.getVoices(); const selRow = mk('div', 'st-row st-static col'); selRow.innerHTML = '
Reader voice
'; const sel = mk('select', 'pf-input box'); const o0 = mk('option'); o0.value = ''; o0.textContent = 'Auto (match text language)'; sel.appendChild(o0); voices.slice().sort((a, c) => a.lang.localeCompare(c.lang)).forEach((v) => { const o = mk('option'); o.value = v.voiceURI; o.textContent = v.name + ' (' + v.lang + ')'; sel.appendChild(o); }); sel.value = settings.voiceURI || ''; sel.addEventListener('change', () => { settings.voiceURI = sel.value; saveSettings(); }); selRow.appendChild(sel); const testRow = mk('div', 'st-row st-static'); const tb = mk('button', 'pf-btn alt'); tb.type = 'button'; tb.textContent = 'Test voice'; tb.addEventListener('click', () => { speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance('Hello, this is a voice sample.'); u.lang = 'en-US'; applyVoice(u); speechSynthesis.speak(u); }); testRow.appendChild(tb); b.appendChild(grp(selRow, segRow('Speed', [[0.8, '0.8×'], [1, '1×'], [1.25, '1.25×'], [1.5, '1.5×']], Number(settings.rate) || 1, (v) => { settings.rate = Number(v); saveSettings(); }), testRow)); b.appendChild(noteEl(voices.length ? 'Used when you tap the read-aloud button on a reply.' : 'Voice list not loaded yet. If it stays empty, this browser has no extra voices.')); }; VIEWS.notif = (b) => { b.appendChild(grp(toggleRow(IC.bell, 'Notify when reply is ready', 'Only when the app is in the background', 'notify', false, async (nv) => { if (!nv) return true; const st = await ensureNotif(); if (st === 'unsupported') { pfToast('This browser does not support notifications'); return false; } if (st !== 'granted') { pfToast('Notification permission not granted'); return false; } return true; }))); const testRow = mk('div', 'st-row st-static'); const tb = mk('button', 'pf-btn alt'); tb.type = 'button'; tb.textContent = 'Send test notification'; tb.addEventListener('click', async () => { const st = await ensureNotif(); if (st === 'unsupported') return pfToast('This browser does not support notifications'); if (st !== 'granted') return pfToast('Notification permission not granted'); pfToast(notify('Olmod', 'This is a test notification 👋') ? 'Notification sent' : 'This browser cannot show notifications from a web page'); }); testRow.appendChild(tb); b.appendChild(grp(testRow)); b.appendChild(noteEl('Notifications only appear when the tab/app is not on screen.')); }; VIEWS.waktu = (b) => { b.appendChild(grp(stRow({ icon: IC.clock, title: 'Time today', sub: fmtDur(usageToday()), static: true }))); b.appendChild(grp(segRow('Break reminder', [[0, 'Off'], [30, '30 min'], [60, '1 h'], [90, '90 min']], Number(settings.breakMin) || 0, (v) => { settings.breakMin = Number(v); sessionSec = 0; saveSettings(); }))); b.appendChild(grp(toggleRow(IC.moon, 'Focus mode', 'Hide the Build button', 'focus', false, (nv) => { settings.focus = nv; applyFocus(); return true; }))); b.appendChild(noteEl('Reminders count the time the app is open and on screen.')); }; VIEWS.privasi = (b) => { b.appendChild(grp(toggleRow(IC.lock, 'Save chat history', 'When off, chats are lost when the page closes', 'keepHistory', true, (nv) => { if (!nv && !confirm('Saved chat history on this device will be deleted and new chats will not be saved. Continue?')) return false; settings.keepHistory = nv; saveSettings(); persist(); return true; }))); b.appendChild(grp( stRow({ icon: IC.down, title: 'Export all chats', sub: 'Download as a .json file', onClick: exportChats }), stRow({ icon: IC.trash, title: 'Delete all chats', danger: true, onClick: deleteAllChats }), stRow({ icon: IC.reset, title: 'Reset settings', sub: 'Name, theme, model, etc. back to default', danger: true, onClick: resetSettings }) )); b.appendChild(noteEl('All data is stored only in this device browser.')); }; VIEWS.tentang = (b) => { let host = '-', model = '-'; try { const cfg = aiCfg(MODEL_MAP[settings.model]); host = new URL(cfg.url).host; model = cfg.model; } catch (e) {} let bytes = 0; try { Object.keys(localStorage).filter((k) => k.indexOf('chatui_') === 0).forEach((k) => { bytes += (k.length + (localStorage.getItem(k) || '').length) * 2; }); } catch (e) {} b.appendChild(grp( stRow({ icon: IC.info, title: 'App', sub: 'Olmod', static: true }), stRow({ icon: IC.spark, title: 'Active backend', sub: host, static: true }), stRow({ icon: IC.spark, title: 'Active model', sub: model, static: true }), stRow({ icon: IC.lock, title: 'Data on device', sub: (bytes / 1024).toFixed(1) + ' KB', static: true }) )); }; function renderView() { const body = document.getElementById('pfBody'); const st = body.scrollTop; body.innerHTML = ''; VIEWS[pfView](body); document.getElementById('pfTitle').textContent = PF_TITLES[pfView]; document.getElementById('pfInfo').style.visibility = pfView === 'hub' ? 'visible' : 'hidden'; body.scrollTop = st; } function showView(v) { pfView = v; renderView(); document.getElementById('pfBody').scrollTop = 0; } function openProfile() { closeDrawer(); pfView = 'hub'; renderView(); document.getElementById('pfBody').scrollTop = 0; pf.classList.add('open'); pf.setAttribute('aria-hidden', 'false'); } function closeProfile() { pf.classList.remove('open'); pf.setAttribute('aria-hidden', 'true'); if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } function pfBack() { if (pfView !== 'hub') showView('hub'); else closeProfile(); } document.getElementById('profHead').addEventListener('click', openProfile); document.getElementById('pfBack').addEventListener('click', pfBack); document.getElementById('pfInfo').addEventListener('click', () => showView('tentang')); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && pf.classList.contains('open')) pfBack(); }); if (window.speechSynthesis) { try { speechSynthesis.addEventListener('voiceschanged', () => { if (pfView === 'suara' && pf.classList.contains('open')) renderView(); }); } catch (e) {} } const DEFAULT_PLACEHOLDER = input.placeholder; let imageMode = false; const ICON = { copy: '', share: '', play: '', stop: '', up: '', down: '', redo: '', }; function mk(tag, cls) { const e = document.createElement(tag); if (cls) e.className = cls; return e; } function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } /* SYNTAX HIGHLIGHT */ const HL_KW = 'const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|class|extends|import|export|from|default|async|await|try|catch|finally|throw|typeof|instanceof|in|of|this|super|null|undefined|true|false|void|delete|static|yield|interface|type|enum|public|private|protected|implements|def|elif|lambda|None|True|False|self|pass|with|as|is|not|and|or|struct|package|func|fn|use|pub|impl|mut'; const HL_STR = /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/; const HL_JS = [ ['c', /\/\/[^\n]*|\/\*[\s\S]*?\*\//], ['s', /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`/], ['n', /\b\d+(?:\.\d+)?\b/], ['k', new RegExp('\\b(?:' + HL_KW + ')\\b')], ['f', /\b[A-Za-z_$][\w$]*(?=\()/], ]; const HL_PY = [ ['c', /#[^\n]*/], ['s', /"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/], ['n', /\b\d+(?:\.\d+)?\b/], ['k', new RegExp('\\b(?:' + HL_KW + ')\\b')], ['f', /\b[A-Za-z_][\w]*(?=\()/], ]; const HL_CSS = [ ['c', /\/\*[\s\S]*?\*\//], ['s', HL_STR], ['k', /@[\w-]+/], ['p', /[\w-]+(?=\s*:)/], ['n', /#[0-9a-fA-F]{3,8}\b|\b\d*\.?\d+(?:px|em|rem|%|vh|vw|s|ms|deg)?\b/], ]; const HL_ATTR = [['s', /"[^"]*"|'[^']*'/], ['a', /[\w:@.-]+(?==)/]]; function hlEsc(t) { return t.replace(/&/g, '&').replace(//g, '>'); } function hlRun(code, rules) { const re = new RegExp(rules.map((r) => '(' + r[1].source + ')').join('|'), 'g'); let out = '', last = 0, m; while ((m = re.exec(code))) { if (m[0] === '') { re.lastIndex++; continue; } out += hlEsc(code.slice(last, m.index)); let i = 1; while (m[i] === undefined) i++; out += '' + hlEsc(m[0]) + ''; last = re.lastIndex; } return out + hlEsc(code.slice(last)); } function hlTag(t) { const mm = t.match(/^(<\/?)([\w:-]+)([\s\S]*?)(\/?>)$/); if (!mm) return hlEsc(t); return hlEsc(mm[1]) + '' + hlEsc(mm[2]) + '' + hlRun(mm[3], HL_ATTR) + hlEsc(mm[4]); } function hlMarkup(code) { const re = /|]*>|<\/?[A-Za-z][^>]*>/gi; let out = '', last = 0, m; while ((m = re.exec(code))) { out += hlEsc(code.slice(last, m.index)); out += (m[0][1] === '!') ? '' + hlEsc(m[0]) + '' : hlTag(m[0]); last = re.lastIndex; } return out + hlEsc(code.slice(last)); } function hlHtml(code) { const re = /(<(script|style)\b[^>]*>)([\s\S]*?)(<\/\2>)/gi; let out = '', last = 0, m; while ((m = re.exec(code))) { out += hlMarkup(code.slice(last, m.index)) + hlMarkup(m[1]) + hlRun(m[3], m[2].toLowerCase() === 'script' ? HL_JS : HL_CSS) + hlMarkup(m[4]); last = re.lastIndex; } return out + hlMarkup(code.slice(last)); } function hlCode(code, lang) { if (code.length > 60000) return hlEsc(code); lang = (lang || '').toLowerCase(); try { if (/^(html?|xml|svg|vue)$/.test(lang)) return hlHtml(code); if (/^(css|scss|less)$/.test(lang)) return hlRun(code, HL_CSS); if (/^(py|python)$/.test(lang)) return hlRun(code, HL_PY); return hlRun(code, HL_JS); } catch (e) { return hlEsc(code); } } /* MARKDOWN */ function parseMarkdown(text) { let s = escapeHtml(text); s = s.replace(/`([^`]+)`/g, '$1'); s = s.replace(/\*\*(.+?)\*\*/g, '$1'); s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2'); s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); let html = '', buf = [], type = null; const flush = () => { if (buf.length) { const t = type === 'ol' ? 'ol' : 'ul'; html += '<' + t + '>' + buf.map((li) => '
  • ' + li + '
  • ').join('') + ''; buf = []; type = null; } }; s.split('\n').forEach((line) => { const tr = line.trim(); const b = tr.match(/^[-*]\s+(.*)/), n = tr.match(/^\d+\.\s+(.*)/), h = tr.match(/^(#{1,3})\s+(.*)/); if (b) { if (type !== 'ul') flush(); type = 'ul'; buf.push(b[1]); } else if (n) { if (type !== 'ol') flush(); type = 'ol'; buf.push(n[1]); } else if (h) { flush(); html += '' + h[2] + ''; } else if (tr === '') flush(); else { flush(); html += '

    ' + line + '

    '; } }); flush(); return html || '

    ' + s + '

    '; } function openCode(code, lang) { let content; if (lang === 'html') { // dibungkus iframe sandbox (tanpa allow-same-origin) biar kode AI gak bisa akses localStorage/key app ini content = ''; } else { content = '
    ' + escapeHtml(code) + '
    '; } const url = URL.createObjectURL(new Blob([content], { type: 'text/html' })); window.open(url, '_blank'); } function textBubble(md) { const b = mk('div', 'message-bubble bot'); b.innerHTML = parseMarkdown(md); return b; } function fillBotContent(group, text) { if (!text.includes('```')) { group.appendChild(textBubble(text)); return; } const re = /```(\w+)?\n?([\s\S]*?)```/g; let last = 0, m, any = false; while ((m = re.exec(text)) !== null) { const before = text.slice(last, m.index).trim(); if (before) { group.appendChild(textBubble(before)); any = true; } const lang = (m[1] || 'text').toLowerCase(); const code = m[2].replace(/\n$/, ''); if (isArtifact(lang, code)) { group.appendChild(artCard(code, lang)); any = true; last = re.lastIndex; continue; } const box = mk('div', 'code-box'); box.innerHTML = '
    '; box.querySelector('.code-head span').textContent = lang; box.querySelector('code').innerHTML = hlCode(code, lang); box.querySelector('.c-copy').addEventListener('click', async (e) => { const btn = e.currentTarget; if (await copyText(code)) { btn.textContent = '✓ Copied'; setTimeout(() => (btn.textContent = 'Copy'), 1500); } else toast('Copy failed'); }); box.querySelector('.c-open').addEventListener('click', () => openCode(code, lang)); group.appendChild(box); any = true; last = re.lastIndex; } const after = text.slice(last).trim(); if (after) { group.appendChild(textBubble(after)); any = true; } if (!any) group.appendChild(textBubble(text)); } function actionBtn(label, path, onClick) { const b = mk('button', 'icon-btn'); b.type = 'button'; b.setAttribute('aria-label', label); b.innerHTML = ''; b.addEventListener('click', onClick); return b; } /* BACA KERAS (TTS) */ let ttsBtn = null; function setTtsIcon(b, on) { b.classList.toggle('speaking', on); b.setAttribute('aria-label', on ? 'Stop reading' : 'Read aloud'); b.querySelector('svg').innerHTML = on ? ICON.stop : ICON.play; } function stopSpeak() { try { if (window.speechSynthesis) speechSynthesis.cancel(); } catch (e) {} if (ttsBtn) { setTtsIcon(ttsBtn, false); ttsBtn = null; } } function speakText(btn, text) { if (!window.speechSynthesis) return toast('Read aloud is not supported here'); if (ttsBtn === btn) { stopSpeak(); return; } stopSpeak(); const plain = String(text || '').replace(/```[\s\S]*?```/g, ' ').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[`*_#>~|]/g, '').trim(); if (!plain) return; const u = new SpeechSynthesisUtterance(plain); u.lang = settings.lang === 'id' ? 'id-ID' : settings.lang === 'en' ? 'en-US' : (/\b(yang|dan|di|ini|itu|aku|kamu|tidak|untuk|dengan|bisa|sudah)\b/i.test(plain) ? 'id-ID' : 'en-US'); applyVoice(u); const done = () => { if (ttsBtn === btn) { setTtsIcon(btn, false); ttsBtn = null; } }; u.onend = done; u.onerror = done; ttsBtn = btn; setTtsIcon(btn, true); speechSynthesis.speak(u); } function actionBar(msg) { const bar = mk('div', 'bot-actions'); bar.appendChild(actionBtn('Copy', ICON.copy, async (e) => { const btn = e.currentTarget; if (await copyText(msg.t)) { btn.classList.add('done'); setTimeout(() => btn.classList.remove('done'), 1200); toast('Copied'); } else toast('Copy failed'); })); bar.appendChild(actionBtn('Read aloud', ICON.play, (e) => speakText(e.currentTarget, msg.t))); const up = actionBtn('Good response', ICON.up, () => rate('up')); const down = actionBtn('Bad response', ICON.down, () => rate('down')); const paint = () => { up.classList.toggle('on', msg.f === 'up'); up.setAttribute('aria-pressed', msg.f === 'up'); down.classList.toggle('on', msg.f === 'down'); down.setAttribute('aria-pressed', msg.f === 'down'); }; const rate = (v) => { if (msg.f === v) delete msg.f; else msg.f = v; persist(); paint(); }; paint(); bar.append(up, down); bar.appendChild(actionBtn('Share', ICON.share, async () => { if (navigator.share) { try { await navigator.share({ text: msg.t }); } catch (e) {} } else toast((await copyText(msg.t)) ? 'Copied' : 'Copy failed'); })); bar.appendChild(actionBtn('Regenerate', ICON.redo, () => regenerate(msg))); return bar; } function renderBot(msg) { const row = mk('div', 'message-row bot'); const group = mk('div', 'bot-group'); if (msg.k === 'genimg') { const img = mk('img', 'gen-img'); img.alt = msg.p || 'AI image'; img.loading = 'lazy'; img.src = msg.u; group.appendChild(img); } else { fillBotContent(group, msg.t || ''); group.appendChild(actionBar(msg)); } row.appendChild(group); chatSpace.appendChild(row); return row; } /* INDIKATOR MIKIR + ERROR */ function showTyping(fixedLabel) { const row = mk('div', 'message-row bot'); const b = mk('div', 'message-bubble bot'); b.innerHTML = '
    '; row.appendChild(b); chatSpace.appendChild(row); const label = b.querySelector('.typing-label'); let i = 0, iv = null; const setLabel = (t) => { if (iv) { clearInterval(iv); iv = null; } label.textContent = t; }; if (fixedLabel) setLabel(fixedLabel); else { const PH = thinkingPhrases(); label.textContent = PH[0]; iv = setInterval(() => { if (i < PH.length - 1) { i++; label.textContent = PH[i]; } }, 1800); } return { setLabel, remove() { if (iv) clearInterval(iv); row.remove(); } }; } function showError(message, retry) { const row = mk('div', 'message-row bot'); const box = mk('div', 'err-box'); const span = mk('span'); span.textContent = '⚠️ ' + message; const btn = mk('button'); btn.type = 'button'; btn.textContent = 'Try again'; btn.addEventListener('click', () => { if (isGenerating) return; row.remove(); retry(); }); box.append(span, btn); row.appendChild(box); chatSpace.appendChild(row); } /* STATE GENERATING (tombol kirim <-> stop) */ const sendArrow = document.getElementById('sendArrow'); const sendStop = document.getElementById('sendStop'); function setGenerating(on) { isGenerating = on; sendArrow.style.display = on ? 'none' : ''; sendStop.style.display = on ? '' : 'none'; updateSendState(); if (on) sendButton.setAttribute('aria-label', 'Stop'); } function abortGeneration() { genToken++; if (activeCtl) { try { activeCtl.abort(); } catch (e) {} activeCtl = null; } setGenerating(false); } function waitMs(ms, signal) { return new Promise((resolve, reject) => { if (signal.aborted) return reject(new DOMException('Aborted', 'AbortError')); const t = setTimeout(resolve, ms); signal.addEventListener('abort', () => { clearTimeout(t); reject(new DOMException('Aborted', 'AbortError')); }, { once: true }); }); } function livePaint(text) { const re = /```(\w*)[^\S\n]*\n?([\s\S]*?)(```|$)/g; let last = 0, m, html = ''; while ((m = re.exec(text)) !== null) { const before = text.slice(last, m.index).trim(); if (before) html += parseMarkdown(before); const lang = (m[1] || 'text').toLowerCase(), code = m[2]; const tm = lang === 'html' ? /([^<]{1,60})<\/title>/i.exec(code) : null; const title = tm && tm[1].trim() ? tm[1].trim() : (lang === 'html' ? 'Web page' : lang === 'svg' ? 'Vector image' : 'Code file'); const done = m[3] === '```'; html += '<div class="art-card art-live"><span class="art-ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m8 8-4 4 4 4M16 8l4 4-4 4M14 5l-4 14"/></svg></span><span><b>' + escapeHtml(title) + '</b><small>' + (uiId() ? (done ? 'Kode · ' : 'Menulis · ') : (done ? 'Code · ' : 'Writing · ')) + escapeHtml(lang === 'text' ? 'TEXT' : lang.toUpperCase()) + ' · ' + code.split('\n').length + (uiId() ? ' baris' : ' lines') + '</small></span></div>'; last = re.lastIndex; } const after = text.slice(last).replace(/`{1,2}$/, '').trim(); if (after) html += parseMarkdown(after); return html; } function typewrite(bubble, full, signal) { const fi = full.indexOf('```'); const pre = fi === -1 ? full : full.slice(0, fi); return new Promise((resolve) => { if (!pre.trim()) return resolve(full); const step = Math.max(1, Math.ceil(pre.length / 60)); let i = 0; const iv = setInterval(() => { i += step; bubble.innerHTML = parseMarkdown(pre.slice(0, i)); if (i >= pre.length) { clearInterval(iv); resolve(full); } }, 30); signal.addEventListener('abort', () => { clearInterval(iv); resolve(full.slice(0, i)); }, { once: true }); }); } /* SIMPAN BALASAN KE PERCAKAPAN YANG BENAR */ function commitBot(convId, msg) { const target = convs.find((c) => c.id === convId); if (!target) return; target.messages.push(msg); target.updated = Date.now(); persist(); if (currentId === convId) renderMessage(msg); if (msg.r === 'a' && msg.t && !msg.k) memoryUpdate(convId); } /* MEMORI JANGKA PANJANG (ala Claude): AI nyimpen fakta tentang kamu; lihat/hapus lewat menu ⋮ > Memori */ const MEM_KEY = 'olmod_memory_v1', MEM_OFF = 'olmod_memory_off'; function memLoad() { try { const a = JSON.parse(localStorage.getItem(MEM_KEY) || '[]'); return Array.isArray(a) ? a.filter((x) => typeof x === 'string') : []; } catch (e) { return []; } } function memSave(a) { try { localStorage.setItem(MEM_KEY, JSON.stringify(a.slice(-40))); } catch (e) {} } function memOn() { try { return localStorage.getItem(MEM_OFF) !== '1'; } catch (e) { return true; } } function memPrompt() { const a = memLoad(); if (!a.length || !memOn()) return ''; return ' Long-term memory about the user (use naturally only when relevant, do not list it unless asked): ' + a.join('; ') + '.'; } function memEl(tag, cls) { const n = document.createElement(tag); if (cls) n.className = cls; return n; } function memChip(text, busy) { const row = memEl('div', 'message-row bot'), b = memEl('button', 'mem-chip' + (busy ? ' busy' : '')); b.type = 'button'; b.textContent = '✎ ' + text + ' ›'; b.addEventListener('click', openMemory); row.appendChild(b); return row; } function openMemory() { const ov = memEl('div', 'mem-ov'), card = memEl('div', 'mem-card'); ov.appendChild(card); ov.addEventListener('click', (e) => { if (e.target === ov) ov.remove(); }); const note = (t, css) => { const d = memEl('div'); d.style.cssText = css; d.textContent = t; card.appendChild(d); }; function draw() { const a = memLoad(); card.innerHTML = ''; const h = memEl('h3'); h.textContent = 'Memori'; card.appendChild(h); note('Hal yang AI ingat tentang kamu. Tersimpan di HP ini saja.', 'font-size:13px;opacity:.65;margin-bottom:8px'); if (!a.length) note('Belum ada memori.', 'padding:14px 0;opacity:.6;font-size:14px'); a.forEach((t, i) => { const r = memEl('div', 'mem-item'), sp = memEl('span'), d = memEl('button'); sp.textContent = t; d.textContent = 'Hapus'; d.onclick = () => { const x = memLoad(); x.splice(i, 1); memSave(x); draw(); }; r.appendChild(sp); r.appendChild(d); card.appendChild(r); }); const f = memEl('div'); f.style.cssText = 'display:flex;gap:8px;margin-top:14px;flex-wrap:wrap'; card.appendChild(f); const btn = (label, fn) => { const b = memEl('button', 'mem-btn'); b.textContent = label; b.onclick = fn; f.appendChild(b); }; btn(memOn() ? 'Matikan memori' : 'Nyalakan memori', () => { try { localStorage.setItem(MEM_OFF, memOn() ? '1' : '0'); } catch (e) {} draw(); }); if (a.length) btn('Hapus semua', () => { memSave([]); draw(); }); btn('Tutup', () => ov.remove()); } draw(); document.body.appendChild(ov); } async function memoryUpdate(convId) { let row = null; try { if (!memOn()) return; const c = convs.find((x) => x.id === convId); if (!c) return; const ms = c.messages, bot = ms[ms.length - 1]; let u = null; for (let i = ms.length - 2; i >= 0; i--) if (ms[i].r !== 'a') { u = ms[i]; break; } if (!u || !u.t || u.t.trim().length < 12) return; if (currentId === convId) { row = memChip('Memperbarui memori', true); chatSpace.appendChild(row); scrollToBottom(false); } const mem = memLoad(), cfg = aiCfg(MODEL_MAP[settings.model] || 'openai'); const sys = 'You maintain a long-term memory about the user. Given the current memory list and the latest exchange, output ONLY JSON: {"add":["short fact"],"remove":[indexes of memory items that are outdated or contradicted]}. Save only durable personal facts the user stated about themselves (name, job, location, projects, skills, preferences, goals, people in their life). Ignore small talk, questions, one-time tasks, and anything about the assistant. Never save passwords, API keys, ID or card numbers, or health details. Write each fact in the user\'s language, max 100 characters. If nothing to save output {"add":[],"remove":[]}.'; const usr = 'Current memory:\n' + (mem.length ? mem.map((x, i) => i + '. ' + x).join('\n') : '(empty)') + '\n\nUser said:\n' + u.t.slice(0, 1500) + '\n\nAssistant replied:\n' + (bot.t || '').slice(0, 500); const data = await aiFetch(cfg, { model: cfg.model, temperature: 0, messages: [{ role: 'system', content: sys }, { role: 'user', content: usr }] }, undefined); const txt = (data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || ''; const j = JSON.parse(txt.slice(txt.indexOf('{'), txt.lastIndexOf('}') + 1)); let next = mem.slice(), changed = false; (Array.isArray(j.remove) ? j.remove : []).forEach((i) => { if (Number.isInteger(i) && mem[i] != null) { next = next.filter((x) => x !== mem[i]); changed = true; } }); (Array.isArray(j.add) ? j.add : []).forEach((f) => { f = String(f || '').trim().slice(0, 140); if (f.length < 4 || /sk-[\w-]{8,}|gsk_|xai-|password|kata sandi|\bpin\b/i.test(f)) return; if (next.some((x) => x.toLowerCase() === f.toLowerCase())) return; next.push(f); changed = true; }); if (changed) { memSave(next); if (row) row.replaceWith(memChip('Memori diperbarui', false)); } else if (row) row.remove(); } catch (e) { if (row) row.remove(); } } document.getElementById('memMenu').addEventListener('click', () => { menuPopover.classList.remove('open'); openMemory(); }); /* RIWAYAT UNTUK API */ function buildHistory(augmented) { const arr = []; let lastUser = -1, lastImg = -1, lastTxt = -1; messages.forEach((m, i) => { if (m.r !== 'a') { if (m.k === 'image' && m.d) lastImg = i; if (m.x) lastTxt = i; } }); messages.forEach((m, i) => { if (m.r === 'a') { if (m.k === 'genimg' || !m.t) return; arr.push({ role: 'assistant', content: m.t }); } else { let c = m.t || ''; if (m.k) { if (m.x && i === lastTxt) c = (c ? c + '\n\n' : '') + '[File: ' + (m.n || '') + ']\n```\n' + m.x + '\n```'; else if (m.k === 'image') c = (c ? c + '\n' : '') + '[Attachment photo: ' + (m.n || '') + ']'; else c = (c ? c + '\n' : '') + '[Attachment file: ' + (m.n || '') + (m.x ? '' : ' - content could not be read by the app') + ']'; } let content = c; if (m.k === 'image' && m.d && i === lastImg) content = [{ type: 'text', text: c || 'What is in this image?' }, { type: 'image_url', image_url: { url: m.d } }]; arr.push({ role: 'user', content }); lastUser = arr.length - 1; } }); if (augmented && lastUser >= 0) arr[lastUser].content = augmented; return [{ role: 'system', content: systemPrompt() + (vcActive ? VC_HINT : '') }].concat(arr.slice(settings.context === false ? -1 : -30)); } /* WEB SEARCH tanpa Tavily: Grok + tool web_search (Responses API, pakai API_KEY yang sama) */ function grokKey() { if (!/(^|\/\/)api\.x\.ai/.test(API_URL)) return ''; const a = settings.api || {}; return cleanKey(a.key) || cleanKey(API_KEY); } async function grokSearchReply(messages, signal) { const key = grokKey(); const input = messages.map((m) => ({ role: m.role, content: String(m.content || '') })); const resp = await fetch(API_RESP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + key }, body: JSON.stringify({ model: API_MODEL, input, tools: [{ type: 'web_search' }] }), signal, }); const raw = await resp.text(); let data = null; try { data = JSON.parse(raw); } catch (e) {} const errObj = data && data.error; if (!resp.ok || !data || errObj) { const em = errObj && (errObj.message || (typeof errObj === 'string' ? errObj : '')); if (resp.status === 401 || resp.status === 403) throw new Error('API key rejected (' + resp.status + ').' + (em ? ' ' + em : '')); throw new Error('Web search failed (' + resp.status + ')' + (em ? ': ' + em : '.')); } let text = typeof data.output_text === 'string' ? data.output_text : ''; const cites = []; (data.output || []).forEach((item) => { if (item.type !== 'message') return; (item.content || []).forEach((c) => { if (!text && c.type === 'output_text' && c.text) text += c.text; (c.annotations || []).forEach((a) => { if (a && a.type === 'url_citation' && a.url) cites.push({ url: a.url, title: a.title }); }); }); }); text = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim(); const seen = {}; const src = cites.filter((w) => !seen[w.url] && (seen[w.url] = 1)).slice(0, 5); if (src.length) text += '\n\nSources:\n' + src.map((w, i) => (i + 1) + '. ' + ((w.title && w.title !== w.url && !/^\d+$/.test(String(w.title))) ? w.title + ' - ' : '') + w.url).join('\n'); return { choices: [{ message: { content: text || 'Sorry, no response.' } }] }; } async function searchWeb(query, signal) { const r = await fetch('https://api.tavily.com/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: TAVILY_KEY, query, max_results: 5, search_depth: 'basic' }), signal, }); const data = await r.json(); if (data.error) throw new Error(typeof data.error === 'string' ? data.error : 'Search failed'); return data.results || []; } function augmentedPrompt(userText, results) { if (!results.length) return null; const ctx = results.map((r, i) => (i + 1) + '. ' + r.title + '\n' + r.content + '\nSource: ' + r.url).join('\n\n'); return 'Latest web search results (use as context and cite sources when relevant):\n\n' + ctx + '\n\nUser question: ' + userText; } /* STREAMING: baca jawaban AI potongan demi potongan (SSE) */ async function aiStream(cfg, payload, signal, onDelta) { const body = JSON.stringify(Object.assign({}, payload, { stream: true })); const errText = (raw) => { let d = null; try { d = JSON.parse(raw); } catch (e) {} const eo = d && (Array.isArray(d) ? (d[0] && d[0].error) : d.error); return (eo && (eo.message || (typeof eo === 'string' ? eo : ''))) || (raw || '').replace(/<[^>]+>/g, ' ').trim().slice(0, 160); }; for (let attempt = 0; attempt < 3; attempt++) { const resp = await fetch(cfg.url, { method: 'POST', headers: cfg.headers, body, signal }); if (!resp.ok) { const msg = errText(await resp.text()); if ((resp.status === 503 || resp.status === 429 || resp.status === 500) && attempt < 2) { await waitMs(1500 * (attempt + 1), signal); continue; } if (resp.status === 401 || resp.status === 403) throw new Error('API key rejected (' + resp.status + '). ' + msg); throw new Error('Server error (' + resp.status + ')' + (msg ? ': ' + msg : '.') + ' [' + new URL(cfg.url).host + ' / ' + payload.model + ']'); } const ct = resp.headers.get('content-type') || ''; if (!resp.body || ct.indexOf('text/event-stream') < 0) { // server tidak support stream -> balasan biasa const raw = await resp.text(); let d = null; try { d = JSON.parse(raw); } catch (e) {} const eo = d && (Array.isArray(d) ? (d[0] && d[0].error) : d.error); if (eo) throw new Error(errText(raw) || 'The server returned an error'); const txt = (d && d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content) || ''; if (txt) onDelta(txt); return txt; } const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = '', full = ''; try { for (;;) { const r = await reader.read(); if (r.done) break; buf += dec.decode(r.value, { stream: true }); let idx; while ((idx = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, idx).trim(); buf = buf.slice(idx + 1); if (line.indexOf('data:') !== 0) continue; const dat = line.slice(5).trim(); if (!dat || dat === '[DONE]') continue; let j; try { j = JSON.parse(dat); } catch (e) { continue; } if (j.error) throw new Error(j.error.message || 'Stream error'); const dl = j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content; if (dl) { full += dl; onDelta(full); } } } } catch (e) { if (e.name === 'AbortError' || !full) throw e; } return full; } } /* MINTA JAWABAN AI */ async function runCompletion() { const convId = currentId; const token = ++genToken; const ctl = new AbortController(); activeCtl = ctl; setGenerating(true); const typing = showTyping(); scrollToBottom(true); const t0 = Date.now(); try { let augmented = null; if (settings.web && TAVILY_KEY) { typing.setLabel('Searching the web'); const lastUser = messages.slice().reverse().find((m) => m.r !== 'a'); try { const results = await searchWeb((lastUser && lastUser.t) || '', ctl.signal); augmented = augmentedPrompt((lastUser && lastUser.t) || '', results); } catch (e) { if (e.name === 'AbortError') throw e; console.warn('Web search failed:', e.message); } } let shown = ''; const useStream = settings.typing !== false && !(settings.web && !TAVILY_KEY && grokKey()); if (useStream) { const cfg = aiCfg(MODEL_MAP[settings.model]); const clean = (t) => t.replace(/<think>[\s\S]*?(<\/think>|$)/g, ''); let holder = null, bub = null, streamed = '', raf = 0; const paint = () => { raf = 0; if (!bub) return; const near = chatSpace.scrollHeight - chatSpace.scrollTop - chatSpace.clientHeight < 160; bub.innerHTML = livePaint(clean(streamed).trimStart()); if (near) scrollToBottom(false); }; try { await aiStream(cfg, { model: cfg.model, messages: buildHistory(augmented) }, ctl.signal, (full) => { streamed = full; if (!holder) { if (currentId !== convId) return; typing.remove(); holder = mk('div', 'message-row bot'); bub = mk('div', 'message-bubble bot streaming'); holder.appendChild(bub); chatSpace.appendChild(holder); } if (!raf) raf = requestAnimationFrame(paint); }); } catch (e) { if (!clean(streamed).trim()) throw e; // belum ada teks -> error / abort biasa } if (raf) cancelAnimationFrame(raf); typing.remove(); if (holder) holder.remove(); shown = clean(streamed).trim() || (ctl.signal.aborted ? '' : 'Sorry, no response.'); } else { let data; if (settings.web && !TAVILY_KEY && grokKey()) { typing.setLabel('Searching the web'); data = await grokSearchReply(buildHistory(null), ctl.signal); } else { const cfg = aiCfg(MODEL_MAP[settings.model]); data = await aiFetch(cfg, { model: cfg.model, messages: buildHistory(augmented) }, ctl.signal); } let reply = (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || 'Sorry, no response.'; reply = reply.replace(/<think>[\s\S]*?<\/think>/g, '').trim() || 'Sorry, no response.'; await waitMs(Math.max(0, (settings.think === false ? 0 : MIN_THINK_MS) - (Date.now() - t0)), ctl.signal); typing.remove(); shown = reply; if (TYPEWRITER && settings.typing !== false && currentId === convId) { const holder = mk('div', 'message-row bot'); const bub = mk('div', 'message-bubble bot streaming'); holder.appendChild(bub); chatSpace.appendChild(holder); shown = await typewrite(bub, reply, ctl.signal); holder.remove(); } } if (shown.trim()) { commitBot(convId, { r: 'a', t: shown }); if (settings.notify && document.hidden) notify('Olmod: reply ready', shown.replace(/\s+/g, ' ').slice(0, 120)); } } catch (e) { typing.remove(); if (e.name !== 'AbortError' && currentId === convId) { showError(e.message === 'Failed to fetch' ? 'Could not reach the server. Check your connection.' : e.message, runCompletion); } } finally { if (token === genToken) { activeCtl = null; setGenerating(false); } } } function regenerate(msg) { if (isGenerating) return; if (messages[messages.length - 1] !== msg) return toast('Only the last reply can be regenerated'); messages.pop(); saveMessages(); if (chatSpace.lastElementChild) chatSpace.lastElementChild.remove(); runCompletion(); } /* BIKIN GAMBAR */ async function generateImage(prompt) { const convId = currentId; const token = ++genToken; const ctl = new AbortController(); activeCtl = ctl; setGenerating(true); const typing = showTyping('Creating image'); scrollToBottom(true); const seed = Math.floor(Math.random() * 1000000); const url = 'https://image.pollinations.ai/prompt/' + encodeURIComponent(prompt) + '?width=768&height=768&nologo=true&seed=' + seed; try { await new Promise((resolve, reject) => { const img = new Image(); const to = setTimeout(() => { img.src = ''; reject(new Error('Image took too long to generate (server is slow).')); }, 30000); img.onload = () => { clearTimeout(to); resolve(); }; img.onerror = () => { clearTimeout(to); reject(new Error('Failed to create the image.')); }; ctl.signal.addEventListener('abort', () => { clearTimeout(to); img.src = ''; reject(new DOMException('Aborted', 'AbortError')); }, { once: true }); img.src = url; }); typing.remove(); commitBot(convId, { r: 'a', k: 'genimg', u: url, p: prompt }); } catch (e) { typing.remove(); if (e.name !== 'AbortError' && currentId === convId) showError(e.message, () => generateImage(prompt)); } finally { if (token === genToken) { activeCtl = null; setGenerating(false); } } } /* MENU: MODEL / WEB / GAMBAR */ function refreshMenuState() { const pi = document.getElementById('pickImage'), pw = document.getElementById('pickWeb'); if (pi) pi.classList.toggle('on', imageMode); if (pw) pw.classList.toggle('on', settings.web); } function setImageMode(on) { imageMode = on; input.placeholder = on ? 'What image do you want to create?' : DEFAULT_PLACEHOLDER; refreshMenuState(); } refreshMenuState(); /* ISI SHEET + : Image / Web / Prompts */ function resetSheetView() { document.getElementById('sheetGrid').style.display = ''; document.getElementById('sheetRows').style.display = ''; document.getElementById('promptList').classList.remove('show'); document.getElementById('sheetTitle').textContent = 'Add to chat'; } document.getElementById('pickImage').addEventListener('click', () => { closeSheet(); setImageMode(!imageMode); toast(imageMode ? 'Image mode on' : 'Image mode off'); if (imageMode) setTimeout(() => input.focus(), 300); }); document.getElementById('pickWeb').addEventListener('click', () => { if (!TAVILY_KEY && !grokKey()) { alert('Web search butuh TAVILY_KEY. Isi baris const TAVILY_KEY di file, simpan, lalu jalankan lagi.'); return; } settings.web = !settings.web; saveSettings(); refreshMenuState(); toast(settings.web ? 'Web search on' : 'Web search off'); }); document.getElementById('pickPrompts').addEventListener('click', () => { document.getElementById('sheetGrid').style.display = 'none'; document.getElementById('sheetRows').style.display = 'none'; document.getElementById('promptList').classList.add('show'); document.getElementById('sheetTitle').textContent = 'Prompts'; }); document.querySelectorAll('#promptList .prompt-item').forEach((b) => b.addEventListener('click', () => { closeSheet(); input.value = b.dataset.p + input.value; autoResize(); updateSendState(); setTimeout(() => { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }, 300); })); /* ===== MODE DEVELOPER ===== */ const dev = document.getElementById('dev'); const devButton = document.getElementById('devButton'); const devClose = document.getElementById('devClose'); const devNew = document.getElementById('devNew'); const devCopy = document.getElementById('devCopy'); const devDownload = document.getElementById('devDownload'); const devOpen = document.getElementById('devOpen'); const devShare = document.getElementById('devShare'); const devSave = document.getElementById('devSave'); const devStage = document.getElementById('devStage'); const devFrame = document.getElementById('devFrame'); const devCodeEl = document.querySelector('#devCode code'); const devBusy = document.getElementById('devBusy'); const devNote = document.getElementById('devNote'); const devInput = document.getElementById('devInput'); const devSendBtn = document.getElementById('devSend'); const devStop = document.getElementById('devStop'); const devArrow = document.getElementById('devArrow'); const DEV_MODEL = 'qwen-coder'; const DEV_SYSTEM = 'You are Olmod, a senior web developer. Never mention who developed you unless explicitly asked; if asked, say TradomodLMC. Always reply with ONE complete HTML file (HTML, CSS and JavaScript inline in a single file) inside a single ```html markdown code fence. Rules: mobile-first and responsive, modern professional design, clean code that runs immediately with no build step, no external libraries except via cdnjs.cloudflare.com, do not rely on localStorage for important data. If the user asks to change existing code, return the COMPLETE updated HTML file, not a snippet. Write all UI text inside the app in English. After the code fence, write at most two short sentences in English describing what you built or changed.'; // localStorage/sessionStorage gak tersedia di iframe sandbox, jadi dikasih pengganti in-memory biar app hasil AI gak error const DEV_SHIM = '<script>(function(){function mem(){var s={};return{getItem:function(k){return Object.prototype.hasOwnProperty.call(s,k)?s[k]:null},setItem:function(k,v){s[k]=String(v)},removeItem:function(k){delete s[k]},clear:function(){s={}},key:function(i){return Object.keys(s)[i]||null},get length(){return Object.keys(s).length}}}["localStorage","sessionStorage"].forEach(function(n){try{window[n].getItem("x")}catch(e){try{Object.defineProperty(window,n,{value:mem(),configurable:true})}catch(_){}}})})();<\/script><style>:where(*,*::before,*::after){box-sizing:border-box}:where(html){-webkit-text-size-adjust:100%}:where(body){margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;line-height:1.6;-webkit-font-smoothing:antialiased}:where(img,video,svg){max-width:100%}</style>'; let devCodeText = ''; let curProjectId = null; /* riwayat versi + autosave project */ const BUILD_KEY = 'chatui_build_v1'; let devHist = []; let devIdx = -1; const devUndo = document.getElementById('devUndo'); const devRedo = document.getElementById('devRedo'); function devSaveProject() { try { if (!devHist.length) localStorage.removeItem(BUILD_KEY); else localStorage.setItem(BUILD_KEY, JSON.stringify({ hist: devHist, idx: devIdx })); } catch (e) { try { localStorage.setItem(BUILD_KEY, JSON.stringify({ hist: [devHist[devIdx]], idx: 0 })); } catch (_) {} } } function devPush(code) { devHist = devHist.slice(0, devIdx + 1); if (devHist[devHist.length - 1] !== code) devHist.push(code); if (devHist.length > 15) devHist.shift(); devIdx = devHist.length - 1; devSaveProject(); } function devGo(i) { if (i < 0 || i >= devHist.length) return; devIdx = i; devCodeText = devHist[i]; renderDev(); devSaveProject(); devSay('Version ' + (i + 1) + ' of ' + devHist.length, true); } let devBusyFlag = false; let devCtl = null; let devNoteBase = ''; let devNoteTimer = null; function devSay(msg, temp) { clearTimeout(devNoteTimer); if (temp) { devNote.textContent = msg; devNoteTimer = setTimeout(() => { devNote.textContent = devNoteBase; }, 2000); } else { devNoteBase = msg; devNote.textContent = msg; } } function openDev() { closePopovers(); closeSheet(); closeDrawer(); if (typeof stopVoice === 'function') stopVoice(); input.blur(); dev.classList.add('open'); dev.setAttribute('aria-hidden', 'false'); devButton.setAttribute('aria-expanded', 'true'); } function closeDev() { if (!dev.classList.contains('open')) return; devInput.blur(); dev.classList.remove('open', 'full'); dev.setAttribute('aria-hidden', 'true'); devButton.setAttribute('aria-expanded', 'false'); } const devDevices = document.getElementById('devDevices'); let devSize = 'fit'; const DEV_WIDTHS = { phone: 390, tablet: 768, desktop: 1280 }; function layoutFrame() { const w = devStage.clientWidth, h = devStage.clientHeight; const f = devFrame.style; if (devSize === 'fit' || !w || !h) { f.width = '100%'; f.height = '100%'; f.transform = 'none'; f.left = '0px'; return; } const target = DEV_WIDTHS[devSize]; const sc = Math.min(1, w / target); f.width = target + 'px'; f.height = (h / sc) + 'px'; f.transform = 'scale(' + sc + ')'; f.left = Math.max(0, (w - target * sc) / 2) + 'px'; } function updateDevBar() { devDevices.style.display = (devCodeText && devStage.dataset.tab === 'preview') ? 'flex' : 'none'; requestAnimationFrame(layoutFrame); } function setDevFull(on) { dev.classList.toggle('full', on); requestAnimationFrame(() => requestAnimationFrame(layoutFrame)); } dev.querySelectorAll('.dev-size').forEach((b) => b.addEventListener('click', () => { devSize = b.dataset.size; dev.querySelectorAll('.dev-size').forEach((x) => x.classList.toggle('on', x === b)); layoutFrame(); })); document.getElementById('devFull').addEventListener('click', () => setDevFull(true)); document.getElementById('devExit').addEventListener('click', () => setDevFull(false)); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') setDevFull(false); }); window.addEventListener('resize', layoutFrame); if (window.visualViewport) window.visualViewport.addEventListener('resize', layoutFrame); function setDevTab(tab) { devStage.dataset.tab = tab; dev.querySelectorAll('.dev-tab[data-tab]').forEach((b) => b.classList.toggle('on', b.dataset.tab === tab)); updateDevBar(); } function withShim(code) { const m = code.match(/<head[^>]*>/i) || code.match(/<html[^>]*>/i); if (m) return code.slice(0, m.index + m[0].length) + DEV_SHIM + code.slice(m.index + m[0].length); return DEV_SHIM + code; } function renderDev() { const has = !!devCodeText; devStage.classList.toggle('has-code', has); devCopy.disabled = devDownload.disabled = devOpen.disabled = devShare.disabled = devSave.disabled = !has; devUndo.disabled = devIdx <= 0; devRedo.disabled = devIdx >= devHist.length - 1; devCodeEl.innerHTML = hlCode(devCodeText, 'html'); devFrame.srcdoc = has ? withShim(devCodeText) : ''; updateDevBar(); } function devExtract(reply) { const closed = reply.match(/```(?:html)?[ \t]*\n([\s\S]*?)```/i); if (closed) return { code: closed[1].trim(), note: reply.replace(closed[0], '').trim() }; const open = reply.match(/```(?:html)?[ \t]*\n([\s\S]*)$/i); // fence gak ketutup (kepotong) if (open) return { code: open[1].trim(), note: '' }; const t = reply.trim(); if (/^<!doctype|^<html/i.test(t)) return { code: t, note: '' }; return { code: '', note: t }; } function devAutosize() { devInput.style.height = 'auto'; devInput.style.height = Math.min(devInput.scrollHeight, 120) + 'px'; } function updateDevSend() { const ok = devBusyFlag || devInput.value.trim().length > 0; devSendBtn.disabled = !ok; devSendBtn.classList.toggle('disabled', !ok); devStop.style.display = devBusyFlag ? '' : 'none'; devArrow.style.display = devBusyFlag ? 'none' : ''; } function setDevBusy(on) { devBusyFlag = on; devBusy.classList.toggle('show', on); updateDevSend(); } async function devSend() { if (devBusyFlag) { if (devCtl) devCtl.abort(); return; } const text = devInput.value.trim(); if (!text) return; devInput.value = ''; devAutosize(); devInput.blur(); let userContent = text; if (devCodeText) { userContent = 'Current code:\n```html\n' + devCodeText + '\n```\n\nChange request: ' + text + '\n\nReply with the complete updated HTML file.'; } const ctl = new AbortController(); devCtl = ctl; setDevBusy(true); devSay(''); try { const dcfg = aiCfg(DEV_MODEL); const data = await aiFetch(dcfg, { model: dcfg.model, max_tokens: 8000, temperature: 0.4, messages: [{ role: 'system', content: DEV_SYSTEM }, { role: 'user', content: userContent }], }, ctl.signal); if (data.error) throw new Error(data.error.message || 'The server returned an error'); const reply = ((data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || '') .replace(/<think>[\s\S]*?<\/think>/g, '').trim(); const { code, note } = devExtract(reply); if (!code) { devSay(note || 'The AI did not return any code. Try describing it again.'); } else { devCodeText = code; devPush(code); renderDev(); setDevTab('preview'); devSay(note); } } catch (e) { if (e.name !== 'AbortError') { devSay(e.message === 'Failed to fetch' ? 'Could not reach the server. Check your connection.' : e.message); } else { devSay('Stopped.', true); } } finally { if (devCtl === ctl) devCtl = null; setDevBusy(false); } } const devSheet = document.getElementById('devSheet'); const devSheetOverlay = document.getElementById('devSheetOverlay'); function openDevSheet() { closePopovers(); closeSheet(); if (typeof stopVoice === 'function') stopVoice(); input.blur(); devSheet.classList.add('open'); devSheetOverlay.classList.add('open'); devSheet.setAttribute('aria-hidden', 'false'); devButton.setAttribute('aria-expanded', 'true'); } function closeDevSheet() { devSheet.classList.remove('open'); devSheetOverlay.classList.remove('open'); devSheet.setAttribute('aria-hidden', 'true'); if (!dev.classList.contains('open')) devButton.setAttribute('aria-expanded', 'false'); } devButton.addEventListener('click', (e) => { e.stopPropagation(); if (devSheet.classList.contains('open')) closeDevSheet(); else openDevSheet(); }); devSheetOverlay.addEventListener('click', closeDevSheet); bindSheetDrag(devSheet, closeDevSheet); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDevSheet(); }); devSheet.querySelectorAll('.sheet-card').forEach((c) => c.addEventListener('click', () => { closeDevSheet(); openDev(); devInput.value = c.dataset.start || ''; devAutosize(); updateDevSend(); setTimeout(() => { devInput.focus(); devInput.setSelectionRange(devInput.value.length, devInput.value.length); }, 320); })); devClose.addEventListener('click', closeDev); dev.querySelectorAll('.dev-tab[data-tab]').forEach((b) => b.addEventListener('click', () => setDevTab(b.dataset.tab))); devNew.addEventListener('click', () => { curProjectId = null; if (devCtl) devCtl.abort(); devCodeText = ''; devHist = []; devIdx = -1; devSaveProject(); renderDev(); setDevTab('preview'); devSay(''); }); devUndo.addEventListener('click', () => devGo(devIdx - 1)); devRedo.addEventListener('click', () => devGo(devIdx + 1)); devCopy.addEventListener('click', async () => { devSay((await copyText(devCodeText)) ? 'Code copied' : 'Copy failed', true); }); devDownload.addEventListener('click', () => { const url = URL.createObjectURL(new Blob([devCodeText], { type: 'text/html' })); const a = document.createElement('a'); a.href = url; a.download = 'app.html'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000); devSay('app.html downloaded', true); }); devOpen.addEventListener('click', () => openCode(devCodeText, 'html')); devShare.addEventListener('click', async () => { if (!devCodeText) return; const file = new File([devCodeText], 'app.html', { type: 'text/html' }); try { if (navigator.canShare && navigator.canShare({ files: [file] })) { await navigator.share({ files: [file], title: 'My Olmod build' }); return; } } catch (e) { if (e && e.name === 'AbortError') return; } devDownload.click(); devSay('Sharing is not supported here. File downloaded instead.', true); }); dev.querySelectorAll('.dev-chip').forEach((c) => c.addEventListener('click', () => { devInput.value = c.dataset.prompt; updateDevSend(); devSend(); })); devInput.addEventListener('input', () => { devAutosize(); updateDevSend(); }); devInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && window.matchMedia('(min-width:700px)').matches) { e.preventDefault(); devSend(); } }); bindTap(devSendBtn, devSend); updateDevSend(); try { const sv = JSON.parse(localStorage.getItem(BUILD_KEY) || 'null'); if (sv && Array.isArray(sv.hist) && sv.hist.length) { devHist = sv.hist.filter((x) => typeof x === 'string'); devIdx = Math.min(Math.max(0, sv.idx | 0), devHist.length - 1); devCodeText = devHist[devIdx] || ''; } } catch (e) {} renderDev(); /* TOMBOL SCROLL KE BAWAH */ (function () { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'scroll-down'; btn.setAttribute('aria-label', 'Scroll to bottom'); btn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14"/><path d="m6 13 6 6 6-6"/></svg>'; btn.addEventListener('mousedown', (e) => e.preventDefault()); btn.addEventListener('click', () => scrollToBottom(true)); app.appendChild(btn); let raf = 0; function check() { raf = 0; const dist = chatSpace.scrollHeight - chatSpace.scrollTop - chatSpace.clientHeight; btn.classList.toggle('show', dist > 240); } function queue() { if (!raf) raf = requestAnimationFrame(check); } chatSpace.addEventListener('scroll', queue, { passive: true }); new MutationObserver(queue).observe(chatSpace, { childList: true, subtree: true, characterData: true }); window.addEventListener('resize', queue); queue(); })(); /* INIT */ loadState(); loadCurrent(); fitViewport(); /* tinggi composer -> ruang bawah chat (composer melayang di atas chat) */ (function () { const wrap = document.getElementById('composerWrap'); let last = 0; function sync() { const h = wrap.offsetHeight; if (h === last) return; const near = chatSpace.scrollHeight - chatSpace.scrollTop - chatSpace.clientHeight < 200; last = h; app.style.setProperty('--composer-h', h + 'px'); if (near) scrollToBottom(false); } if (window.ResizeObserver) new ResizeObserver(sync).observe(wrap); window.addEventListener('resize', sync); sync(); })(); /* LOGIN GATE (simulasi) */ (function () { const AK = 'chatui_auth_v1', g = document.getElementById('gate'); if (!g) return; const $ = (i) => document.getElementById(i); const ACC = [ { name: 'Budi Santoso', email: 'budi.santoso@gmail.com', color: '#3f6fd8' }, { name: 'Sari Dewi', email: 'sari.dewi@gmail.com', color: '#b5467a' } ]; try { const m = /src="([^"]+)"/.exec(LOGO_SVG); if (m) $('gateLogo').src = m[1]; } catch (e) {} const mailOk = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1); function enter(a) { try { localStorage.setItem(AK, JSON.stringify(a)); } catch (e) {} if (!(settings.name || '').trim()) { settings.name = a.name.split(' ')[0].slice(0, 30); saveSettings(); try { applyProfile(); } catch (e) {} try { refreshGreeting(); } catch (e) {} } g.classList.add('out'); setTimeout(() => document.documentElement.setAttribute('data-auth', '1'), 260); } function login(a, viaGoogle) { $('gOv').classList.remove('open'); if (viaGoogle) { $('gGoogleTx').textContent = 'Connecting…'; $('gGoogle').disabled = true; } setTimeout(() => enter(a), 1100); } function drawAccs() { const box = $('gAccs'); box.innerHTML = ''; ACC.forEach((a) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'g-acc'; b.innerHTML = '<span class="g-av" style="background:' + a.color + '">' + a.name[0] + '</span><span><b>' + a.name + '</b><small>' + a.email + '</small></span>'; b.onclick = () => login(a, true); box.appendChild(b); }); } $('gGoogle').onclick = () => { drawAccs(); $('gOv').classList.add('open'); }; $('gOv').onclick = (e) => { if (e.target === $('gOv')) $('gOv').classList.remove('open'); }; $('gMail').addEventListener('input', () => { $('gErr').textContent = ''; $('gGo').classList.toggle('show', $('gMail').value.trim().length > 0); }); function mailGo() { const v = $('gMail').value.trim(); if (!mailOk(v)) { $('gErr').textContent = 'Invalid email. Example: name@gmail.com'; return; } const n = v.split('@')[0].split(/[._]+/).map(cap).join(' '); login({ name: n, email: v, color: '#2563eb' }, false); } $('gGo').onclick = mailGo; $('gMail').addEventListener('keydown', (e) => { if (e.key === 'Enter') mailGo(); }); })(); /* DRAWER NAV */ (function () { const go = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('click', fn); }; go('drChats', () => { const l = document.getElementById('historyList'); if (l) l.scrollTo({ top: 0, behavior: 'smooth' }); }); go('drBuild', () => { closeDrawer(); setTimeout(() => devButton.click(), 260); }); go('drProjects', () => { closeDrawer(); setTimeout(openProjects, 200); }); })(); /* PROJECTS (simpan file proyek Build) */ const PJ_KEY = 'chatui_projects_v1'; function pjLoad() { try { const a = JSON.parse(localStorage.getItem(PJ_KEY) || '[]'); return Array.isArray(a) ? a : []; } catch (e) { return []; } } function pjStore(list) { try { localStorage.setItem(PJ_KEY, JSON.stringify(list)); return true; } catch (e) { toast('Storage is full'); return false; } } function pjSize(n) { return n < 1024 ? n + ' B' : (n / 1024).toFixed(1) + ' KB'; } function pjDate(t) { return new Date(t).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); } function openProjects() { drawPj(); const el = document.getElementById('projects'); el.classList.add('open'); el.setAttribute('aria-hidden', 'false'); } function closeProjects() { const el = document.getElementById('projects'); el.classList.remove('open'); el.setAttribute('aria-hidden', 'true'); } function drawPj() { const box = document.getElementById('pjList'), list = pjLoad().sort((a, b) => b.updated - a.updated); box.innerHTML = ''; document.getElementById('pjHint').style.display = list.length ? '' : 'none'; if (!list.length) { const e = document.createElement('div'); e.className = 'pj-empty'; e.textContent = 'No projects yet. Build something, then tap the bookmark icon in Build mode to save it here.'; box.appendChild(e); return; } list.forEach((p) => { const r = document.createElement('button'); r.type = 'button'; r.className = 'pj-row'; const b = document.createElement('b'); b.textContent = p.name; const m = document.createElement('small'); m.textContent = pjDate(p.updated) + ' · ' + pjSize(p.code.length); r.append(b, m); r.addEventListener('click', () => openProject(p)); longPress(r, () => showCtx(r, [ ['Rename', () => { const n = (prompt('Project name', p.name) || '').trim(); if (!n) return; const a = pjLoad(), x = a.find((q) => q.id === p.id); if (x) { x.name = n.slice(0, 60); pjStore(a); drawPj(); } }], ['Download', () => { const u = URL.createObjectURL(new Blob([p.code], { type: 'text/html' })); const a = document.createElement('a'); a.href = u; a.download = (p.name.replace(/[^\w\-]+/g, '_') || 'project') + '.html'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(u), 4000); }], ['Delete', () => { if (!confirm('Delete "' + p.name + '"?')) return; pjStore(pjLoad().filter((q) => q.id !== p.id)); if (curProjectId === p.id) curProjectId = null; drawPj(); }, true] ])); box.appendChild(r); }); } function openProject(p) { closeProjects(); devHist = [p.code]; devIdx = 0; devCodeText = p.code; curProjectId = p.id; devSaveProject(); renderDev(); setDevTab('preview'); openDev(); devSay('Opened ' + p.name, true); } document.getElementById('pjBack').addEventListener('click', closeProjects); devSave.addEventListener('click', () => { if (!devCodeText) return; const list = pjLoad(), cur = list.find((x) => x.id === curProjectId); if (cur) { cur.code = devCodeText; cur.updated = Date.now(); if (pjStore(list)) devSay('Project updated', true); return; } const t = (/<title>([^<]{1,60})<\/title>/i.exec(devCodeText) || [])[1]; const name = (prompt('Project name', (t || 'Untitled project').trim()) || '').trim(); if (!name) return; const p = { id: uid(), name: name.slice(0, 60), code: devCodeText, updated: Date.now() }; list.unshift(p); if (pjStore(list)) { curProjectId = p.id; devSay('Saved to Projects', true); } }); /* TOOLS */ const TOOLS = [ ['Summarize', 'Condense any text into key points', 'Summarize the following text in 3 to 5 concise bullet points:\n\n'], ['Translate', 'Translate text between languages', 'Translate the following text to English and keep the tone:\n\n'], ['Rewrite', 'Polish wording and tone', 'Rewrite the following text so it sounds clear and professional:\n\n'], ['Write an email', 'Draft a clear, professional email', 'Write a professional email about: '], ['Explain code', 'Step-by-step breakdown of what code does', 'Explain what this code does, step by step:\n\n'], ['Create image', 'Generate an image from a description', null] ]; function openTools() { const box = document.getElementById('tlList'); box.innerHTML = ''; TOOLS.forEach((t) => { const r = document.createElement('button'); r.type = 'button'; r.className = 'pj-row'; const b = document.createElement('b'); b.textContent = t[0]; const m = document.createElement('small'); m.textContent = t[1]; r.append(b, m); r.addEventListener('click', () => { closeTools(); if (messages.length) newChat(); setTimeout(() => { if (t[2] === null) { try { setImageMode(true); } catch (e) {} input.placeholder = 'Describe the image'; } else { input.value = t[2]; autoResize(); updateSendState(); } input.focus(); try { input.setSelectionRange(input.value.length, input.value.length); } catch (e) {} }, 80); }); box.appendChild(r); }); const el = document.getElementById('tools'); el.classList.add('open'); el.setAttribute('aria-hidden', 'false'); } function closeTools() { const el = document.getElementById('tools'); el.classList.remove('open'); el.setAttribute('aria-hidden', 'true'); } document.getElementById('tlBack').addEventListener('click', closeTools); document.getElementById('drTools').addEventListener('click', () => { closeDrawer(); setTimeout(openTools, 200); }); /* ARTIFACT CARD + FULLSCREEN VIEWER */ const ART_EXT = { html: 'html', svg: 'svg', css: 'css', javascript: 'js', js: 'js', typescript: 'ts', ts: 'ts', python: 'py', py: 'py', json: 'json', java: 'java', php: 'php', sql: 'sql', bash: 'sh', sh: 'sh', xml: 'xml', markdown: 'md', md: 'md', jsx: 'jsx', tsx: 'tsx' }; function isArtifact(lang, code) { return lang === 'html' || lang === 'svg' || code.split('\n').length > 14; } function artTitle(code, lang) { if (lang === 'html') { const t = /<title>([^<]{1,60})<\/title>/i.exec(code); if (t && t[1].trim()) return t[1].trim(); return 'Web page'; } if (lang === 'svg') return 'Vector image'; return (lang === 'text' ? 'Code' : lang.charAt(0).toUpperCase() + lang.slice(1)) + ' file'; } function artCard(code, lang) { const b = document.createElement('button'); b.type = 'button'; b.className = 'art-card'; b.innerHTML = '<span class="art-ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m8 8-4 4 4 4M16 8l4 4-4 4M14 5l-4 14"/></svg></span><span><b></b><small></small></span>'; b.querySelector('b').textContent = artTitle(code, lang); b.querySelector('small').textContent = (uiId() ? 'Kode · ' : 'Code · ') + (lang === 'text' ? 'TEXT' : lang.toUpperCase()); b.addEventListener('click', () => openArt(code, lang)); return b; } let artCur = null; function avTab(t) { const v = document.getElementById('artv'); v.dataset.tab = t; document.querySelectorAll('#avTabs button').forEach((x) => x.classList.toggle('on', x.dataset.t === t)); } function openArt(code, lang) { artCur = { code, lang, title: artTitle(code, lang) }; const v = document.getElementById('artv'), canPrev = lang === 'html' || lang === 'svg'; document.getElementById('avTitle').textContent = artCur.title; document.getElementById('avSub').textContent = (uiId() ? 'Kode · ' : 'Code · ') + (lang === 'text' ? 'TEXT' : lang.toUpperCase()); document.querySelector('#avCode code').innerHTML = hlCode(code, lang); document.getElementById('avTabs').classList.toggle('one', !canPrev); document.getElementById('avFrame').srcdoc = canPrev ? (lang === 'html' ? withShim(code) : '<!DOCTYPE html><html><body style="margin:0;display:grid;place-items:center;min-height:100vh;background:#fff">' + code + '</body></html>') : ''; avTab(canPrev ? 'preview' : 'code'); v.classList.add('open'); v.setAttribute('aria-hidden', 'false'); } function closeArt() { const v = document.getElementById('artv'); v.classList.remove('open'); v.setAttribute('aria-hidden', 'true'); document.getElementById('avFrame').srcdoc = ''; avMenuClose(); closeCtx(); } function artDownload() { if (!artCur) return; const ext = ART_EXT[artCur.lang] || 'txt'; const name = (artCur.title.replace(/[^\w\-]+/g, '_').replace(/^_+|_+$/g, '') || 'file') + '.' + ext; const u = URL.createObjectURL(new Blob([artCur.code], { type: 'text/plain;charset=utf-8' })); const a = document.createElement('a'); a.href = u; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(u), 4000); toast(name + ' downloaded'); } document.getElementById('avClose').addEventListener('click', closeArt); document.querySelectorAll('#avTabs button').forEach((b) => b.addEventListener('click', () => avTab(b.dataset.t))); function avMenuClose() { const m = document.getElementById('avMenu'); if (m) m.remove(); } document.getElementById('avMore').addEventListener('click', (e) => { e.stopPropagation(); if (document.getElementById('avMenu')) { avMenuClose(); return; } if (!artCur) return; const items = []; const isHtml = artCur.lang === 'html'; if (isHtml) { items.push(['Edit in Build', () => { const c = artCur.code; closeArt(); curProjectId = null; devHist = [c]; devIdx = 0; devCodeText = c; devSaveProject(); renderDev(); setDevTab('preview'); openDev(); }]); items.push(['Save to Projects', () => { const l = pjLoad(); l.unshift({ id: uid(), name: artCur.title.slice(0, 60), code: artCur.code, updated: Date.now() }); if (pjStore(l)) toast('Saved to Projects'); else toast('Failed to save'); }]); items.push(['Open in new tab', () => openCode(artCur.code, 'html')]); } items.push(['Copy code', async () => toast((await copyText(artCur.code)) ? 'Code copied' : 'Copy failed')]); items.push(['Download', () => artDownload()]); items.push(['Share', async () => { const ext = ART_EXT[artCur.lang] || 'txt'; const f = new File([artCur.code], 'file.' + ext, { type: 'text/plain' }); try { if (navigator.canShare && navigator.canShare({ files: [f] })) { await navigator.share({ files: [f], title: artCur.title }); return; } } catch (err) { if (err && err.name === 'AbortError') return; } artDownload(); }]); const ov = document.createElement('div'); ov.id = 'avMenu'; ov.className = 'av-menu-ov'; const box = document.createElement('div'); box.className = 'av-menu'; items.forEach((it) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = it[0]; b.addEventListener('click', () => { avMenuClose(); try { it[1](); } catch (err) { toast('Error'); } }); box.appendChild(b); }); ov.appendChild(box); ov.addEventListener('click', (ev) => { if (ev.target === ov) avMenuClose(); }); document.getElementById('artv').appendChild(ov); }); /* ===== AI VOICE CHAT (ngobrol pakai suara: dengar -> kirim -> AI jawab -> dibacakan -> dengar lagi) ===== */ let vcActive = false, vcState = 'idle', vcMuted = false, vcRec = null, vcEmpty = 0, vcTok = 0, vcWake = null, vcRetryTimer = null, vcFallbackTimer = null; let vcEl = null, vcStatus = null, vcTxt = null, vcMuteBtn = null; const VC_HINT = '\n\n[Mode voice chat: jawabanmu dibacakan lewat suara. Jawab singkat dan natural seperti ngobrol (1-3 kalimat), tanpa markdown, daftar, kode, atau emoji. Pakai bahasa yang sama dengan pengguna.]'; const VC_LABEL = { listen: 'Mendengarkan...', think: 'Berpikir...', speak: 'Berbicara... (ketuk untuk potong)', paused: 'Ketuk untuk bicara', muted: 'Mic dimatikan', idle: '' }; function vcBuild() { if (vcEl) return; const st = document.createElement('style'); st.textContent = ` .vc{position:fixed;inset:0;z-index:170;display:none;flex-direction:column;align-items:center;background:#0b0b0f;color:#fff;padding:max(24px,env(safe-area-inset-top)) 24px max(28px,env(safe-area-inset-bottom));opacity:0;transition:opacity .24s ease;-webkit-user-select:none;user-select:none;overflow:hidden} .vc.on{display:flex}.vc.show{opacity:1} .vc-top{font-size:14px;font-weight:600;color:rgba(255,255,255,.52);letter-spacing:.3px;padding-top:12px} .vc-stage{flex:1;width:100%;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:30px} .vc-orb{position:relative;width:min(48vw,190px);height:min(48vw,190px);min-width:140px;min-height:140px;max-width:190px;max-height:190px;flex-shrink:0;border-radius:50%;border:0;padding:0;cursor:pointer;background:radial-gradient(circle at 35% 28%,#fff 0%,#c3d2ff 24%,#7297ff 48%,#2563eb 78%,#123ba7 100%);box-shadow:0 0 85px rgba(37,99,235,.32),0 0 28px rgba(169,193,255,.18),inset 0 0 20px rgba(255,255,255,.35);-webkit-tap-highlight-color:transparent;transition:opacity .2s,filter .2s} .vc-orb:after{content:'';position:absolute;inset:10%;border-radius:50%;border:1px solid rgba(255,255,255,.22);box-shadow:inset 0 0 24px rgba(255,255,255,.12)} .vc.s-listen .vc-orb{animation:vcBreath 2.8s ease-in-out infinite} .vc.s-think .vc-orb{animation:vcThink 1.4s ease-in-out infinite;filter:saturate(.7)} .vc.s-speak .vc-orb{animation:vcSpeak .9s ease-in-out infinite} .vc.s-paused .vc-orb,.vc.s-muted .vc-orb{opacity:.52;animation:none} @keyframes vcBreath{0%,100%{transform:scale(1)}50%{transform:scale(1.055)}} @keyframes vcThink{0%,100%{transform:scale(.96);opacity:.8}50%{transform:scale(1.02);opacity:1}} @keyframes vcSpeak{0%,100%{transform:scale(1)}25%{transform:scale(1.07)}50%{transform:scale(.98)}75%{transform:scale(1.045)}} .vc-status{font-size:13px;font-weight:600;letter-spacing:.15px;text-align:center;color:rgba(255,255,255,.58);margin-top:2px} .vc-txt{min-height:42px;max-width:330px;width:100%;max-height:120px;overflow:auto;text-align:center;font-size:clamp(19px,5.7vw,24px);line-height:1.38;font-weight:650;letter-spacing:-.35px;color:#fff;overflow-wrap:anywhere;scrollbar-width:none} .vc-txt:empty{min-height:0} .vc-pill{display:flex;gap:6px;align-items:center;padding:6px;border-radius:44px;background:#1a1a21;border:1px solid rgba(255,255,255,.045);box-shadow:0 12px 36px rgba(0,0,0,.3);flex-shrink:0;margin-bottom:4px} .vc-btn{width:58px;height:58px;border-radius:50%;border:0;padding:0;background:#2a2a33;color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:transform .15s,background .15s} .vc-btn:active{transform:scale(.95)} .vc-btn svg{width:25px;height:25px;display:block} .vc-btn.off{background:#fff;color:#0b0b0f} .vc-btn.end{background:#fff;color:#0b0b0f} .vc-pill-label{min-width:112px;max-width:180px;padding:0 15px;font-size:14px;font-weight:700;text-align:center;white-space:nowrap;color:#fff} .vc-pill-label small{display:block;font-size:11px;font-weight:500;color:rgba(255,255,255,.48);margin-top:3px} html[data-theme="light"] .vc{background:#f8f8fb;color:#111} html[data-theme="light"] .vc-top{color:rgba(0,0,0,.48)} html[data-theme="light"] .vc-txt{color:#15151a} html[data-theme="light"] .vc-status{color:rgba(0,0,0,.55)} html[data-theme="light"] .vc-pill{background:#ededf2;border-color:rgba(0,0,0,.04)} html[data-theme="light"] .vc-btn{background:#dedee5;color:#111} html[data-theme="light"] .vc-btn.off,html[data-theme="light"] .vc-btn.end{background:#17171c;color:#fff} html[data-theme="light"] .vc-pill-label{color:#15151a} html[data-theme="light"] .vc-pill-label small{color:rgba(0,0,0,.48)} @media(max-height:650px){.vc{padding-top:max(12px,env(safe-area-inset-top));padding-bottom:max(16px,env(safe-area-inset-bottom))}.vc-stage{gap:16px}.vc-orb{width:125px;height:125px;min-width:125px;min-height:125px}.vc-txt{font-size:18px;min-height:28px}.vc-btn{width:50px;height:50px}.vc-pill-label{min-width:90px;padding:0 10px}} @media(prefers-reduced-motion:reduce){.vc .vc-orb{animation:none!important;transition:none!important}}`; document.head.appendChild(st); vcEl = document.createElement('div'); vcEl.className = 'vc'; vcEl.innerHTML = '<div class="vc-top">Olmod Voice</div>' + '<div class="vc-stage"><button type="button" class="vc-orb" id="vcOrb" aria-label="Ketuk untuk bicara atau potong"></button>' + '<div class="vc-status" id="vcStatus"></div><div class="vc-txt" id="vcTxt"></div></div>' + '<div class="vc-pill">' + '<button type="button" class="vc-btn" id="vcMute" aria-label="Matikan mic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/><path class="vc-slash" d="M3 3l18 18" style="display:none"/></svg></button>' + '<div class="vc-pill-label" id="vcPillLabel">Mendengarkan<small>Voice Chat aktif</small></div>' + '<button type="button" class="vc-btn end" id="vcEnd" aria-label="Akhiri Voice Chat"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>' + '</div>'; document.body.appendChild(vcEl); vcStatus = vcEl.querySelector('#vcStatus'); vcTxt = vcEl.querySelector('#vcTxt'); vcMuteBtn = vcEl.querySelector('#vcMute'); vcEl.querySelector('#vcOrb').addEventListener('click', vcOrbTap); vcMuteBtn.addEventListener('click', vcToggleMute); vcEl.querySelector('#vcEnd').addEventListener('click', vcEnd); } function vcSet(state) { vcState = state; if (!vcEl) return; vcEl.className = 'vc on show s-' + state; vcStatus.textContent = VC_LABEL[state] || ''; const pillLabel = vcEl.querySelector('#vcPillLabel'); if (pillLabel) { const labels = { listen: 'Mendengarkan', think: 'Sedang berpikir', speak: 'Berbicara', paused: 'Siap bicara', muted: 'Mic dimatikan', idle: 'Voice Chat' }; const subtitles = { listen: 'Bicara kapan saja', think: 'Sebentar ya', speak: 'Ketuk orb untuk memotong', paused: 'Ketuk orb untuk mulai', muted: 'Aktifkan mic untuk bicara', idle: 'Voice Chat' }; pillLabel.innerHTML = (labels[state] || 'Voice Chat') + '<small>' + (subtitles[state] || 'Voice Chat aktif') + '</small>'; } } function vcLang() { return settings.lang === 'id' ? 'id-ID' : settings.lang === 'en' ? 'en-US' : (navigator.language || 'en-US'); } function vcClearTimers() { if (vcRetryTimer) { clearTimeout(vcRetryTimer); vcRetryTimer = null; } if (vcFallbackTimer) { clearTimeout(vcFallbackTimer); vcFallbackTimer = null; } } function vcListen() { if (!vcActive) return; if (vcMuted) { vcSet('muted'); return; } if (isGenerating) return; if (!SR) { toast('Browser tidak mendukung pengenalan suara. Coba Chrome atau Edge terbaru'); vcSet('paused'); return; } vcClearTimers(); try { if (window.speechSynthesis) speechSynthesis.cancel(); } catch (e) {} vcSet('listen'); vcTxt.textContent = ''; let got = ''; let failed = false; const r = new SR(); vcRec = r; r.lang = vcLang(); r.interimResults = true; r.continuous = false; r.onresult = (e) => { let t = ''; for (let i = 0; i < e.results.length; i++) t += e.results[i][0].transcript; got = t; if (vcTxt) vcTxt.textContent = t; }; r.onerror = (e) => { failed = true; if (vcRec !== r) return; vcRec = null; if (e.error === 'not-allowed' || e.error === 'service-not-allowed') { toast(IN_FRAME ? 'Mic diblokir di preview. Buka file ini langsung di Chrome/Safari' : 'Izin mikrofon ditolak. Aktifkan izin mikrofon di pengaturan browser'); vcEnd(); } else if (e.error === 'network') { toast('Pengenalan suara memerlukan koneksi internet. Periksa koneksi lalu ketuk orb untuk mencoba lagi'); vcSet('paused'); } else if (e.error === 'language-not-supported') { toast('Bahasa suara tidak didukung browser ini. Ubah bahasa di Pengaturan Olmod'); vcSet('paused'); } else if (e.error === 'audio-capture') { toast('Mikrofon tidak ditemukan atau sedang dipakai aplikasi lain'); vcSet('paused'); } else if (e.error === 'no-speech' || e.error === 'aborted') { // Diam atau sesi dihentikan: onend akan menangani status/retry. } else { toast('Voice chat mengalami kendala. Ketuk orb untuk mencoba lagi'); vcSet('paused'); } }; r.onend = () => { if (vcRec !== r) return; vcRec = null; if (!vcActive || vcState !== 'listen' || failed) return; const txt = got.trim(); if (txt) { vcEmpty = 0; vcSubmit(txt); return; } vcEmpty++; if (vcEmpty >= 6) { vcSet('paused'); return; } vcRetryTimer = setTimeout(() => { vcRetryTimer = null; if (vcActive && vcState === 'listen' && !vcRec && !vcMuted && !isGenerating) vcListen(); }, 350); }; try { r.start(); } catch (e) { if (vcRec === r) vcRec = null; vcSet('paused'); toast('Mikrofon belum bisa dimulai. Ketuk orb untuk mencoba lagi'); } } function vcSubmit(text) { if (!vcActive || !text || !text.trim()) return; vcClearTimers(); vcSet('think'); vcTxt.textContent = text.trim(); input.value = text.trim(); autoResize(); updateSendState(); try { sendMessage(); } catch (e) { vcSet('paused'); toast('Pesan suara gagal dikirim. Ketuk orb untuk mencoba lagi'); return; } // Fallback hanya jika proses kirim benar-benar tidak mulai; dibatalkan saat AI mulai menjawab. vcFallbackTimer = setTimeout(() => { vcFallbackTimer = null; if (vcActive && vcState === 'think' && !isGenerating) vcSet('paused'); }, 5000); } function vcClean(t) { return String(t || '').replace(/```[\s\S]*?```/g, ' ').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/https?:\/\/\S+/g, ' ').replace(/[`*_#>~|]/g, '').replace(/\s+/g, ' ').trim(); } function vcSpeak(text) { vcClearTimers(); const plain = vcClean(text); if (!plain || !window.speechSynthesis) { vcListen(); return; } stopSpeak(); vcSet('speak'); const my = ++vcTok; const parts = plain.match(/[^.!?…\n]+[.!?…]*/g) || [plain]; const chunks = []; let cur = ''; parts.forEach((p) => { if ((cur + p).length > 180 && cur) { chunks.push(cur.trim()); cur = p; } else cur += p; }); if (cur.trim()) chunks.push(cur.trim()); const lang = settings.lang === 'id' ? 'id-ID' : settings.lang === 'en' ? 'en-US' : (/\b(yang|dan|di|ini|itu|aku|kamu|tidak|untuk|dengan|bisa|sudah)\b/i.test(plain) ? 'id-ID' : 'en-US'); let i = 0; const next = () => { if (my !== vcTok || !vcActive) return; if (i >= chunks.length) { vcListen(); return; } const u = new SpeechSynthesisUtterance(chunks[i++]); u.lang = lang; applyVoice(u); u.onend = next; u.onerror = () => { if (my === vcTok) next(); }; speechSynthesis.speak(u); }; next(); } function vcOrbTap() { if (!vcActive) return; vcClearTimers(); if (vcState === 'speak') { vcTok++; try { speechSynthesis.cancel(); } catch (e) {} vcEmpty = 0; vcListen(); } else if (vcState === 'think') { abortGeneration(); vcEmpty = 0; vcListen(); } else if (vcState === 'paused') { vcEmpty = 0; vcListen(); } } function vcToggleMute() { if (!vcActive) return; vcMuted = !vcMuted; vcMuteBtn.classList.toggle('off', vcMuted); vcMuteBtn.setAttribute('aria-label', vcMuted ? 'Nyalakan mic' : 'Matikan mic'); const sl = vcMuteBtn.querySelector('.vc-slash'); if (sl) sl.style.display = vcMuted ? '' : 'none'; if (vcMuted) { if (vcState === 'listen' || vcState === 'paused') { const r = vcRec; vcRec = null; if (r) { r.onresult = r.onend = r.onerror = null; try { r.abort(); } catch (e) {} } vcSet('muted'); } } else if (vcState === 'muted') { vcEmpty = 0; vcListen(); } } function vcStart() { vcClearTimers(); if (window.isSecureContext === false) return toast('Mic diblokir: halaman ini bukan https. Upload file ke hosting (Netlify/GitHub Pages) lalu buka lewat link https'); if (!SR) return toast(IN_FRAME || IS_TOUCH ? 'Browser/preview ini tidak mendukung suara. Buka di Chrome (Android) atau Safari (iPhone)' : 'Browser ini tidak mendukung voice chat. Pakai Chrome atau Edge'); if (vcActive) return; if (listening) stopVoice(); closePopovers(); stopSpeak(); vcBuild(); vcActive = true; vcMuted = false; vcEmpty = 0; vcMuteBtn.classList.remove('off'); const sl = vcMuteBtn.querySelector('.vc-slash'); if (sl) sl.style.display = 'none'; vcEl.className = 'vc on'; vcSet('listen'); try { if (navigator.wakeLock) navigator.wakeLock.request('screen').then((w) => { if (vcActive) vcWake = w; else w.release().catch(() => {}); }).catch(() => {}); } catch (e) {} vcListen(); } function vcEnd() { vcClearTimers(); if (!vcActive && !(vcEl && vcEl.classList.contains('on'))) return; vcActive = false; vcTok++; const r = vcRec; vcRec = null; if (r) { r.onresult = r.onend = r.onerror = null; try { r.abort(); } catch (e) {} } try { if (window.speechSynthesis) speechSynthesis.cancel(); } catch (e) {} if (isGenerating) abortGeneration(); if (vcWake) { try { vcWake.release(); } catch (e) {} vcWake = null; } vcState = 'idle'; if (vcEl) { vcEl.classList.remove('show'); setTimeout(() => { if (!vcActive && vcEl) vcEl.className = 'vc'; }, 260); } } /* sambungkan ke alur chat yang ada: jawaban AI otomatis dibacakan saat voice chat aktif */ const _vcCommitBot = commitBot; commitBot = function (convId, msg) { _vcCommitBot(convId, msg); if (vcActive && vcState === 'think' && msg && msg.r === 'a' && msg.t && !msg.k) vcSpeak(msg.t); }; const _vcSetGenerating = setGenerating; setGenerating = function (on) { _vcSetGenerating(on); if (on && vcFallbackTimer) { clearTimeout(vcFallbackTimer); vcFallbackTimer = null; } if (!on && vcActive && vcState === 'think') { vcClearTimers(); vcRetryTimer = setTimeout(() => { vcRetryTimer = null; if (vcActive && vcState === 'think' && !isGenerating) vcSet('paused'); }, 1200); } }; </script> </body> </html>