Add full monorepo: virtual-banker, backend, frontend, docs, scripts, deployment
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
27
widget/package.json
Normal file
27
widget/package.json
Normal 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
12
widget/public/index.html
Normal 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
80
widget/public/widget.js
Normal 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
94
widget/src/App.css
Normal 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
189
widget/src/App.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
60
widget/src/components/AvatarView.css
Normal file
60
widget/src/components/AvatarView.css
Normal 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;
|
||||
}
|
||||
|
||||
53
widget/src/components/AvatarView.tsx
Normal file
53
widget/src/components/AvatarView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
17
widget/src/components/Captions.css
Normal file
17
widget/src/components/Captions.css
Normal 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);
|
||||
}
|
||||
|
||||
20
widget/src/components/Captions.tsx
Normal file
20
widget/src/components/Captions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
141
widget/src/components/ChatPanel.css
Normal file
141
widget/src/components/ChatPanel.css
Normal 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;
|
||||
}
|
||||
|
||||
101
widget/src/components/ChatPanel.tsx
Normal file
101
widget/src/components/ChatPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
70
widget/src/components/Settings.css
Normal file
70
widget/src/components/Settings.css
Normal 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;
|
||||
}
|
||||
|
||||
56
widget/src/components/Settings.tsx
Normal file
56
widget/src/components/Settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
116
widget/src/components/VoiceControls.css
Normal file
116
widget/src/components/VoiceControls.css
Normal 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;
|
||||
}
|
||||
|
||||
88
widget/src/components/VoiceControls.tsx
Normal file
88
widget/src/components/VoiceControls.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
47
widget/src/hooks/useConversation.ts
Normal file
47
widget/src/hooks/useConversation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
89
widget/src/hooks/useSession.ts
Normal file
89
widget/src/hooks/useSession.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
74
widget/src/hooks/useWebRTC.ts
Normal file
74
widget/src/hooks/useWebRTC.ts
Normal 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
33
widget/src/index.css
Normal 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
36
widget/src/index.tsx
Normal 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,
|
||||
};
|
||||
|
||||
68
widget/src/services/api.ts
Normal file
68
widget/src/services/api.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
64
widget/src/services/postMessage.ts
Normal file
64
widget/src/services/postMessage.ts
Normal 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
55
widget/src/types/index.ts
Normal 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
25
widget/tsconfig.json
Normal 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
45
widget/webpack.config.js
Normal 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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user