diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..f82763e08 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20 + +# Set the working directory in the container +WORKDIR /app + +# Copy package.json and package-lock.json files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy the rest of the application code +COPY . . + +# Expose the port the app runs on +EXPOSE 3000 + +# Start the app +CMD ["npm", "run", "dev"] + + diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 89e357ecc..000000000 --- a/LICENSE +++ /dev/null @@ -1,9 +0,0 @@ -MIT License - -Copyright (c) 2025 OpenAI - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 4e75c1a5d..51a95da4a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,25 @@ You should be able to use this repo to prototype your own multi-agent realtime v - Start the server with `npm run dev` - Open your browser to [http://localhost:3000](http://localhost:3000) to see the app. It should automatically connect to the `simpleExample` Agent Set. +## Alternative Docker Setup + +- You can also run this in a Docker container. +- Build the Docker image with the following command: + + ```bash + docker build -t realtime-api-agents-demo . + ``` + +- Run the Docker container with the following command: + + ```bash + docker run -it --rm -p 3000:3000 + -e OPENAI_API_KEY=replace-with-api-key + -v local-copy-of/openai-realtime-agents:/app + -v /app/node_modules + realtime-api-agents-demo + ``` + ## Configuring Agents Configuration in `src/app/agentConfigs/simpleExample.ts` ```javascript @@ -49,6 +68,156 @@ export default agents; This fully specifies the agent set that was used in the interaction shown in the screenshot above. +### Sequence Diagram + +#### SimpleExample Flow + +This diagram illustrates the interaction flow defined in `src/app/agentConfigs/simpleExample.ts`. + +```mermaid +sequenceDiagram + participant User + participant WebClient as Next.js Client (App.tsx) + participant NextAPI as /api/session + participant RealtimeAPI as OpenAI Realtime API + participant AgentManager as AgentConfig (greeter, haiku) + + Note over WebClient: User navigates to the app with ?agentConfig=simpleExample + User->>WebClient: Open Page (Next.js SSR fetches page.tsx/layout.tsx) + WebClient->>WebClient: useEffect loads agent configs (simpleExample) + WebClient->>WebClient: connectToRealtime() called + + Note right of WebClient: Fetch ephemeral session + WebClient->>NextAPI: GET /api/session + NextAPI->>RealtimeAPI: POST /v1/realtime/sessions + RealtimeAPI->>NextAPI: Returns ephemeral session token + NextAPI->>WebClient: Returns ephemeral token (JSON) + + Note right of WebClient: Start RTC handshake + WebClient->>RealtimeAPI: POST /v1/realtime?model=gpt-4o-realtime-preview-2024-12-17 + RealtimeAPI->>WebClient: Returns SDP answer + WebClient->>WebClient: DataChannel "oai-events" established + + Note over WebClient: The user speaks or sends text + User->>WebClient: "Hello!" (mic or text) + WebClient->>AgentManager: conversation.item.create (role=user) + WebClient->>RealtimeAPI: data channel event: {type: "conversation.item.create"} + WebClient->>RealtimeAPI: data channel event: {type: "response.create"} + + Note left of AgentManager: Agents parse user message + AgentManager->>greeter: "greeter" sees new user message + greeter->>AgentManager: Potentially calls "transferAgents(haiku)" if user says "Yes" + AgentManager-->>WebClient: event: transferAgents => destination_agent="haiku" + + Note left of WebClient: data channel function call + WebClient->>WebClient: handleFunctionCall: sets selectedAgentName="haiku" + + Note left of AgentManager: "haiku" agent now handles user messages + haiku->>AgentManager: Respond with a haiku + AgentManager->>WebClient: "Here is a haiku…" (assistant role) + WebClient->>User: Display/Play final answer +``` + +#### FrontDeskAuthentication Flow + +This diagram illustrates the interaction flow defined in `src/app/agentConfigs/frontDeskAuthentication/`. + +```mermaid +sequenceDiagram + participant User + participant WebClient as Next.js Client (App.tsx) + participant NextAPI as /api/session + participant RealtimeAPI as OpenAI Realtime API + participant AgentManager as Agents (authenticationAgent, tourGuide) + + Note over WebClient: User navigates to ?agentConfig=frontDeskAuthentication + User->>WebClient: Open Page + WebClient->>NextAPI: GET /api/session + NextAPI->>RealtimeAPI: POST /v1/realtime/sessions + RealtimeAPI->>NextAPI: Returns ephemeral session + NextAPI->>WebClient: Returns ephemeral token (JSON) + + Note right of WebClient: Start RTC handshake + WebClient->>RealtimeAPI: Offer SDP (WebRTC) + RealtimeAPI->>WebClient: SDP answer + WebClient->>WebClient: DataChannel "oai-events" established + + Note over WebClient,AgentManager: The user is connected to "authenticationAgent" first + User->>WebClient: "Hello, I need to check in." + WebClient->>AgentManager: conversation.item.create (role=user) + WebClient->>RealtimeAPI: data channel event: {type: "conversation.item.create"} + WebClient->>RealtimeAPI: data channel event: {type: "response.create"} + + Note over AgentManager: authenticationAgent prompts for user details + authenticationAgent->>AgentManager: calls authenticate_user_information() (tool function) + AgentManager-->>WebClient: function_call => name="authenticate_user_information" + WebClient->>WebClient: handleFunctionCall => possibly calls your custom backend or a mock to confirm + + Note left of AgentManager: Once user is authenticated + authenticationAgent->>AgentManager: calls transferAgents("tourGuide") + AgentManager-->>WebClient: function_call => name="transferAgents" args={destination: "tourGuide"} + + WebClient->>WebClient: setSelectedAgentName("tourGuide") + Note over AgentManager: "tourGuide" welcomes the user with a friendly introduction + tourGuide->>AgentManager: "Here's a guided tour..." + AgentManager->>WebClient: conversation.item.create (assistant role) + WebClient->>User: Displays or plays back the tour content +``` + +#### CustomerServiceRetail Flow + +This diagram illustrates the interaction flow defined in `src/app/agentConfigs/customerServiceRetail/`. + +```mermaid +sequenceDiagram + participant User + participant WebClient as Next.js Client + participant NextAPI as /api/session + participant RealtimeAPI as OpenAI Realtime API + participant AgentManager as Agents (authentication, returns, sales, simulatedHuman) + participant o1mini as "o1-mini" (Escalation Model) + + Note over WebClient: User navigates to ?agentConfig=customerServiceRetail + User->>WebClient: Open Page + WebClient->>NextAPI: GET /api/session + NextAPI->>RealtimeAPI: POST /v1/realtime/sessions + RealtimeAPI->>NextAPI: Returns ephemeral session + NextAPI->>WebClient: Returns ephemeral token (JSON) + + Note right of WebClient: Start RTC handshake + WebClient->>RealtimeAPI: Offer SDP (WebRTC) + RealtimeAPI->>WebClient: SDP answer + WebClient->>WebClient: DataChannel "oai-events" established + + Note over AgentManager: Default agent is "authentication" + User->>WebClient: "Hi, I'd like to return my snowboard." + WebClient->>AgentManager: conversation.item.create (role=user) + WebClient->>RealtimeAPI: {type: "conversation.item.create"} + WebClient->>RealtimeAPI: {type: "response.create"} + + authentication->>AgentManager: Requests user info, calls authenticate_user_information() + AgentManager-->>WebClient: function_call => name="authenticate_user_information" + WebClient->>WebClient: handleFunctionCall => verifies details + + Note over AgentManager: After user is authenticated + authentication->>AgentManager: transferAgents("returns") + AgentManager-->>WebClient: function_call => name="transferAgents" args={ destination: "returns" } + WebClient->>WebClient: setSelectedAgentName("returns") + + Note over returns: The user wants to process a return + returns->>AgentManager: function_call => checkEligibilityAndPossiblyInitiateReturn + AgentManager-->>WebClient: function_call => name="checkEligibilityAndPossiblyInitiateReturn" + + Note over WebClient: The WebClient calls /api/chat/completions with model="o1-mini" + WebClient->>o1mini: "Is this item eligible for return?" + o1mini->>WebClient: "Yes/No (plus notes)" + + Note right of returns: Returns uses the result from "o1-mini" + returns->>AgentManager: "Return is approved" or "Return is denied" + AgentManager->>WebClient: conversation.item.create (assistant role) + WebClient->>User: Displays final verdict +``` + ### Next steps - Check out the configs in `src/app/agentConfigs`. The example above is a minimal demo that illustrates the core concepts. - [frontDeskAuthentication](src/app/agentConfigs/frontDeskAuthentication) Guides the user through a step-by-step authentication flow, confirming each value character-by-character, authenticates the user with a tool call, and then transfers to another agent. Note that the second agent is intentionally "bored" to show how to prompt for personality and tone. diff --git a/reasoning_flask_app/main.py b/reasoning_flask_app/main.py new file mode 100644 index 000000000..fec5e36fa --- /dev/null +++ b/reasoning_flask_app/main.py @@ -0,0 +1,35 @@ +from flask import Flask, request, jsonify +import openai # Using OpenAI model for reasoning +import os +from dotenv import load_dotenv +from openai import OpenAI +# Load environment variables +load_dotenv() + +app = Flask(__name__) + +@app.route("/reason", methods=["POST"]) +def reason(): + try: + data = request.get_json() + if not data or "query" not in data: + return jsonify({"error": "Missing 'query' in request"}), 400 + + query = data["query"] + openai.api_key = os.getenv("OPENAI_API_KEY") # Use API key from environment + client = OpenAI() + response = client.chat.completions.create( + model="o1-mini", + messages=[ + {"role": "developer", "content": "You are a helpful assistant."}, + {"role": "user", "content": query} + ] + max_completion_tokens=150 + ) + + return jsonify({"response": response.choices[0].message.content}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/src/app/App.tsx b/src/app/App.tsx index e785ca744..4b2c12d4f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,6 +10,8 @@ import Image from "next/image"; import Transcript from "./components/Transcript"; import Events from "./components/Events"; import BottomToolbar from "./components/BottomToolbar"; +import { ThemeToggle } from "./components/ThemeToggle"; +import { ThemeProvider } from "./components/ThemeProvider"; // Types import { AgentConfig, SessionStatus } from "@/app/types"; @@ -26,6 +28,15 @@ import { createRealtimeConnection } from "./lib/realtimeConnection"; import { allAgentSets, defaultAgentSetKey } from "@/app/agentConfigs"; function App() { + return ( + + + + + ); +} + +function AppContent() { const searchParams = useSearchParams(); const { transcriptItems, addTranscriptMessage, addTranscriptBreadcrumb } = @@ -401,11 +412,22 @@ function App() { } }, [isAudioPlaybackEnabled]); + useEffect(() => { + // (un)mute microphone + if (pcRef.current) { + pcRef.current.getSenders().forEach((sender) => { + if (sender.track) { + sender.track.enabled = isAudioPlaybackEnabled + } + }); + } + }, [isAudioPlaybackEnabled, pcRef.current]) + const agentSetKey = searchParams.get("agentConfig") || "default"; return ( -
-
+
+
window.location.reload()} style={{ cursor: 'pointer' }}> OpenAI Logo
- Realtime API Agents + Realtime API Agents
@@ -428,7 +450,7 @@ function App() { {selectedAgentConfigSet?.map(agent => (
-
+
label "Disconnect" -> red - return `bg-red-600 hover:bg-red-700 ${cursorClass} ${baseClasses}`; - } - // Disconnected or connecting -> label is either "Connect" or "Connecting" -> black - return `bg-black hover:bg-gray-900 ${cursorClass} ${baseClasses}`; + return `${cursorClass} ${baseClasses}`; } return ( -
+
@@ -94,9 +90,9 @@ function BottomToolbar({ checked={isAudioPlaybackEnabled} onChange={e => setIsAudioPlaybackEnabled(e.target.checked)} disabled={!isConnected} - className="w-4 h-4" + className="w-4 h-4 accent-primary dark:accent-primary cursor-pointer" /> -
@@ -107,9 +103,9 @@ function BottomToolbar({ type="checkbox" checked={isEventsPaneExpanded} onChange={e => setIsEventsPaneExpanded(e.target.checked)} - className="w-4 h-4" + className="w-4 h-4 accent-primary dark:accent-primary cursor-pointer" /> -
diff --git a/src/app/components/Events.tsx b/src/app/components/Events.tsx index 22a1a34d1..7994e4e0d 100644 --- a/src/app/components/Events.tsx +++ b/src/app/components/Events.tsx @@ -15,9 +15,9 @@ function Events({ isExpanded }: EventsProps) { const { loggedEvents, toggleExpand } = useEvent(); const getDirectionArrow = (direction: string) => { - if (direction === "client") return { symbol: "▲", color: "#7f5af0" }; - if (direction === "server") return { symbol: "▼", color: "#2cb67d" }; - return { symbol: "•", color: "#555" }; + if (direction === "client") return { symbol: "▲", color: "var(--primary)" }; + if (direction === "server") return { symbol: "▼", color: "var(--accent)" }; + return { symbol: "•", color: "var(--muted)" }; }; useEffect(() => { @@ -33,15 +33,16 @@ function Events({ isExpanded }: EventsProps) { return (
{isExpanded && (
-
+
Logs
@@ -54,7 +55,7 @@ function Events({ isExpanded }: EventsProps) { return (
toggleExpand(log.id)} @@ -65,25 +66,27 @@ function Events({ isExpanded }: EventsProps) { style={{ color: arrowInfo.color }} className="ml-1 mr-2" > - {arrowInfo.symbol} + {arrowInfo.symbol} {log.eventName}
-
+
{log.timestamp}
{log.expanded && log.eventData && ( -
-
+                    
+
                         {JSON.stringify(log.eventData, null, 2)}
                       
diff --git a/src/app/components/ThemeProvider.tsx b/src/app/components/ThemeProvider.tsx new file mode 100644 index 000000000..e60354684 --- /dev/null +++ b/src/app/components/ThemeProvider.tsx @@ -0,0 +1,76 @@ +'use client'; + +import React, { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'blue' | 'green' | 'purple' | 'default'; +type Mode = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + mode: Mode; + setTheme: (theme: Theme) => void; + setMode: (mode: Mode) => void; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState('default'); + const [mode, setMode] = useState('light'); + + useEffect(() => { + // Load saved preferences + const savedTheme = localStorage.getItem('theme') as Theme; + const savedMode = localStorage.getItem('mode') as Mode; + + if (savedTheme) setTheme(savedTheme); + if (savedMode) setMode(savedMode); + }, []); + + useEffect(() => { + // Update document classes and data attributes + const root = document.documentElement; + + // Update dark mode class + if (mode === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + + // Update theme + if (theme !== 'default') { + root.setAttribute('data-theme', `${theme}-${mode}`); + } else { + root.removeAttribute('data-theme'); + } + + // Apply background color transition + document.body.style.transition = 'background-color 0.3s ease'; + + // Save preferences + localStorage.setItem('theme', theme); + localStorage.setItem('mode', mode); + }, [theme, mode]); + + const value = { + theme, + mode, + setTheme: (newTheme: Theme) => setTheme(newTheme), + setMode: (newMode: Mode) => setMode(newMode), + }; + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/app/components/ThemeToggle.tsx b/src/app/components/ThemeToggle.tsx new file mode 100644 index 000000000..8f40f1cd8 --- /dev/null +++ b/src/app/components/ThemeToggle.tsx @@ -0,0 +1,83 @@ +'use client'; + +import React, { useState } from 'react'; +import { useTheme } from './ThemeProvider'; + +export function ThemeToggle() { + const { theme, mode, setTheme, setMode } = useTheme(); + const [showThemeSelector, setShowThemeSelector] = useState(false); + + const themes = [ + { id: 'default', name: 'Default' }, + { id: 'blue', name: 'Blue' }, + { id: 'green', name: 'Green' }, + { id: 'purple', name: 'Purple' }, + ]; + + return ( + <> +
+ {/* Mode Toggle */} + + + {/* Theme Selector Toggle */} + +
+ + {/* Theme Selector Dropdown */} + {showThemeSelector && ( +
+ {themes.map((t) => ( + + ))} +
+ )} + + ); +} diff --git a/src/app/components/Transcript.tsx b/src/app/components/Transcript.tsx index bf64c67f8..c178c6fa1 100644 --- a/src/app/components/Transcript.tsx +++ b/src/app/components/Transcript.tsx @@ -48,7 +48,6 @@ function Transcript({ setPrevLogs(transcriptItems); }, [transcriptItems]); - // Autofocus on text box input on load useEffect(() => { if (canSend && inputRef.current) { inputRef.current.focus(); @@ -67,11 +66,11 @@ function Transcript({ }; return ( -
+
@@ -91,15 +90,19 @@ function Transcript({ const isUser = role === "user"; const baseContainer = "flex justify-end flex-col"; const containerClasses = `${baseContainer} ${isUser ? "items-end" : "items-start"}`; - const bubbleBase = `max-w-lg p-3 rounded-xl ${isUser ? "bg-gray-900 text-gray-100" : "bg-gray-100 text-black"}`; + const bubbleBase = `max-w-lg p-3 rounded-lg transition-all duration-200 shadow-md ${ + isUser + ? "bg-[#4a4a4a] dark:bg-accent/20 text-white dark:text-white border border-[#2a2a2a] dark:border-accent/20" + : "bg-[#4a2a2a]/10 bg-accent/20 text-foreground dark:text-foreground border border-[#C4C4C4] dark:border-[#404040]" + }`; const isBracketedMessage = title.startsWith("[") && title.endsWith("]"); - const messageStyle = isBracketedMessage ? "italic text-gray-400" : ""; + const messageStyle = isBracketedMessage ? "italic text-muted dark:text-muted" : ""; const displayTitle = isBracketedMessage ? title.slice(1, -1) : title; return (
-
+
{timestamp}
@@ -112,18 +115,18 @@ function Transcript({ return (
{timestamp}
data && toggleTranscriptItemExpand(itemId)} > {data && ( @@ -133,8 +136,8 @@ function Transcript({ {title}
{expanded && data && ( -
-
+                    
+
                         {JSON.stringify(data, null, 2)}
                       
@@ -142,11 +145,10 @@ function Transcript({
); } else { - // Fallback if type is neither MESSAGE nor BREADCRUMB return (
Unknown item type: {type}{" "} {timestamp} @@ -157,7 +159,7 @@ function Transcript({
-
+
diff --git a/src/app/globals.css b/src/app/globals.css index 30f9f4b6e..8f32dd2af 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,21 +2,191 @@ @tailwind components; @tailwind utilities; +/* Default Light Theme */ :root { --background: #fafafa; --foreground: #171717; + --primary: #2563eb; + --secondary: #4f46e5; + --accent: #0ea5e9; + --muted: #6b7280; + --border: #6b7280; + --divider: #282828; + --disconnect: #dc2626; + --disconnect-hover: #b91c1c; + --surface: #f1f5f9; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +/* Default Dark Theme */ +/* Default Dark Theme */ +.dark { + --background: #1a1a1a; + --foreground: #fffff; + --primary: #60a5fa; + --secondary: #818cf8; + --accent: #38bdf8; + --muted: #9ca3af; + --border: #2a2a2a; + --panel-bg: #202020; + --panel-border: #303030; + --panel-border-light: #6a2a2a; + --panel-border-dark: #404040; + --disconnect: #dc2626; + --disconnect-hover: #b91c1c; + --surface: #242424; + --divider: #282828; +} + +/* Material Design Shadows and Transitions */ +.dark .panel { + background: var(--panel-bg); + border: 1px solid var(--panel-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +/* Custom shadows for dark mode */ +.dark .shadow-md { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3), + 0 4px 8px rgba(0, 0, 0, 0.2); +} + +/* Custom shadows for light mode */ +.shadow-md { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), + 0 4px 8px rgba(0, 0, 0, 0.1); +} + +/* Smooth transitions */ +* { + transition: background-color 0.1s ease-in-out, + color 0.1s ease-in-out, + border-color 0.1s ease-in-out, + box-shadow 0.1s ease-in-out; +} + +/* Disconnect button styles */ +.disconnect-btn { + background: var(--disconnect); + border: 1px solid var(--border); +} + +.disconnect-btn:hover { + background: var(--disconnect-hover); +} +/* Blue Theme - Light */ +[data-theme="blue-light"] { + --background: #f0f9ff; + --foreground: #0c4a6e; + --primary: #0284c7; + --secondary: #0369a1; + --accent: #0ea5e9; + --muted: #64748b; +} + +/* Green Theme - Light */ +[data-theme="green-light"] { + --background: #f0fdf4; + --foreground: #14532d; + --primary: #16a34a; + --secondary: #15803d; + --accent: #22c55e; + --muted: #64748b; +} + +/* Purple Theme - Light */ +[data-theme="purple-light"] { + --background: #faf5ff; + --foreground: #581c87; + --primary: #9333ea; + --secondary: #7e22ce; + --accent: #a855f7; + --muted: #64748b; +} + +/* Dark Theme Variants */ +.dark { + --background: #0a0a0a; + --foreground: #ededed; + --primary: #60a5fa; + --secondary: #818cf8; + --accent: #38bdf8; + --muted: #9ca3af; +} + +/* Blue Theme - Dark */ +.dark[data-theme="blue-dark"] { + --background: #0c4a6e; + --foreground: #e0f2fe; + --primary: #38bdf8; + --secondary: #0ea5e9; + --accent: #7dd3fc; + --muted: #94a3b8; +} + +/* Green Theme - Dark */ +.dark[data-theme="green-dark"] { + --background: #14532d; + --foreground: #dcfce7; + --primary: #4ade80; + --secondary: #22c55e; + --accent: #86efac; + --muted: #94a3b8; +} + +/* Purple Theme - Dark */ +.dark[data-theme="purple-dark"] { + --background: #581c87; + --foreground: #f3e8ff; + --primary: #c084fc; + --secondary: #a855f7; + --accent: #d8b4fe; + --muted: #94a3b8; +} + +@layer base { + body { + @apply text-foreground bg-background transition-colors duration-100; + } + + /* Improved dark mode transitions */ + * { + @apply transition-[background-color,color,border-color,box-shadow] duration-100; } } body { - color: var(--foreground); - background: var(--background); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; + min-height: 100vh; + margin: 0; +} + +/* Dark mode scrollbar */ +.dark ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.dark ::-webkit-scrollbar-track { + background: var(--background); +} + +.dark ::-webkit-scrollbar-thumb { + background: var(--primary); + border-radius: 5px; +} + +.dark ::-webkit-scrollbar-thumb:hover { + background: var(--accent); +} + +/* Responsive Design Adjustments */ +@layer utilities { + .theme-toggle { + @apply fixed bottom-4 right-4 md:bottom-8 md:right-8; + } + + .theme-selector { + @apply fixed bottom-16 right-4 md:bottom-20 md:right-8 bg-background border border-muted rounded-lg p-2 shadow-lg; + } } diff --git a/src/app/hooks/useHandleServerEvent.ts b/src/app/hooks/useHandleServerEvent.ts index b564cdb6a..0a4de39a4 100644 --- a/src/app/hooks/useHandleServerEvent.ts +++ b/src/app/hooks/useHandleServerEvent.ts @@ -31,6 +31,23 @@ export function useHandleServerEvent({ const { logServerEvent } = useEvent(); + // A ref to hold the list of active output audio buffers (using their IDs) + const outputAudioBuffersRef = useRef([]); + + // Wait until all output audio buffers are empty + async function waitUntilAudioBuffersEmpty() { + if (outputAudioBuffersRef.current.length > 0) { + await new Promise((resolve) => { + const interval = setInterval(() => { + if (outputAudioBuffersRef.current.length === 0) { + clearInterval(interval); + resolve(); + } + }, 100); + }); + } + } + const handleFunctionCall = async (functionCallParams: { name: string; call_id?: string; @@ -59,11 +76,15 @@ export function useHandleServerEvent({ output: JSON.stringify(fnResult), }, }); + + await waitUntilAudioBuffersEmpty(); sendClientEvent({ type: "response.create" }); } else if (functionCallParams.name === "transferAgents") { const destinationAgent = args.destination_agent; const newAgentConfig = selectedAgentConfigSet?.find((a) => a.name === destinationAgent) || null; + + await waitUntilAudioBuffersEmpty(); if (newAgentConfig) { setSelectedAgentName(destinationAgent); } @@ -98,6 +119,8 @@ export function useHandleServerEvent({ output: JSON.stringify(simulatedResult), }, }); + + await waitUntilAudioBuffersEmpty(); sendClientEvent({ type: "response.create" }); } }; @@ -187,6 +210,26 @@ export function useHandleServerEvent({ break; } + // Handle creation of an output audio buffer. + case "output_audio_buffer.started": { + // Assuming serverEvent carries a unique buffer ID in serverEvent.response_id. + if (serverEvent.response_id) { + outputAudioBuffersRef.current.push(serverEvent.response_id); + } + break; + } + + // Handle deletion of an output audio buffer. + case "output_audio_buffer.stopped": + case "output_audio_buffer.cleared": { + if (serverEvent.response_id) { + outputAudioBuffersRef.current = outputAudioBuffersRef.current.filter( + (id) => id !== serverEvent.response_id + ); + } + break; + } + default: break; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index acb7c79dd..d4086be27 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,19 +1,47 @@ import type { Metadata } from "next"; import "./globals.css"; +import { ThemeProvider } from "./components/ThemeProvider"; +import { ThemeToggle } from "./components/ThemeToggle"; export const metadata: Metadata = { title: "Realtime API Agents", description: "A demo app from OpenAI.", }; +function ThemeScript() { + return ( +