Orki / app.js
Devmightwin's picture
Update app.js
956fe44 verified
Raw History Blame Contribute Delete
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);