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
JavaScript
;
/*
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>×</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