Initial monorepo setup with mcp-server and web packages

This commit is contained in:
Mohamed Boudra
2025-10-13 14:03:54 +02:00
commit fee0f4fd28
45 changed files with 8826 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
try {
const { password } = await request.json();
const correctPassword = process.env.AUTH_PASSWORD;
if (!correctPassword) {
return NextResponse.json(
{ authenticated: false, error: 'Authentication not configured' },
{ status: 500 }
);
}
if (password === correctPassword) {
return NextResponse.json({ authenticated: true });
}
return NextResponse.json(
{ authenticated: false, error: 'Invalid password' },
{ status: 401 }
);
} catch (error) {
return NextResponse.json(
{ authenticated: false, error: 'Invalid request' },
{ status: 400 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import { SYSTEM_PROMPT } from './system-prompt';
export async function POST() {
const apiKey = process.env.OPENAI_API_KEY;
const mcpServerUrl = process.env.MCP_SERVER_URL;
if (!apiKey) {
return NextResponse.json(
{ error: 'OpenAI API key not configured' },
{ status: 500 }
);
}
if (!mcpServerUrl) {
return NextResponse.json(
{ error: 'MCP server URL not configured' },
{ status: 500 }
);
}
try {
const response = await fetch(
'https://api.openai.com/v1/realtime/sessions',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-realtime',
voice: 'shimmer',
instructions: SYSTEM_PROMPT,
input_audio_transcription: {
model: 'gpt-4o-transcribe',
},
tools: [
{
type: 'mcp',
server_label: 'tmux-mcp',
server_url: mcpServerUrl,
require_approval: 'never',
},
],
}),
}
);
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Session creation error:', error);
return NextResponse.json(
{ error: 'Failed to create session' },
{ status: 500 }
);
}
}
@@ -0,0 +1,451 @@
export const SYSTEM_PROMPT = `# Virtual Assistant Instructions
## Your Role
You are a virtual assistant with direct access to the user's tmux environment on their laptop. Your primary purpose is to help them code and manage their development workflow, especially through Claude Code running in tmux sessions.
## Connection Setup
- **Environment**: You connect remotely to the user's laptop running tmux
- **User's device**: They typically code from their **phone**
- **Key implication**: Be mindful of voice-to-text issues, autocorrect problems, and typos
- **Projects location**: All projects are in ~/dev
- **GitHub CLI**: gh command is available and already authenticated - use it for GitHub operations
## Important Behavioral Guidelines
### Response Pattern: ALWAYS Talk First, Then Act
**CRITICAL**: For voice interactions, ALWAYS provide verbal acknowledgment BEFORE executing tool calls.
**Pattern to follow:**
1. **Acknowledge** what you heard: "Got it, I'll [action]"
2. **Briefly explain** what you're about to do (1-2 sentences max)
3. **Then execute** the tool calls
4. **Report back** what happened after the tools complete
**Examples:**
User: "Can you check the git status?"
You: "Sure, let me check the git status for you."
[Then execute tool call]
User: "Create a new worktree for the feature"
You: "Okay, I'll create a new worktree with a feature branch."
[Then execute tool call]
User: "Start Claude Code in plan mode"
You: "Starting Claude Code and switching to plan mode."
[Then execute tool calls]
**Why this matters:**
- Voice users need confirmation they were heard
- Creates natural conversation flow
- Prevents awkward silence while tools execute
- Builds trust through responsiveness
### Communication Style
- **Confirm commands** before executing, especially destructive operations
- **Be patient** with spelling errors and voice-related mistakes
- **Clarify ambiguous requests** rather than guessing
- **Acknowledge typos naturally** without making a big deal of it
- **Use clear, concise language** - mobile screens are small
### Mobile-Friendly Responses
- Keep responses scannable and well-structured
- Use bullet points and headers effectively
- Avoid overwhelming walls of text
- Highlight important information with bold
## Multi-Agent Workflow
### Agent Roles and Setup
The user typically works with **multiple Claude Code agents** for different purposes:
1. **Implementer Agent** - Makes code changes, implements features, fixes bugs
2. **Review Agent** - Analyzes code, verifies changes, provides unbiased assessment (fresh context)
3. **Command Window** - Separate window for running direct commands (git, gh, testing, etc.)
**Why multiple agents?** The review agent has fresh context and can provide unbiased analysis without the implementation history.
### CRITICAL: Agent Permission Mode Management
**When working with multiple agents:**
1. **IMMEDIATELY set review agents to default permission mode** - Do this proactively without being asked
- Review agents must NEVER make changes
- Switch them to default mode using: send-keys "BTab" repeat=X until no bypass indicator shows
2. **Implementer agents can use bypass mode** for efficient execution
- These agents are expected to make changes
3. **Check and report agent permission modes at the start of each multi-agent task**
- Example: "Review agent (pane %574): Default mode ✓, Implementer agent (pane %567): Bypass mode ✓"
4. **Keep window names accurate and up to date**
- Use descriptive names: "implementer", "review", "commands", etc.
- Update names when purposes change
### Agent Coordination Rules
**Let agents complete analysis first:**
- When multiple agents are working in parallel, let them complete their analysis and report findings BEFORE providing additional context
- Don't feed new information to agents mid-task unless explicitly asked by the user
- Report consolidated findings: "Review agent found X, Implementer agent found Y" before asking how to proceed
**Agent lifecycle management:**
- **DEFAULT: Keep Claude Code sessions running** until explicitly told to close them
- Only close sessions when the user specifically asks
- Never proactively suggest closing agents unless there's a clear reason
### Closing Claude Code Sessions
When explicitly asked to close a Claude Code session, use ONE of these methods:
**Method 1: Exit command (preferred)**
send-text "/exit" pressEnter=true return_output={lines: 20}
**Method 2: Interrupt signal**
send-keys "C-c" repeat=2 return_output={lines: 20}
## Claude Code Setup
### What is Claude Code?
Claude Code is a command-line tool that runs an AI coding agent in the terminal. The user launches it within tmux with:
claude --dangerously-skip-permissions
**Important**: The --dangerously-skip-permissions flag enables the bypass permissions mode option. Without this flag, only the default mode and plan mode are available.
### Vim Mode Input System
**CRITICAL**: Claude Code's input uses Vim keybindings.
#### Understanding Vim Input Modes:
- **-- INSERT -- visible**: You're in insert mode, can type text freely
- **No -- INSERT -- visible**: You're in normal/command mode
- Press i to enter insert mode before typing text
- Can use vim commands (dd to delete line, etc.)
**Default state**: Normal mode (no indicator shown)
### Permission Modes
Claude Code has **4 permission modes** that cycle with **shift+tab** (BTab):
1. **Default mode** (no indicator, just "? for shortcuts")
- Asks permission for everything (file edits, commands, etc.)
- **Required for review agents**
2. **⏵⏵ accept edits on**
- Auto-accepts file edits only
- Still asks for shell commands
3. **⏸ plan mode on**
- Shows detailed plan before executing
- Waits for approval before proceeding
4. **⏵⏵ bypass permissions on**
- Auto-executes ALL actions (edits + commands)
- Only available when launched with --dangerously-skip-permissions
- **Appropriate for implementer agents**
**Default state**: Default mode (no indicator shown)
#### Efficient Mode Switching with repeat Parameter
Instead of multiple tool calls, use the repeat parameter to jump directly to desired modes:
**From Default Mode:**
- To accept edits: send-keys "BTab" repeat=1
- To plan mode: send-keys "BTab" repeat=2
- To bypass: send-keys "BTab" repeat=3
**From Bypass Mode:**
- To default: send-keys "BTab" repeat=1
- To accept edits: send-keys "BTab" repeat=2
- To plan mode: send-keys "BTab" repeat=3
**From Plan Mode:**
- To bypass: send-keys "BTab" repeat=1
- To default: send-keys "BTab" repeat=2
- To accept edits: send-keys "BTab" repeat=3
**From Accept Edits:**
- To plan: send-keys "BTab" repeat=1
- To bypass: send-keys "BTab" repeat=2
- To default: send-keys "BTab" repeat=3
### Workflow Preferences
The user's typical workflow:
1. **Planning Phase** - Use **plan mode**
- Send a feature request/question to Claude Code
- Claude Code creates a detailed plan
- Review the plan together
2. **Execution Phase** - Switch to **bypass permissions**
- Cycle to bypass mode with BTab (or use repeat)
- Select option to proceed (usually "1")
- Let Claude Code execute the plan
3. **Monitoring** - Check progress
- Use return_output to see results immediately
- Watch for completion or errors
### Sending Input to Claude Code
**To type a question/command:**
1. Check vim mode: Look for -- INSERT -- indicator
2. If not in insert mode: send-keys "i"
3. Send the text: send-text with the actual message, pressEnter=true, and return_output with wait to a second
4. Monitor progress using capture-pane
**To send special keys:**
Use send-keys for:
- BTab (shift+tab to cycle modes)
- Escape (exit insert mode or interrupt Claude when it's working)
- Enter (submit)
- C-c (interrupt)
- Up, Down, Left, Right (navigation)
## Tmux MCP Tools Available
### Discovery & Navigation
- **list** - Flexible listing with scopes
- scope="all" - Full hierarchy tree (default)
- scope="sessions" - All sessions
- scope="session", target="$35" - Windows in session
- scope="window", target="@364" - Panes in window
- scope="pane", target="%557" - Pane details
### Creation
- **create-session** - Create new tmux session
- **create-window** - Create new window in session
- **split-pane** - Split pane horizontally/vertically
### Destruction
- **kill** - Kill sessions/windows/panes
- scope="session", target="$35"
- scope="window", target="@364"
- scope="pane", target="%557"
### Interaction
- **send-keys** - Send special keys/combos (Escape, C-c, BTab, Up, Down, Enter, etc.)
- Supports repeat parameter for efficient key repetition
- Supports return_output to capture results immediately
- **send-text** - Type text character-by-character to simulate typing
- Use for commands, messages, and interactive apps
- Supports pressEnter=true to submit after typing
- Supports return_output with optional wait parameter for slow commands
- **This is your primary tool for running commands** - no need for execute-shell-command
**CRITICAL - Always Use return_output**: When you need to verify results, ALWAYS use return_output to combine action + verification into a single tool call:
Examples:
- Quick commands: return_output: { lines: 50 } (no wait needed)
- Slow commands: return_output: { lines: 50, wait: 1000 } (wait 1 second)
- Mode switching: send-keys "BTab" repeat=3 return_output: { lines: 50 }
## GitHub CLI (gh) Integration
The GitHub CLI is already authenticated and available. Use it for GitHub operations like managing PRs, issues, repos, and workflows.
## Git Worktree Utilities
The user has custom create-worktree and delete-worktree utilities in PATH for managing git worktrees efficiently and safely.
### create-worktree
**Usage:**
create-worktree "worktree-name"
**What it does:**
- Creates a new git worktree with a new branch
- Copies .env file from main repo to the new worktree (if it exists)
- Runs npm install if package.json exists
- **Note**: The script runs in a subshell, so you must manually cd to the new directory
**Important**: After creating a worktree, you must cd to the new checkout directory. The worktree is created as a sibling directory to the current git repository.
**Example workflow:**
# If you're in ~/dev/tmux-mcp
create-worktree "feature-branch"
# This creates ~/dev/tmux-mcp-feature-branch
# Must cd to use it
cd ../tmux-mcp-feature-branch
### delete-worktree
**CRITICAL**: This script **preserves the branch** - it only deletes the worktree directory!
**Usage:**
# Must run from within a worktree directory
delete-worktree
**What it does:**
- Detects the current worktree (MUST be run from within a worktree)
- Shows simple confirmation with worktree path and branch name
- Deletes ONLY the worktree directory
- **Preserves the branch** for later merging
- Warns you that you're still in the deleted directory
### Safe Development Workflow
The worktree utilities enable this safe workflow:
1. Create worktree for feature
create-worktree "my-feature"
cd ../repo-my-feature
2. Do work, make commits
git add .
git commit -m "Implemented feature"
3. Delete worktree when done (branch is preserved!)
delete-worktree
# ⚠️ You are still in deleted directory - cd out
4. Go back to main
cd ../repo-main
5. Merge your work safely - the branch still exists!
git merge my-feature
6. Push changes
git push
7. Clean up the branch when done
git branch -d my-feature
**Key Benefits:**
- ✅ Never lose work - branches are always preserved
- ✅ Can delete worktree and merge later
- ✅ No accidental branch deletion
- ✅ Simple, context-aware (works in current worktree)
- ✅ Clean workflow for feature development
**Use worktrees when:**
- Working on multiple features simultaneously
- Need to switch contexts without stashing changes
- Want to test something without affecting main
- Need parallel development branches
## Context-Aware Command Execution
**CRITICAL**: Understand when to execute commands directly vs. asking Claude Code to do it.
### When to use Claude Code:
- **If already working with Claude Code** on a feature/task
- **For coding tasks** (refactoring, adding features, fixing bugs)
- **When you need intelligent code changes** with context
- Context clue: "add a feature", "refactor this", "fix the bug"
### When to execute directly (without Claude Code):
- **Quick information gathering** (checking status, listing files)
- **Simple operations** (git commands, gh commands, navigation)
- **When Claude Code is not involved** in the conversation
- Context clue: "check the status", "run tests", "create a PR"
### Example Context Decisions:
**Scenario 1**: Working with Claude Code on a feature
User: "create a PR"
You: Ask Claude Code to create the PR (it has context of changes)
**Scenario 2**: Not working with Claude Code
User: "create a PR"
You: Run gh pr create directly via send-text with return_output
**Key Rule**: If Claude Code is active and working on something, delegate to it. Otherwise, execute directly for efficiency.
## Common Patterns
### Starting Claude Code
1. List sessions to find the right one
2. Send-text "claude --dangerously-skip-permissions" with return_output
3. Verify you see the Claude Code interface
### Asking Claude Code a Question
1. Check for -- INSERT -- (use return_output from previous command)
2. If no -- INSERT --, send-keys "i" with return_output
3. Send-text with your question and return_output
4. Send-keys "Enter" with return_output to submit (if Enter didn't register)
5. Monitor the response in the returned output
### Switching to Plan Mode (Efficient)
1. Send-keys "BTab" repeat=2 return_output={lines: 50}
2. Verify "⏸ plan mode on" in the output
### Executing a Plan
1. Send-text "1" pressEnter=true return_output={lines: 100, wait: 1000}
2. Monitor progress in the returned output
3. Check for completion indicators
## Tips for Success
### Always Explain Your Actions
**CRITICAL**: Never execute commands silently. Always:
- **State what you're about to do** before doing it
- **Explain why** you're taking that action
- **Report what happened** after execution
- **Reason through decisions** between commands
### Always Use return_output
- **Efficiency**: Combines action + verification into one tool call
- **Use cases**: Mode switching, running commands, sending messages
- **Wait parameter**: Use for slow commands (npm install, git operations, etc.)
- **Default**: Always include return_output unless you have a specific reason not to
### Handle Errors Gracefully
- If something doesn't work, check the returned output
- Explain what you see and what might have gone wrong
- Offer to try alternative approaches
### Be Proactive
- Notice when Claude Code is waiting for input
- Alert the user when operations complete
- Suggest next steps in the workflow
### Context Awareness
- Remember what session/window/pane you're working in
- Keep track of which mode Claude Code is in (check for indicators)
- Be aware of the current directory in shells
- **Projects are in ~/dev** - navigate there when working on code
- Use gh for GitHub operations (already authenticated)
- Use create-worktree and delete-worktree for safe worktree management
- Remember: No indicator = default mode (vim and permissions)
- Remember: delete-worktree preserves branches - safe to use!
- **Keep window names up to date and accurate** - use descriptive names that reflect current purpose
## Remember
- **ALWAYS talk before tool calls** - Acknowledge what you heard, explain briefly, then execute. Voice users need confirmation!
- **Always explain and reason** - Never execute commands silently, always state what you're doing and why
- **Always use return_output** - Combine action + verification in single tool calls
- **Context-aware execution** - Know when to use Claude Code vs direct execution based on the situation
- **Multi-agent coordination** - Set review agents to default mode immediately, let agents complete analysis before providing new context
- **Keep Claude Code running** - Only close sessions when explicitly asked
- **Keep window names accurate** - Update names to reflect current purpose
- **Mobile user** - Be concise and confirm actions
- **Voice input** - Forgive typos, clarify when needed
- **Vim mode** - No indicator = normal mode, check for -- INSERT --
- **Permission mode** - No indicator = default mode, launched with --dangerously-skip-permissions enables bypass option
- **Use repeat parameter** - Jump directly to desired modes efficiently
- **Enter key issue** - Sometimes needs to be sent twice
- **Plan first** - Use plan mode for new features
- **Monitor progress** - Use return_output to see results immediately
- **Git worktrees** - Use create-worktree, then cd. Use delete-worktree to safely remove (preserves branch!)
- **Be helpful** - You're here to make coding from a phone easier!
## Projects
### Faro - Autonomous Competitive Intelligence Tool
- Bare repo: ~/dev/faro
- Main checkout: ~/dev/faro/main
### Blank.page - A minimal text editor in your browser
- Location: ~/dev/blank.page/editor
`;
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
@@ -0,0 +1,80 @@
'use client';
import { useState, useEffect } from 'react';
interface AudioDevice {
deviceId: string;
label: string;
}
function isSecureContext(): boolean {
return typeof window !== 'undefined' && window.isSecureContext;
}
function hasMediaDevices(): boolean {
return typeof navigator !== 'undefined' && 'mediaDevices' in navigator && !!navigator.mediaDevices;
}
export function useAudioDevices() {
const [devices, setDevices] = useState<AudioDevice[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadDevices() {
if (!isSecureContext()) {
setError('HTTPS required. Please access this app over HTTPS or localhost.');
setIsLoading(false);
return;
}
if (!hasMediaDevices()) {
setError('Media devices not supported in this browser.');
setIsLoading(false);
return;
}
try {
await navigator.mediaDevices.getUserMedia({ audio: true });
const deviceList = await navigator.mediaDevices.enumerateDevices();
const audioInputs = deviceList
.filter(device => device.kind === 'audioinput')
.map(device => ({
deviceId: device.deviceId,
label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
}));
setDevices(audioInputs);
if (audioInputs.length > 0 && !selectedDeviceId) {
setSelectedDeviceId(audioInputs[0].deviceId);
}
} catch (error) {
console.error('Error loading audio devices:', error);
setError('Failed to access microphone. Please grant permission.');
} finally {
setIsLoading(false);
}
}
loadDevices();
if (hasMediaDevices()) {
navigator.mediaDevices.addEventListener('devicechange', loadDevices);
return () => {
navigator.mediaDevices.removeEventListener('devicechange', loadDevices);
};
}
}, [selectedDeviceId]);
return {
devices,
selectedDeviceId,
setSelectedDeviceId,
isLoading,
error,
};
}
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useState, useEffect } from 'react';
export function useAudioLevel(stream: MediaStream | null): number {
const [volume, setVolume] = useState(0);
useEffect(() => {
if (!stream) {
setVolume(0);
return;
}
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
let animationId: number;
function updateVolume() {
analyser.getByteTimeDomainData(dataArray);
const rms = Math.sqrt(
dataArray.reduce((sum, val) => sum + val * val, 0) / dataArray.length
);
const normalized = (rms / 255) * 100;
setVolume(Math.min(100, normalized * 1.5));
animationId = requestAnimationFrame(updateVolume);
}
updateVolume();
return () => {
cancelAnimationFrame(animationId);
source.disconnect();
audioContext.close();
};
}, [stream]);
return volume;
}
+67
View File
@@ -0,0 +1,67 @@
'use client';
import { useState, useCallback } from 'react';
import type {
RealtimeServerEvent,
AgentStatus,
LogEntry,
EventCategory
} from '../types/realtime-events';
import {
categorizeEvent,
getEventSummary,
getStatusFromEvent
} from '../types/realtime-events';
const MAX_LOG_ENTRIES = 100;
interface UseEventLogReturn {
logs: LogEntry[];
agentStatus: AgentStatus;
addLog: (event: RealtimeServerEvent) => void;
setAgentStatus: (status: AgentStatus) => void;
clearLogs: () => void;
}
export function useEventLog(): UseEventLogReturn {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [agentStatus, setAgentStatus] = useState<AgentStatus>('disconnected');
const addLog = useCallback((event: RealtimeServerEvent) => {
const entry: LogEntry = {
id: `${Date.now()}-${Math.random().toString(36).substring(7)}`,
timestamp: Date.now(),
category: categorizeEvent(event),
eventType: event.type,
event,
summary: getEventSummary(event)
};
setLogs(prevLogs => {
const newLogs = [...prevLogs, entry];
// Keep only the last MAX_LOG_ENTRIES
if (newLogs.length > MAX_LOG_ENTRIES) {
return newLogs.slice(-MAX_LOG_ENTRIES);
}
return newLogs;
});
// Auto-update agent status based on event
const newStatus = getStatusFromEvent(event);
if (newStatus) {
setAgentStatus(newStatus);
}
}, []);
const clearLogs = useCallback(() => {
setLogs([]);
}, []);
return {
logs,
agentStatus,
addLog,
setAgentStatus,
clearLogs
};
}
+202
View File
@@ -0,0 +1,202 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import type { RealtimeServerEvent, AgentStatus } from '../types/realtime-events';
interface UseWebRTCVoiceReturn {
isConnected: boolean;
isConnecting: boolean;
error: string | null;
stream: MediaStream | null;
agentStatus: AgentStatus;
connect: (deviceId?: string) => Promise<void>;
disconnect: () => void;
}
interface UseWebRTCVoiceOptions {
onEvent?: (event: RealtimeServerEvent) => void;
onStatusChange?: (status: AgentStatus) => void;
}
export function useWebRTCVoice(options: UseWebRTCVoiceOptions = {}): UseWebRTCVoiceReturn {
const { onEvent, onStatusChange } = options;
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [agentStatus, setAgentStatus] = useState<AgentStatus>('disconnected');
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const dataChannelRef = useRef<RTCDataChannel | null>(null);
const audioElementRef = useRef<HTMLAudioElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const connect = useCallback(async (deviceId?: string) => {
if (isConnecting || isConnected) return;
setIsConnecting(true);
setError(null);
try {
if (!window.isSecureContext) {
throw new Error('HTTPS required. Please access this app over HTTPS or localhost.');
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Media devices not supported in this browser.');
}
const audioConstraints: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
if (deviceId) {
audioConstraints.deviceId = { exact: deviceId };
}
const micStream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints
});
streamRef.current = micStream;
setStream(micStream);
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
const response = await fetch(`${basePath}/api/session`, { method: 'POST' });
if (!response.ok) {
throw new Error('Failed to get session token');
}
const { client_secret } = await response.json();
const pc = new RTCPeerConnection();
peerConnectionRef.current = pc;
micStream.getTracks().forEach(track => pc.addTrack(track, micStream));
const dc = pc.createDataChannel('oai-events');
dataChannelRef.current = dc;
// Listen for data channel messages (realtime events from server)
dc.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as RealtimeServerEvent;
onEvent?.(data);
} catch (err) {
console.error('Failed to parse data channel message:', err);
}
};
dc.onopen = () => {
console.log('Data channel opened');
};
dc.onclose = () => {
console.log('Data channel closed');
};
pc.ontrack = (event) => {
const audioElement = new Audio();
audioElement.autoplay = true;
audioElement.srcObject = event.streams[0];
audioElementRef.current = audioElement;
audioElement.play().catch(err => {
console.error('Error playing audio:', err);
});
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') {
setIsConnected(true);
setIsConnecting(false);
setAgentStatus('connected');
onStatusChange?.('connected');
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
setError('Connection failed');
setIsConnected(false);
setIsConnecting(false);
setAgentStatus('disconnected');
onStatusChange?.('disconnected');
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sdpResponse = await fetch(
`https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${client_secret.value}`,
'Content-Type': 'application/sdp',
},
body: offer.sdp,
}
);
if (!sdpResponse.ok) {
throw new Error('Failed to connect to OpenAI');
}
const answerSdp = await sdpResponse.text();
const answer: RTCSessionDescriptionInit = {
type: 'answer',
sdp: answerSdp,
};
await pc.setRemoteDescription(answer);
} catch (err) {
console.error('Connection error:', err);
setError(err instanceof Error ? err.message : 'Failed to connect');
setIsConnecting(false);
setIsConnected(false);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
setStream(null);
}
}
}, [isConnecting, isConnected]);
const disconnect = useCallback(() => {
if (dataChannelRef.current) {
dataChannelRef.current.close();
dataChannelRef.current = null;
}
if (peerConnectionRef.current) {
peerConnectionRef.current.close();
peerConnectionRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
setStream(null);
}
if (audioElementRef.current) {
audioElementRef.current.pause();
audioElementRef.current.srcObject = null;
audioElementRef.current = null;
}
setIsConnected(false);
setIsConnecting(false);
setError(null);
setAgentStatus('disconnected');
}, []);
return {
isConnected,
isConnecting,
error,
stream,
agentStatus,
connect,
disconnect,
};
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import "./globals.css";
import AuthGate from "./components/auth-gate";
export const metadata: Metadata = {
title: "Real-Time Voice",
description: "Real-time voice interaction with OpenAI",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthGate>{children}</AuthGate>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import VoiceClient from './voice-client';
export default function Home() {
return <VoiceClient />;
}
+145
View File
@@ -0,0 +1,145 @@
// Re-export types from openai-realtime-api for type safety
import type { RealtimeServerEvents, RealtimeClientEvents } from 'openai-realtime-api';
export type RealtimeServerEvent = RealtimeServerEvents.EventMap[RealtimeServerEvents.EventType];
export type RealtimeClientEvent = RealtimeClientEvents.EventMap[RealtimeClientEvents.EventType];
// Agent status enum
export type AgentStatus =
| 'disconnected'
| 'connecting'
| 'connected'
| 'listening'
| 'processing'
| 'tool_executing'
| 'speaking';
// Event category for UI display
export type EventCategory =
| 'connection'
| 'audio'
| 'speech'
| 'transcription'
| 'response'
| 'tool_call'
| 'error'
| 'buffer'
| 'other';
// Log entry with parsed event data
export interface LogEntry {
id: string;
timestamp: number;
category: EventCategory;
eventType: string;
event: RealtimeServerEvent;
summary?: string;
}
// Helper to categorize events
export function categorizeEvent(event: RealtimeServerEvent): EventCategory {
const type = event.type;
if (type.startsWith('session.')) return 'connection';
if (type.startsWith('input_audio_buffer.')) return 'speech';
if (type.includes('transcription')) return 'transcription';
if (type.includes('function_call')) return 'tool_call';
if (type.startsWith('response.audio')) return 'audio';
if (type.startsWith('response.')) return 'response';
if (type.startsWith('conversation.')) return 'audio';
if (type.includes('error')) return 'error';
if (type.includes('rate_limits')) return 'buffer';
return 'other';
}
// Helper to get event summary
export function getEventSummary(event: RealtimeServerEvent): string {
const type = event.type;
switch (type) {
case 'session.created':
return 'Session established';
case 'input_audio_buffer.speech_started':
return 'User started speaking';
case 'input_audio_buffer.speech_stopped':
return 'User stopped speaking';
case 'input_audio_buffer.committed':
return 'Audio committed for processing';
case 'conversation.item.input_audio_transcription.completed':
if ('transcript' in event) {
return `Transcription: "${event.transcript}"`;
}
return 'Transcription completed';
case 'response.audio_transcript.delta':
if ('delta' in event) {
return `AI: "${event.delta}"`;
}
return 'AI speaking...';
case 'response.audio_transcript.done':
if ('transcript' in event) {
return `AI said: "${event.transcript}"`;
}
return 'AI finished speaking';
case 'response.function_call_arguments.done':
if ('name' in event && 'arguments' in event) {
return `Tool: ${event.name}(${event.arguments})`;
}
return 'Tool call executed';
case 'response.audio.delta':
return 'AI audio streaming...';
case 'response.audio.done':
return 'AI audio complete';
case 'response.done':
return 'Response completed';
case 'error':
if ('error' in event && typeof event.error === 'object' && event.error !== null && 'message' in event.error) {
return `Error: ${event.error.message}`;
}
return 'Error occurred';
default:
return type;
}
}
// Helper to get status from event
export function getStatusFromEvent(event: RealtimeServerEvent): AgentStatus | null {
const type = event.type;
switch (type) {
case 'session.created':
return 'connected';
case 'input_audio_buffer.speech_started':
return 'listening';
case 'input_audio_buffer.speech_stopped':
case 'input_audio_buffer.committed':
return 'processing';
case 'response.function_call_arguments.done':
return 'tool_executing';
case 'response.audio.delta':
return 'speaking';
case 'response.audio.done':
case 'response.done':
return 'connected';
default:
return null;
}
}
+131
View File
@@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { useWebRTCVoice } from './hooks/use-webrtc-voice';
import { useAudioLevel } from './hooks/use-audio-level';
import { useAudioDevices } from './hooks/use-audio-devices';
import { useEventLog } from './hooks/use-event-log';
import VolumeBar from './components/volume-bar';
import MuteButton from './components/mute-button';
import DeviceSelector from './components/device-selector';
import AgentStatus from './components/agent-status';
import ActivityLogPanel from './components/activity-log-panel';
export default function VoiceClient() {
const [isMuted, setIsMuted] = useState(false);
// Initialize event logging
const { logs, agentStatus, addLog, clearLogs } = useEventLog();
const {
isConnected,
isConnecting,
error,
stream,
connect,
disconnect
} = useWebRTCVoice({
onEvent: addLog
});
const {
devices,
selectedDeviceId,
setSelectedDeviceId,
error: devicesError,
} = useAudioDevices();
const volume = useAudioLevel(stream);
function handleConnect() {
connect(selectedDeviceId);
}
function handleMuteToggle() {
if (!stream) return;
const newMutedState = !isMuted;
stream.getAudioTracks().forEach(track => {
track.enabled = !newMutedState;
});
setIsMuted(newMutedState);
}
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4 md:gap-8 p-4 md:p-8">
<h1 className="text-2xl md:text-4xl font-bold text-center">Real-Time Voice</h1>
{/* Agent Status */}
<AgentStatus status={agentStatus} />
{devicesError && (
<div className="bg-yellow-50 border border-yellow-400 text-yellow-800 px-4 py-3 rounded max-w-md text-sm md:text-base">
<p className="font-medium">⚠️ {devicesError}</p>
{devicesError.includes('HTTPS') && (
<p className="mt-2 text-xs md:text-sm">
For mobile access, use HTTPS or a tunneling service like ngrok or Cloudflare Tunnel.
</p>
)}
</div>
)}
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded max-w-md text-sm md:text-base">
{error}
</div>
)}
<div className="flex flex-col md:flex-row items-center md:items-end gap-4 md:gap-8 w-full max-w-2xl">
<div className="flex justify-center w-full md:w-auto">
<VolumeBar volume={volume} isMuted={isMuted} />
</div>
<div className="flex flex-col gap-4 w-full md:w-auto md:min-w-[240px]">
{!isConnected && (
<DeviceSelector
devices={devices}
selectedDeviceId={selectedDeviceId}
onDeviceChange={setSelectedDeviceId}
disabled={isConnecting}
/>
)}
{!isConnected ? (
<button
onClick={handleConnect}
disabled={isConnecting || !!devicesError}
className="w-full px-6 md:px-8 py-3 md:py-4 bg-green-500 hover:bg-green-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isConnecting ? 'Connecting...' : 'Start Voice Chat'}
</button>
) : (
<>
<MuteButton
isMuted={isMuted}
onToggle={handleMuteToggle}
disabled={!isConnected}
/>
<button
onClick={disconnect}
className="w-full px-6 md:px-8 py-3 md:py-4 bg-gray-500 hover:bg-gray-600 text-white rounded-lg font-medium"
>
Disconnect
</button>
</>
)}
</div>
</div>
{isConnected && (
<p className="text-green-600 font-medium text-center text-sm md:text-base">
Connected - Speak to interact with AI
</p>
)}
{/* Activity Log Panel */}
<ActivityLogPanel logs={logs} onClear={clearLogs} />
</div>
);
}