UNPKG

ttc-ai-client

Version:

TypeScript client sdk for TTC AI services with decorators and schema validation.

1,118 lines (1,116 loc) 47.9 kB
"use strict"; /* TTC Floating Chat Widget Public initializer: initTTCChatWidget(options) Responsibilities: 1. Inject styles (if not already present) 2. Render floating button + chat panel skeleton 3. On first open: check localStorage for auth token - If absent: request authorization URL from backend via provided callbacks - Redirect user to authorize (OAuth) and handle callback params (code,state) - Exchange code for token, store, initialize chat 4. Provide simple send message -> display streaming / final response 5. Expose minimal API for extension (onMessage, destroy, open, close) This is framework-agnostic (vanilla TS/JS) so it can be dropped into any site. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.initTTCChatWidget = void 0; const ttc_server_1 = require("../ttc_server"); const core_1 = require("../core"); const styles_1 = require("./styles"); const integration_1 = require("../calls/integration"); const botRenderer_1 = require("./botRenderer"); const happyAnimations_1 = require("./happyAnimations"); const sadAnimations_1 = require("./sadAnimations"); const DEFAULT_STORAGE_KEY = 'ttc_chat_token_v1'; const DEFAULT_CHAT_ID_KEY = 'ttc_chat_id_v1'; const DEFAULT_EMOTE_KEY = 'ttc_bot_emote_v1'; function injectStyles() { if (document.getElementById('ttc-widget-styles')) return; const style = document.createElement('style'); style.id = 'ttc-widget-styles'; style.textContent = styles_1.widgetStyles; document.head.appendChild(style); } function createEl(tag, cls, html) { const el = document.createElement(tag); if (cls) el.className = cls; if (html) el.innerHTML = html; return el; } // Helper function to convert hex to darker shade for hover function darkenColor(color, amount = 0.1) { // Simple darkening for hex colors if (color.startsWith('#')) { const num = parseInt(color.replace('#', ''), 16); const amt = Math.round(2.55 * amount * 100); const R = (num >> 16) - amt; const G = (num >> 8 & 0x00FF) - amt; const B = (num & 0x0000FF) - amt; return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + (B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1); } return color; // Return original if not hex } // Apply complete theme configuration function applyTheme(theme) { const root = document.documentElement; // Primary colors if (theme.primary) { root.style.setProperty('--ttc-primary', theme.primary); root.style.setProperty('--ttc-primary-hover', theme.primaryHover || darkenColor(theme.primary)); } if (theme.primaryHover) { root.style.setProperty('--ttc-primary-hover', theme.primaryHover); } // Background colors if (theme.background) root.style.setProperty('--ttc-bg', theme.background); if (theme.messagesBackground) root.style.setProperty('--ttc-messages-bg', theme.messagesBackground); if (theme.headerBackground) root.style.setProperty('--ttc-header-bg', theme.headerBackground); if (theme.footerBackground) root.style.setProperty('--ttc-footer-bg', theme.footerBackground); // Text colors if (theme.textColor) root.style.setProperty('--ttc-text', theme.textColor); if (theme.headerTextColor) root.style.setProperty('--ttc-header-text', theme.headerTextColor); if (theme.userBubbleColor) root.style.setProperty('--ttc-user-bubble', theme.userBubbleColor); if (theme.aiBubbleColor) root.style.setProperty('--ttc-ai-bubble', theme.aiBubbleColor); if (theme.userBubbleTextColor) root.style.setProperty('--ttc-user-bubble-text', theme.userBubbleTextColor); if (theme.aiBubbleTextColor) root.style.setProperty('--ttc-ai-bubble-text', theme.aiBubbleTextColor); if (theme.statusTextColor) root.style.setProperty('--ttc-status-text', theme.statusTextColor); // Border and UI colors if (theme.borderColor) root.style.setProperty('--ttc-border', theme.borderColor); if (theme.scrollbarColor) root.style.setProperty('--ttc-scrollbar', theme.scrollbarColor); if (theme.focusColor) root.style.setProperty('--ttc-focus', theme.focusColor); // Fonts if (theme.fontFamily) root.style.setProperty('--ttc-font', theme.fontFamily); // Bot specific colors if (theme.botBorderColor) root.style.setProperty('--ttc-bot-border', theme.botBorderColor); if (theme.botBackgroundColor) root.style.setProperty('--ttc-bot-bg', theme.botBackgroundColor); if (theme.botEyeColor) root.style.setProperty('--ttc-bot-eye', theme.botEyeColor); if (theme.botAntennaColor) root.style.setProperty('--ttc-bot-antenna', theme.botAntennaColor); } function initTTCChatWidget(options) { const mergedUI = { title: 'Chat', placeholder: 'Type a message...', openLabel: 'Chat', ...(options.ui || {}) }; const opts = { storageKey: DEFAULT_STORAGE_KEY, chatIdStorageKey: DEFAULT_CHAT_ID_KEY, emoteStorageKey: DEFAULT_EMOTE_KEY, ...options, ui: mergedUI }; // Check if widget instance already exists to prevent duplicates during hot reloading const existingWidget = document.querySelector('.ttc-bot-container'); if (existingWidget) { console.log('TTC Chat Widget instance already exists, returning existing instance'); // Return a mock API object to maintain compatibility return { open: () => { const panel = document.querySelector('.ttc-chat-panel'); if (panel && panel.classList.contains('ttc-hidden')) { panel.classList.remove('ttc-hidden'); setTimeout(() => panel.classList.add('ttc-panel-visible'), 10); } }, close: () => { const panel = document.querySelector('.ttc-chat-panel'); if (panel && !panel.classList.contains('ttc-hidden')) { panel.classList.remove('ttc-panel-visible'); setTimeout(() => panel.classList.add('ttc-hidden'), 400); } }, destroy: () => { const botContainer = document.querySelector('.ttc-bot-container'); const panel = document.querySelector('.ttc-chat-panel'); botContainer?.remove(); panel?.remove(); }, send: (text) => { const input = document.querySelector('.ttc-input'); const form = document.querySelector('.ttc-chat-form'); if (input && form) { input.value = text; form.requestSubmit(); } }, getToken: () => localStorage.getItem(opts.storageKey), getChatId: () => localStorage.getItem(opts.chatIdStorageKey), isInitialized: () => true, newChat: () => { const messages = document.querySelector('[data-messages]'); if (messages) { messages.innerHTML = ''; } localStorage.removeItem(opts.chatIdStorageKey); }, setEmote: (emote) => { localStorage.setItem(opts.emoteStorageKey, emote); }, getEmote: () => localStorage.getItem(opts.emoteStorageKey), Play: () => { } // No-op for existing instance }; } injectStyles(); const state = {}; // Simple call integration setup function initializeCallIntegration() { const existingToken = localStorage.getItem(opts.storageKey); const existingChatId = loadChatId(); const conversationId = options.conversationId || existingChatId; if (!existingToken || !conversationId) { console.warn('Cannot initialize calls - missing token or conversationId'); return; } state.callIntegration = new integration_1.CallIntegration({ conversationId, token: existingToken, serverUrl: 'wss://audio-api.tentarclesai.com', onUserMessage: (text) => { // Add user message to chat addMessage('user', text); setStatus('Thinking...'); state.isWaitingForResponse = true; // Start thinking animation if (state.botRenderer) { state.botRenderer.playThinking(); } // Send message to TTC (response will come via ttc.subscribe('message')) try { if (!state.chatId) { state.chatId = loadChatId(); } if (!state.chatId && options.conversationId) { // Create new chat if needed core_1.ttc.server.ttcCore.createChat(options.conversationId).then(res => { if (res?.status === 'success') { saveChatId(res.data.chatId); state.chatId = res.data.chatId; // Send the message after creating chat core_1.ttc.server.ttcCore.chatAI(state.chatId, text); } }); } else if (state.chatId) { // Send message to existing chat core_1.ttc.server.ttcCore.chatAI(state.chatId, text); } } catch (error) { console.error('Error sending voice message:', error); setStatus('Error sending message'); state.isWaitingForResponse = false; if (state.botRenderer) { state.botRenderer.stop(); } } }, onCallStart: () => { state.isInCall = true; core_1.ttc.ai.on_call = true; button.classList.add('ttc-call-active'); animationControls.startCall(); }, onCallEnd: () => { state.isInCall = false; core_1.ttc.ai.on_call = false; button.classList.remove('ttc-call-active'); animationControls.stopCall(); }, onError: (error) => { console.error('Call error:', error); setStatus(`Call error: ${error}`); }, onStatusChange: (status) => { setStatus(status); } }); } // Simple call functions async function startCall() { if (!state.callIntegration) { initializeCallIntegration(); } if (!state.callIntegration) { setStatus('Voice calls unavailable'); return; } if (state.isInCall) { endCall(); return; } // Collapse the chat panel if it's open if (!panel.classList.contains('ttc-hidden')) { togglePanel(); } state.callIntegration.startCall(); } function endCall() { if (state.callIntegration) { state.callIntegration.endCall(); } } // Helper functions for chatId persistence function loadChatId() { try { if (options.conversationId) { return options.conversationId; } return localStorage.getItem(opts.chatIdStorageKey); } catch { return null; } } function saveChatId(chatId) { try { localStorage.setItem(opts.chatIdStorageKey, chatId); state.chatId = chatId; } catch (error) { console.warn('Failed to save chatId to localStorage:', error); state.chatId = chatId; // Still set in memory } } function clearChatId() { try { localStorage.removeItem(opts.chatIdStorageKey); } catch (error) { console.warn('Failed to clear chatId from localStorage:', error); } state.chatId = undefined; } // Helper functions for emote persistence function loadEmote() { try { const stored = localStorage.getItem(opts.emoteStorageKey); return stored || null; } catch { return null; } } function saveEmote(emote) { try { localStorage.setItem(opts.emoteStorageKey, emote); state.emote = emote; } catch (error) { console.warn('Failed to save emote to localStorage:', error); state.emote = emote; // Still set in memory } } function clearEmote() { try { localStorage.removeItem(opts.emoteStorageKey); } catch (error) { console.warn('Failed to clear emote from localStorage:', error); } state.emote = undefined; } // Initialize unauthenticated RPC client for OAuth operations async function initUnauthenticatedRPC() { if (!state.unauthenticatedRpc) { // Create RPC client with API key from callback if available, otherwise empty const apiKey = opts.getApiKey ? await opts.getApiKey() : ''; const api_url = 'https://api.tentarclesai.com'; // Replace with your API base URL state.unauthenticatedRpc = new ttc_server_1.RPCClient(api_url, async () => apiKey); } return state.unauthenticatedRpc; } // DOM Structure const botContainer = createEl('div', 'ttc-bot-container'); const button = createEl('button', 'ttc-floating-bot'); button.title = opts.ui.openLabel; // Create SVG bot using BotRenderer // Start with default shape, will be updated when emote is fetched from server const botShape = 'hexagonal'; // Default shape while loading const primaryColor = opts.ui?.theme?.primary || '#565656ff'; const bodyColor = opts.ui?.theme?.botBackgroundColor || '#ffffff'; const borderColor = opts.ui?.theme?.botBorderColor || '#111827'; const eyeColor = opts.ui?.theme?.botEyeColor || '#111827'; const botRenderer = new botRenderer_1.BotRenderer(button, { shape: botShape, size: 60, primaryColor: primaryColor, bodyColor: bodyColor, borderColor: borderColor, eyeColor: eyeColor }); state.botRenderer = botRenderer; // Add call icon const callIcon = createEl('div', 'ttc-call-icon'); button.appendChild(callIcon); botContainer.appendChild(button); const panel = createEl('div', 'ttc-chat-panel ttc-hidden'); panel.innerHTML = ` <div class="ttc-chat-header"> <h4>${opts.ui.title}</h4> <div class="ttc-chat-actions"> <button class="ttc-chat-clear" data-tooltip="Clear chat history" data-clear> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M3 6h18"></path> <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path> <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path> </svg> </button> <button class="ttc-chat-reset" data-tooltip="Reset conversation" data-reset> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path> <path d="M21 3v5h-5"></path> <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path> <path d="M8 16H3v5"></path> </svg> </button> <div class="ttc-chat-close" data-tooltip="Close chat" data-close>&times;</div> </div> </div> <div class="ttc-chat-messages" data-messages></div> <div class="ttc-chat-footer"> <div class="ttc-chat-status" data-status></div> <form class="ttc-chat-form" data-form> <input class="ttc-input" data-input type="text" placeholder="${opts.ui.placeholder}" autocomplete="off" /> </form> </div>`; document.body.appendChild(botContainer); document.body.appendChild(panel); const messagesEl = panel.querySelector('[data-messages]'); const formEl = panel.querySelector('[data-form]'); const inputEl = panel.querySelector('[data-input]'); const closeEl = panel.querySelector('[data-close]'); const clearEl = panel.querySelector('[data-clear]'); const resetEl = panel.querySelector('[data-reset]'); const statusEl = panel.querySelector('[data-status]'); // Apply theme color configuration if (opts.ui?.theme) { applyTheme(opts.ui.theme); } else if (opts.ui?.themeColor) { // Legacy support for themeColor applyTheme({ primary: opts.ui.themeColor }); } // Utility to show message with typewriter effect function addMessage(role, content, animate = true) { const wrap = createEl('div', `ttc-msg ${role === 'user' ? 'ttc-msg-user' : ''}`); const bubble = createEl('div', `ttc-bubble ${role === 'user' ? 'ttc-bubble-user' : 'ttc-bubble-ai'}`); wrap.appendChild(bubble); // Add message to the beginning for bottom-anchored chat messagesEl.insertBefore(wrap, messagesEl.firstChild); // For user messages or when animation is disabled, show immediately if (role === 'user' || !animate) { bubble.textContent = content; // Scroll to top to show the new message messagesEl.scrollTop = 0; opts.onMessage?.({ role, content }); return; } // Typewriter effect for assistant messages let charIndex = 0; const typingSpeed = opts.ui?.typingSpeed || 30; // milliseconds per character bubble.textContent = ''; // Start happy animation when typewriter begins Play('happy'); const typeWriter = () => { if (charIndex < content.length) { bubble.textContent += content.charAt(charIndex); charIndex++; // Scroll to top to show the typing message messagesEl.scrollTop = 0; // Add a small pause at punctuation marks for more natural feel const currentChar = content.charAt(charIndex - 1); const nextChar = charIndex < content.length ? content.charAt(charIndex) : ''; let delay = typingSpeed; if (currentChar === '.' || currentChar === '!' || currentChar === '?') { delay = typingSpeed * 3; // Longer pause after sentences } else if (currentChar === ',' || currentChar === ';') { delay = typingSpeed * 2; // Medium pause after commas } setTimeout(typeWriter, delay); } else { // Animation complete, stop happy animation and return to idle Play('idle'); opts.onMessage?.({ role, content }); } }; // Start the typewriter animation setTimeout(typeWriter, 100); // Small initial delay } function setStatus(msg) { if (statusEl) { statusEl.textContent = msg; } } function showAuthPrompt() { setStatus('Authentication required'); messagesEl.innerHTML = ` <div style="text-align: center; padding: 20px;"> <p style="margin-bottom: 16px; color: #6b7280;">Please authenticate to start chatting</p> <button class="ttc-auth-btn" style=" background: var(--ttc-primary); color: white; border: none; padding: 10px 20px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; ">Connect Account</button> </div> `; const authBtn = messagesEl.querySelector('.ttc-auth-btn'); authBtn.addEventListener('click', () => { bootstrapAuthFlow().catch(err => { console.error(err); opts.onInitError?.(err); }); }); } function showWaitingForAuth() { setStatus('Waiting for authentication...'); messagesEl.innerHTML = ` <div style="text-align: center; padding: 20px;"> <p style="margin-bottom: 16px; color: #6b7280;">Complete authentication in the new tab</p> <button class="ttc-check-auth-btn" style=" background: var(--ttc-primary); color: white; border: none; padding: 10px 20px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; ">Check Authentication</button> <p style="margin-top: 12px; font-size: 12px; color: #9ca3af;">Click after completing authentication in the other tab</p> </div> `; const checkBtn = messagesEl.querySelector('.ttc-check-auth-btn'); checkBtn.addEventListener('click', () => { bootstrapAuthFlow().catch(err => { console.error(err); opts.onInitError?.(err); }); }); } function togglePanel() { const isHidden = panel.classList.contains('ttc-hidden'); if (isHidden) { // Opening the panel panel.classList.remove('ttc-hidden'); // Small delay to ensure DOM is ready setTimeout(() => { panel.classList.add('ttc-panel-visible'); botContainer.classList.add('ttc-bot-moved'); }, 10); // Handle authentication state if (!state.token) { // Check if this is an OAuth callback first (support both standard and ttc_ prefixed params) const url = new URL(window.location.href); const code = url.searchParams.get('code') || url.searchParams.get('ttc_code'); if (code) { // This is a callback, process it bootstrapAuthFlow().catch(err => { console.error(err); opts.onInitError?.(err); }); } else { // No callback, show auth prompt showAuthPrompt(); } } } else { // Closing the panel panel.classList.remove('ttc-panel-visible'); botContainer.classList.remove('ttc-bot-moved'); // Wait for animation to complete before hiding setTimeout(() => { panel.classList.add('ttc-hidden'); }, 400); } } // Floating button behavior: open panel if closed, else trigger call action button.addEventListener('click', (e) => { const isHidden = panel.classList.contains('ttc-hidden'); if (isHidden) { togglePanel(); } else { // If already in call, end it; otherwise start call if (state.isInCall) { endCall(); } else { startCall(); } } }); closeEl.addEventListener('click', togglePanel); clearEl.addEventListener('click', async () => { if (!state.chatId || !state.token) return; try { setStatus('Clearing chat history...'); await core_1.ttc.server.ttcCore.clearChatHistory(state.chatId, 'soft'); messagesEl.innerHTML = ''; setStatus('Chat history cleared'); setTimeout(() => setStatus('Ready'), 2000); } catch (error) { console.error('Failed to clear chat history:', error); setStatus('Failed to clear chat history'); setTimeout(() => setStatus('Ready'), 2000); } }); resetEl.addEventListener('click', async () => { if (!state.chatId || !state.token) return; if (!confirm('This will permanently delete the entire conversation. Are you sure?')) return; try { setStatus('Resetting conversation...'); await core_1.ttc.server.ttcCore.clearChatHistory(state.chatId, 'hard'); clearChatId(); messagesEl.innerHTML = ''; setStatus('Conversation reset'); setTimeout(() => setStatus('Ready'), 2000); } catch (error) { console.error('Failed to reset conversation:', error); setStatus('Failed to reset conversation'); setTimeout(() => setStatus('Ready'), 2000); } }); formEl.addEventListener('submit', async (e) => { e.preventDefault(); const text = inputEl.value.trim(); if (!text || !state.token) return; inputEl.value = ''; addMessage('user', text); setStatus('Thinking...'); state.isWaitingForResponse = true; // Start thinking animation if (state.botRenderer) { state.botRenderer.playThinking(); } try { // Use TTC core for chat functionality if (!state.isInitialized) throw new Error('TTC not initialized'); // Load existing chatId or create a new one if (!state.chatId) { state.chatId = loadChatId(); } if (!state.chatId) { const res = await core_1.ttc.server.ttcCore.createChat(options.conversationId); if (res?.status === 'success') { const newChatId = res.data?.id || res.data?.chatId || res.data?.chat_id; if (newChatId) { saveChatId(newChatId); } } } if (!state.chatId) throw new Error('Chat not created'); // Send message using TTC core await core_1.ttc.server.ttcCore.chatAI(state.chatId, text); } catch (err) { // Stop thinking animation on error if (state.botRenderer) { state.botRenderer.stop(); } addMessage('assistant', 'Error sending message', false); // Show error instantly setStatus(err.message || 'Error'); state.isWaitingForResponse = false; } }); // Add keyboard shortcut to end call (Escape key) document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && state.isInCall) { endCall(); } }); // Animation controls for the bot (BotRenderer handles all animation) const animationControls = { startCall: () => { if (state.botRenderer) { state.botRenderer.startCall(); } }, stopCall: () => { if (state.botRenderer) { state.botRenderer.stopCall(); } } }; // Mood-based animation system (simplified for BotRenderer) let currentMood = 'idle'; // Play function for mood-based animations function Play(mood, animationName) { currentMood = mood; if (mood === 'idle') { // Stop any current mood animation if (state.botRenderer) { state.botRenderer.stop(); } return; } if (!state.botRenderer) return; let selectedAnimation; if (animationName) { // Find animation by name if (mood === 'happy') { selectedAnimation = (0, happyAnimations_1.getHappyAnimation)(animationName); } else if (mood === 'sad') { selectedAnimation = (0, sadAnimations_1.getSadAnimation)(animationName); } } else { // Get random animation for the mood if (mood === 'happy') { selectedAnimation = (0, happyAnimations_1.getRandomHappyAnimation)(); } else if (mood === 'sad') { selectedAnimation = (0, sadAnimations_1.getRandomSadAnimation)(); } } if (selectedAnimation) { state.botRenderer.play(selectedAnimation); } } // AUTH FLOW ------------------------------------------------------ async function bootstrapAuthFlow() { setStatus('Checking authentication...'); const existing = localStorage.getItem(opts.storageKey); if (existing) { state.token = existing; await initTTC(); setStatus('Ready'); messagesEl.innerHTML = ''; // Clear auth prompt // Reinitialize call manager with loaded token console.log('Reinitializing call manager with existing token...'); reinitializeCallManager(); return; } // Check if this is a callback URL with code/state (support both standard and ttc_ prefixed params) const url = new URL(window.location.href); const code = url.searchParams.get('code') || url.searchParams.get('ttc_code'); const stateParam = url.searchParams.get('state') || url.searchParams.get('ttc_state'); if (code) { setStatus('Exchanging code...'); try { const redirectUri = opts.redirectUri || window.location.origin + window.location.pathname; let tokenResp; if (opts.authorization?.exchangeCode) { tokenResp = await opts.authorization.exchangeCode(code, opts.appId, redirectUri); } else { // Use unauthenticated RPC method const unauthRpc = await initUnauthenticatedRPC(); tokenResp = await unauthRpc.AppOAuth.exchangeCodeForToken(code, opts.appId, redirectUri); } const token = tokenResp.accessToken || tokenResp.token || tokenResp.access_token || tokenResp.data?.accessToken; if (!token) throw new Error('No access token in response'); // Store the token localStorage.setItem(opts.storageKey, token); state.token = token; opts.onToken?.(token); // Capture emote from response for bot shape const emote = tokenResp.emote || tokenResp.data?.emote; if (emote) { saveEmote(emote); console.log('Saved bot emote to localStorage:', emote); } // Reinitialize call manager now that we have authentication console.log('Reinitializing call manager with new token...'); reinitializeCallManager(); // Clean up stored auth state after successful authorization localStorage.removeItem('ttc_auth_pending'); // Clean URL (remove both standard and ttc_ prefixed params) url.searchParams.delete('code'); url.searchParams.delete('state'); url.searchParams.delete('ttc_code'); url.searchParams.delete('ttc_state'); window.history.replaceState({}, document.title, url.toString()); await initTTC(); setStatus('Ready'); messagesEl.innerHTML = ''; // Clear auth prompt } catch (err) { opts.onInitError?.(err); setStatus('Auth error'); showAuthPrompt(); // Show auth prompt again on error } return; } // Start authorization (user clicked the button) await startAuthorization(); } async function startAuthorization() { setStatus('Requesting authorization URL...'); const redirectUri = opts.redirectUri || window.location.origin + window.location.pathname; const stateVal = Math.random().toString(36).slice(2); try { let data; if (opts.authorization?.generateUrl) { data = await opts.authorization.generateUrl(opts.appId, redirectUri, 'basic', stateVal); } else { // Use unauthenticated RPC method const unauthRpc = await initUnauthenticatedRPC(); data = await unauthRpc.AppOAuth.generateAuthorizationUrl(opts.appId, redirectUri, 'basic', stateVal); } const authUrl = data.authorizationUrl || data.data?.authorizationUrl; if (!authUrl) throw new Error('No authorizationUrl received'); // Store authorization state for potential window communication const authState = { state: stateVal, timestamp: Date.now(), redirectUri: redirectUri }; localStorage.setItem('ttc_auth_pending', JSON.stringify(authState)); // Redirect the current page to the authorization URL window.location.href = authUrl; } catch (err) { setStatus('Failed to start auth'); opts.onInitError?.(err); } } async function initTTC() { if (!state.token) return; try { // Initialize TTC with the token core_1.ttc.init({ app_id: opts.appId, publickKey: state.token, modules: opts.modules || [], socketCb: async (socket) => { if (socket && socket.id) { console.log('Widget socket connected:', socket.id); setStatus('Connected'); } else { console.warn('Widget socket connected but no socket ID available'); setStatus('Connection established (limited features)'); } // Add connection error handling socket?.on('connect_error', (error) => { console.error('Socket connection error:', error); setStatus('Connection error - some features may not work'); // Try to reconnect after a delay setTimeout(() => { if (!socket?.connected) { console.log('Attempting to reconnect socket...'); socket?.connect(); } }, 5000); }); socket?.on('disconnect', (reason) => { console.log('Socket disconnected:', reason); setStatus('Disconnected'); if (reason === 'io server disconnect') { // Server disconnected, try to reconnect console.log('Server disconnected, attempting to reconnect...'); setTimeout(() => { if (!socket?.connected) { setStatus('Reconnecting...'); socket?.connect(); } }, 2000); } else if (reason === 'io client disconnect') { // Client disconnected manually setStatus('Disconnected by user'); } else { // Other disconnection reasons setStatus('Connection lost - trying to reconnect...'); setTimeout(() => { if (!socket?.connected) { socket?.connect(); } }, 3000); } }); socket?.on('reconnect', (attemptNumber) => { console.log('Socket reconnected after', attemptNumber, 'attempts'); setStatus('Reconnected'); }); socket?.on('reconnect_error', (error) => { console.error('Socket reconnection failed:', error); setStatus('Reconnection failed'); }); } }).connect(); // Subscribe to TTC events core_1.ttc.subscribe('message', async (data) => { // console.log('TTC message:', data.id); // Display AI message in the widget addMessage('assistant', data.payload); setStatus('Ready'); state.isWaitingForResponse = false; // Stop thinking animation when response arrives if (state.botRenderer) { state.botRenderer.stop(); } // If in call mode, send response for TTS synthesis if (state.isInCall && state.callIntegration && data.payload) { try { const voiceManager = state.callIntegration.getVoiceManager(); if (voiceManager) { await voiceManager.respondWithSpeech(data.payload); } } catch (error) { console.error('TTS synthesis error:', error); } } }); // Handle connection failures gracefully setTimeout(() => { console.warn('Checking connection status after timeout'); setStatus('Limited mode - some features unavailable'); // addMessage('assistant', '⚠️ Connection issues detected. You can still send text messages, but some features may be limited.', false); // Show system message instantly }, 10000); // 10 second timeout core_1.ttc.subscribe('function_call', async (data) => { // console.log('TTC function call:', data); // addMessage('assistant', data.payload, false); if (state.isWaitingForResponse) { state.isWaitingForResponse = false; // Stop thinking animation when function call response arrives if (state.botRenderer) { state.botRenderer.stop(); } } }); core_1.ttc.subscribe('permission', async (data) => { // console.log('TTC permission request:', data); // Display permission request in widget if (data) { const permissionMsg = `🔐 Permission requested for: ${data.payload}\nParameters: ${JSON.stringify(data.payload || {}, null, 2)}`; addMessage('assistant', permissionMsg, false); } // Auto-approve for widget (you can customize this) await core_1.ttc.approveFunction(data.id, true); if (state.isWaitingForResponse) { state.isWaitingForResponse = false; // Stop thinking animation when permission response arrives if (state.botRenderer) { state.botRenderer.stop(); } } }); // Load existing chatId if available if (!state.chatId) { state.chatId = loadChatId(); } // Fetch emote from server instead of localStorage try { const emoteResponse = await core_1.ttc.server.ttcCore.fetchEmote(); if (emoteResponse.status === 'success' && emoteResponse.data) { const fetchedEmote = emoteResponse.data.emote; state.emote = fetchedEmote; saveEmote(fetchedEmote); // Cache in localStorage for offline use // Store model ID if available if (emoteResponse.data.modelId) { state.modelId = emoteResponse.data.modelId; } // Update bot renderer with fetched emote if (state.botRenderer) { state.botRenderer.updateOptions({ shape: fetchedEmote }); } } else { // Fallback to localStorage if API call fails // console.warn('Failed to fetch emote from server, using localStorage fallback'); state.emote = loadEmote(); } } catch (error) { console.warn('Failed to fetch emote from server, using localStorage fallback:', error); // Fallback to localStorage if API call fails state.emote = loadEmote(); } // Fetch and display chat history if chatId exists if (state.chatId) { try { const historyResponse = await core_1.ttc.server.ttcCore.fetchChatHistory(state.chatId, 50, 1); // Fetch last 50 messages from page 1 if (historyResponse.status === 'success' && historyResponse.data) { const messages = Array.isArray(historyResponse.data) ? historyResponse.data : []; // Display messages in reverse order (oldest first, since we insert at beginning) for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg && msg.role && msg.content) { addMessage(msg.role, msg.content, false); // Don't animate history messages } } } } catch (error) { console.warn('Failed to fetch chat history:', error); // Continue without history - not a critical error } } state.isInitialized = true; setStatus('Ready'); } catch (error) { console.error('Failed to initialize TTC:', error); setStatus('Initialization failed'); } } // Function to handle voice message and response cycle async function sendVoiceMessageAndHandleResponse(chatId, text) { try { console.log('Sending voice message to AI and waiting for response...'); // Send message to AI and get response const response = await core_1.ttc.server.ttcCore.chatAI(chatId, text); let fullResponse = ''; // Handle response based on its structure if (response?.data?.content) { fullResponse = response.data.content; } else if (response?.data) { fullResponse = typeof response.data === 'string' ? response.data : JSON.stringify(response.data); } else if (typeof response === 'string') { fullResponse = response; } else { console.warn('Unexpected response format:', response); fullResponse = 'I received your message but had trouble processing it.'; } if (fullResponse.trim()) { console.log('AI response received:', fullResponse); // Display the response in the chat addMessage('assistant', fullResponse); // Response handling is done by call integration setStatus('Response ready'); if (state.botRenderer) { try { state.botRenderer.stopCall(); } catch (e) { // Fallback if method doesn't exist } } Play('happy'); } else { console.warn('Empty response from AI'); setStatus('No response from AI'); } } catch (error) { console.error('Error in voice message flow:', error); setStatus('Error processing voice message'); } } // Function to reinitialize call integration after authentication function reinitializeCallManager() { if (state.callIntegration) { state.callIntegration.destroy(); state.callIntegration = undefined; } initializeCallIntegration(); } // Public API for external control const api = { open: () => { if (panel.classList.contains('ttc-hidden')) togglePanel(); }, close: () => { if (!panel.classList.contains('ttc-hidden')) togglePanel(); }, destroy: () => { button.remove(); panel.remove(); }, send: (text) => { inputEl.value = text; formEl.requestSubmit(); }, getToken: () => state.token, getChatId: () => state.chatId, isInitialized: () => state.isInitialized, reinitializeCallManager: reinitializeCallManager, newChat: () => { clearChatId(); messagesEl.innerHTML = ''; setStatus('Ready for new conversation'); }, setEmote: (emote) => { saveEmote(emote); // Update bot renderer with new emote if (state.botRenderer) { state.botRenderer.updateOptions({ shape: emote }); } }, getEmote: () => state.emote, // Mood-based animation control Play: (mood, animationName) => Play(mood, animationName), }; // Attempt silent init (handle existing tokens and OAuth callbacks automatically) (async () => { try { const existing = localStorage.getItem(opts.storageKey); if (existing) { state.token = existing; await initTTC(); setStatus('Ready'); // Initialize call manager with existing token reinitializeCallManager(); return; } // Check if this is an OAuth callback (handle both standard and ttc_ prefixed params) const url = new URL(window.location.href); const code = url.searchParams.get('code') || url.searchParams.get('ttc_code'); if (code) { // This is a callback, process it automatically setStatus('Processing authentication...'); await bootstrapAuthFlow(); return; } // No existing token and not a callback - wait for user to open panel setStatus('Authenticate to start'); } catch (e) { setStatus('Initialization error'); opts.onInitError?.(e); } })(); return api; } exports.initTTCChatWidget = initTTCChatWidget; exports.default = initTTCChatWidget; // Attach to window (browser) for direct script inclusion fallback // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (typeof window !== 'undefined') { window.initTTCChatWidget = initTTCChatWidget; window.ttc = core_1.ttc; } //# sourceMappingURL=index.js.map