window.onerror = (msg, url, line) => { const banner = document.getElementById('debug-banner'); if (banner) { banner.textContent = `JS Error: ${msg} (line ${line})`; banner.classList.remove('hidden'); } }; // ============================================================ // CONFIG & STATE // ============================================================ const STREAM_API_URL = "https://aqhedgyiworhbfkrzreb.supabase.co/functions/v1/quick-processor"; const TTS_API_URL = "https://aqhedgyiworhbfkrzreb.supabase.co/functions/v1/swift-worker"; const TRANSLITERATE_API_URL = "https://aqhedgyiworhbfkrzreb.supabase.co/functions/v1/quick-task"; const RAZORPAY_KEY_ID = "rzp_test_TZ9SeSSO5G8eCq"; const firebaseConfig = { apiKey: "AIzaSyAChu97QI-tyulpisxH6aECBYlj88DGB1o", authDomain: "arki-ai-280b8.firebaseapp.com", projectId: "arki-ai-280b8", storageBucket: "arki-ai-280b8.firebasestorage.app", messagingSenderId: "1075792140029", appId: "1:1075792140029:web:9f3af7c6b89530c7ee9094", measurementId: "G-PL1YY34MBZ" }; const UI_TRANSLATIONS = { en: { welcomeTitle: "Welcome", welcomeSub: "Start chatting with Orki AI", inputPlaceholder: "Message Orki...", newChat: "New Chat", conversations: "Conversations", dailyQuota: "Daily Quota", disclaimer: "Orki AI can make mistakes. Always double check." }, deva: { welcomeTitle: "बरायबाय", welcomeSub: "Orki AI जों सावरायनायखौ जागायदो", inputPlaceholder: "Orki नो लिरहर...", newChat: "गोदानै सावराय", conversations: "सावरायनायफोर", dailyQuota: "सानसेनि बाहायनाय", disclaimer: "Orki AI आबो गोरोन्थि खालामनो हागौ। खेबसे नायबिजिर फिन।" }, roman: { welcomeTitle: "Boraibai", welcomeSub: "Orki AI jwng saorainaikow jagaidw", inputPlaceholder: "Orki nw lirhor...", newChat: "Gwdanwi Saorai", conversations: "Saorainaifwr", dailyQuota: "Sanseni Bahainai", disclaimer: "Orki AI bw gwrwnti khalamnw hagwu. kebse nai bijir fin." } }; const DAILY_LIMITS = { Guest: 5, Free: 20, Plus: 60, Pro: Infinity }; const MEMORY_DEPTH = { Guest: -4, Free: -6, Plus: -12, Pro: -60 }; const PRICING = { weekly: { plus: 29, pro: 59, plusPaise: 2900, proPaise: 5900, label: '/wk' }, monthly: { plus: 99, pro: 199, plusPaise: 9900, proPaise: 19900, label: '/mo' }, yearly: { plus: 799, pro: 1599, plusPaise: 79900, proPaise: 159900, label: '/yr' } }; const $ = id => document.getElementById(id); const on = (id, event, handler) => { const el = $(id); if (el) el.addEventListener(event, handler); }; const safeGet = (key, fallback) => { try { return localStorage.getItem(key) || fallback; } catch { return fallback; } }; const safeSet = (key, val) => { try { localStorage.setItem(key, val); } catch {} }; const getTodayString = () => new Date().toISOString().split('T')[0]; let currentScript = safeGet('arki_script', 'deva'); let uiLanguage = safeGet('arki_ui_lang', 'deva'); let userPersona = safeGet('arki_persona', ''); let userName = safeGet('arki_username', ''); let currentUser = null, currentPlan = "Free", selectedModel = 'orki-3.0', currentDailyUsage = 0; let abortController = null, isGenerating = false, currentChatId = null; let currentChatMessages = [], selectedCycle = 'monthly', cachedThreads = [], isIncognito = false; let mediaRecorder = null, audioChunks = [], isRecording = false; let isLiveTalkActive = false, liveTalkAudioCtx = null, liveTalkAnalyser = null, liveTalkSilenceTimer = null, liveTalkStream = null; let currentAudio = null, activeTtsBtn = null, ttsAudioCache = new Map(); // Unlock mobile autoplay by priming a silent clip on a real user tap. // Reuses the same currentAudio instance that playBodoSpeech/bindAudioToPlayer // already control, so there's only ever one audio player in the app. function unlockAudio() { if (!currentAudio) currentAudio = new Audio(); currentAudio.src = 'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA'; currentAudio.play().catch(() => {}); } // Synthesized "live talk opening" chime — a soft rising two-note // swell, generated in-browser so it plays instantly with zero // network requests or bundled audio files. function playLiveTalkOpenSound() { try { const AudioCtx = window.AudioContext || window.webkitAudioContext; const ctx = new AudioCtx(); const now = ctx.currentTime; const notes = [ { freq: 523.25, start: 0, dur: 0.16 }, // C5 { freq: 783.99, start: 0.10, dur: 0.22 } // G5 ]; notes.forEach(({ freq, start, dur }) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, now + start); gain.gain.setValueAtTime(0, now + start); gain.gain.linearRampToValueAtTime(0.18, now + start + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, now + start + dur); osc.connect(gain); gain.connect(ctx.destination); osc.start(now + start); osc.stop(now + start + dur + 0.02); }); setTimeout(() => ctx.close().catch(() => {}), 600); } catch (e) { console.warn("Live talk chime unavailable:", e); } } let auth = null, db = null; // ============================================================ // TRANSLITERATION ENGINE // ============================================================ async function cleanBodoText(devanagariText) { try { const res = await fetch(TRANSLITERATE_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: devanagariText }) }); const data = await res.json(); return data.roman || devanagariText; } catch (err) { console.error("Transliteration failed:", err); return devanagariText; } } // ============================================================ // DOM ELEMENTS // ============================================================ const chat = $('chat'), form = $('chat-form'), input = $('prompt'), sendBtn = $('send-btn'), thinking = $('thinking'), micBtn = $('mic-btn'); const liveTalkBtn = $('live-talk-btn'), liveTalkPulse = $('live-talk-pulse'), liveTalkIcon = $('live-talk-icon'); const liveTalkOverlay = $('live-talk-overlay'), liveTalkOrb = $('live-talk-orb'), liveTalkRing = $('live-talk-ring'); const liveTalkStatusPill = $('live-talk-status-pill'), liveTalkTranscript = $('live-talk-transcript'), liveTalkEndBtn = $('live-talk-end-btn'); const menuBtn = $('menu-btn'), drawer = $('drawer'), drawerBackdrop = $('drawer-backdrop'), closeDrawer = $('close-drawer'); const newChatBtn = $('new-chat-btn'), incognitoBtn = $('incognito-btn'), incognitoBadge = $('incognito-badge'), clearAllBtn = $('clear-all-btn'), historyList = $('history-list'); const authActionBtn = $('auth-action-btn'); const authModal = $('auth-modal'), authError = $('auth-error'); const userDisplay = $('user-display'), userPlan = $('user-plan'), upgradeBtn = $('upgrade-btn'), syncStatus = $('sync-status'); const modelSelectBtn = $('model-select-btn'), modelDropdown = $('model-dropdown'), currentModelDisplay = $('current-model-display'), selectOrki = $('select-orki'), selectOkafwr = $('select-okafwr'), okafwrLockIcon = $('okafwr-lock-icon'); const usageCountText = $('usage-count-text'), usageProgressBar = $('usage-progress-bar'); const upgradeModal = $('upgrade-modal'), closeUpgrade = $('close-upgrade'), cycleWeekly = $('cycle-weekly'), cycleMonthly = $('cycle-monthly'), cycleYearly = $('cycle-yearly'); const plusPriceText = $('plus-price'), proPriceText = $('pro-price'), buyPlusBtn = $('buy-plus-btn'), buyProBtn = $('buy-pro-btn'); const openSettingsBtn = $('open-settings'), closeSettingsBtn = $('close-settings'), settingsModal = $('settings-modal'); const optDeva = $('opt-deva'), optRom = $('opt-rom'), optUiDeva = $('opt-ui-deva'), optUiRom = $('opt-ui-rom'), optUiEn = $('opt-ui-en'); const usernameInput = $('username-input'), personaInput = $('persona-input'), saveSettingsBtn = $('save-settings'); const ACTIVE_BTN = "py-2 rounded-lg text-xs font-semibold bg-emerald-600 text-white transition cursor-pointer"; const INACTIVE_BTN = "py-2 rounded-lg text-xs font-medium text-zinc-400 hover:text-white transition cursor-pointer"; const stopIcon = ``; const sendIcon = ``; const trashIcon = ``; // ============================================================ // UI HELPERS // ============================================================ function openAuthModal() { if (authError) authError.classList.add('hidden'); if (authModal) authModal.classList.remove('hidden'); } function applyUILanguage(lang) { const t = UI_TRANSLATIONS[lang] || UI_TRANSLATIONS.en; const set = (id, val) => { const el = $(id); if (el) el.textContent = val; }; const qSet = (sel, val) => { const el = document.querySelector(sel); if (el) el.textContent = val; }; qSet('#welcome-screen h2', t.welcomeTitle); qSet('#welcome-screen p', t.welcomeSub); if (input && currentUser) input.placeholder = t.inputPlaceholder; set('new-chat-label', t.newChat); set('quota-label', t.dailyQuota); set('conversations-label', t.conversations); set('disclaimer-label', t.disclaimer); } function setGeneratingState(generating) { isGenerating = generating; if (!sendBtn) return; if (generating) { sendBtn.classList.replace('bg-emerald-600', 'bg-red-600'); sendBtn.classList.replace('hover:bg-emerald-500', 'hover:bg-red-500'); sendBtn.title = "Stop generating"; sendBtn.innerHTML = stopIcon; } else { sendBtn.classList.replace('bg-red-600', 'bg-emerald-600'); sendBtn.classList.replace('hover:bg-red-500', 'hover:bg-emerald-500'); sendBtn.title = "Send message"; sendBtn.innerHTML = sendIcon; } } function updateUsageUI() { if (!usageCountText || !usageProgressBar) return; const limit = DAILY_LIMITS[currentPlan] || 20; if (limit === Infinity) { usageCountText.textContent = "Unlimited"; usageCountText.className = "text-[10px] font-bold text-amber-400"; usageProgressBar.style.width = "100%"; usageProgressBar.className = "h-full bg-amber-400 rounded-full"; return; } const pct = Math.min(100, Math.round((currentDailyUsage / limit) * 100)); usageCountText.textContent = `${currentDailyUsage} / ${limit}`; usageCountText.className = pct >= 100 ? "text-[10px] font-bold text-red-400" : "text-[10px] font-medium text-zinc-400"; usageProgressBar.style.width = `${pct}%`; usageProgressBar.className = `h-full rounded-full transition-all duration-300 ${pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500'}`; } function updateModelLockUI() { if (!okafwrLockIcon) return; okafwrLockIcon.textContent = currentPlan === 'Pro' ? '✓' : '🔒'; okafwrLockIcon.className = currentPlan === 'Pro' ? 'text-amber-400 text-xs font-bold' : 'text-zinc-500 text-xs'; } function updateIncognitoUI() { if (incognitoBadge) incognitoBadge.classList.toggle('hidden', !isIncognito); if (incognitoBtn) { incognitoBtn.className = isIncognito ? "px-3 py-2 rounded-xl bg-purple-950/60 border border-purple-500 text-purple-300 text-xs font-medium transition cursor-pointer flex items-center gap-1 shrink-0 shadow-sm" : "px-3 py-2 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-purple-300 text-xs font-medium transition cursor-pointer border border-zinc-700/50 flex items-center gap-1 shrink-0"; } } function updatePricingUI() { const p = PRICING[selectedCycle]; if (plusPriceText) plusPriceText.innerHTML = `₹${p.plus}${p.label}`; if (proPriceText) proPriceText.innerHTML = `₹${p.pro}${p.label}`; [cycleWeekly, cycleMonthly, cycleYearly].forEach(btn => { if (btn) { const isActive = selectedCycle === btn.id.replace('cycle-', ''); btn.className = `py-1.5 rounded-lg transition cursor-pointer ${isActive ? 'bg-emerald-600 text-white font-semibold' : 'font-medium text-zinc-400 hover:text-white'}`; } }); } function updateScriptUI() { if (!optDeva || !optRom) return; optDeva.className = currentScript === 'deva' ? ACTIVE_BTN : INACTIVE_BTN; optRom.className = currentScript === 'roman' ? ACTIVE_BTN : INACTIVE_BTN; } function updateUiLangUI() { if (!optUiDeva || !optUiRom || !optUiEn) return; optUiDeva.className = uiLanguage === 'deva' ? ACTIVE_BTN : INACTIVE_BTN; optUiRom.className = uiLanguage === 'roman' ? ACTIVE_BTN : INACTIVE_BTN; optUiEn.className = uiLanguage === 'en' ? ACTIVE_BTN : INACTIVE_BTN; } // ============================================================ // CORE LOGIC & TTS ENGINE // ============================================================ function buildPersonaContext() { return [userName ? `The user's name is ${userName}; address them by name naturally.` : '', userPersona].filter(Boolean).join(' '); } function renderMarkdown(text) { try { if (window.marked && typeof window.marked.parse === 'function') return window.marked.parse(text); } catch {} return text.replace(/\n/g, '
'); } function createWelcomeScreen() { const div = document.createElement('div'); div.id = 'welcome-screen'; div.className = 'flex-1 flex flex-col items-center justify-center select-none text-center'; div.innerHTML = `

