Initial monorepo setup with mcp-server and web packages
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { LogEntry, EventCategory } from '../types/realtime-events';
|
||||
|
||||
interface ActivityLogPanelProps {
|
||||
logs: LogEntry[];
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function ActivityLogPanel({ logs, onClear }: ActivityLogPanelProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const prevLogsLengthRef = useRef(0);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
if (logContainerRef.current && isOpen) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, isOpen]);
|
||||
|
||||
// Track unread count
|
||||
useEffect(() => {
|
||||
if (!isOpen && logs.length > prevLogsLengthRef.current) {
|
||||
const newLogs = logs.length - prevLogsLengthRef.current;
|
||||
setUnreadCount(prev => prev + newLogs);
|
||||
}
|
||||
prevLogsLengthRef.current = logs.length;
|
||||
}, [logs, isOpen]);
|
||||
|
||||
// Reset unread count when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setUnreadCount(0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
function getCategoryColor(category: EventCategory): string {
|
||||
switch (category) {
|
||||
case 'connection':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'speech':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'transcription':
|
||||
return 'bg-cyan-100 text-cyan-800 border-cyan-200';
|
||||
case 'tool_call':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
case 'response':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'buffer':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'audio':
|
||||
return 'bg-indigo-100 text-indigo-800 border-indigo-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0');
|
||||
const ms = date.getMilliseconds().toString().padStart(3, '0');
|
||||
return `${hours}:${minutes}:${seconds}.${ms}`;
|
||||
}
|
||||
|
||||
function LogEntryItem({ entry }: { entry: LogEntry }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-b border-gray-700 pb-2 mb-2 last:border-b-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs text-gray-400 font-mono shrink-0">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded border font-medium shrink-0 ${getCategoryColor(entry.category)}`}
|
||||
>
|
||||
{entry.category.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-200">
|
||||
{entry.summary || entry.eventType}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 mt-1"
|
||||
>
|
||||
{isExpanded ? '▼ Hide details' : '▶ Show details'}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<pre className="mt-2 p-2 bg-gray-900 rounded text-xs text-gray-300 overflow-x-auto">
|
||||
{JSON.stringify(entry.event, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="fixed bottom-4 right-4 z-50 bg-gray-800 hover:bg-gray-700 text-white rounded-full p-3 shadow-lg transition-colors"
|
||||
aria-label="Toggle activity log"
|
||||
>
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`fixed bottom-0 right-0 z-40 bg-gray-800 border-l border-t border-gray-700 shadow-2xl transition-transform duration-300 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
} w-full md:w-[450px] h-[50vh] md:h-[60vh] flex flex-col`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-white">Activity Log</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-sm px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Container */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-2"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
No events yet. Connect to start logging.
|
||||
</div>
|
||||
) : (
|
||||
logs.map(entry => <LogEntryItem key={entry.id} entry={entry} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { AgentStatus } from '../types/realtime-events';
|
||||
|
||||
interface AgentStatusProps {
|
||||
status: AgentStatus;
|
||||
}
|
||||
|
||||
export default function AgentStatus({ status }: AgentStatusProps) {
|
||||
function getStatusConfig() {
|
||||
switch (status) {
|
||||
case 'disconnected':
|
||||
return {
|
||||
color: 'bg-gray-500',
|
||||
text: 'Disconnected',
|
||||
icon: '○',
|
||||
animate: false
|
||||
};
|
||||
case 'connecting':
|
||||
return {
|
||||
color: 'bg-yellow-500',
|
||||
text: 'Connecting',
|
||||
icon: '◐',
|
||||
animate: true
|
||||
};
|
||||
case 'connected':
|
||||
return {
|
||||
color: 'bg-green-500',
|
||||
text: 'Connected',
|
||||
icon: '●',
|
||||
animate: false
|
||||
};
|
||||
case 'listening':
|
||||
return {
|
||||
color: 'bg-blue-500',
|
||||
text: 'Listening',
|
||||
icon: '🎤',
|
||||
animate: true
|
||||
};
|
||||
case 'processing':
|
||||
return {
|
||||
color: 'bg-purple-500',
|
||||
text: 'Processing',
|
||||
icon: '⚙',
|
||||
animate: true
|
||||
};
|
||||
case 'tool_executing':
|
||||
return {
|
||||
color: 'bg-orange-500',
|
||||
text: 'Executing Tool',
|
||||
icon: '🔧',
|
||||
animate: true
|
||||
};
|
||||
case 'speaking':
|
||||
return {
|
||||
color: 'bg-cyan-500',
|
||||
text: 'Speaking',
|
||||
icon: '🔊',
|
||||
animate: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const config = getStatusConfig();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${config.color} ${config.animate ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{config.icon} {config.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface AuthGateProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'auth_session';
|
||||
|
||||
export default function AuthGate({ children }: AuthGateProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Check for existing session on mount
|
||||
useEffect(() => {
|
||||
const session = sessionStorage.getItem(SESSION_KEY);
|
||||
if (session === 'authenticated') {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
setIsChecking(false);
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
|
||||
const response = await fetch(`${basePath}/api/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.authenticated) {
|
||||
sessionStorage.setItem(SESSION_KEY, 'authenticated');
|
||||
setIsAuthenticated(true);
|
||||
setPassword('');
|
||||
} else {
|
||||
setError('Invalid password');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Authentication failed');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<h1 className="text-2xl font-bold text-center mb-6">
|
||||
Authentication Required
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
placeholder="Enter password"
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !password}
|
||||
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Authenticating...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
interface DeviceSelectorProps {
|
||||
devices: Array<{ deviceId: string; label: string }>;
|
||||
selectedDeviceId: string;
|
||||
onDeviceChange: (deviceId: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function DeviceSelector({
|
||||
devices,
|
||||
selectedDeviceId,
|
||||
onDeviceChange,
|
||||
disabled,
|
||||
}: DeviceSelectorProps) {
|
||||
if (devices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label htmlFor="audio-device" className="text-xs md:text-sm font-medium text-gray-700">
|
||||
Microphone
|
||||
</label>
|
||||
<select
|
||||
id="audio-device"
|
||||
value={selectedDeviceId}
|
||||
onChange={(e) => onDeviceChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 text-sm md:text-base focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
interface MuteButtonProps {
|
||||
isMuted: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function MuteButton({ isMuted, onToggle, disabled }: MuteButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-full px-6 md:px-8 py-3 md:py-4 rounded-lg font-medium transition-colors
|
||||
${isMuted
|
||||
? 'bg-red-500 hover:bg-red-600 text-white'
|
||||
: 'bg-blue-500 hover:bg-blue-600 text-white'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
{isMuted ? 'Unmute' : 'Mute'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
interface VolumeBarProps {
|
||||
volume: number;
|
||||
isMuted?: boolean;
|
||||
}
|
||||
|
||||
export default function VolumeBar({ volume, isMuted }: VolumeBarProps) {
|
||||
function getColor() {
|
||||
if (isMuted) return 'bg-gray-300';
|
||||
if (volume > 80) return 'bg-red-500';
|
||||
if (volume > 60) return 'bg-yellow-400';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-10 h-48 bg-gray-200 rounded-lg overflow-hidden flex flex-col-reverse">
|
||||
<div
|
||||
className={`${getColor()} transition-all duration-100 ease-out`}
|
||||
style={{ height: `${isMuted ? 0 : volume}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user