├── .gitignore ├── LICENSE ├── README.md ├── app ├── api │ └── session │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── banner.tsx ├── broadcast-button.tsx ├── header.tsx ├── language-switcher.tsx ├── message-controls.tsx ├── mobile-nav.tsx ├── status.tsx ├── text-input.tsx ├── theme-provider.tsx ├── theme-switcher.tsx ├── token-usage.tsx ├── tools-education.tsx ├── translations-context.tsx ├── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── breadcrumb.tsx │ ├── button.tsx │ ├── card.tsx │ ├── carousel.tsx │ ├── chart.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── confetti.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hover-card.tsx │ ├── input-otp.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── navigation-menu.tsx │ ├── pagination.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── radio-group.tsx │ ├── resizable.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── three-dots-wave.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── toggle-group.tsx │ ├── toggle.tsx │ ├── tooltip.tsx │ └── transcriber.tsx ├── voice-select.tsx └── welcome.tsx ├── config └── site.ts ├── deno.json ├── deno.lock ├── eslint.config.mjs ├── hooks ├── use-mobile.tsx ├── use-toast.ts ├── use-tools.ts └── use-webrtc.ts ├── lib ├── conversations.ts ├── tools.ts ├── translations │ ├── en.ts │ ├── es.ts │ ├── fr.ts │ └── zh.ts └── utils.ts ├── next-env.d.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── demo.gif ├── demo.mp4 ├── file.svg ├── globe.svg ├── next.svg ├── ui.png ├── vercel.svg └── window.svg ├── tailwind.config.ts ├── tsconfig.json └── types └── index.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 skrivov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAI WebRTC Shadcn Next15 Starter 2 | This is a WebRTC-based Voice AI stream application using `OpenAI`'s `Realtime API` and `WebRTC`. Project contains `/api` route and UI components developed with `Next.js` and `shadcn/ui`. It supports real-time audio conversations implented in [skrivov/openai-voice-webrtc-next](https://github.com/skrivov/openai-voice-webrtc-next) with the addition of a hook to abstract the WebRTC handling. 3 | 4 | https://github.com/user-attachments/assets/ea9324af-5c18-48d2-b980-2b81baeea4c0 5 | 6 | ## Features 7 | - **Next.js Framework**: Built with Next.js for server-side rendering and API routes. 8 | - **Modern UI**: Animated using Tailwind CSS & Framer Motion & shadcn/ui. 9 | - **Use-WebRTC Hook**: A hook to abstract the OpenAI WebRTC handling. 10 | - **Tool Calling**: 6 example functions to demonstrate client side tools along with Realtime API: `getCurrentTime`, `partyMode`, `changeBackground`, `launchWebsite`, `copyToClipboard`, `scrapeWebsite` (requires FireCrawl API key) 11 | - **Localization**: Select language for app strings and the voice agent (English, Spanish, French, Chinese) 12 | - **Type Safety**: TypeScript with strict eslint rules (optional) 13 | 14 | 15 | ## Requirements 16 | - **Deno runtime** or **Node.js** 17 | - OpenAI API Key or Azure OpenAI API Key in `.env` file 18 | 19 | ## Installation 20 | 21 | ### 1. Clone the Repository 22 | ```bash 23 | git clone https://github.com/cameronking4/openai-realtime-api-nextjs.git 24 | cd openai-realtime-api-nextjs 25 | ``` 26 | 27 | ### 2. Environment Setup 28 | Create a `.env` file in the root directory: 29 | ```env 30 | OPENAI_API_KEY=your-openai-api-key 31 | ``` 32 | 33 | ### 3. Install Dependencies 34 | If using **Node.js**: 35 | ```bash 36 | npm install 37 | ``` 38 | 39 | If using **Deno**: 40 | ```bash 41 | deno install 42 | ``` 43 | 44 | ### 4. Run the Application 45 | 46 | #### Using Node.js: 47 | ```bash 48 | npm run dev 49 | ``` 50 | 51 | #### Using Deno: 52 | ```bash 53 | deno task start 54 | ``` 55 | 56 | The application will be available at `http://localhost:3000`. 57 | 58 | ## Usage 59 | 1. Open the app in your browser: `http://localhost:3000`. 60 | 3. Select a voice and start the audio session. 61 | 62 | ## Deploy to Vercel 63 | **Deploy in one-click** 64 | 65 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcameronking4%2Fopenai-realtime-api-nextjs&env=OPENAI_API_KEY&envDescription=OpenAI%20Key%20(Realtime%20API%20Beta%20access)&envLink=https%3A%2F%2Fplatform.openai.com%2Fapi-keys&project-name=openai-rt-shadcn&repository-name=openai-realtime-api-nextjs-clone&demo-title=OpenAI%20Realtime%20API%20(WebRTC)%20x%20shadcn%2Fui&demo-description=Next.js%2015%20template%20to%20create%20beautiful%20Voice%20AI%20applications%20with%20OpenAI%20Realtime%20API%20Beta&demo-url=https%3A%2F%2Fopenai-rt-shadcn.vercel.app&demo-image=http%3A%2F%2Fopenai-rt-shadcn.vercel.app%2Fdemo.gif) 66 | 67 | ## License 68 | This project is licensed under the MIT License. See the `LICENSE` file for details. 69 | 70 | ## Acknowledgements 71 | - [OpenAI](https://openai.com/) for their API and models. 72 | - [Next.js](https://nextjs.org/) for the framework. 73 | - [Tailwind CSS](https://tailwindcss.com/) for styling. 74 | - [Simon Willison’s Weblog](https://simonwillison.net/2024/Dec/17/openai-webrtc/) for inspiration 75 | - [Originator: skrivov](https://github.com/skrivov/openai-voice-webrtc-next) for the WebRTC and Nextjs implementation 76 | -------------------------------------------------------------------------------- /app/api/session/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | export async function POST() { 4 | try { 5 | if (!process.env.OPENAI_API_KEY){ 6 | throw new Error(`OPENAI_API_KEY is not set`); 7 | 8 | } 9 | const response = await fetch("https://api.openai.com/v1/realtime/sessions", { 10 | method: "POST", 11 | headers: { 12 | Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 13 | "Content-Type": "application/json", 14 | }, 15 | body: JSON.stringify({ 16 | model: "gpt-4o-realtime-preview-2024-12-17", 17 | voice: "alloy", 18 | modalities: ["audio", "text"], 19 | instructions:"Start conversation with the user by saying 'Hello, how can I help you today?' Use the available tools when relevant. After executing a tool, you will need to respond (create a subsequent conversation item) to the user sharing the function result or error. If you do not respond with additional message with function result, user will not know you successfully executed the tool. Speak and respond in the language of the user.", 20 | tool_choice: "auto", 21 | }), 22 | }); 23 | 24 | if (!response.ok) { 25 | throw new Error(`API request failed with status ${JSON.stringify(response)}`); 26 | } 27 | 28 | const data = await response.json(); 29 | 30 | // Return the JSON response to the client 31 | return NextResponse.json(data); 32 | } catch (error) { 33 | console.error("Error fetching session data:", error); 34 | return NextResponse.json({ error: "Failed to fetch session data" }, { status: 500 }); 35 | } 36 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cameronking4/openai-realtime-api-nextjs/3093bffb1ca0dae2df66b3e0f81ca2336f220a25/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .audio-indicator { 6 | @apply inline-block w-5 h-5 rounded-full align-middle bg-gray-400; 7 | } 8 | 9 | .audio-indicator.active { 10 | @apply bg-green-500; 11 | animation: pulse 1s infinite; 12 | } 13 | 14 | @keyframes pulse { 15 | 0% { 16 | opacity: 1; 17 | } 18 | 19 | 50% { 20 | opacity: 0.5; 21 | } 22 | 23 | 100% { 24 | opacity: 1; 25 | } 26 | } 27 | 28 | .controls { 29 | @apply my-5; 30 | } 31 | 32 | .form-group { 33 | @apply mb-4; 34 | } 35 | 36 | 37 | @layer base { 38 | :root { 39 | --background: 0 0% 100%; 40 | --foreground: 222.2 84% 4.9%; 41 | --card: 0 0% 100%; 42 | --card-foreground: 222.2 84% 4.9%; 43 | --popover: 0 0% 100%; 44 | --popover-foreground: 222.2 84% 4.9%; 45 | --primary: 222.2 47.4% 11.2%; 46 | --primary-foreground: 210 40% 98%; 47 | --secondary: 210 40% 96.1%; 48 | --secondary-foreground: 222.2 47.4% 11.2%; 49 | --muted: 210 40% 96.1%; 50 | --muted-foreground: 215.4 16.3% 46.9%; 51 | --accent: 210 40% 96.1%; 52 | --accent-foreground: 222.2 47.4% 11.2%; 53 | --destructive: 0 84.2% 60.2%; 54 | --destructive-foreground: 210 40% 98%; 55 | --border: 214.3 31.8% 91.4%; 56 | --input: 214.3 31.8% 91.4%; 57 | --ring: 222.2 84% 4.9%; 58 | --radius: 0.5rem; 59 | } 60 | 61 | .dark { 62 | --background: 222.2 84% 4.9%; 63 | --foreground: 210 40% 98%; 64 | --card: 222.2 84% 4.9%; 65 | --card-foreground: 210 40% 98%; 66 | --popover: 222.2 84% 4.9%; 67 | --popover-foreground: 210 40% 98%; 68 | --primary: 210 40% 98%; 69 | --primary-foreground: 222.2 47.4% 11.2%; 70 | --secondary: 217.2 32.6% 17.5%; 71 | --secondary-foreground: 210 40% 98%; 72 | --muted: 217.2 32.6% 17.5%; 73 | --muted-foreground: 215 20.2% 65.1%; 74 | --accent: 217.2 32.6% 17.5%; 75 | --accent-foreground: 210 40% 98%; 76 | --destructive: 0 62.8% 30.6%; 77 | --destructive-foreground: 210 40% 98%; 78 | --border: 217.2 32.6% 17.5%; 79 | --input: 217.2 32.6% 17.5%; 80 | --ring: 212.7 26.8% 83.9%; 81 | } 82 | } 83 | 84 | @layer base { 85 | * { 86 | @apply border-border; 87 | } 88 | body { 89 | @apply bg-background text-foreground; 90 | } 91 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist } from "next/font/google"; 3 | import "./globals.css"; 4 | import { Header } from "@/components/header"; 5 | import { cn } from "@/lib/utils"; 6 | import { ThemeProvider } from "@/components/theme-provider"; 7 | import { siteConfig } from "@/config/site"; 8 | import { Toaster } from "@/components/ui/sonner" 9 | import { Analytics } from "@vercel/analytics/react" 10 | import { TranslationsProvider } from "@/components/translations-context" 11 | import { Banner } from "@/components/banner"; 12 | 13 | const geistSans = Geist({ 14 | variable: "--font-geist-sans", 15 | subsets: ["latin"], 16 | }); 17 | 18 | export const metadata: Metadata = { 19 | title: "Next.js + OpenAI Realtime API (WebRTC)", 20 | description: "Next.js Starter for using the OpenAI Realtime API WebRTC method. Starter showcases capabilities of OpenAI's latest Realtime API (12/17/2024). It has all shadcn/ui components to build your own real-time voice AI application. Fastest & latest way to do Voice AI (Dec 2024), implementing API advancements of Day of OpenAI's 12 days of Christmas.", 21 | authors: [{ name: siteConfig.author, url: siteConfig.links.twitter }], 22 | creator: siteConfig.author, 23 | metadataBase: new URL(siteConfig.url), 24 | openGraph: { 25 | images: "/opengraph-image.png", 26 | }, 27 | icons: { 28 | icon: "/favicon.ico", 29 | }, 30 | keywords: ["AI Blocks", "OpenAI Blocks", "Blocks", "OpenAI Realtime API", "OpenAI Realtime", "OpenAI WebRTC", "Livekit", "OpenAI Realtime WebRTC", "OpenAI Realtime Starter", "Voice AI", "Voice AI components", "web components", "UI components", "UI Library", "shadcn", "aceternity", "AI", "Next.js", "React", "Tailwind CSS", "Framer Motion", "TypeScript", "Design engineer", "shadcn ai"], 31 | }; 32 | 33 | export default function RootLayout({ 34 | children, 35 | }: Readonly<{ 36 | children: React.ReactNode; 37 | }>) { 38 | return ( 39 | 40 | 46 | 52 | 53 |
54 |
55 | 56 |
57 | {children} 58 |
59 |
60 | 61 |
62 |
63 | 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useEffect, useState } from "react" 4 | import useWebRTCAudioSession from "@/hooks/use-webrtc" 5 | import { tools } from "@/lib/tools" 6 | import { Welcome } from "@/components/welcome" 7 | import { VoiceSelector } from "@/components/voice-select" 8 | import { BroadcastButton } from "@/components/broadcast-button" 9 | import { StatusDisplay } from "@/components/status" 10 | import { TokenUsageDisplay } from "@/components/token-usage" 11 | import { MessageControls } from "@/components/message-controls" 12 | import { ToolsEducation } from "@/components/tools-education" 13 | import { TextInput } from "@/components/text-input" 14 | import { motion } from "framer-motion" 15 | import { useToolsFunctions } from "@/hooks/use-tools" 16 | 17 | const App: React.FC = () => { 18 | // State for voice selection 19 | const [voice, setVoice] = useState("ash") 20 | 21 | // WebRTC Audio Session Hook 22 | const { 23 | status, 24 | isSessionActive, 25 | registerFunction, 26 | handleStartStopClick, 27 | msgs, 28 | conversation, 29 | sendTextMessage 30 | } = useWebRTCAudioSession(voice, tools) 31 | 32 | // Get all tools functions 33 | const toolsFunctions = useToolsFunctions(); 34 | 35 | useEffect(() => { 36 | // Register all functions by iterating over the object 37 | Object.entries(toolsFunctions).forEach(([name, func]) => { 38 | const functionNames: Record = { 39 | timeFunction: 'getCurrentTime', 40 | backgroundFunction: 'changeBackgroundColor', 41 | partyFunction: 'partyMode', 42 | launchWebsite: 'launchWebsite', 43 | copyToClipboard: 'copyToClipboard', 44 | scrapeWebsite: 'scrapeWebsite' 45 | }; 46 | 47 | registerFunction(functionNames[name], func); 48 | }); 49 | }, [registerFunction, toolsFunctions]) 50 | 51 | return ( 52 |
53 | 59 | 60 | 61 | 67 | 68 | 69 |
70 | 74 |
75 | {msgs.length > 4 && } 76 | {status && ( 77 | 84 | 85 | 89 | 90 | )} 91 |
92 | 93 | {status && } 94 |
95 | 96 |
97 |
98 |
99 | ) 100 | } 101 | 102 | export default App; -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "stone", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/banner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTranslations } from "@/components/translations-context" 4 | 5 | export const Banner = () => { 6 | const { t } = useTranslations(); 7 | 8 | return ( 9 |
10 |
11 | {t('header.banner')} 12 | 16 | {t('header.bannerLink')} 17 | 18 |
19 |
20 | ); 21 | }; 22 | 23 | export default Banner; 24 | -------------------------------------------------------------------------------- /components/broadcast-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import { Badge } from "@/components/ui/badge" 3 | import { useTranslations } from "@/components/translations-context"; 4 | 5 | interface BroadcastButtonProps { 6 | isSessionActive: boolean 7 | onClick: () => void 8 | } 9 | 10 | export function BroadcastButton({ isSessionActive, onClick }: BroadcastButtonProps) { 11 | const { t } = useTranslations(); 12 | return ( 13 | 25 | ) 26 | } -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Link from "next/link"; 5 | import { ThemeSwitcher } from "@/components/theme-switcher"; 6 | import { MobileNav } from "./mobile-nav"; 7 | import { Badge } from "./ui/badge"; 8 | import { siteConfig } from "@/config/site"; 9 | import { TwitterIcon, StarIcon } from "lucide-react"; 10 | import { motion } from "framer-motion"; 11 | import { LanguageSwitcher } from "@/components/language-switcher"; 12 | import { useTranslations } from "@/components/translations-context"; 13 | 14 | export function Header() { 15 | const { t } = useTranslations() 16 | return ( 17 | 23 |
24 | 25 | 31 | 32 | 36 | {t('header.logo')} 37 | 38 | 43 | 44 | {t('header.beta')} 45 | 46 | 47 | 48 | 49 | 55 | 56 | 62 | 63 | 72 | 73 | 74 | 80 | 81 | 90 | 91 | 92 | 93 | 94 |
95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /components/language-switcher.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Select, 5 | SelectContent, 6 | SelectItem, 7 | SelectTrigger, 8 | SelectValue, 9 | } from "@/components/ui/select" 10 | import { toast } from "sonner" 11 | import { Languages } from "lucide-react" 12 | import { useTranslations } from "@/components/translations-context" 13 | 14 | export function LanguageSwitcher() { 15 | const { t, locale, setLocale } = useTranslations() 16 | 17 | const languages = [ 18 | { code: 'en', label: 'English', icon: '🇬🇧' }, 19 | { code: 'es', label: 'Español', icon: '🇪🇸' }, 20 | { code: 'fr', label: 'Français', icon: '🇫🇷' }, 21 | { code: 'zh', label: '中文', icon: '🇨🇳' }, 22 | ] 23 | 24 | const selectedLanguage = languages.find(lang => lang.code === locale) 25 | 26 | const onSelect = (value: string) => { 27 | setLocale(value); 28 | toast.success(`${t('status.language')} ${locale}`) 29 | } 30 | 31 | return ( 32 | 47 | ) 48 | } -------------------------------------------------------------------------------- /components/message-controls.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import Transcriber from "@/components/ui/transcriber" 3 | import { Conversation } from "@/lib/conversations" 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | } from "@/components/ui/dialog" 11 | import { 12 | Table, 13 | TableBody, 14 | TableCell, 15 | TableHead, 16 | TableHeader, 17 | TableRow, 18 | } from "@/components/ui/table" 19 | import { Message as MessageType } from "@/types" 20 | import { ScrollArea } from "@/components/ui/scroll-area" 21 | import { Input } from "@/components/ui/input" 22 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 23 | import { useState } from "react" 24 | import { Terminal } from "lucide-react" 25 | import { useTranslations } from "@/components/translations-context" 26 | 27 | function FilterControls({ 28 | typeFilter, 29 | setTypeFilter, 30 | searchQuery, 31 | setSearchQuery, 32 | messageTypes, 33 | messages, 34 | }: { 35 | typeFilter: string 36 | setTypeFilter: (value: string) => void 37 | searchQuery: string 38 | setSearchQuery: (value: string) => void 39 | messageTypes: string[] 40 | messages: MessageType[] 41 | }) { 42 | const { t } = useTranslations(); 43 | 44 | return ( 45 |
46 | 58 | setSearchQuery(e.target.value)} 62 | className="flex-1" 63 | /> 64 | 68 |
69 | ) 70 | } 71 | 72 | export function MessageControls({ conversation, msgs }: { conversation: Conversation[], msgs: MessageType[] }) { 73 | const { t } = useTranslations(); 74 | const [typeFilter, setTypeFilter] = useState("all") 75 | const [searchQuery, setSearchQuery] = useState("") 76 | 77 | if (conversation.length === 0) return null 78 | 79 | // Get unique message types 80 | const messageTypes = ["all", ...new Set(msgs.map(msg => msg.type))] 81 | 82 | // Filter messages based on type and search query 83 | const filteredMsgs = msgs.filter(msg => { 84 | const matchesType = typeFilter === "all" || msg.type === typeFilter 85 | const matchesSearch = searchQuery === "" || 86 | JSON.stringify(msg).toLowerCase().includes(searchQuery.toLowerCase()) 87 | return matchesType && matchesSearch 88 | }) 89 | 90 | return ( 91 |
92 |
93 |

{t('messageControls.logs')}

94 | 95 | 96 | 99 | 100 | 101 | 102 | {t('messageControls.logs')} 103 | 104 | 112 |
113 | 114 | 115 | 116 | 117 | {t('messageControls.type')} 118 | {t('messageControls.content')} 119 | 120 | 121 | 122 | {filteredMsgs.map((msg, i) => ( 123 | 124 | {msg.type} 125 | 126 | {JSON.stringify(msg, null, 2)} 127 | 128 | 129 | ))} 130 | 131 |
132 |
133 |
134 |
135 |
136 |
137 | 138 | 139 |
140 | ) 141 | } -------------------------------------------------------------------------------- /components/mobile-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogHeader, 7 | DialogTitle, 8 | DialogTrigger, 9 | } from "@/components/ui/dialog"; 10 | import { useState } from "react"; 11 | import { Button } from "@/components/ui/button"; 12 | import { MenuIcon } from "lucide-react"; 13 | import Link from "next/link"; 14 | import { siteConfig } from "@/config/site"; 15 | import { Badge } from "./ui/badge"; 16 | import { useTranslations } from "@/components/translations-context" 17 | 18 | export function MobileNav() { 19 | const { t } = useTranslations(); 20 | const [open, setOpen] = useState(false); 21 | 22 | return ( 23 |
24 | 25 | 26 | 29 | 30 | 31 | 32 | 33 | setOpen(false)} 36 | className="flex items-center gap-3 text-2xl" 37 | > 38 | {siteConfig.name} 39 | 40 | {t('header.beta')} 41 | 42 | 43 | 44 | 45 |

{t('header.title')}

46 |

47 | {t('header.about')} 48 |

49 |
50 |
51 |
52 | ); 53 | } -------------------------------------------------------------------------------- /components/status.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect } from 'react' 4 | import { toast } from 'sonner' 5 | import { useTranslations } from "@/components/translations-context" 6 | 7 | interface StatusDisplayProps { 8 | status: string 9 | } 10 | 11 | export function StatusDisplay({ status }: StatusDisplayProps) { 12 | const { t } = useTranslations(); 13 | useEffect(() => { 14 | if (status.startsWith("Error")) { 15 | toast.error(t('status.error'), { 16 | description: status, 17 | duration: 3000, 18 | }) 19 | } 20 | else if (status.startsWith("Session established")) { 21 | toast.success(t('status.success'), { 22 | description: status, 23 | duration: 5000, 24 | }) 25 | } 26 | else { 27 | toast.info(t('status.info'), { 28 | description: status, 29 | duration: 3000, 30 | }) 31 | } 32 | }, [status, t]) 33 | return null 34 | } -------------------------------------------------------------------------------- /components/text-input.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useState } from "react" 4 | import { Button } from "@/components/ui/button" 5 | import { Input } from "@/components/ui/input" 6 | import { Send } from "lucide-react" 7 | 8 | interface TextInputProps { 9 | onSubmit: (text: string) => void 10 | disabled?: boolean 11 | } 12 | 13 | export function TextInput({ onSubmit, disabled = false }: TextInputProps) { 14 | const [text, setText] = useState("") 15 | 16 | const handleSubmit = (e: React.FormEvent) => { 17 | e.preventDefault() 18 | if (text.trim()) { 19 | onSubmit(text.trim()) 20 | setText("") 21 | } 22 | } 23 | 24 | return ( 25 |
26 | setText(e.target.value)} 31 | disabled={disabled} 32 | className="flex-1" 33 | /> 34 | 41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 3 | import { type ThemeProviderProps } from "next-themes"; 4 | 5 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 6 | return {children}; 7 | } 8 | -------------------------------------------------------------------------------- /components/theme-switcher.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useTheme } from "next-themes"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { SunIcon, MoonIcon } from "lucide-react"; 11 | import { useTranslations } from "@/components/translations-context"; 12 | 13 | export function ThemeSwitcher() { 14 | const { t } = useTranslations() 15 | const { setTheme } = useTheme(); 16 | 17 | return ( 18 | 19 | 20 | 25 | 26 | 27 | setTheme("light")}> 28 | {t('header.light')} 29 | 30 | setTheme("dark")}> 31 | {t('header.dark')} 32 | 33 | setTheme("system")}> 34 | {t('header.system')} 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /components/token-usage.tsx: -------------------------------------------------------------------------------- 1 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" 2 | import { Card, CardContent, CardTitle } from "@/components/ui/card" 3 | import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table" 4 | import { useTranslations } from "@/components/translations-context" 5 | import { Message } from "@/types" 6 | 7 | interface TokenUsageDisplayProps { 8 | messages: Message[] 9 | } 10 | 11 | export function TokenUsageDisplay({ messages }: TokenUsageDisplayProps) { 12 | const { t } = useTranslations(); 13 | return ( 14 | <> 15 | { messages.length > 0 && ( 16 | 17 | 18 | 19 | {t('tokenUsage.usage')} 20 | 21 | 22 | 23 | 24 |
25 | {messages 26 | .filter((msg) => msg.type === 'response.done') 27 | .slice(-1) 28 | .map((msg) => { 29 | const tokenData = [ 30 | { label: t('tokenUsage.total'), value: msg.response?.usage?.total_tokens }, 31 | { label: t('tokenUsage.input'), value: msg.response?.usage?.input_tokens }, 32 | { label: t('tokenUsage.output'), value: msg.response?.usage?.output_tokens } 33 | ]; 34 | 35 | return ( 36 | 37 | 38 | {tokenData.map(({label, value}) => ( 39 | 40 | {label} 41 | {value} 42 | 43 | ))} 44 | 45 |
46 | ); 47 | })} 48 |
49 |
50 |
51 |
52 |
53 |
54 | ) 55 | } 56 | 57 | ) 58 | } -------------------------------------------------------------------------------- /components/tools-education.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Accordion, 5 | AccordionContent, 6 | AccordionItem, 7 | AccordionTrigger, 8 | } from "@/components/ui/accordion" 9 | import { 10 | Table, 11 | TableBody, 12 | TableCell, 13 | TableRow, 14 | } from "@/components/ui/table" 15 | import { useTranslations } from "@/components/translations-context" 16 | 17 | 18 | export function ToolsEducation() { 19 | const { t } = useTranslations(); 20 | 21 | const AVAILABLE_TOOLS = [ 22 | { 23 | name: t('tools.availableTools.copyFn.name'), 24 | description: t('tools.availableTools.copyFn.description'), 25 | }, 26 | { 27 | name: t('tools.availableTools.getTime.name'), 28 | description: t('tools.availableTools.getTime.description'), 29 | }, 30 | { 31 | name: t('tools.availableTools.themeSwitcher.name'), 32 | description: t('tools.availableTools.themeSwitcher.description'), 33 | }, 34 | { 35 | name: t('tools.availableTools.partyMode.name'), 36 | description: t('tools.availableTools.partyMode.description'), 37 | }, 38 | { 39 | name: t('tools.availableTools.launchWebsite.name'), 40 | description: t('tools.availableTools.launchWebsite.description'), 41 | }, 42 | { 43 | name: t('tools.availableTools.scrapeWebsite.name'), 44 | description: t('tools.availableTools.scrapeWebsite.description'), 45 | }, 46 | ] as const; 47 | 48 | return ( 49 |
50 | 51 | 52 | {t('tools.availableTools.title')} 53 | 54 | 55 | 56 | {AVAILABLE_TOOLS.map((tool) => ( 57 | 58 | {tool.name} 59 | 60 | {tool.description} 61 | 62 | 63 | ))} 64 | 65 |
66 |
67 |
68 |
69 |
70 | ) 71 | } -------------------------------------------------------------------------------- /components/translations-context.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { createContext, useContext, useState, ReactNode } from 'react' 4 | import { en } from '@/lib/translations/en' 5 | import { es } from '@/lib/translations/es' 6 | import { fr } from '@/lib/translations/fr' 7 | import { zh } from '@/lib/translations/zh' 8 | 9 | type TranslationValue = string | { [key: string]: TranslationValue } 10 | 11 | type Translations = { 12 | [key: string]: TranslationValue 13 | } 14 | 15 | const translations: { [key: string]: Translations } = { 16 | en, 17 | es, 18 | fr, 19 | zh 20 | } 21 | 22 | type TranslationsContextType = { 23 | t: (key: string) => string 24 | locale: string 25 | setLocale: (locale: string) => void 26 | } 27 | 28 | const TranslationsContext = createContext(null) 29 | 30 | export function TranslationsProvider({ children }: { children: ReactNode }) { 31 | const [locale, setLocale] = useState('en') 32 | 33 | const t = (key: string): string => { 34 | const keys = key.split('.') 35 | let value: TranslationValue = translations[locale] 36 | 37 | for (const k of keys) { 38 | if (value === undefined) return key 39 | value = typeof value === 'object' ? value[k] : key 40 | } 41 | 42 | return typeof value === 'string' ? value : key 43 | } 44 | 45 | return ( 46 | 47 | {children} 48 | 49 | ) 50 | } 51 | 52 | export function useTranslations() { 53 | const context = useContext(TranslationsContext) 54 | if (!context) { 55 | throw new Error('useTranslations must be used within a TranslationsProvider') 56 | } 57 | return context 58 | } -------------------------------------------------------------------------------- /components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )) 55 | 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 57 | 58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 59 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>