Welcome

Start chatting with Orki AI

`; return div; } function startNewChat() { if (isGenerating && abortController) abortController.abort(); currentChatId = null; currentChatMessages = []; if (chat) { chat.innerHTML = ''; chat.appendChild(createWelcomeScreen()); } toggleDrawer(false); renderHistorySidebar(); } function toggleDrawer(open) { if (!drawer || !drawerBackdrop) return; drawer.style.transform = open ? 'translateX(0%)' : 'translateX(-100%)'; drawerBackdrop.classList.toggle('hidden', !open); } function ensureDevanagariForTTS(text) { if (/[\u0900-\u097F]/.test(text)) return text; return text .toLowerCase() .replace(/\bkhulumbai\b/g, "खुलुमबाय") .replace(/\bmabwi\b/g, "माबोरै") .replace(/\bmabrwi\b/g, "माबोरै") .replace(/\bmabwrwi\b/g, "माबोरै") .replace(/\bmwjang\b/g, "मोजां") .replace(/\bmwzang\b/g, "मोजां") .replace(/\bjwmwi\b/g, "जोम्वै") .replace(/\bdong\b/g, "दं") .replace(/\bdonga\b/g, "दङ") .replace(/\bnwng\b/g, "नों") .replace(/\bnwngha\b/g, "नोंहा") .replace(/\bang\b/g, "आं") .replace(/\bangha\b/g, "आंहा") .replace(/\bma\b/g, "मा") .replace(/\bkhobor\b/g, "खबर") .replace(/\bthang\b/g, "थां") .replace(/\bthangnw\b/g, "थांनो") .replace(/\bkhalam\b/g, "खालाम") .replace(/\bkhalamnw\b/g, "खालामनो") .replace(/\bswr\b/g, "सोर") .replace(/\bboha\b/g, "बहा") .replace(/\bfai\b/g, "फै") .replace(/\bphai\b/g, "फै") .replace(/\bonkham\b/g, "ओंखाम") .replace(/\bwngkham\b/g, "ओंखाम") .replace(/kh/g, "ख").replace(/ph/g, "फ").replace(/th/g, "थ") .replace(/ng/g, "ं") .replace(/wi/g, "ुइ").replace(/ai/g, "ै").replace(/ao/g, "ौ") .replace(/jw/g, "जो").replace(/nw/g, "नो").replace(/bw/g, "बो").replace(/w/g, "ु") .replace(/b/g, "ब").replace(/d/g, "द").replace(/g/g, "ग").replace(/h/g, "ह") .replace(/j/g, "ज").replace(/k/g, "क").replace(/l/g, "ल").replace(/m/g, "म") .replace(/n/g, "न").replace(/p/g, "प").replace(/r/g, "र").replace(/s/g, "स") .replace(/t/g, "त").replace(/y/g, "य").replace(/z/g, "ज") .replace(/a/g, "ा").replace(/i/g, "ि").replace(/u/g, "ु").replace(/e/g, "े").replace(/o/g, "ो"); } function cleanTextForTTS(rawText) { const cleaned = rawText .replace(/[#*_`~>\[\]\(\)]/g, '') .replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}]/gu, '') .trim(); return ensureDevanagariForTTS(cleaned); } // ============================================================ // FLOATING AUDIO PILL CONTROLLER // ============================================================ const audioPlayerBar = $('audio-player-bar'); const playerToggleBtn = $('player-toggle-btn'); const playerPlayIcon = $('player-play-icon'); const playerPauseIcon = $('player-pause-icon'); const playerTimer = $('player-timer'); const playerSeeker = $('player-seeker'); const playerCloseBtn = $('player-close-btn'); const playerLoadingIcon = $('player-loading-icon'); function formatAudioTime(seconds) { if (isNaN(seconds)) return "00:00"; const m = Math.floor(seconds / 60).toString().padStart(2, '0'); const s = Math.floor(seconds % 60).toString().padStart(2, '0'); return `${m}:${s}`; } function showPlayerLoading() { if (!audioPlayerBar) return; audioPlayerBar.classList.remove('hidden'); playerPlayIcon?.classList.add('hidden'); playerPauseIcon?.classList.add('hidden'); playerLoadingIcon?.classList.remove('hidden'); if (playerTimer) playerTimer.textContent = "--:--"; if (playerSeeker) { playerSeeker.value = 0; playerSeeker.disabled = true; } if (playerToggleBtn) playerToggleBtn.disabled = true; } function bindAudioToPlayer(audio) { if (!audioPlayerBar) return; audioPlayerBar.classList.remove('hidden'); playerLoadingIcon?.classList.add('hidden'); if (playerSeeker) playerSeeker.disabled = false; if (playerToggleBtn) playerToggleBtn.disabled = false; audio.ontimeupdate = () => { playerTimer.textContent = formatAudioTime(audio.currentTime); if (audio.duration) { playerSeeker.value = (audio.currentTime / audio.duration) * 100; } }; audio.onplay = () => { playerPlayIcon?.classList.add('hidden'); playerPauseIcon?.classList.remove('hidden'); }; audio.onpause = () => { playerPlayIcon?.classList.remove('hidden'); playerPauseIcon?.classList.add('hidden'); }; audio.onended = () => { audioPlayerBar.classList.add('hidden'); if (playerSeeker) playerSeeker.value = 0; if (playerTimer) playerTimer.textContent = "00:00"; if (activeTtsBtn) activeTtsBtn.innerHTML = `🔊 Listen`; currentAudio = null; activeTtsBtn = null; }; } on('player-toggle-btn', 'click', () => { if (!currentAudio) return; if (currentAudio.paused) currentAudio.play(); else currentAudio.pause(); }); if (playerSeeker) { playerSeeker.oninput = () => { if (currentAudio && currentAudio.duration) { currentAudio.currentTime = (playerSeeker.value / 100) * currentAudio.duration; } }; } on('player-close-btn', 'click', () => { if (currentAudio) { currentAudio.pause(); currentAudio.currentTime = 0; currentAudio = null; } if (activeTtsBtn) activeTtsBtn.innerHTML = `🔊 Listen`; activeTtsBtn = null; audioPlayerBar?.classList.add('hidden'); }); async function playBodoSpeech(btn, text) { if (activeTtsBtn === btn && currentAudio && !currentAudio.paused) { currentAudio.pause(); btn.innerHTML = `🔊 Listen`; return; } if (currentAudio) { currentAudio.pause(); if (activeTtsBtn) activeTtsBtn.innerHTML = `🔊 Listen`; } const cleanText = cleanTextForTTS(text); if (!cleanText) return; showPlayerLoading(); if (ttsAudioCache.has(cleanText)) { activeTtsBtn = btn; btn.disabled = false; btn.innerHTML = `⏹ Stop`; if (!currentAudio) currentAudio = new Audio(); currentAudio.src = ttsAudioCache.get(cleanText); bindAudioToPlayer(currentAudio); const cachedBaseOnEnded = currentAudio.onended; currentAudio.onended = () => { cachedBaseOnEnded && cachedBaseOnEnded(); btn.innerHTML = `🔊 Listen`; }; try { await currentAudio.play(); } catch { btn.innerHTML = `⚠️ Retry`; } return; } try { activeTtsBtn = btn; btn.disabled = true; btn.innerHTML = `⏳ Rendering...`; const res = await fetch(TTS_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: cleanText }) }); if (!res.ok) { const errData = await res.json().catch(() => ({})); throw new Error(errData.error || `Server status ${res.status}`); } const data = await res.json(); const audioUrl = data.audioUrl; if (!audioUrl) throw new Error("Audio URL missing from server response"); ttsAudioCache.set(cleanText, audioUrl); btn.disabled = false; btn.innerHTML = `⏹ Stop`; if (!currentAudio) currentAudio = new Audio(); currentAudio.src = audioUrl; bindAudioToPlayer(currentAudio); const freshBaseOnEnded = currentAudio.onended; currentAudio.onended = () => { freshBaseOnEnded && freshBaseOnEnded(); btn.innerHTML = `🔊 Listen`; }; await currentAudio.play(); } catch (err) { console.error("TTS generation error:", err); btn.disabled = false; btn.innerHTML = `⚠️ Retry`; audioPlayerBar?.classList.add('hidden'); alert(`TTS Failed: ${err.message}`); } } function attachTtsButton(bubble, text) { if (!bubble || !text.trim()) return; const actionRow = document.createElement('div'); actionRow.className = "flex items-center gap-2 mt-2 pt-1 border-t border-zinc-700/30"; const ttsBtn = document.createElement('button'); ttsBtn.type = "button"; ttsBtn.className = "text-[11px] font-medium text-zinc-400 hover:text-emerald-400 flex items-center gap-1 transition cursor-pointer"; ttsBtn.innerHTML = `🔊 Listen`; ttsBtn.onclick = () => playBodoSpeech(ttsBtn, text.trim()); actionRow.appendChild(ttsBtn); bubble.appendChild(actionRow); } function createBubbleElement(role) { const wrapper = document.createElement('div'); wrapper.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'}`; const bubble = document.createElement('div'); bubble.className = role === 'user' ? 'bg-emerald-600 text-white rounded-2xl rounded-tr-xs px-4 py-2.5 max-w-[85%] text-[15px] whitespace-pre-wrap leading-relaxed shadow-sm break-words [overflow-wrap:anywhere]' : 'bg-[#242427] border border-zinc-700/40 text-zinc-100 rounded-2xl rounded-tl-xs px-4 py-2.5 max-w-[85%] text-[15px] shadow-sm leading-relaxed prose-custom break-words [overflow-wrap:anywhere]'; wrapper.appendChild(bubble); if (chat) { chat.appendChild(wrapper); chat.scrollTop = chat.scrollHeight; } return bubble; } function appendMessage(role, text) { const bubble = createBubbleElement(role); bubble.innerHTML = role === 'user' ? text : renderMarkdown(text); if (role === 'model') attachTtsButton(bubble, text); if (chat) chat.scrollTop = chat.scrollHeight; } async function processStream(reader, onChunk) { const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('data: ')) { const jsonStr = trimmed.slice(6).trim(); if (!jsonStr) continue; try { const parsed = JSON.parse(jsonStr); const chunk = parsed.candidates?.[0]?.content?.parts?.[0]?.text || ''; if (chunk) onChunk(chunk); } catch {} } } } } async function getAuthToken() { if (!auth || !auth.currentUser) return null; try { return await auth.currentUser.getIdToken(); } catch (e) { console.warn("Failed to get ID token:", e); return null; } } async function persistUsageIncrement() { currentDailyUsage++; updateUsageUI(); const today = getTodayString(); if (currentUser && db) { try { await db.collection("users").doc(currentUser.uid).set({ dailyUsage: { date: today, count: currentDailyUsage } }, { merge: true }); } catch (e) { console.warn("Usage sync error:", e); } } else { safeSet('arki_guest_usage', JSON.stringify({ date: today, count: currentDailyUsage })); } } // ============================================================ // DUAL RESPONSE PARSER (display text vs. clean TTS text) // ============================================================ function parseDualResponse(rawText) { if (!rawText.includes('---TTS---')) { return { displayText: rawText.trim(), ttsText: rawText.trim() }; } const [displayPart, ...ttsParts] = rawText.split('---TTS---'); return { displayText: displayPart.trim(), ttsText: ttsParts.join('---TTS---').trim() }; } // ============================================================ // SEND HANDLERS // ============================================================ async function handleSend() { if (isGenerating) { abortController?.abort(); return; } const limit = DAILY_LIMITS[currentPlan] || 20; if (currentDailyUsage >= limit) { if (!currentUser) { alert(`You've used your ${limit} free guest messages for today. Sign in with Google for a higher daily limit.`); openAuthModal(); } else { alert(`You reached your daily limit of ${limit} messages. Upgrade to get higher limits.`); upgradeModal && upgradeModal.classList.remove('hidden'); } return; } const userText = input ? input.value.trim() : ''; if (!userText) return; const welcome = $('welcome-screen'); if (welcome) welcome.remove(); appendMessage('user', userText); currentChatMessages.push({ role: 'user', text: userText }); input.value = ''; input.style.height = 'auto'; setGeneratingState(true); thinking && thinking.classList.remove('hidden'); const scriptConstraint = currentScript === 'roman' ? `\n[Constraint:1. Provide the reply in natural, colloquial Roman Bodo (using 'w', 'dong', and keeping English words like 'Spanish', 'phone' as-is).2. Follow immediately with '---TTS---' on a new line, then the EXACT same sentence in Bodo Devanagari script (बर' हांखो) for speech synthesis.Example format:Oi jwmwi! Spanish raylainw nagirdwng nama? ma khobor nwngna?---TTS---ओइ जोमै! स्पेनिस रायलायनो नागिरदों नामा? मा खबर नोंना?]` : "\n[Constraint: Reply strictly in Bodo Devanagari script (बर' हांखो). Never output Roman script.]"; const depth = MEMORY_DEPTH[currentPlan] || -6; const apiHistory = currentChatMessages.slice(depth).map((msg, index, arr) => ({ role: msg.role === 'user' ? 'user' : 'model', parts: [{ text: (msg.role === 'user' && index === arr.length - 1) ? msg.text + scriptConstraint : msg.text }] })); abortController = new AbortController(); let fullAiText = '', aiBubble = null; try { const idToken = await getAuthToken(); const headers = { 'Content-Type': 'application/json' }; if (idToken) headers['Authorization'] = `Bearer ${idToken}`; const res = await fetch(STREAM_API_URL, { method: 'POST', headers, signal: abortController.signal, body: JSON.stringify({ plan: currentPlan, model: selectedModel, script: currentScript, userPersona: buildPersonaContext(), contents: apiHistory, generationConfig: { temperature: 0.35, maxOutputTokens: selectedModel === 'okafwr-2.1' ? 4000 : 1500 } }) }); if (!res.ok) { if (res.status === 401) throw new Error("Session expired. Please sign in again."); if (res.status === 429) throw new Error("Daily limit reached. Try again tomorrow or upgrade."); const errData = await res.json().catch(() => null) || {}; throw new Error(typeof errData.error?.message === 'object' ? JSON.stringify(errData.error.message) : (errData.error?.message || errData.error || `HTTP ${res.status}`)); } thinking && thinking.classList.add('hidden'); aiBubble = createBubbleElement('model'); await processStream(res.body.getReader(), (chunk) => { fullAiText += chunk; const { displayText } = parseDualResponse(fullAiText); aiBubble.innerHTML = renderMarkdown(displayText); if (chat) chat.scrollTop = chat.scrollHeight; }); if (fullAiText.trim()) { const { displayText, ttsText } = parseDualResponse(fullAiText); aiBubble.innerHTML = renderMarkdown(displayText); currentChatMessages.push({ role: 'model', text: displayText }); await saveCurrentThreadToCloud(); await persistUsageIncrement(); attachTtsButton(aiBubble, ttsText); } } catch (err) { if (err.name === 'AbortError' && fullAiText.trim()) { const { displayText, ttsText } = parseDualResponse(fullAiText); aiBubble.innerHTML = renderMarkdown(displayText); currentChatMessages.push({ role: 'model', text: displayText }); await saveCurrentThreadToCloud(); await persistUsageIncrement(); attachTtsButton(aiBubble, ttsText); } else { thinking && thinking.classList.add('hidden'); let friendlyMsg = err.message; if (err.message.includes('429')) { friendlyMsg = currentScript === 'roman' ? 'Rate limit hit. 15s nena-nwi fin lwir.' : 'रेत लिमिट थफैबाय। 15 सेकेन्द नेनानै फिन लिर।'; } else if (err.message.includes('high demand') || err.message.includes('503')) { friendlyMsg = currentScript === 'roman' ? 'Server-ao traffic baraydwng. 10s unao fin sonanai nay.' : 'सार्भार आव मानसि बारा जादों। 10 सेकेन्द उनाव फिन नाजा।'; } appendMessage('model', friendlyMsg); } } finally { thinking && thinking.classList.add('hidden'); setGeneratingState(false); abortController = null; input && input.focus(); } } async function handleAudioSend(audioBlob, mimeType, liveMode = false) { if (isGenerating) return; const limit = DAILY_LIMITS[currentPlan] || 20; if (currentDailyUsage >= limit) { if (!currentUser) { alert(`You've used your ${limit} free guest messages for today. Sign in with Google for a higher daily limit.`); openAuthModal(); } else { alert(`You reached your daily limit of ${limit} messages. Upgrade to get higher limits.`); upgradeModal && upgradeModal.classList.remove('hidden'); } return; } const welcome = $('welcome-screen'); if (welcome) welcome.remove(); const userBubble = createBubbleElement('user'); userBubble.textContent = '🎙️ Transcribing...'; currentChatMessages.push({ role: 'user', text: '[Voice Note]' }); setGeneratingState(true); thinking && thinking.classList.remove('hidden'); if (liveMode) setLiveTalkUI('thinking'); const base64Audio = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result.split(',')[1]); reader.onerror = reject; reader.readAsDataURL(audioBlob); }); const scriptConstraint = currentScript === 'roman' ? `Constraint:1. Provide the reply in natural, colloquial Roman Bodo (using 'w', 'dong', and keeping English words like 'Spanish', 'phone' as-is).2. Follow immediately with '---TTS---' on a new line, then the EXACT same sentence in Bodo Devanagari script (बर' हांखो) for speech synthesis.Example format:Oi jwmwi! Spanish raylainw nagirdwng nama? ma khobor nwngna?---TTS---ओइ जोमै! स्पेनिस रायलायनो नागिरदों नामा? मा खबर नोंना?` : "Reply strictly in Bodo Devanagari script (बर' हांखो). Never output Roman script."; const depth = MEMORY_DEPTH[currentPlan] || -6; const apiHistory = currentChatMessages.slice(depth).map(msg => ({ role: msg.role === 'user' ? 'user' : 'model', parts: [{ text: msg.text }] })); apiHistory[apiHistory.length - 1] = { role: 'user', parts: [ { inlineData: { mimeType, data: base64Audio } }, { text: `1. Transcribe accurately.\n2. Formulate conversational reply as Orki.\nConstraint: ${scriptConstraint}\n\nOutput MUST strictly follow:\nTRANSCRIPTION: \nRESPONSE: ` } ] }; abortController = new AbortController(); let fullAiText = '', aiBubble = null; try { const idToken = await getAuthToken(); const headers = { 'Content-Type': 'application/json' }; if (idToken) headers['Authorization'] = `Bearer ${idToken}`; const res = await fetch(STREAM_API_URL, { method: 'POST', headers, signal: abortController.signal, body: JSON.stringify({ plan: currentPlan, model: selectedModel, script: currentScript, userPersona: buildPersonaContext(), contents: apiHistory, generationConfig: { temperature: 0.35, maxOutputTokens: selectedModel === 'okafwr-2.1' ? 4000 : 1500 } }) }); if (!res.ok) { if (res.status === 401) throw new Error("Session expired. Please sign in again."); if (res.status === 429) throw new Error("Daily limit reached. Try again tomorrow or upgrade."); const errData = await res.json().catch(() => null) || {}; throw new Error(typeof errData.error?.message === 'object' ? JSON.stringify(errData.error.message) : (errData.error?.message || errData.error || `HTTP ${res.status}`)); } thinking && thinking.classList.add('hidden'); aiBubble = createBubbleElement('model'); await processStream(res.body.getReader(), (chunk) => { fullAiText += chunk; if (fullAiText.includes('RESPONSE:')) { const parts = fullAiText.split('RESPONSE:'); const transcript = parts[0].replace('TRANSCRIPTION:', '').trim(); const reply = parts.slice(1).join('RESPONSE:').trimStart(); const { displayText } = parseDualResponse(reply); userBubble.textContent = transcript ? `🎙️ "${transcript}"` : '🎙️ [Voice Note]'; aiBubble.innerHTML = renderMarkdown(displayText); if (liveMode && liveTalkTranscript) liveTalkTranscript.textContent = displayText; } else if (fullAiText.includes('TRANSCRIPTION:')) { const liveTranscript = fullAiText.replace('TRANSCRIPTION:', '').trim(); userBubble.textContent = liveTranscript ? `🎙️ "${liveTranscript}..."` : '🎙️ Transcribing...'; if (liveMode && liveTalkTranscript) liveTalkTranscript.textContent = liveTranscript ? `"${liveTranscript}"` : ""; } else { aiBubble.innerHTML = renderMarkdown(fullAiText); } if (chat) chat.scrollTop = chat.scrollHeight; }); if (fullAiText.includes('RESPONSE:')) { const parts = fullAiText.split('RESPONSE:'); const transcript = parts[0].replace('TRANSCRIPTION:', '').trim(); const rawReply = parts.slice(1).join('RESPONSE:').trim(); const { displayText, ttsText } = parseDualResponse(rawReply); aiBubble.innerHTML = renderMarkdown(displayText); currentChatMessages[currentChatMessages.length - 1].text = transcript || '[Voice Note]'; currentChatMessages.push({ role: 'model', text: displayText }); await saveCurrentThreadToCloud(); await persistUsageIncrement(); attachTtsButton(aiBubble, ttsText); if (liveMode) await speakThenResumeLiveTalk(ttsText); } else if (fullAiText.trim()) { const { displayText, ttsText } = parseDualResponse(fullAiText); aiBubble.innerHTML = renderMarkdown(displayText); currentChatMessages.push({ role: 'model', text: displayText }); await saveCurrentThreadToCloud(); await persistUsageIncrement(); attachTtsButton(aiBubble, ttsText); if (liveMode) await speakThenResumeLiveTalk(ttsText); } else if (liveMode && isLiveTalkActive) { // Nothing came back — just listen again startRecording(true); } } catch (err) { if (err.name !== 'AbortError') { thinking && thinking.classList.add('hidden'); userBubble.textContent = '🎙️ [Failed Voice Note]'; appendMessage('model', `Voice processing error: ${err.message}`); } if (liveMode) stopLiveTalk(); } finally { thinking && thinking.classList.add('hidden'); setGeneratingState(false); abortController = null; } } // Plays the TTS reply through the shared player and, once it // finishes, automatically starts listening again (live talk loop). async function speakThenResumeLiveTalk(ttsText) { if (!isLiveTalkActive) return; const cleanText = cleanTextForTTS(ttsText); if (!cleanText) { if (isLiveTalkActive) startRecording(true); return; } setLiveTalkUI('speaking'); try { let audioUrl = ttsAudioCache.get(cleanText); if (!audioUrl) { const res = await fetch(TTS_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: cleanText }) }); if (!res.ok) throw new Error(`Server status ${res.status}`); const data = await res.json(); if (!data.audioUrl) throw new Error("Audio URL missing from server response"); audioUrl = data.audioUrl; ttsAudioCache.set(cleanText, audioUrl); } if (!currentAudio) currentAudio = new Audio(); currentAudio.src = audioUrl; bindAudioToPlayer(currentAudio); currentAudio.onended = () => { audioPlayerBar && audioPlayerBar.classList.add('hidden'); if (isLiveTalkActive) startRecording(true); }; await currentAudio.play(); } catch (err) { console.error("Live talk TTS error:", err); if (isLiveTalkActive) startRecording(true); } } // ============================================================ // AUDIO RECORDING // ============================================================ async function startRecording(liveMode = false) { if (isGenerating) return; // Prime the shared audio player right on the tap so the browser // authorizes autoplay later when the TTS reply comes back. unlockAudio(); try { // Live Talk reuses one persistent stream across turns (acquired in // startLiveTalk, released in stopLiveTalk) to avoid repeatedly // creating/tearing down audio streams, which causes popping and lag // on Android. Regular (non-live) mic taps still request fresh each time. let stream; if (liveMode) { if (!liveTalkStream || !liveTalkStream.getAudioTracks().some(t => t.readyState === 'live')) { liveTalkStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } stream = liveTalkStream; } else { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } mediaRecorder = new MediaRecorder(stream); audioChunks = []; mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.onstop = async () => { stopSilenceWatcher(); const mimeType = mediaRecorder.mimeType.split(';')[0] || 'audio/webm'; const audioBlob = new Blob(audioChunks, { type: mimeType }); if (!liveMode) stream.getTracks().forEach(track => track.stop()); if (audioBlob.size < 800) { if (liveMode) { // Nothing worth sending — just listen again instead of erroring out if (isLiveTalkActive) startRecording(true); } else { alert("Recording too short. Speak and tap mic again when done."); } return; } await handleAudioSend(audioBlob, mimeType, liveMode); }; mediaRecorder.start(); isRecording = true; if (liveMode) { setLiveTalkUI('listening'); startSilenceWatcher(stream); } else { micBtn.classList.replace('bg-zinc-800', 'bg-red-600'); micBtn.classList.replace('text-zinc-300', 'text-white'); micBtn.classList.add('animate-pulse'); } } catch { alert("Microphone permission denied or not supported."); if (liveMode) stopLiveTalk(); } } function stopRecording() { stopSilenceWatcher(); if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop(); isRecording = false; if (micBtn) { micBtn.classList.replace('bg-red-600', 'bg-zinc-800'); micBtn.classList.replace('text-white', 'text-zinc-300'); micBtn.classList.remove('animate-pulse'); } } // ------------------------------------------------------------ // Silence detection: auto-stops the recording ~1.2s after the // person stops talking, so live talk needs no manual mic tap. // ------------------------------------------------------------ function startSilenceWatcher(stream) { try { const AudioCtx = window.AudioContext || window.webkitAudioContext; liveTalkAudioCtx = new AudioCtx(); const source = liveTalkAudioCtx.createMediaStreamSource(stream); liveTalkAnalyser = liveTalkAudioCtx.createAnalyser(); liveTalkAnalyser.fftSize = 512; source.connect(liveTalkAnalyser); const data = new Uint8Array(liveTalkAnalyser.frequencyBinCount); const SILENCE_THRESHOLD = 14; // avg amplitude below this = quiet (slightly higher to ignore soft room hums) const SILENCE_DURATION = 800; // ms of quiet before auto-stop (cut from 1200ms for faster handoffs) const MIN_SPEECH_MS = 600; // ignore silence before the person has said anything let hasHeardSpeech = false; const startedAt = Date.now(); const tick = () => { if (!isRecording || !liveTalkAnalyser) return; liveTalkAnalyser.getByteFrequencyData(data); const avg = data.reduce((a, b) => a + b, 0) / data.length; // React the orb to vocal energy: scales smoothly between 1.0x and ~1.35x if (liveTalkOrb && isRecording) { const scale = Math.min(1.35, 1 + (avg / 60)); liveTalkOrb.style.transform = `scale(${scale.toFixed(2)})`; } if (avg > SILENCE_THRESHOLD) { hasHeardSpeech = true; clearTimeout(liveTalkSilenceTimer); liveTalkSilenceTimer = null; } else if (hasHeardSpeech && !liveTalkSilenceTimer && Date.now() - startedAt > MIN_SPEECH_MS) { liveTalkSilenceTimer = setTimeout(() => { if (isRecording) stopRecording(); }, SILENCE_DURATION); } if (isRecording) requestAnimationFrame(tick); }; requestAnimationFrame(tick); } catch (e) { console.warn("Silence detection unavailable:", e); } } function stopSilenceWatcher() { clearTimeout(liveTalkSilenceTimer); liveTalkSilenceTimer = null; liveTalkAnalyser = null; if (liveTalkOrb) liveTalkOrb.style.transform = ''; if (liveTalkAudioCtx) { liveTalkAudioCtx.close().catch(() => {}); liveTalkAudioCtx = null; } } // ------------------------------------------------------------ // Live Talk mode: tap once to start a hands-free loop of // listen -> send -> play reply -> listen again. // ------------------------------------------------------------ function setLiveTalkUI(state) { if (!liveTalkBtn) return; // Footer button state (kept for consistency if overlay somehow isn't present) if (state === 'listening') { liveTalkBtn.classList.replace('bg-zinc-800', 'bg-red-600'); liveTalkBtn.classList.replace('text-zinc-300', 'text-white'); liveTalkPulse && liveTalkPulse.classList.remove('hidden'); liveTalkBtn.title = "Listening… tap to end Live Talk"; } else if (state === 'thinking' || state === 'speaking') { liveTalkBtn.classList.remove('bg-red-600'); liveTalkBtn.classList.add('bg-amber-500', 'text-white'); liveTalkPulse && liveTalkPulse.classList.add('hidden'); liveTalkBtn.title = state === 'speaking' ? "Orki is speaking… tap to end Live Talk" : "Thinking… tap to end Live Talk"; } else { liveTalkBtn.classList.remove('bg-red-600', 'bg-amber-500', 'text-white'); liveTalkBtn.classList.add('bg-zinc-800', 'text-zinc-300'); liveTalkPulse && liveTalkPulse.classList.add('hidden'); liveTalkBtn.title = "Live Talk"; } // Full-screen overlay orb + status pill if (!liveTalkOrb || !liveTalkStatusPill || !liveTalkRing) return; if (state === 'listening') { liveTalkStatusPill.textContent = "Listening"; liveTalkStatusPill.className = "text-[11px] font-semibold px-3 py-1 rounded-full bg-emerald-500/20 text-emerald-300 transition-colors duration-300"; liveTalkOrb.style.background = "radial-gradient(circle at 35% 30%, #6ee7b7, #10b981 45%, #047857 100%)"; liveTalkOrb.style.boxShadow = "0 0 60px 10px rgba(16,185,129,0.45)"; liveTalkOrb.classList.add('animate-pulse'); liveTalkRing.className = "absolute inset-0 rounded-full border border-emerald-400/40 transition-all duration-500 scale-100"; } else if (state === 'thinking') { liveTalkStatusPill.textContent = "Thinking"; liveTalkStatusPill.className = "text-[11px] font-semibold px-3 py-1 rounded-full bg-amber-500/20 text-amber-300 transition-colors duration-300"; liveTalkOrb.style.background = "radial-gradient(circle at 35% 30%, #fde68a, #f59e0b 45%, #b45309 100%)"; liveTalkOrb.style.boxShadow = "0 0 60px 10px rgba(245,158,11,0.45)"; liveTalkOrb.classList.remove('animate-pulse'); liveTalkRing.className = "absolute inset-0 rounded-full border border-amber-400/40 transition-all duration-500 scale-95 animate-spin"; liveTalkRing.style.animationDuration = "3s"; } else if (state === 'speaking') { liveTalkStatusPill.textContent = "Speaking"; liveTalkStatusPill.className = "text-[11px] font-semibold px-3 py-1 rounded-full bg-sky-500/20 text-sky-300 transition-colors duration-300"; liveTalkOrb.style.background = "radial-gradient(circle at 35% 30%, #bae6fd, #0ea5e9 45%, #075985 100%)"; liveTalkOrb.style.boxShadow = "0 0 70px 14px rgba(14,165,233,0.5)"; liveTalkOrb.classList.remove('animate-pulse'); liveTalkOrb.classList.add('animate-pulse'); liveTalkRing.className = "absolute inset-0 rounded-full border border-sky-400/50 transition-all duration-500 scale-110"; liveTalkRing.style.animationDuration = ""; } } async function startLiveTalk() { isLiveTalkActive = true; playLiveTalkOpenSound(); if (liveTalkOverlay) { liveTalkOverlay.classList.remove('hidden'); liveTalkOverlay.classList.add('flex'); } if (liveTalkTranscript) liveTalkTranscript.textContent = ""; // Acquire the mic stream once for the whole live-talk session. try { liveTalkStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { alert("Microphone permission denied or not supported."); isLiveTalkActive = false; if (liveTalkOverlay) { liveTalkOverlay.classList.add('hidden'); liveTalkOverlay.classList.remove('flex'); } return; } startRecording(true); } function stopLiveTalk() { isLiveTalkActive = false; stopSilenceWatcher(); if (isRecording) stopRecording(); if (currentAudio && !currentAudio.paused) currentAudio.pause(); if (liveTalkStream) { liveTalkStream.getTracks().forEach(t => t.stop()); liveTalkStream = null; } setLiveTalkUI('idle'); if (liveTalkOrb) { liveTalkOrb.classList.remove('animate-pulse'); liveTalkOrb.style.transform = ''; } if (liveTalkRing) liveTalkRing.classList.remove('animate-spin'); if (liveTalkTranscript) liveTalkTranscript.textContent = ""; if (liveTalkOverlay) { liveTalkOverlay.classList.add('hidden'); liveTalkOverlay.classList.remove('flex'); } } on('live-talk-btn', 'click', () => { if (isLiveTalkActive) { stopLiveTalk(); } else { startLiveTalk(); } }); on('live-talk-end-btn', 'click', () => stopLiveTalk()); // ------------------------------------------------------------ // Tap-to-interrupt / force-send: tapping the orb while Orki is // speaking barges in and starts listening immediately; tapping // it while you're speaking force-sends without waiting on the // silence timer. // ------------------------------------------------------------ on('live-talk-orb', 'click', () => { if (!isLiveTalkActive) return; // 1. Interrupt Orki if it's talking if (currentAudio && !currentAudio.paused) { currentAudio.pause(); currentAudio.currentTime = 0; startRecording(true); return; } // 2. Force-send speech immediately instead of waiting for silence timer if (isRecording) { stopRecording(); } }); // ============================================================ // FIRESTORE & HISTORY // ============================================================ async function saveCurrentThreadToCloud() { if (isIncognito || !currentUser || !db || currentChatMessages.length === 0) return; const firstPrompt = currentChatMessages.find(m => m.role === 'user')?.text || "New Conversation"; const threadTitle = firstPrompt.length > 28 ? firstPrompt.slice(0, 28) + '...' : firstPrompt; try { const threadCollection = db.collection("users").doc(currentUser.uid).collection("threads"); if (!currentChatId) { const docRef = await threadCollection.add({ title: threadTitle, messages: currentChatMessages, updatedAt: firebase.firestore.FieldValue.serverTimestamp() }); currentChatId = docRef.id; } else { await threadCollection.doc(currentChatId).set({ title: threadTitle, messages: currentChatMessages, updatedAt: firebase.firestore.FieldValue.serverTimestamp() }, { merge: true }); } await loadUserThreads(currentUser.uid); } catch (e) { console.error("Failed to sync thread:", e); } } async function loadUserThreads(uid) { if (!db) return; try { const snap = await db.collection("users").doc(uid).collection("threads").orderBy("updatedAt", "desc").limit(20).get(); cachedThreads = snap.docs.map(doc => ({ id: doc.id, ...doc.data() })); renderHistorySidebar(); } catch (e) { console.warn("Error fetching threads:", e); } } function renderHistorySidebar() { if (!historyList) return; if (!currentUser) { historyList.innerHTML = `

