UNPKG

@trillet-ai/web-sdk

Version:

Trillet Web SDK for real-time audio communication with AI agents

349 lines (348 loc) 15.3 kB
import { EventEmitter } from 'eventemitter3'; import { TrilletWebSDK } from './TrilletWebSDK.js'; import { TranscriptManager } from './TranscriptManager.js'; import { BrowserFingerprint } from './BrowserFingerprint.js'; // Export BrowserFingerprint for external use export { BrowserFingerprint }; const TRILLET_API_URL = { CONNECT: 'https://api.trillet.ai/v1/api/web-call', PUBLIC_CONNECT: 'https://api.trillet.ai/v1/api/web-call/public', }; const decoder = new TextDecoder(); // Browser compatibility check const isBrowser = typeof window !== 'undefined'; // Safari iOS in private mode (and some proxy configs) returns isSecureContext=false // even on HTTPS. Fall back to protocol/hostname so we don't block valid contexts. const isSecureContext = isBrowser && (window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); export class TrilletAgent extends EventEmitter { constructor(config) { super(); this.sdkToken = null; // Validate environment if (!isBrowser) { throw new Error('Trillet SDK must be used in a browser environment'); } if (!isSecureContext) { throw new Error('Trillet SDK requires a secure context (HTTPS or localhost)'); } if (!config.agentId) { throw new Error('agentId is required'); } this.config = { ...config, mode: config.mode || 'voice', // Default to voice mode }; this.sdk = new TrilletWebSDK(); this.transcriptManager = new TranscriptManager(); this.setupEventForwarding(); } setupEventForwarding() { // Forward all SDK events to agent listeners this.sdk.on('disconnected', () => this.emit('disconnected')); this.sdk.on('error', (error) => this.emit('error', error)); this.sdk.on('status', (status) => this.emit('status', status)); this.sdk.on('metadata', (metadata) => this.emit('metadata', metadata)); this.sdk.on('assistantStartedSpeaking', () => this.emit('assistantStartedSpeaking')); this.sdk.on('assistantStoppedSpeaking', () => this.emit('assistantStoppedSpeaking')); this.sdk.on('audioData', (data) => this.emit('audioData', data)); // Handle incoming tool usage from the agent this.sdk.on('toolStarted', (toolCall) => { this.transcriptManager.addToolStarted(toolCall); this.emit('toolStarted', toolCall); this.emit('transcriptUpdate', this.transcriptManager.getTranscripts()); }); this.sdk.on('toolCompleted', (toolResult) => { this.transcriptManager.addToolCompleted(toolResult); this.emit('toolCompleted', toolResult); this.emit('transcriptUpdate', this.transcriptManager.getTranscripts()); }); this.sdk.on('transcript', (transcriptData, participant) => { const participantId = participant.identity; const isAgent = participantId.startsWith('agent'); const updated = this.transcriptManager.handleTranscript(participantId, isAgent, transcriptData); if (updated) { this.emit('transcriptUpdate', this.transcriptManager.getTranscripts()); } }); // Handle incoming text messages from the agent this.sdk.on('message', (message) => { var _a, _b; const agentParticipant = Array.from((_b = (_a = this.sdk.getRoom()) === null || _a === void 0 ? void 0 : _a.remoteParticipants.values()) !== null && _b !== void 0 ? _b : []).find((p) => p.identity.startsWith('agent')); this.transcriptManager.addMessage('assistant', message.text, agentParticipant === null || agentParticipant === void 0 ? void 0 : agentParticipant.identity); this.emit('message', message); this.emit('transcriptUpdate', this.transcriptManager.getTranscripts()); }); } /** * Set up default RPC handlers for common agent interactions * This should be called after connection is established */ setupDefaultRpcHandlers() { // Register handler for receiving chat messages from agent this.registerRpcMethod('receiveChatMessage', async (data) => { var _a, _b, _c; const messageText = typeof data.payload === 'string' ? data.payload : ((_a = data.payload) === null || _a === void 0 ? void 0 : _a.text) || ''; if (messageText) { const agentParticipant = Array.from((_c = (_b = this.sdk.getRoom()) === null || _b === void 0 ? void 0 : _b.remoteParticipants.values()) !== null && _c !== void 0 ? _c : []).find((p) => p.identity.startsWith('agent')); this.transcriptManager.addMessage('assistant', messageText, agentParticipant === null || agentParticipant === void 0 ? void 0 : agentParticipant.identity); this.emit('message', { text: messageText }); this.emit('transcriptUpdate', this.transcriptManager.getTranscripts()); } return 'Message received'; }); // Register handler for agent status updates this.registerRpcMethod('agentStatus', async (data) => { this.emit('status', data.payload); return 'Status received'; }); } async startCall() { try { // Generate browser fingerprint before making the call. // Non-fatal: see startPublicCall for rationale. let browserFingerprint = null; try { browserFingerprint = await BrowserFingerprint.generate(); } catch (fpError) { console.warn('Trillet SDK: fingerprint generation failed, proceeding without it', fpError); } // 2. Initialize call with API KEY const response = await fetch(TRILLET_API_URL.CONNECT, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-API-KEY': this.config.apiKey, }, body: JSON.stringify({ call_agent_id: this.config.agentId, mode: this.config.mode, dynamic_variables: this.config.variables || {}, callback_url: this.config.callbackUrl, browser_fingerprint: browserFingerprint, }), }); if (!response.ok) { let errorMessage; try { const errorData = await response.json(); errorMessage = errorData.message || errorData.error || 'Unknown error'; } catch (_a) { errorMessage = await response.text(); } throw new Error(`API Error: ${errorMessage}`); } const data = await response.json(); if (!data.token || !data.roomName) { throw new Error('Invalid response from server: missing token or room name'); } // Initialize call with LiveKit token await this.sdk.initializeCall({ token: data.token, wsUrl: process.env.LIVEKIT_WS_URL || 'wss://trillet-ai-xdx0dw5r.livekit.cloud', mode: this.config.mode, audioSettings: { sampleRate: 48000, }, features: { enableRawAudio: this.config.mode === 'voice', }, }); // Enable audio playback for voice mode if (this.config.mode === 'voice') { await this.sdk.enableAudioPlayback(); } // Set up default RPC handlers for text communication this.setupDefaultRpcHandlers(); // Emit additional connection details this.emit('connected', { callId: data.callId, roomName: data.roomName, agent: data.agent, }); } catch (error) { console.error('Trillet SDK Error:', error); this.emit('error', error instanceof Error ? error : new Error(String(error))); throw error; } } async startPublicCall() { try { // Generate browser fingerprint before making the call. // Non-fatal: if it fails (Safari iOS quirks with crypto.subtle / canvas.toDataURL // in private mode or restricted contexts), fall back to null so the fetch still // happens. Backend's per-user limit tracking degrades gracefully. let browserFingerprint = null; try { browserFingerprint = await BrowserFingerprint.generate(); } catch (fpError) { console.warn('Trillet SDK: fingerprint generation failed, proceeding without it', fpError); } // 2. Initialize public call const response = await fetch(TRILLET_API_URL.PUBLIC_CONNECT, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'x-workspace-id': this.config.workspaceId, }, body: JSON.stringify({ call_agent_id: this.config.agentId, mode: this.config.mode, dynamic_variables: this.config.variables || {}, callback_url: this.config.callbackUrl, browser_fingerprint: browserFingerprint, demo_hash: this.config.demoHash, // Send demo hash if provided }), }); if (!response.ok) { let errorMessage; try { const errorData = await response.json(); errorMessage = errorData.message || errorData.error || 'Unknown error'; } catch (_a) { errorMessage = await response.text(); } throw new Error(`API Error: ${errorMessage}`); } const data = await response.json(); if (!data.token || !data.roomName) { throw new Error('Invalid response from server: missing token or room name'); } // Initialize call with LiveKit token await this.sdk.initializeCall({ token: data.token, wsUrl: 'wss://trillet-ai-xdx0dw5r.livekit.cloud', mode: this.config.mode, audioSettings: { sampleRate: 48000, }, features: { enableRawAudio: this.config.mode === 'voice', }, }); // Enable audio playback for voice mode if (this.config.mode === 'voice') { await this.sdk.enableAudioPlayback(); } // Set up default RPC handlers for text communication this.setupDefaultRpcHandlers(); // Emit additional connection details this.emit('connected', { callId: data.callId, roomName: data.roomName, agent: data.agent, }); } catch (error) { console.error('Trillet SDK Error (public call):', error); this.emit('error', error instanceof Error ? error : new Error(String(error))); throw error; } } endCall() { this.sdk.endCall(); } toggleMicrophone(enabled) { this.sdk.toggleMicrophone(enabled); } get isAssistantSpeaking() { return this.sdk.isAssistantSpeaking; } getTranscripts() { return this.transcriptManager.getTranscripts(); } getCurrentTranscript() { return this.transcriptManager.getCurrentTranscript(); } /** * Register an RPC method handler for receiving calls from the agent * @param method - The RPC method name to handle * @param handler - The function to handle the RPC call */ registerRpcMethod(method, handler) { const room = this.sdk.getRoom(); if (!room) { console.warn('Cannot register RPC method: not connected to room'); return; } room.localParticipant.registerRpcMethod(method, async (data) => { try { const result = await handler(data); return result; } catch (error) { console.error(`RPC method ${method} error:`, error); throw error; } }); } /** * Send an RPC call to the agent * @param method - The RPC method name * @param payload - The data to send * @param responseTimeout - Timeout in milliseconds (default: 30000) */ async sendRpcMessage(method, payload, responseTimeout = 30000) { const room = this.sdk.getRoom(); if (!room || !this.sdk.isConnected) { throw new Error('Not connected. Cannot send RPC message.'); } const agentParticipant = Array.from(room.remoteParticipants.values()).find((p) => p.identity.startsWith('agent')); if (!agentParticipant) { throw new Error('No agent found to send RPC message to.'); } try { const response = await room.localParticipant.performRpc({ destinationIdentity: agentParticipant.identity, method, payload, responseTimeout, }); return response; } catch (error) { console.error(`RPC call to method ${method} failed:`, error); throw error; } } async sendTextMessage(text) { if (this.config.mode !== 'text') { console.warn('Cannot send text message in voice mode.'); return; } const room = this.sdk.getRoom(); if (!room || !this.sdk.isConnected) { this.emit('error', new Error('Not connected. Cannot send message.')); return; } // Add user message to transcripts immediately this.transcriptManager.addMessage('user', text, room.localParticipant.identity); this.emit('transcriptUpdate', this.getTranscripts()); // Send the text message using LiveKit's sendText API with topic 'lk.chat'. try { await room.localParticipant.sendText(text, { topic: 'lk.chat' }); console.log('Text message sent to agent:', text); } catch (error) { console.error('Failed to send text message:', error); this.emit('error', new Error(`Failed to send message: ${error instanceof Error ? error.message : String(error)}`)); } } /** * Get the browser fingerprint for this session * Useful for demo pages to send fingerprint when checking limits */ async getBrowserFingerprint() { return await BrowserFingerprint.generate(); } }