Add full monorepo: virtual-banker, backend, frontend, docs, scripts, deployment

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-02-10 11:32:49 -08:00
commit b4753cef7e
81 changed files with 9255 additions and 0 deletions

27
widget/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@explorer/virtual-banker-widget",
"version": "1.0.0",
"description": "Embeddable Virtual Banker widget",
"main": "dist/widget.js",
"scripts": {
"build": "webpack --mode production",
"dev": "webpack --mode development --watch",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"typescript": "^5.3.3",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4",
"ts-loader": "^9.5.1",
"css-loader": "^6.8.1",
"style-loader": "^3.3.3",
"html-webpack-plugin": "^5.5.3"
}
}

12
widget/public/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Virtual Banker Widget</title>
</head>
<body>
<div id="virtual-banker-widget"></div>
</body>
</html>

80
widget/public/widget.js Normal file
View File

@@ -0,0 +1,80 @@
/**
* Virtual Banker Widget Loader
*
* Usage:
* <script src="path/to/widget.js"
* data-tenant-id="your-tenant-id"
* data-user-id="user-id"
* data-auth-token="jwt-token"
* data-api-url="https://api.example.com"
* data-avatar-enabled="true"></script>
* <div id="virtual-banker-widget"></div>
*/
(function() {
'use strict';
// Get configuration from script tag
const script = document.currentScript;
const config = {
tenantId: script.getAttribute('data-tenant-id') || 'default',
userId: script.getAttribute('data-user-id') || undefined,
authToken: script.getAttribute('data-auth-token') || undefined,
apiUrl: script.getAttribute('data-api-url') || undefined,
avatarEnabled: script.getAttribute('data-avatar-enabled') !== 'false',
};
// Load React and ReactDOM (should be loaded separately or bundled)
// For now, this is a placeholder - the actual widget will be loaded via the built bundle
console.log('Virtual Banker Widget Loader initialized', config);
// Create container if it doesn't exist
let container = document.getElementById('virtual-banker-widget');
if (!container) {
container = document.createElement('div');
container.id = 'virtual-banker-widget';
document.body.appendChild(container);
}
// Store config for widget initialization
container.dataset.tenantId = config.tenantId;
if (config.userId) container.dataset.userId = config.userId;
if (config.authToken) container.dataset.authToken = config.authToken;
if (config.apiUrl) container.dataset.apiUrl = config.apiUrl;
container.dataset.avatarEnabled = config.avatarEnabled.toString();
// Export API for programmatic control
window.VirtualBankerWidgetAPI = {
open: function() {
const widget = document.getElementById('virtual-banker-widget');
if (widget) {
widget.style.display = 'block';
}
},
close: function() {
const widget = document.getElementById('virtual-banker-widget');
if (widget) {
widget.style.display = 'none';
}
},
minimize: function() {
const widget = document.getElementById('virtual-banker-widget');
if (widget) {
widget.classList.add('minimized');
}
},
setContext: function(context) {
const widget = document.getElementById('virtual-banker-widget');
if (widget) {
widget.dataset.context = JSON.stringify(context);
}
},
setAuthToken: function(token) {
const widget = document.getElementById('virtual-banker-widget');
if (widget) {
widget.dataset.authToken = token;
}
},
};
})();

94
widget/src/App.css Normal file
View File