Sign in to sync history

`; clearAllBtn && clearAllBtn.classList.add('hidden'); return; } if (cachedThreads.length === 0) { historyList.innerHTML = `

No conversations yet

`; clearAllBtn && clearAllBtn.classList.add('hidden'); return; } clearAllBtn && clearAllBtn.classList.remove('hidden'); historyList.innerHTML = ''; cachedThreads.forEach(thread => { const isActive = thread.id === currentChatId; const row = document.createElement('div'); row.className = `group flex items-center justify-between p-1.5 rounded-lg text-[12px] gap-1 transition ${isActive ? 'bg-zinc-800 text-white font-medium' : 'text-zinc-300 hover:bg-zinc-800/60'}`; const btn = document.createElement('button'); btn.type = "button"; btn.className = "flex-1 text-left truncate cursor-pointer py-0.5 px-1"; btn.textContent = thread.title || "Conversation"; btn.onclick = () => openThread(thread); const delBtn = document.createElement('button'); delBtn.type = "button"; delBtn.className = "text-zinc-500 hover:text-red-400 p-1 rounded hover:bg-zinc-700/50 opacity-70 group-hover:opacity-100 transition cursor-pointer shrink-0"; delBtn.title = "Delete conversation"; delBtn.innerHTML = trashIcon; delBtn.onclick = async (e) => { e.stopPropagation(); try { await db.collection("users").doc(currentUser.uid).collection("threads").doc(thread.id).delete(); if (currentChatId === thread.id) startNewChat(); else await loadUserThreads(currentUser.uid); } catch (err) { console.error("Delete failed:", err); } }; row.appendChild(btn); row.appendChild(delBtn); historyList.appendChild(row); }); } function openThread(thread) { if (isGenerating && abortController) abortController.abort(); currentChatId = thread.id; currentChatMessages = Array.isArray(thread.messages) ? [...thread.messages] : []; if (chat) { chat.innerHTML = ''; if (currentChatMessages.length === 0) chat.appendChild(createWelcomeScreen()); else currentChatMessages.forEach(msg => appendMessage(msg.role, msg.text)); } renderHistorySidebar(); toggleDrawer(false); } // ============================================================ // PAYMENT & SETTINGS // ============================================================ function initiatePayment(planTier, amountPaise) { if (!currentUser) { openAuthModal(); return; } upgradeModal && upgradeModal.classList.add('hidden'); const options = { key: RAZORPAY_KEY_ID, amount: amountPaise, currency: "INR", name: "Orki AI", description: `${planTier} Plan (${selectedCycle})`, prefill: { email: currentUser ? currentUser.email : "", contact: "9999999999" }, theme: { color: planTier === "Pro" ? "#f59e0b" : "#059669" }, handler: async function (response) { try { // NOTE: In production, verify razorpay_signature on Supabase server before updating plan const daysToAdd = selectedCycle === 'yearly' ? 365 : selectedCycle === 'monthly' ? 30 : 7; const expiresAt = new Date(Date.now() + daysToAdd * 24 * 60 * 60 * 1000); await db.collection("users").doc(currentUser.uid).set({ plan: planTier, cycle: selectedCycle, expiresAt: expiresAt, updatedAt: firebase.firestore.FieldValue.serverTimestamp() }, { merge: true }); currentPlan = planTier; userPlan.textContent = `Plan: ${planTier}`; userPlan.className = `text-[11px] font-semibold ${planTier === "Pro" ? 'text-amber-400' : 'text-emerald-400'}`; upgradeBtn.textContent = `${planTier} Active`; upgradeBtn.disabled = true; updateModelLockUI(); updateUsageUI(); alert(`Success! You are now subscribed to ${planTier} until ${expiresAt.toLocaleDateString()}.`); } catch (err) { alert("Activation failed: " + err.message); } } }; new Razorpay(options).open(); } // ============================================================ // EVENT BINDINGS // ============================================================ on('mic-btn', 'click', () => { if (isLiveTalkActive) { stopLiveTalk(); return; } isRecording ? stopRecording() : startRecording(); }); on('menu-btn', 'click', () => toggleDrawer(true)); on('close-drawer', 'click', () => toggleDrawer(false)); on('drawer-backdrop', 'click', () => toggleDrawer(false)); on('new-chat-btn', 'click', startNewChat); on('incognito-btn', 'click', () => { isIncognito = !isIncognito; updateIncognitoUI(); startNewChat(); }); on('model-select-btn', 'click', (e) => { e.stopPropagation(); modelDropdown && modelDropdown.classList.toggle('hidden'); }); document.addEventListener('click', () => modelDropdown && !modelDropdown.classList.contains('hidden') && modelDropdown.classList.add('hidden')); on('select-orki', 'click', () => { selectedModel = 'orki-3.0'; if (currentModelDisplay) { currentModelDisplay.textContent = 'Orki 3.0'; currentModelDisplay.className = 'text-zinc-200'; } modelDropdown && modelDropdown.classList.add('hidden'); }); on('select-okafwr', 'click', () => { if (currentPlan !== 'Pro') { modelDropdown && modelDropdown.classList.add('hidden'); upgradeModal && upgradeModal.classList.remove('hidden'); alert("Okafwr 2.1 requires an active Pro subscription."); return; } selectedModel = 'okafwr-2.1'; if (currentModelDisplay) { currentModelDisplay.textContent = 'Okafwr 2.1 ⚡'; currentModelDisplay.className = 'text-amber-400 font-bold'; } modelDropdown && modelDropdown.classList.add('hidden'); }); on('cycle-weekly', 'click', () => { selectedCycle = 'weekly'; updatePricingUI(); }); on('cycle-monthly', 'click', () => { selectedCycle = 'monthly'; updatePricingUI(); }); on('cycle-yearly', 'click', () => { selectedCycle = 'yearly'; updatePricingUI(); }); on('upgrade-btn', 'click', () => { if (!currentUser) { openAuthModal(); return; } upgradeModal && upgradeModal.classList.remove('hidden'); toggleDrawer(false); }); on('close-upgrade', 'click', () => upgradeModal && upgradeModal.classList.add('hidden')); on('buy-plus-btn', 'click', () => initiatePayment("Plus", PRICING[selectedCycle].plusPaise)); on('buy-pro-btn', 'click', () => initiatePayment("Pro", PRICING[selectedCycle].proPaise)); on('open-settings', 'click', () => { toggleDrawer(false); if (personaInput) personaInput.value = userPersona; if (usernameInput) usernameInput.value = userName; updateScriptUI(); updateUiLangUI(); settingsModal && settingsModal.classList.remove('hidden'); }); on('close-settings', 'click', () => settingsModal && settingsModal.classList.add('hidden')); on('opt-deva', 'click', () => { currentScript = 'deva'; updateScriptUI(); }); on('opt-rom', 'click', () => { currentScript = 'roman'; updateScriptUI(); }); on('opt-ui-deva', 'click', () => { uiLanguage = 'deva'; updateUiLangUI(); applyUILanguage(uiLanguage); }); on('opt-ui-rom', 'click', () => { uiLanguage = 'roman'; updateUiLangUI(); applyUILanguage(uiLanguage); }); on('opt-ui-en', 'click', () => { uiLanguage = 'en'; updateUiLangUI(); applyUILanguage(uiLanguage); }); on('save-settings', 'click', () => { userPersona = personaInput ? personaInput.value.trim() : ''; userName = usernameInput ? usernameInput.value.trim() : ''; safeSet('arki_script', currentScript); safeSet('arki_ui_lang', uiLanguage); safeSet('arki_persona', userPersona); safeSet('arki_username', userName); settingsModal && settingsModal.classList.add('hidden'); }); on('clear-all-btn', 'click', async () => { if (!currentUser || !db || !confirm("Clear all conversations?")) return; try { const snap = await db.collection("users").doc(currentUser.uid).collection("threads").get(); await Promise.all(snap.docs.map(d => d.ref.delete())); startNewChat(); await loadUserThreads(currentUser.uid); } catch (err) { console.error("Clear all failed:", err); } }); if (input) { input.addEventListener('input', function () { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 112) + 'px'; }); input.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }); } if (form) { form.onsubmit = (e) => { e.preventDefault(); handleSend(); }; } // ============================================================ // FIREBASE INIT & GOOGLE AUTH // ============================================================ try { if (window.firebase) { firebase.initializeApp(firebaseConfig); auth = firebase.auth(); db = firebase.firestore(); // Catch returning redirect from Google auth.getRedirectResult().then((result) => { if (result && result.user) { if (authModal) authModal.classList.add('hidden'); } }).catch((err) => { console.error("Redirect return error:", err); alert("Login error: " + err.message); }); auth.onAuthStateChanged(async (user) => { currentUser = user; const today = getTodayString(); if (user) { alert("Firebase confirmed login: " + user.email); // <--- DEBUG ALERT if (authModal) authModal.classList.add('hidden'); // <--- FORCE HIDE MODAL userDisplay && (userDisplay.textContent = user.displayName || user.email.split('@')[0]); syncStatus && (syncStatus.textContent = "Synced to Cloud"); authActionBtn && (authActionBtn.textContent = "Logout"); upgradeBtn && upgradeBtn.classList.remove('hidden'); if (input) { input.disabled = false; input.placeholder = "Message Orki..."; } if (sendBtn) sendBtn.disabled = false; if (micBtn) micBtn.disabled = false; if (liveTalkBtn) liveTalkBtn.disabled = false; try { const doc = await db.collection("users").doc(user.uid).get(); if (doc.exists) { const data = doc.data(); const now = new Date(); const expiresAt = data.expiresAt ? data.expiresAt.toDate() : null; if (expiresAt && expiresAt < now) { currentPlan = "Free"; await db.collection("users").doc(user.uid).update({ plan: "Free" }); } else { currentPlan = data.plan || "Free"; } const usageData = data.dailyUsage || {}; if (usageData.date !== today) { currentDailyUsage = 0; await db.collection("users").doc(user.uid).set({ dailyUsage: { date: today, count: 0 } }, { merge: true }); } else { currentDailyUsage = usageData.count || 0; } } else { currentPlan = "Free"; currentDailyUsage = 0; } } catch { currentPlan = "Free"; currentDailyUsage = 0; } if (userPlan) { userPlan.textContent = `Plan: ${currentPlan}`; userPlan.className = `text-[11px] font-semibold ${currentPlan === "Pro" ? 'text-amber-400' : 'text-emerald-400'}`; if (upgradeBtn) { upgradeBtn.textContent = currentPlan === "Pro" ? "Pro Active" : currentPlan === "Plus" ? "Plus Active" : "Upgrade"; upgradeBtn.disabled = currentPlan === "Pro"; } } updateModelLockUI(); updateUsageUI(); loadUserThreads(user.uid); } else { userDisplay && (userDisplay.textContent = "Guest User"); syncStatus && (syncStatus.textContent = "Local Session (not saved)"); authActionBtn && (authActionBtn.textContent = "Login"); if (userPlan) { userPlan.textContent = "Plan: Guest"; userPlan.className = "text-[11px] font-semibold text-zinc-400"; } upgradeBtn && upgradeBtn.classList.add('hidden'); currentPlan = "Guest"; selectedModel = 'orki-3.0'; if (currentModelDisplay) { currentModelDisplay.textContent = 'Orki 3.0'; currentModelDisplay.className = 'text-zinc-200'; } // Guest quota lives in localStorage only — no cloud sync, resets daily try { const guestUsage = JSON.parse(safeGet('arki_guest_usage', 'null')) || {}; currentDailyUsage = guestUsage.date === today ? (guestUsage.count || 0) : 0; } catch { currentDailyUsage = 0; } updateModelLockUI(); updateUsageUI(); cachedThreads = []; // Guests can chat freely — only cloud sync/history/upgrades require sign-in if (input) { input.disabled = false; input.placeholder = "Message Orki... (sign in to save history)"; } if (sendBtn) sendBtn.disabled = false; if (micBtn) micBtn.disabled = false; if (liveTalkBtn) liveTalkBtn.disabled = false; renderHistorySidebar(); } }); } } catch (e) { console.warn("Firebase restricted:", e); } on('auth-action-btn', 'click', () => { if (currentUser && auth) { auth.signOut(); } else { openAuthModal(); toggleDrawer(false); } }); on('auth-close-btn', 'click', () => authModal && authModal.classList.add('hidden')); // Google Auth Handler (Redirect mode for mobile reliability) on('auth-google-btn', 'click', async () => { if (!auth) { alert("Firebase not ready. Please wait a second and try again."); return; } const provider = new firebase.auth.GoogleAuthProvider(); provider.setCustomParameters({ prompt: 'select_account' }); try { // Navigates the entire browser tab cleanly — no dropped popups await auth.signInWithRedirect(provider); } catch (err) { alert("Redirect trigger error: " + err.message); } }); // ============================================================ // INIT // ============================================================ // Guests can chat right away — Firebase auth state resolves shortly // after and will upgrade the UI to a signed-in view if applicable. currentPlan = "Guest"; try { const guestUsage = JSON.parse(safeGet('arki_guest_usage', 'null')) || {}; currentDailyUsage = guestUsage.date === getTodayString() ? (guestUsage.count || 0) : 0; } catch { currentDailyUsage = 0; } if (input) { input.disabled = false; input.placeholder = "Message Orki... (sign in to save history)"; } if (sendBtn) sendBtn.disabled = false; if (micBtn) micBtn.disabled = false; if (liveTalkBtn) liveTalkBtn.disabled = false; updateScriptUI(); updateUiLangUI(); updatePricingUI(); updateUsageUI(); updateIncognitoUI(); applyUILanguage(uiLanguage); // ============================================================ // EDGE FUNCTION WARM-UP PING // ============================================================ function warmUpEdgeFunctions() { const targets = [STREAM_API_URL, TTS_API_URL]; targets.forEach(url => { fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ping: true, warmup: true }) }).catch(() => {}); // Catch silently; all we care about is forcing Deno to spin up }); } // 1. Wake them up immediately on page load warmUpEdgeFunctions(); // 2. Keep containers warm every 4 minutes while the tab is open setInterval(warmUpEdgeFunctions, 4 * 60 * 1000);