Spaces:
Running
Running
Download app.js from Devmightwin/Orki: direct link, hf CLI and curl.
- Browser
- Download file 66.8 kB
-
https://huggingface.co/spaces/Devmightwin/Orki/resolve/main/app.js
- Command line
-
hf download hf://spaces/Devmightwin/Orki/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Devmightwin/Orki/resolve/main/app.js
66.8 kB
| 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 pointer-events-none"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`; | |
| const sendIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 transform rotate-[-45deg] translate-x-0.5 -translate-y-0.5 pointer-events-none"><path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z"/></svg>`; | |
| const trashIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>`; | |
| // ============================================================ | |
| // 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}<span class="text-xs text-zinc-500 font-normal">${p.label}</span>`; | |
| if (proPriceText) proPriceText.innerHTML = `₹${p.pro}<span class="text-xs text-zinc-500 font-normal">${p.label}</span>`; | |
| [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, '<br>'); | |
| } | |
| 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 = `<h2 class="text-emerald-400 font-semibold text-xl tracking-wide">Welcome</h2><p class="text-xs text-zinc-500 mt-1">Start chatting with Orki AI</p>`; | |
| 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 = `<span class="animate-pulse">⏳ Rendering...</span>`; | |
| 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: <exact words>\nRESPONSE: <reply>` } | |
| ] | |
| }; | |
| 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 = `<p class="text-zinc-500 px-2 py-2 text-[11px]">Sign in to sync history</p>`; | |
| clearAllBtn && clearAllBtn.classList.add('hidden'); | |
| return; | |
| } | |
| if (cachedThreads.length === 0) { | |
| historyList.innerHTML = `<p class="text-zinc-500 px-2 py-2 text-[11px]">No conversations yet</p>`; | |
| 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); |