@@ -0,0 +1,94 @@
.widget-container {
width: 400px;
height: 600px;
border: 1px solid #ddd;
border-radius: 8px;
display: flex;
flex-direction: column;
background: white;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
}
.widget-container.loading,
.widget-container.error {
align-items: center;
justify-content: center;
padding: 24px;
}
.loading-spinner {
font-size: 16px;
color: #666;
}
.error-message {
color: #d32f2f;
margin-bottom: 16px;
text-align: center;
}
.widget-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
}
.widget-header h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.settings-button {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.settings-button:hover {
background-color: #f0f0f0;
}
.widget-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.widget-avatar-section {
height: 200px;
padding: 8px;
}
.widget-chat-section {
flex: 1;
min-height: 0;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.widget-container {
width: 100%;
height: 100vh;
border-radius: 0;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

189
widget/src/App.tsx Normal file
View File

@@ -0,0 +1,189 @@
import React, { useState, useEffect } from 'react';
import { ChatPanel } from './components/ChatPanel';
import { VoiceControls } from './components/VoiceControls';
import { AvatarView } from './components/AvatarView';
import { Captions } from './components/Captions';
import { Settings } from './components/Settings';
import { useSession } from './hooks/useSession';
import { useConversation } from './hooks/useConversation';
import { useWebRTC } from './hooks/useWebRTC';
import { PostMessageAPI } from './services/postMessage';
import { WidgetConfig } from './types';
import './App.css';
// Default config - can be overridden via postMessage or data attributes
const getConfig = (): WidgetConfig => {
const script = document.querySelector('script[data-tenant-id]');
if (script) {
return {
tenantId: script.getAttribute('data-tenant-id') || 'default',
userId: script.getAttribute('data-user-id') || undefined,
authToken: script.getAttribute('data-auth-token') || undefined,
apiUrl: script.getAttribute('data-api-url') || undefined,
avatarEnabled: script.getAttribute('data-avatar-enabled') !== 'false',
};
}
return {
tenantId: 'default',
avatarEnabled: true,
};
};
export const App: React.FC = () => {
const [config] = useState<WidgetConfig>(getConfig());
const [showSettings, setShowSettings] = useState(false);
const [showCaptions, setShowCaptions] = useState(true);
const [avatarEnabled, setAvatarEnabled] = useState(config.avatarEnabled ?? true);
const [volume, setVolume] = useState(100);
const [isMuted, setIsMuted] = useState(false);
const [captionText, setCaptionText] = useState('');
const postMessage = new PostMessageAPI();
const { session, loading, error, createSession, endSession } = useSession(config);
const {
messages,
isListening,
isSpeaking,
setIsListening,
setIsSpeaking,
sendMessage,
receiveMessage,
} = useConversation();
const { isConnected, remoteStream, initializeWebRTC, closeWebRTC } = useWebRTC();
// Initialize session on mount
useEffect(() => {
createSession();
}, []);
// Initialize WebRTC when session is ready
useEffect(() => {
if (session && !isConnected) {
initializeWebRTC();
}
}, [session, isConnected]);
// Cleanup on unmount
useEffect(() => {
return () => {
endSession();
closeWebRTC();
};
}, []);
// Send ready event
useEffect(() => {
if (session) {
postMessage.ready();
postMessage.sessionStarted(session.sessionId);
}
}, [session]);
// Listen for messages from host
useEffect(() => {
const unsubscribe = postMessage.on('open', () => {
// Widget opened
});
return unsubscribe;
}, []);
const handleSendMessage = (message: string) => {
sendMessage(message);
// TODO: Send to backend via WebRTC or WebSocket
};
const handlePushToTalk = () => {
setIsListening(true);
// TODO: Start audio capture
};
const handleHandsFree = () => {
setIsListening(true);
// TODO: Enable continuous listening
};
const handleToggleMute = () => {
setIsMuted(!isMuted);
// TODO: Mute/unmute audio
};
if (loading) {
return (
<div className="widget-container loading">
<div className="loading-spinner">Loading...</div>
</div>
);
}
if (error) {
return (
<div className="widget-container error">
<div className="error-message">Error: {error}</div>
<button onClick={createSession}>Retry</button>
</div>
);
}
return (
<div className="widget-container">
<div className="widget-header">
<h1>Virtual Banker</h1>
<button
onClick={() => setShowSettings(true)}
className="settings-button"
aria-label="Settings"
>
</button>
</div>
<div className="widget-content">
{avatarEnabled && (
<div className="widget-avatar-section">
<AvatarView
enabled={avatarEnabled}
videoStream={remoteStream || undefined}
onToggle={() => setAvatarEnabled(false)}
/>
</div>
)}
<div className="widget-chat-section">
<ChatPanel
messages={messages}
onSendMessage={handleSendMessage}
isListening={isListening}
isSpeaking={isSpeaking}
showCaptions={showCaptions}
onToggleCaptions={() => setShowCaptions(!showCaptions)}
/>
</div>
</div>
<VoiceControls
onPushToTalk={handlePushToTalk}
onHandsFree={handleHandsFree}
isListening={isListening}
isMuted={isMuted}
onToggleMute={handleToggleMute}
volume={volume}
onVolumeChange={setVolume}
/>
<Captions text={captionText} visible={showCaptions} />
{showSettings && (
<Settings
showCaptions={showCaptions}
onToggleCaptions={() => setShowCaptions(!showCaptions)}
avatarEnabled={avatarEnabled}
onToggleAvatar={() => setAvatarEnabled(!avatarEnabled)}
onClose={() => setShowSettings(false)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,60 @@
.avatar-view {
position: relative;
width: 100%;
height: 100%;
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
overflow: hidden;
}
.avatar-view.disabled {
background-color: #f5f5f5;
}
.avatar-video {
width: 100%;
height: 100%;
object-fit: contain;
}
.avatar-toggle {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background-color: rgba(0, 0, 0, 0.6);
color: white;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.avatar-toggle:hover {
background-color: rgba(0, 0, 0, 0.8);
}
.enable-avatar-button {
padding: 12px 24px;
background-color: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.enable-avatar-button:hover {
background-color: #0052a3;
}

View File

@@ -0,0 +1,53 @@
import React, { useRef, useEffect } from 'react';
import './AvatarView.css';
interface AvatarViewProps {
enabled: boolean;
videoStream?: MediaStream;
onToggle: () => void;
}
export const AvatarView: React.FC<AvatarViewProps> = ({
enabled,
videoStream,
onToggle,
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (videoRef.current && videoStream) {
videoRef.current.srcObject = videoStream;
}
}, [videoStream]);
if (!enabled) {
return (
<div className="avatar-view disabled">
<button onClick={onToggle} className="enable-avatar-button" aria-label="Enable avatar">
Enable Avatar
</button>
</div>
);
}
return (
<div className="avatar-view" role="region" aria-label="Avatar video">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="avatar-video"
aria-label="Virtual Banker avatar"
/>
<button
onClick={onToggle}
className="avatar-toggle"
aria-label="Hide avatar"
>
</button>
</div>
);
};

View File

@@ -0,0 +1,17 @@
.captions {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
max-width: 80%;
padding: 12px 16px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
font-size: 16px;
line-height: 1.5;
z-index: 1000;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import './Captions.css';
interface CaptionsProps {
text: string;
visible: boolean;
}
export const Captions: React.FC<CaptionsProps> = ({ text, visible }) => {
if (!visible || !text) {
return null;
}
return (
<div className="captions" role="status" aria-live="polite" aria-atomic="true">
{text}
</div>
);
};

View File

@@ -0,0 +1,141 @@
.chat-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
}
.chat-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.caption-toggle {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.caption-toggle:hover {
background-color: #f0f0f0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
display: flex;
flex-direction: column;
max-width: 80%;
padding: 12px;
border-radius: 8px;
}
.message-user {
align-self: flex-end;
background-color: #0066cc;
color: white;
}
.message-assistant {
align-self: flex-start;
background-color: #f0f0f0;
color: #333;
}
.message-content {
word-wrap: break-word;
line-height: 1.5;
}
.message-timestamp {
font-size: 11px;
opacity: 0.7;
margin-top: 4px;
}
.chat-status {
padding: 8px 16px;
min-height: 32px;
display: flex;
align-items: center;
}
.status-indicator {
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
}
.status-indicator.listening {
background-color: #e3f2fd;
color: #1976d2;
}
.status-indicator.speaking {
background-color: #fff3e0;
color: #f57c00;
}
.chat-input-form {
display: flex;
padding: 16px;
border-top: 1px solid #e0e0e0;
gap: 8px;
}
.chat-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.chat-input:focus {
outline: none;
border-color: #0066cc;
}
.send-button {
padding: 10px 20px;
background-color: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.send-button:hover:not(:disabled) {
background-color: #0052a3;
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,101 @@
import React, { useState, useRef, useEffect } from 'react';
import { Message } from '../types';
import './ChatPanel.css';
interface ChatPanelProps {
messages: Message[];
onSendMessage: (message: string) => void;
isListening: boolean;
isSpeaking: boolean;
showCaptions: boolean;
onToggleCaptions: () => void;
}
export const ChatPanel: React.FC<ChatPanelProps> = ({
messages,
onSendMessage,
isListening,
isSpeaking,
showCaptions,
onToggleCaptions,
}) => {
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim()) {
onSendMessage(input.trim());
setInput('');
}
};
return (
<div className="chat-panel" role="region" aria-label="Chat conversation">
<div className="chat-header">
<h2>Virtual Banker</h2>
<button
onClick={onToggleCaptions}
className="caption-toggle"
aria-label={showCaptions ? 'Hide captions' : 'Show captions'}
>
{showCaptions ? '📝' : '📄'}
</button>
</div>
<div className="chat-messages" role="log" aria-live="polite" aria-atomic="false">
{messages.map((message) => (
<div
key={message.id}
className={`message message-${message.role}`}
role={message.role === 'user' ? 'user' : 'assistant'}
>
<div className="message-content">{message.content}</div>
<div className="message-timestamp">
{message.timestamp.toLocaleTimeString()}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="chat-status">
{isListening && (
<span className="status-indicator listening" aria-label="Listening">
🎤 Listening...
</span>
)}
{isSpeaking && (
<span className="status-indicator speaking" aria-label="Speaking">
🔊 Speaking...
</span>
)}
</div>
<form onSubmit={handleSubmit} className="chat-input-form">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
className="chat-input"
aria-label="Message input"
disabled={isSpeaking}
/>
<button
type="submit"
className="send-button"
aria-label="Send message"
disabled={!input.trim() || isSpeaking}
>
Send
</button>
</form>
</div>
);
};

View File

@@ -0,0 +1,70 @@
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.settings-panel {
background-color: white;
border-radius: 8px;
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.settings-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.close-button {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 4px;
line-height: 1;
color: #666;
transition: color 0.2s;
}
.close-button:hover {
color: #000;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.setting-item label {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-size: 16px;
}
.setting-item input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}

View File

@@ -0,0 +1,56 @@
import React, { useState } from 'react';
import './Settings.css';
interface SettingsProps {
showCaptions: boolean;
onToggleCaptions: () => void;
avatarEnabled: boolean;
onToggleAvatar: () => void;
onClose: () => void;
}
export const Settings: React.FC<SettingsProps> = ({
showCaptions,
onToggleCaptions,
avatarEnabled,
onToggleAvatar,
onClose,
}) => {
return (
<div className="settings-overlay" role="dialog" aria-label="Settings">
<div className="settings-panel">
<div className="settings-header">
<h2>Settings</h2>
<button onClick={onClose} className="close-button" aria-label="Close settings">
</button>
</div>
<div className="settings-content">
<div className="setting-item">
<label>
<input
type="checkbox"
checked={showCaptions}
onChange={onToggleCaptions}
/>
<span>Show captions</span>
</label>
</div>
<div className="setting-item">
<label>
<input
type="checkbox"
checked={avatarEnabled}
onChange={onToggleAvatar}
/>
<span>Enable avatar</span>
</label>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,116 @@
.voice-controls {
padding: 16px;
border-top: 1px solid #e0e0e0;
background-color: #f9f9f9;
}
.voice-mode-selector {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.mode-button {
flex: 1;
padding: 8px 16px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.mode-button:hover {
background-color: #f0f0f0;
}
.mode-button.active {
background-color: #0066cc;
color: white;
border-color: #0066cc;
}
.voice-controls-row {
display: flex;
align-items: center;
gap: 12px;
}
.control-button {
padding: 8px 12px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
font-size: 18px;
transition: background-color 0.2s;
}
.control-button:hover {
background-color: #f0f0f0;
}
.control-button.muted {
background-color: #ffebee;
border-color: #f44336;
}
.volume-control {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.volume-slider {
flex: 1;
height: 4px;
border-radius: 2px;
background: #ddd;
outline: none;
}
.volume-slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #0066cc;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #0066cc;
cursor: pointer;
border: none;
}
.volume-value {
font-size: 12px;
color: #666;
min-width: 40px;
text-align: right;
}
.listening-indicator {
font-size: 14px;
color: #1976d2;
font-weight: 500;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

View File

@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import './VoiceControls.css';
interface VoiceControlsProps {
onPushToTalk: () => void;
onHandsFree: () => void;
isListening: boolean;
isMuted: boolean;
onToggleMute: () => void;
volume: number;
onVolumeChange: (volume: number) => void;
}
export const VoiceControls: React.FC<VoiceControlsProps> = ({
onPushToTalk,
onHandsFree,
isListening,
isMuted,
onToggleMute,
volume,
onVolumeChange,
}) => {
const [mode, setMode] = useState<'push-to-talk' | 'hands-free'>('push-to-talk');
const handleModeChange = (newMode: 'push-to-talk' | 'hands-free') => {
setMode(newMode);
if (newMode === 'push-to-talk') {
onPushToTalk();
} else {
onHandsFree();
}
};
return (
<div className="voice-controls" role="group" aria-label="Voice controls">
<div className="voice-mode-selector">
<button
onClick={() => handleModeChange('push-to-talk')}
className={`mode-button ${mode === 'push-to-talk' ? 'active' : ''}`}
aria-pressed={mode === 'push-to-talk'}
>
Push to Talk
</button>
<button
onClick={() => handleModeChange('hands-free')}
className={`mode-button ${mode === 'hands-free' ? 'active' : ''}`}
aria-pressed={mode === 'hands-free'}
>
Hands Free
</button>
</div>
<div className="voice-controls-row">
<button
onClick={onToggleMute}
className={`control-button mute-button ${isMuted ? 'muted' : ''}`}
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? '🔇' : '🔊'}
</button>
<div className="volume-control">
<label htmlFor="volume-slider" className="sr-only">
Volume
</label>
<input
id="volume-slider"
type="range"
min="0"
max="100"
value={volume}
onChange={(e) => onVolumeChange(Number(e.target.value))}
className="volume-slider"
aria-label="Volume"
/>
<span className="volume-value">{volume}%</span>
</div>
{isListening && (
<span className="listening-indicator" aria-live="polite">
🎤 Listening
</span>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,47 @@
import { useState, useCallback } from 'react';
import { Message } from '../types';
export function useConversation() {
const [messages, setMessages] = useState<Message[]>([]);
const [isListening, setIsListening] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const addMessage = useCallback((message: Omit<Message, 'id' | 'timestamp'>) => {
const newMessage: Message = {
...message,
id: `msg-${Date.now()}-${Math.random()}`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, newMessage]);
}, []);
const sendMessage = useCallback((content: string) => {
addMessage({
role: 'user',
content,
});
}, [addMessage]);
const receiveMessage = useCallback((content: string) => {
addMessage({
role: 'assistant',
content,
});
}, [addMessage]);
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
return {
messages,
isListening,
isSpeaking,
setIsListening,
setIsSpeaking,
sendMessage,
receiveMessage,
clearMessages,
};
}

View File

@@ -0,0 +1,89 @@
import { useState, useEffect } from 'react';
import { APIClient } from '../services/api';
import { Session, WidgetConfig } from '../types';
export function useSession(config: WidgetConfig) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const apiClient = new APIClient(config);
const createSession = async () => {
if (!config.tenantId || !config.userId) {
setError('tenantId and userId are required');
return;
}
setLoading(true);
setError(null);
try {
const authAssertion = config.authToken || 'anonymous';
const newSession = await apiClient.createSession(
config.tenantId,
config.userId,
authAssertion
);
setSession(newSession);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create session');
} finally {
setLoading(false);
}
};
const refreshToken = async () => {
if (!session) return;
try {
const result = await apiClient.refreshToken(session.sessionId);
setSession({
...session,
ephemeralToken: result.ephemeralToken,
expiresAt: result.expiresAt,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to refresh token');
}
};
const endSession = async () => {
if (!session) return;
try {
await apiClient.endSession(session.sessionId);
setSession(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to end session');
}
};
// Auto-refresh token before expiration
useEffect(() => {
if (!session) return;
const expiresAt = new Date(session.expiresAt).getTime();
const now = Date.now();
const timeUntilExpiry = expiresAt - now;
const refreshTime = timeUntilExpiry - 5 * 60 * 1000; // Refresh 5 minutes before expiry
if (refreshTime > 0) {
const timer = setTimeout(() => {
refreshToken();
}, refreshTime);
return () => clearTimeout(timer);
}
}, [session]);
return {
session,
loading,
error,
createSession,
refreshToken,
endSession,
};
}

View File

@@ -0,0 +1,74 @@
import { useState, useRef, useEffect } from 'react';
export function useWebRTC() {
const [isConnected, setIsConnected] = useState(false);
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const initializeWebRTC = async () => {
try {
// Get user media
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false, // Audio only for now
});
setLocalStream(stream);
// Create peer connection (simplified - should use proper signaling)
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
],
});
// Add local stream tracks
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
// Handle remote stream
pc.ontrack = (event) => {
setRemoteStream(event.streams[0]);
};
pc.onconnectionstatechange = () => {
setIsConnected(pc.connectionState === 'connected');
};
peerConnectionRef.current = pc;
} catch (err) {
console.error('Failed to initialize WebRTC:', err);
}
};
const closeWebRTC = () => {
if (localStream) {
localStream.getTracks().forEach((track) => track.stop());
setLocalStream(null);
}
if (peerConnectionRef.current) {
peerConnectionRef.current.close();
peerConnectionRef.current = null;
}
setRemoteStream(null);
setIsConnected(false);
};
useEffect(() => {
return () => {
closeWebRTC();
};
}, []);
return {
isConnected,
localStream,
remoteStream,
initializeWebRTC,
closeWebRTC,
};
}

33
widget/src/index.css Normal file
View File

@@ -0,0 +1,33 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#virtual-banker-widget {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
}
/* Accessibility: Focus styles */
*:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* Keyboard navigation support */
button:focus-visible,
input:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}

36
widget/src/index.tsx Normal file
View File

@@ -0,0 +1,36 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './index.css';
// Initialize widget when DOM is ready
function initWidget() {
const containerId = 'virtual-banker-widget';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
}
const root = ReactDOM.createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
// Auto-initialize if script is loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWidget);
} else {
initWidget();
}
// Export for manual initialization
(window as any).VirtualBankerWidget = {
init: initWidget,
};

View File

@@ -0,0 +1,68 @@
import { Session, WidgetConfig } from '../types';
const DEFAULT_API_URL = 'http://localhost:8081';
export class APIClient {
private apiUrl: string;
private authToken?: string;
constructor(config: WidgetConfig) {
this.apiUrl = config.apiUrl || DEFAULT_API_URL;
this.authToken = config.authToken;
}
async createSession(tenantId: string, userId: string, authAssertion: string): Promise<Session> {
const response = await fetch(`${this.apiUrl}/v1/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.authToken && { Authorization: `Bearer ${this.authToken}` }),
},
body: JSON.stringify({
tenant_id: tenantId,
user_id: userId,
auth_assertion: authAssertion,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create session');
}
return response.json();
}
async refreshToken(sessionId: string): Promise<{ ephemeralToken: string; expiresAt: string }> {
const response = await fetch(`${this.apiUrl}/v1/sessions/${sessionId}/refresh-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.authToken && { Authorization: `Bearer ${this.authToken}` }),
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to refresh token');
}
return response.json();
}
async endSession(sessionId: string): Promise<void> {
const response = await fetch(`${this.apiUrl}/v1/sessions/${sessionId}/end`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.authToken && { Authorization: `Bearer ${this.authToken}` }),
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to end session');
}
}
}

View File

@@ -0,0 +1,64 @@
export interface PostMessageEvent {
type: string;
payload?: any;
}
export class PostMessageAPI {
private targetOrigin: string;
constructor(targetOrigin: string = '*') {
this.targetOrigin = targetOrigin;
}
// Send events to parent window
send(type: string, payload?: any): void {
if (typeof window !== 'undefined' && window.parent) {
window.parent.postMessage(
{
type,
payload,
source: 'virtual-banker-widget',
},
this.targetOrigin
);
}
}
// Listen for messages from parent window
on(type: string, callback: (payload?: any) => void): () => void {
const handler = (event: MessageEvent) => {
if (event.data && event.data.type === type && event.data.source === 'virtual-banker-host') {
callback(event.data.payload);
}
};
window.addEventListener('message', handler);
// Return unsubscribe function
return () => {
window.removeEventListener('message', handler);
};
}
// Widget events
ready(): void {
this.send('ready');
}
sessionStarted(sessionId: string): void {
this.send('session_started', { sessionId });
}
actionRequested(action: string, params: any): void {
this.send('action_requested', { action, params });
}
actionCompleted(action: string, result: any): void {
this.send('action_completed', { action, result });
}
handoffToHuman(reason: string): void {
this.send('handoff_to_human', { reason });
}
}

55
widget/src/types/index.ts Normal file
View File

@@ -0,0 +1,55 @@
export interface WidgetConfig {
tenantId: string;
userId?: string;
authToken?: string;
apiUrl?: string;
theme?: ThemeConfig;
avatarEnabled?: boolean;
greeting?: string;
}
export interface ThemeConfig {
primaryColor?: string;
secondaryColor?: string;
backgroundColor?: string;
textColor?: string;
logo?: string;
}
export interface Session {
sessionId: string;
ephemeralToken: string;
config: TenantConfig;
expiresAt: string;
}
export interface TenantConfig {
theme: ThemeConfig;
avatarEnabled: boolean;
greeting: string;
allowedTools: string[];
policy: PolicyConfig;
}
export interface PolicyConfig {
maxSessionDuration: number;
rateLimitPerMinute: number;
requireConsent: boolean;
}
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
audioUrl?: string;
}
export interface ConversationState {
sessionId: string;
messages: Message[];
isListening: boolean;
isSpeaking: boolean;
isConnected: boolean;
}

25
widget/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"declaration": true,
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

45
widget/webpack.config.js Normal file
View File

@@ -0,0 +1,45 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'widget.js',
library: 'VirtualBankerWidget',
libraryTarget: 'umd',
globalObject: 'this',
clean: true,
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
filename: 'index.html',
}),
],
externals: {
react: 'React',
'react-dom': 'ReactDOM',
},
devtool: 'source-map',
};