add chat message overlay
This commit is contained in:
parent
eeeed36494
commit
5be6ab12f3
214
src/components/chat/ChatOverlay.tsx
Normal file
214
src/components/chat/ChatOverlay.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { TranscriptionTile } from "@/transcriptions/TranscriptionTile";
|
||||
import { TrackReferenceOrPlaceholder } from "@livekit/components-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface ChatOverlayProps {
|
||||
agentAudioTrack?: TrackReferenceOrPlaceholder;
|
||||
accentColor: string;
|
||||
inputDisabled?: boolean;
|
||||
isVisible: boolean;
|
||||
position: { x: number; y: number };
|
||||
onPositionChange: (position: { x: number; y: number }) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function ChatOverlay({
|
||||
agentAudioTrack,
|
||||
accentColor,
|
||||
inputDisabled,
|
||||
isVisible,
|
||||
position,
|
||||
onPositionChange,
|
||||
containerRef,
|
||||
onToggle,
|
||||
}: ChatOverlayProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Responsive sizing based on container size
|
||||
const [containerSize, setContainerSize] = useState({ width: 360, height: 500 });
|
||||
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setContainerSize({ width: rect.width, height: rect.height });
|
||||
}
|
||||
};
|
||||
|
||||
updateSize();
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
// Calculate overlay size as percentage of container, with min/max constraints
|
||||
// Width: larger (up to 95% of container)
|
||||
const overlayWidth = Math.min(
|
||||
Math.max(containerSize.width * 0.9, 280),
|
||||
containerSize.width * 0.95
|
||||
);
|
||||
// Height: smaller (reduced from 60-85% to 40-60%)
|
||||
const overlayHeight = Math.min(
|
||||
Math.max(containerSize.height * 0.4, 250),
|
||||
containerSize.height * 0.6
|
||||
);
|
||||
|
||||
// Position overlay at center when first shown
|
||||
const hasPositionedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isVisible && containerRef.current && containerSize.width > 0 && overlayWidth > 0 && overlayHeight > 0) {
|
||||
// Calculate center position
|
||||
const centerX = (containerSize.width - overlayWidth) / 2;
|
||||
const centerY = (containerSize.height - overlayHeight) / 2;
|
||||
|
||||
// Only auto-position on first show (when position is at origin)
|
||||
if (!hasPositionedRef.current && position.x === 0 && position.y === 0) {
|
||||
onPositionChange({ x: Math.max(0, centerX), y: Math.max(0, centerY) });
|
||||
hasPositionedRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [isVisible, containerSize.width, containerSize.height, overlayWidth, overlayHeight, containerRef, position, onPositionChange]);
|
||||
|
||||
const handleDragStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!overlayRef.current || !headerRef.current) return;
|
||||
|
||||
// Only allow dragging from the header, but not from buttons
|
||||
const target = e.target as HTMLElement;
|
||||
if (!headerRef.current.contains(target)) return;
|
||||
|
||||
// Don't drag if clicking on the close button
|
||||
if (target.closest('button') || target.closest('svg')) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
dragOffset.current = {
|
||||
x: clientX - position.x,
|
||||
y: clientY - position.y,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDragMove = useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging || !containerRef.current || !overlayRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const overlayRect = overlayRef.current.getBoundingClientRect();
|
||||
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
let newX = clientX - dragOffset.current.x;
|
||||
let newY = clientY - dragOffset.current.y;
|
||||
|
||||
// Constrain within container bounds
|
||||
const maxX = containerRect.width - overlayRect.width;
|
||||
const maxY = containerRect.height - overlayRect.height;
|
||||
const minY = 0; // Allow dragging to top
|
||||
|
||||
newX = Math.max(0, Math.min(newX, maxX));
|
||||
newY = Math.max(minY, Math.min(newY, maxY));
|
||||
|
||||
onPositionChange({ x: newX, y: newY });
|
||||
}, [isDragging, containerRef, overlayRef, onPositionChange]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener("mousemove", handleDragMove);
|
||||
window.addEventListener("mouseup", handleDragEnd);
|
||||
window.addEventListener("touchmove", handleDragMove, { passive: false });
|
||||
window.addEventListener("touchend", handleDragEnd);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleDragMove);
|
||||
window.removeEventListener("mouseup", handleDragEnd);
|
||||
window.removeEventListener("touchmove", handleDragMove);
|
||||
window.removeEventListener("touchend", handleDragEnd);
|
||||
};
|
||||
}, [isDragging, handleDragMove, handleDragEnd]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="absolute z-40 rounded-lg border border-white/20 shadow-2xl backdrop-blur-md transition-all duration-300 flex flex-col"
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: `${overlayWidth}px`,
|
||||
height: `${overlayHeight}px`,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
cursor: isDragging ? 'grabbing' : 'default',
|
||||
display: isVisible ? 'flex' : 'none',
|
||||
}}
|
||||
onMouseDown={handleDragStart}
|
||||
onTouchStart={handleDragStart}
|
||||
>
|
||||
{/* Header with drag handle and close button */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="flex items-center justify-between px-4 py-2 border-b border-white/10 cursor-move select-none"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.3)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-white/40"></div>
|
||||
<span className="text-white text-xs font-medium">Chat</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}}
|
||||
className="text-white hover:text-white transition-colors p-2 rounded hover:bg-white/10 flex items-center justify-center"
|
||||
aria-label="Close chat overlay"
|
||||
style={{ minWidth: '32px', minHeight: '32px' }}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat content with padding */}
|
||||
<div className="overflow-hidden flex flex-col px-2 py-2" style={{ height: `calc(100% - 40px)` }}>
|
||||
<TranscriptionTile
|
||||
agentAudioTrack={agentAudioTrack}
|
||||
accentColor={accentColor}
|
||||
inputDisabled={inputDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,8 +13,9 @@ import {
|
||||
} from "@livekit/components-react";
|
||||
import { ConnectionState, Track, LocalParticipant, Room } from "livekit-client";
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||
import { BatteryIcon, ImageIcon, MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, WifiIcon, SwitchCameraIcon, VoiceIcon, CheckIcon } from "./icons";
|
||||
import { BatteryIcon, ImageIcon, MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, WifiIcon, SwitchCameraIcon, VoiceIcon, CheckIcon, ChatIcon } from "./icons";
|
||||
import { useToast } from "@/components/toast/ToasterProvider";
|
||||
import { ChatOverlay } from "@/components/chat/ChatOverlay";
|
||||
|
||||
export interface PhoneSimulatorProps {
|
||||
onConnect: () => void;
|
||||
@ -67,6 +68,8 @@ export function PhoneSimulator({
|
||||
const [interruptRejected, setInterruptRejected] = useState(false);
|
||||
const [isPushToTalkMode, setIsPushToTalkMode] = useState(true); // false = realtime mode, true = PTT mode (default)
|
||||
const pushToTalkButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [showChatOverlay, setShowChatOverlay] = useState(false);
|
||||
const [chatOverlayPosition, setChatOverlayPosition] = useState({ x: 0, y: 0 }); // Will be positioned at top-right by ChatOverlay component
|
||||
|
||||
useEffect(() => {
|
||||
const voiceAttr = config.settings.attributes?.find(a => a.key === "voice");
|
||||
@ -852,6 +855,28 @@ export function PhoneSimulator({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Toggle Button - Top Right, aligned with audio visualizer */}
|
||||
{roomState === ConnectionState.Connected &&
|
||||
voiceAssistant.agent &&
|
||||
phoneMode !== "important_message" &&
|
||||
phoneMode !== "capture" && (
|
||||
<button
|
||||
className={`absolute right-2 z-50 p-3 rounded-full backdrop-blur-md transition-colors shadow-lg ${
|
||||
showChatOverlay
|
||||
? "bg-blue-500/80 text-white"
|
||||
: "bg-gray-800/70 text-white hover:bg-gray-800/90"
|
||||
}`}
|
||||
onClick={() => setShowChatOverlay(!showChatOverlay)}
|
||||
title={showChatOverlay ? "Hide chat" : "Show chat"}
|
||||
style={{
|
||||
top: '56px', // Align with audio visualizer initial position
|
||||
right: '8px',
|
||||
}}
|
||||
>
|
||||
<ChatIcon className="w-5 h-5 md:w-6 md:h-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div ref={phoneContainerRef} className="flex-grow relative bg-gray-950 w-full h-full overflow-hidden"
|
||||
style={{
|
||||
@ -1002,6 +1027,23 @@ export function PhoneSimulator({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Overlay - Hidden during capture and important_message modes */}
|
||||
{roomState === ConnectionState.Connected &&
|
||||
voiceAssistant.agent &&
|
||||
phoneMode !== "capture" &&
|
||||
phoneMode !== "important_message" && (
|
||||
<ChatOverlay
|
||||
agentAudioTrack={voiceAssistant.audioTrack}
|
||||
accentColor={config.settings.theme_color}
|
||||
inputDisabled={phoneMode === "important_message" || phoneMode === "hand_off"}
|
||||
isVisible={showChatOverlay}
|
||||
position={chatOverlayPosition}
|
||||
onPositionChange={setChatOverlayPosition}
|
||||
containerRef={phoneContainerRef}
|
||||
onToggle={() => setShowChatOverlay(!showChatOverlay)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Call Controls Overlay */}
|
||||
{roomState === ConnectionState.Connected && (
|
||||
phoneMode === "capture" ? (
|
||||
@ -1120,9 +1162,20 @@ export function PhoneSimulator({
|
||||
|
||||
{/* Push-to-Talk Mode Layout */}
|
||||
{isPushToTalkMode && phoneMode !== "hand_off" && voiceAssistant.agent && (
|
||||
<div className="w-full flex items-center justify-center gap-8">
|
||||
{/* Mic Toggle and Camera Switch Buttons - Left (hidden in important_message mode) */}
|
||||
{phoneMode !== "important_message" && (
|
||||
<>
|
||||
{/* Important Message Mode - Centered End Call Button */}
|
||||
{phoneMode === "important_message" ? (
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<button
|
||||
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
|
||||
onClick={handleDisconnect}
|
||||
>
|
||||
<PhoneOffIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex items-center justify-between gap-8">
|
||||
{/* Left side: Mic Toggle and Camera Switch Buttons */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Mic Toggle Button */}
|
||||
<button
|
||||
@ -1169,10 +1222,8 @@ export function PhoneSimulator({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Large Push-to-Talk Button - Center (hidden in important_message mode) */}
|
||||
{phoneMode !== "important_message" && (
|
||||
{/* Center: Large Push-to-Talk Button */}
|
||||
<button
|
||||
ref={pushToTalkButtonRef}
|
||||
className={`w-24 h-24 rounded-full backdrop-blur-md transition-all flex flex-col items-center justify-center gap-2 aspect-square select-none ${
|
||||
@ -1194,9 +1245,8 @@ export function PhoneSimulator({
|
||||
{interruptRejected ? "不允许打断" : "按住说话"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* End Call Button - Right (always shown in PTT mode) */}
|
||||
{/* Right side: End Call Button */}
|
||||
<button
|
||||
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
|
||||
onClick={handleDisconnect}
|
||||
@ -1205,11 +1255,26 @@ export function PhoneSimulator({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Realtime Mode Layout */}
|
||||
{!isPushToTalkMode && (
|
||||
<div className="w-full flex items-center justify-center gap-8">
|
||||
{phoneMode !== "important_message" && phoneMode !== "hand_off" && (
|
||||
<>
|
||||
{/* Important Message Mode - Centered End Call Button */}
|
||||
{phoneMode === "important_message" ? (
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<button
|
||||
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
|
||||
onClick={handleDisconnect}
|
||||
>
|
||||
<PhoneOffIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex items-center justify-between gap-8">
|
||||
{/* Left side: Mic Toggle */}
|
||||
{phoneMode !== "hand_off" && (
|
||||
<button
|
||||
className={`p-4 rounded-full backdrop-blur-md transition-colors ${
|
||||
!isMicEnabled
|
||||
@ -1226,7 +1291,7 @@ export function PhoneSimulator({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* End Call Button */}
|
||||
{/* Right side: End Call Button */}
|
||||
<button
|
||||
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
|
||||
onClick={handleDisconnect}
|
||||
@ -1235,6 +1300,8 @@ export function PhoneSimulator({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hand Off Mode - Show only End Call Button */}
|
||||
{phoneMode === "hand_off" && (
|
||||
|
||||
@ -207,3 +207,20 @@ export const VoiceIcon = ({ className }: { className?: string }) => (
|
||||
<line x1="12" y1="19" x2="12" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChatIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user