Based on the git diff showing extensive package-lock.json changes and modifications to the agent system prompt and configuration, here's the commit message:

```
feat: add AI SDK dependencies and refine agent voice interaction config

- Add @ai-sdk packages for enhanced AI capabilities
- Install Deepgram SDK, livekit-client, and werift for voice/WebRTC
- Expand agent system prompt with multi-tool workflows and Claude Code interaction patterns
- Adjust VAD timing (1.2s silence threshold) and turn detection for better voice UX
- Switch TTS to inworld/inworld-tts-1 with Olivia voice
- Configure interruption timing (0.2s detection, no word requirement)
- Add comprehensive guidelines for conversational responses vs structured output
- Document plan mode triggers and Claude response type handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
```
This commit is contained in:
Mohamed Boudra
2025-10-19 15:41:42 +02:00
parent dabcf63ebc
commit 40b199e329
44 changed files with 12183 additions and 1715 deletions
@@ -0,0 +1,173 @@
import { streamText, tool } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod';
import {
listTerminals,
createTerminal,
captureTerminal,
sendText,
renameTerminal,
killTerminal,
} from '../daemon/terminal-manager.js';
let openaiClient: ReturnType<typeof createOpenAI> | null = null;
/**
* Initialize OpenAI client with API key
*/
export function initializeOpenAI(apiKey: string): void {
openaiClient = createOpenAI({ apiKey });
}
/**
* Terminal tools using Vercel AI SDK tool() function
* These are automatically executed when the LLM calls them
*/
export const terminalTools = {
list_terminals: tool({
description: 'List all available terminals in the voice-dev session',
parameters: z.object({}),
execute: async () => {
const terminals = await listTerminals();
return { terminals };
},
}),
create_terminal: tool({
description: 'Create a new terminal with specified name and working directory',
parameters: z.object({
name: z.string().describe('Unique name for the terminal'),
workingDirectory: z.string().describe('Working directory path'),
initialCommand: z.string().optional().describe('Optional command to run on creation'),
}),
execute: async ({ name, workingDirectory, initialCommand }) => {
const terminal = await createTerminal({ name, workingDirectory, initialCommand });
return { terminal };
},
}),
capture_terminal: tool({
description: 'Capture and return the output from a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID (e.g., @123)'),
lines: z.number().optional().describe('Number of lines to capture (default: 200)'),
wait: z.number().optional().describe('Milliseconds to wait before capture'),
}),
execute: async ({ terminalId, lines, wait }) => {
const output = await captureTerminal(terminalId, lines, wait);
return { output };
},
}),
send_text: tool({
description: 'Send text or commands to a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID (e.g., @123)'),
text: z.string().describe('Text to send to the terminal'),
pressEnter: z.boolean().optional().describe('Whether to press Enter after text'),
return_output: z
.object({
lines: z.number().optional(),
wait: z.number().optional(),
})
.optional()
.describe('Capture output after sending'),
}),
execute: async ({ terminalId, text, pressEnter, return_output }) => {
const output = await sendText(terminalId, text, pressEnter, return_output);
return { output };
},
}),
rename_terminal: tool({
description: 'Rename an existing terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID to rename'),
newName: z.string().describe('New unique name for the terminal'),
}),
execute: async ({ terminalId, newName }) => {
await renameTerminal(terminalId, newName);
return { success: true };
},
}),
kill_terminal: tool({
description: 'Close and destroy a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID to kill'),
}),
execute: async ({ terminalId }) => {
await killTerminal(terminalId);
return { success: true };
},
}),
};
/**
* Message interface for conversation
*/
export interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}
/**
* Streaming LLM parameters
*/
export interface StreamLLMParams {
messages: Message[];
onChunk?: (chunk: string) => void;
onToolCall?: (toolName: string, args: any) => void;
onToolResult?: (toolName: string, result: any) => void;
onFinish?: (fullText: string) => void;
}
/**
* Stream LLM response with automatic tool execution
*/
export async function streamLLM(params: StreamLLMParams): Promise<string> {
if (!openaiClient) {
throw new Error('OpenAI client not initialized. Call initializeOpenAI() first.');
}
const result = await streamText({
model: openaiClient('gpt-4o') as any, // Type assertion to work around AI SDK version conflicts
messages: params.messages,
tools: terminalTools,
maxSteps: 5, // Allow up to 5 tool executions per turn
onStepFinish: async (event) => {
// Called after each step (tool call or text generation)
if (event.toolCalls && event.toolCalls.length > 0) {
for (const toolCall of event.toolCalls) {
if (params.onToolCall) {
params.onToolCall(toolCall.toolName, toolCall.args);
}
}
}
if (event.toolResults && event.toolResults.length > 0) {
for (const toolResult of event.toolResults) {
if (params.onToolResult) {
params.onToolResult(toolResult.toolName, toolResult.result);
}
}
}
},
});
let fullText = '';
// Stream text chunks to the caller
for await (const chunk of result.textStream) {
fullText += chunk;
if (params.onChunk) {
params.onChunk(chunk);
}
}
if (params.onFinish) {
params.onFinish(fullText);
}
return fullText;
}
@@ -0,0 +1,173 @@
import { v4 as uuidv4 } from 'uuid';
import { getSystemPrompt } from './system-prompt.js';
import { streamLLM, type Message } from './llm-openai.js';
import type { VoiceAssistantWebSocketServer } from '../websocket-server.js';
interface ConversationContext {
id: string;
messages: Message[];
createdAt: Date;
lastActivity: Date;
}
/**
* Store active conversations (in-memory for now)
* In production, this could be persisted to a database
*/
const conversations = new Map<string, ConversationContext>();
/**
* Create a new conversation with system prompt
*/
export function createConversation(): string {
const id = uuidv4();
conversations.set(id, {
id,
messages: [{ role: 'system', content: getSystemPrompt() }],
createdAt: new Date(),
lastActivity: new Date(),
});
return id;
}
/**
* Get conversation by ID
*/
export function getConversation(id: string): ConversationContext | null {
return conversations.get(id) || null;
}
/**
* Process user message through the LLM orchestrator
* Handles streaming, tool calls, and WebSocket broadcasting
*/
export async function processUserMessage(params: {
conversationId: string;
message: string;
wsServer?: VoiceAssistantWebSocketServer;
enableTTS?: boolean;
}): Promise<string> {
const conversation = conversations.get(params.conversationId);
if (!conversation) {
throw new Error('Conversation not found');
}
// Add user message to context
conversation.messages.push({
role: 'user',
content: params.message,
});
conversation.lastActivity = new Date();
// Broadcast user message to WebSocket clients
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'transcript',
content: params.message,
});
}
let assistantResponse = '';
try {
// Stream LLM response with tool execution
assistantResponse = await streamLLM({
messages: conversation.messages,
onChunk: (chunk) => {
// Broadcast streaming chunks to WebSocket
if (params.wsServer) {
params.wsServer.broadcast({
type: 'assistant_chunk',
payload: { chunk },
});
}
},
onToolCall: (toolName, args) => {
// Broadcast tool call to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'tool_call',
content: `Calling ${toolName}`,
metadata: { toolName, arguments: args },
});
}
},
onToolResult: (toolName, result) => {
// Broadcast tool result to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'tool_result',
content: `Tool ${toolName} completed`,
metadata: { toolName, result },
});
}
},
onFinish: (fullText) => {
// Broadcast complete assistant response
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'assistant',
content: fullText,
});
}
},
});
// Add assistant response to context
conversation.messages.push({
role: 'assistant',
content: assistantResponse,
});
} catch (error: any) {
// Broadcast error to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
content: `Error: ${error.message}`,
});
}
throw error;
}
return assistantResponse;
}
/**
* Clean up old conversations (call periodically)
*/
export function cleanupConversations(maxAgeMinutes: number = 60): void {
const now = new Date();
for (const [id, conv] of conversations.entries()) {
const ageMinutes = (now.getTime() - conv.lastActivity.getTime()) / (1000 * 60);
if (ageMinutes > maxAgeMinutes) {
conversations.delete(id);
}
}
}
/**
* Get conversation statistics
*/
export function getConversationStats(): {
total: number;
conversations: Array<{ id: string; messageCount: number; lastActivity: Date }>;
} {
return {
total: conversations.size,
conversations: Array.from(conversations.values()).map((conv) => ({
id: conv.id,
messageCount: conv.messages.length,
lastActivity: conv.lastActivity,
})),
};
}
@@ -0,0 +1,100 @@
import OpenAI from 'openai';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { v4 as uuidv4 } from 'uuid';
export interface STTConfig {
apiKey: string;
model?: 'whisper-1';
}
export interface TranscriptionResult {
text: string;
language?: string;
duration?: number;
}
let openaiClient: OpenAI | null = null;
let config: STTConfig | null = null;
export function initializeSTT(sttConfig: STTConfig): void {
config = sttConfig;
openaiClient = new OpenAI({
apiKey: sttConfig.apiKey,
});
console.log('✓ STT (OpenAI Whisper) initialized');
}
export async function transcribeAudio(
audioBuffer: Buffer,
format: string
): Promise<TranscriptionResult> {
if (!openaiClient || !config) {
throw new Error('STT not initialized. Call initializeSTT() first.');
}
const startTime = Date.now();
let tempFilePath: string | null = null;
try {
// Map format to file extension
const ext = getFileExtension(format);
// Write audio buffer to temporary file
// OpenAI API requires file upload, not raw buffer
tempFilePath = join(tmpdir(), `audio-${uuidv4()}.${ext}`);
await writeFile(tempFilePath, audioBuffer);
console.log(`[STT] Transcribing audio file: ${tempFilePath} (${audioBuffer.length} bytes)`);
// Call OpenAI Whisper API
const response = await openaiClient.audio.transcriptions.create({
file: await import('fs').then((fs) => fs.createReadStream(tempFilePath!)),
model: config.model || 'whisper-1',
response_format: 'verbose_json', // Get language and duration info
});
const duration = Date.now() - startTime;
console.log(`[STT] Transcription complete in ${duration}ms: "${response.text}"`);
return {
text: response.text,
language: response.language,
duration: duration,
};
} catch (error: any) {
console.error('[STT] Transcription error:', error);
throw new Error(`STT transcription failed: ${error.message}`);
} finally {
// Clean up temporary file
if (tempFilePath) {
try {
await unlink(tempFilePath);
} catch (cleanupError) {
console.warn(`[STT] Failed to clean up temp file: ${tempFilePath}`);
}
}
}
}
function getFileExtension(format: string): string {
// Map mime types or format strings to file extensions
const formatLower = format.toLowerCase();
if (formatLower.includes('webm')) return 'webm';
if (formatLower.includes('ogg')) return 'ogg';
if (formatLower.includes('mp3')) return 'mp3';
if (formatLower.includes('wav')) return 'wav';
if (formatLower.includes('m4a')) return 'm4a';
if (formatLower.includes('mp4')) return 'mp4';
if (formatLower.includes('flac')) return 'flac';
// Default to webm (common browser format)
return 'webm';
}
export function isSTTInitialized(): boolean {
return openaiClient !== null && config !== null;
}
@@ -0,0 +1,22 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load system prompt from agent-prompt.md in package root
export function loadSystemPrompt(): string {
const promptPath = path.join(__dirname, '../../../agent-prompt.md');
return readFileSync(promptPath, 'utf-8');
}
// Cache the loaded prompt
let cachedPrompt: string | null = null;
export function getSystemPrompt(): string {
if (!cachedPrompt) {
cachedPrompt = loadSystemPrompt();
}
return cachedPrompt;
}
@@ -0,0 +1,94 @@
import OpenAI from 'openai';
import { Readable } from 'stream';
export interface TTSConfig {
apiKey: string;
model?: 'tts-1' | 'tts-1-hd';
voice?: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
responseFormat?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
}
export interface SpeechResult {
audio: Buffer;
format: string;
}
let openaiClient: OpenAI | null = null;
let config: TTSConfig | null = null;
export function initializeTTS(ttsConfig: TTSConfig): void {
config = {
model: 'tts-1',
voice: 'alloy',
responseFormat: 'mp3',
...ttsConfig,
};
openaiClient = new OpenAI({
apiKey: ttsConfig.apiKey,
});
console.log(`✓ TTS (OpenAI) initialized with voice: ${config.voice}, model: ${config.model}`);
}
export async function synthesizeSpeech(text: string): Promise<SpeechResult> {
if (!openaiClient || !config) {
throw new Error('TTS not initialized. Call initializeTTS() first.');
}
if (!text || text.trim().length === 0) {
throw new Error('Cannot synthesize empty text');
}
const startTime = Date.now();
try {
console.log(`[TTS] Synthesizing speech: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
// Call OpenAI TTS API with streaming
const response = await openaiClient.audio.speech.create({
model: config.model!,
voice: config.voice!,
input: text,
response_format: config.responseFormat as 'mp3' | 'opus' | 'aac' | 'flac',
});
// Response is a stream, convert to buffer
const audioBuffer = await streamToBuffer(response.body as unknown as Readable);
const duration = Date.now() - startTime;
console.log(`[TTS] Speech synthesis complete in ${duration}ms (${audioBuffer.length} bytes)`);
return {
audio: audioBuffer,
format: config.responseFormat || 'mp3',
};
} catch (error: any) {
console.error('[TTS] Speech synthesis error:', error);
throw new Error(`TTS synthesis failed: ${error.message}`);
}
}
async function streamToBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => {
chunks.push(Buffer.from(chunk));
});
stream.on('end', () => {
resolve(Buffer.concat(chunks));
});
stream.on('error', (error) => {
reject(error);
});
});
}
export function isTTSInitialized(): boolean {
return openaiClient !== null && config !== null;
}
export function getTTSConfig(): TTSConfig | null {
return config;
}
@@ -0,0 +1,201 @@
import {
findSessionByName,
createSession,
listWindows,
createWindow,
capturePaneContent,
sendText as tmuxSendText,
renameWindow,
killWindow,
isWindowNameUnique,
getCurrentWorkingDirectory,
getCurrentCommand,
} from "./tmux.js";
const DEFAULT_SESSION = "voice-dev";
// Terminal model: session → windows (single pane per window)
// Terminal ID = window ID (format: @123)
export interface TerminalInfo {
id: string; // window ID
name: string;
workingDirectory: string;
currentCommand: string;
}
export interface CreateTerminalParams {
name: string;
workingDirectory: string;
initialCommand?: string;
}
export interface Terminal extends TerminalInfo {
active: boolean;
sessionId: string;
}
/**
* Initialize the default "voice-dev" tmux session
* Creates it if it doesn't exist
*/
export async function initializeDefaultSession(): Promise<void> {
const session = await findSessionByName(DEFAULT_SESSION);
if (!session) {
await createSession(DEFAULT_SESSION);
}
}
/**
* List all terminals in the voice-dev session
* Returns terminal info including ID, name, working directory, and current command
*/
export async function listTerminals(): Promise<TerminalInfo[]> {
const session = await findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Session '${DEFAULT_SESSION}' not found. Call initializeDefaultSession() first.`);
}
const windows = await listWindows(session.id);
const terminals: TerminalInfo[] = [];
for (const window of windows) {
// Get the first (and only) pane in this window
const paneId = `${window.id}.0`;
try {
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
terminals.push({
id: window.id,
name: window.name,
workingDirectory,
currentCommand,
});
} catch (error) {
// If we can't get pane info, still include the terminal with empty values
terminals.push({
id: window.id,
name: window.name,
workingDirectory: "",
currentCommand: "",
});
}
}
return terminals;
}
/**
* Create a new terminal (tmux window) with specified name and working directory
* Optionally execute an initial command
*/
export async function createTerminal(params: CreateTerminalParams): Promise<Terminal> {
const session = await findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Session '${DEFAULT_SESSION}' not found. Call initializeDefaultSession() first.`);
}
// Validate name uniqueness
const isUnique = await isWindowNameUnique(session.id, params.name);
if (!isUnique) {
throw new Error(
`Terminal with name '${params.name}' already exists. Please choose a unique name.`
);
}
// Create the window
const windowResult = await createWindow(session.id, params.name, {
workingDirectory: params.workingDirectory,
command: params.initialCommand,
});
if (!windowResult) {
throw new Error(`Failed to create terminal '${params.name}'`);
}
const paneId = windowResult.paneId;
// Get terminal info
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
return {
id: windowResult.id,
name: windowResult.name,
active: windowResult.active,
sessionId: session.id,
workingDirectory,
currentCommand,
};
}
/**
* Capture output from a terminal
* Returns the last N lines of terminal content
*/
export async function captureTerminal(
terminalId: string,
lines: number = 200,
wait?: number
): Promise<string> {
// Terminal ID is window ID, get the first pane
const paneId = `${terminalId}.0`;
// Optional wait before capture
if (wait) {
await new Promise((resolve) => setTimeout(resolve, wait));
}
return capturePaneContent(paneId, lines, false);
}
/**
* Send text to a terminal, optionally press Enter, optionally return output
*/
export async function sendText(
terminalId: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; wait?: number }
): Promise<string | void> {
// Terminal ID is window ID, get the first pane
const paneId = `${terminalId}.0`;
return tmuxSendText({
paneId,
text,
pressEnter,
return_output,
});
}
/**
* Rename a terminal
* Validates that the new name is unique
*/
export async function renameTerminal(
terminalId: string,
newName: string
): Promise<void> {
const session = await findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Session '${DEFAULT_SESSION}' not found.`);
}
// renameWindow handles uniqueness validation internally
await renameWindow(session.id, terminalId, newName);
}
/**
* Kill (close/destroy) a terminal
*/
export async function killTerminal(terminalId: string): Promise<void> {
await killWindow(terminalId);
}
@@ -0,0 +1,852 @@
import { exec as execCallback } from "child_process";
import { promisify } from "util";
import { v4 as uuidv4 } from "uuid";
import os from "node:os";
const exec = promisify(execCallback);
// Basic interfaces for tmux objects
export interface TmuxSession {
id: string;
name: string;
attached: boolean;
windows: number;
}
export interface TmuxWindow {
id: string;
name: string;
active: boolean;
sessionId: string;
}
export interface TmuxPane {
id: string;
windowId: string;
active: boolean;
title: string;
}
interface CommandExecution {
id: string;
paneId: string;
command: string;
status: "pending" | "completed" | "error";
startTime: Date;
result?: string;
exitCode?: number;
rawMode?: boolean;
}
export type ShellType = "bash" | "zsh" | "fish";
let shellConfig: { type: ShellType } = { type: "bash" };
export function setShellConfig(config: { type: string }): void {
// Validate shell type
const validShells: ShellType[] = ["bash", "zsh", "fish"];
if (validShells.includes(config.type as ShellType)) {
shellConfig = { type: config.type as ShellType };
} else {
shellConfig = { type: "bash" };
}
}
/**
* Execute a tmux command and return the result
*/
export async function executeTmux(tmuxCommand: string): Promise<string> {
try {
const { stdout } = await exec(`tmux ${tmuxCommand}`);
return stdout.trim();
} catch (error: any) {
throw new Error(`Failed to execute tmux command: ${error.message}`);
}
}
/**
* Check if tmux server is running
*/
export async function isTmuxRunning(): Promise<boolean> {
try {
await executeTmux("list-sessions -F '#{session_name}'");
return true;
} catch (error) {
return false;
}
}
/**
* List all tmux sessions
*/
export async function listSessions(): Promise<TmuxSession[]> {
const format =
"#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
const output = await executeTmux(`list-sessions -F '${format}'`);
if (!output) return [];
return output.split("\n").map((line) => {
const [id, name, attached, windows] = line.split(":");
return {
id,
name,
attached: attached === "1",
windows: parseInt(windows, 10),
};
});
}
/**
* Find a session by name
*/
export async function findSessionByName(
name: string
): Promise<TmuxSession | null> {
try {
const sessions = await listSessions();
return sessions.find((session) => session.name === name) || null;
} catch (error) {
return null;
}
}
/**
* Find a window by name in a session
*/
export async function findWindowByName(
sessionId: string,
name: string
): Promise<TmuxWindow | null> {
try {
const windows = await listWindows(sessionId);
return windows.find((window) => window.name === name) || null;
} catch (error) {
return null;
}
}
/**
* Check if a window name is unique in a session
*/
export async function isWindowNameUnique(
sessionId: string,
name: string
): Promise<boolean> {
const window = await findWindowByName(sessionId, name);
return window === null;
}
/**
* List windows in a session
*/
export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
const output = await executeTmux(
`list-windows -t '${sessionId}' -F '${format}'`
);
if (!output) return [];
return output.split("\n").map((line) => {
const [id, name, active] = line.split(":");
return {
id,
name,
active: active === "1",
sessionId,
};
});
}
/**
* List panes in a window
*/
export async function listPanes(windowId: string): Promise<TmuxPane[]> {
const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
const output = await executeTmux(
`list-panes -t '${windowId}' -F '${format}'`
);
if (!output) return [];
return output.split("\n").map((line) => {
const [id, title, active] = line.split(":");
return {
id,
windowId,
title: title,
active: active === "1",
};
});
}
/**
* Capture content from a specific pane, by default the latest 200 lines.
*/
export async function capturePaneContent(
paneId: string,
lines: number = 200,
includeColors: boolean = false
): Promise<string> {
const colorFlag = includeColors ? "-e" : "";
return executeTmux(
`capture-pane -p ${colorFlag} -t '${paneId}' -S -${lines} -E -`
);
}
/**
* Get the current working directory of a pane
*/
export async function getCurrentWorkingDirectory(
paneId: string
): Promise<string> {
try {
const tmuxPath = await executeTmux(`display-message -p -t '${paneId}' '#{pane_current_path}'`);
// If tmux returns a valid path, use it
if (tmuxPath && tmuxPath.trim()) {
return tmuxPath;
}
// Fallback: get the PID and use lsof to find the actual CWD
const shellPid = await executeTmux(`display-message -p -t '${paneId}' '#{pane_pid}'`);
const { stdout } = await exec(`lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-`);
return stdout.trim() || tmuxPath;
} catch (error) {
// If all else fails, return empty string
return "";
}
}
/**
* Get the current command running in a pane (full command line with arguments)
* Gets the immediate child process of the shell, not the shell itself
*/
export async function getCurrentCommand(paneId: string): Promise<string> {
try {
// Get the shell PID (the pane's main process)
const shellPid = await executeTmux(`display-message -p -t '${paneId}' '#{pane_pid}'`);
// First, check if there's a child process using comm= (works for all programs including top)
// Use 'ax' flags to see all processes
const { stdout: childPid } = await exec(`ps ax -o pid=,ppid=,comm= | awk '$2 == ${shellPid.trim()} { print $1; exit }'`);
if (childPid.trim()) {
// Found a child process, get its full command with args
const { stdout: fullCmd } = await exec(`ps -p ${childPid.trim()} -o args= | sed 's/\\\\012.*//'`);
const command = fullCmd.trim();
if (command) {
return command;
}
}
// No child process, just return the shell name
const { stdout: shellCmd } = await exec(`ps -p ${shellPid} -o comm=`);
return shellCmd.trim();
} catch (error) {
// Fallback to just the command name if ps fails
return executeTmux(`display-message -p -t '${paneId}' '#{pane_current_command}'`);
}
}
/**
* Create a new tmux session with a default window named "default" in home directory
*/
export async function createSession(name: string): Promise<TmuxSession | null> {
const homeDir = process.env.HOME || "~";
await executeTmux(`new-session -d -s "${name}" -n "default" -c "${homeDir}"`);
// Disable automatic window renaming for all windows in the session
await executeTmux(`set-window-option -t "${name}" automatic-rename off`);
return findSessionByName(name);
}
/**
* Expand tilde in path to home directory
*/
function expandTilde(path: string): string {
if (path.startsWith('~/')) {
const homeDir = process.env.HOME || os.homedir();
return path.replace('~', homeDir);
}
if (path === '~') {
return process.env.HOME || os.homedir();
}
return path;
}
/**
* Create a new window in a session with optional working directory and initial command
*/
export async function createWindow(
sessionId: string,
name: string,
options?: {
workingDirectory?: string;
command?: string;
}
): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> {
// Validate name uniqueness
const isUnique = await isWindowNameUnique(sessionId, name);
if (!isUnique) {
throw new Error(
`Terminal with name '${name}' already exists. Please choose a unique name.`
);
}
// Build new-window command with optional working directory
let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`;
if (options?.workingDirectory) {
// Expand tilde to home directory before passing to tmux
const expandedPath = expandTilde(options.workingDirectory);
newWindowCmd += ` -c '${expandedPath}'`;
}
await executeTmux(newWindowCmd);
const windows = await listWindows(sessionId);
const window = windows.find((window) => window.name === name);
if (!window) return null;
// Disable automatic window renaming
await executeTmux(`set-window-option -t '${window.id}' automatic-rename off`);
// Get the default pane created with the window
const panes = await listPanes(window.id);
const defaultPane = panes[0];
let commandOutput: string | undefined;
// If command is provided, execute it in the new pane
if (options?.command && defaultPane) {
await sendText({
paneId: defaultPane.id,
text: options.command,
pressEnter: true,
});
// Sleep for 1 second to allow command to execute
await new Promise((resolve) => setTimeout(resolve, 1000));
// Capture the pane content
commandOutput = await capturePaneContent(defaultPane.id, 200, false);
}
return {
...window,
paneId: defaultPane?.id || "",
output: commandOutput,
};
}
/**
* Kill a tmux session by ID
*/
export async function killSession(sessionId: string): Promise<void> {
await executeTmux(`kill-session -t '${sessionId}'`);
}
/**
* Kill a tmux window by ID
*/
export async function killWindow(windowId: string): Promise<void> {
await executeTmux(`kill-window -t '${windowId}'`);
}
/**
* Kill a tmux pane by ID
*/
export async function killPane(paneId: string): Promise<void> {
await executeTmux(`kill-pane -t '${paneId}'`);
}
/**
* Rename a tmux window by name or ID
*/
export async function renameWindow(
sessionId: string,
windowNameOrId: string,
newName: string
): Promise<void> {
// Validate new name is unique
const isUnique = await isWindowNameUnique(sessionId, newName);
if (!isUnique) {
throw new Error(
`Terminal with name '${newName}' already exists. Please choose a unique name.`
);
}
// Check if windowNameOrId is a window ID (starts with @) or a name
let windowId: string;
if (windowNameOrId.startsWith("@")) {
windowId = windowNameOrId;
} else {
// Resolve name to ID
const window = await findWindowByName(sessionId, windowNameOrId);
if (!window) {
throw new Error(`Terminal '${windowNameOrId}' not found.`);
}
windowId = window.id;
}
await executeTmux(`rename-window -t '${windowId}' '${newName}'`);
// Disable automatic renaming to preserve the manual name
await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`);
}
/**
* Split a tmux pane horizontally or vertically
*/
export async function splitPane(
targetPaneId: string,
direction: "horizontal" | "vertical" = "vertical",
size?: number
): Promise<TmuxPane | null> {
// Build the split-window command
let splitCommand = "split-window";
// Add direction flag (-h for horizontal, -v for vertical)
if (direction === "horizontal") {
splitCommand += " -h";
} else {
splitCommand += " -v";
}
// Add target pane
splitCommand += ` -t '${targetPaneId}'`;
// Add size if specified (as percentage)
if (size !== undefined && size > 0 && size < 100) {
splitCommand += ` -p ${size}`;
}
// Execute the split command
await executeTmux(splitCommand);
// Get the window ID from the target pane to list all panes
const windowInfo = await executeTmux(
`display-message -p -t '${targetPaneId}' '#{window_id}'`
);
// List all panes in the window to find the newly created one
const panes = await listPanes(windowInfo);
// The newest pane is typically the last one in the list
return panes.length > 0 ? panes[panes.length - 1] : null;
}
// Map to track ongoing command executions
const activeCommands = new Map<string, CommandExecution>();
const startMarkerText = "TMUX_MCP_START";
const endMarkerPrefix = "TMUX_MCP_DONE_";
// Execute a command in a tmux pane and track its execution
export async function executeCommand(
paneId: string,
command: string,
rawMode?: boolean,
noEnter?: boolean
): Promise<string> {
// Generate unique ID for this command execution
const commandId = uuidv4();
let fullCommand: string;
if (rawMode || noEnter) {
fullCommand = command;
} else {
const endMarkerText = getEndMarkerText();
fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
}
// Store command in tracking map
activeCommands.set(commandId, {
id: commandId,
paneId,
command,
status: "pending",
startTime: new Date(),
rawMode: rawMode || noEnter,
});
// Send the command to the tmux pane
if (noEnter) {
// Check if this is a special key or key combination
// Special keys in tmux are typically capitalized or have special names
const specialKeys = [
"Up",
"Down",
"Left",
"Right",
"Escape",
"Tab",
"Enter",
"Space",
"BSpace",
"Delete",
"Home",
"End",
"PageUp",
"PageDown",
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12",
"BTab",
];
// Split the command into parts to handle combinations like "C-b" or "M-x"
const parts = fullCommand.split("-");
const isSpecialKey = parts.length === 1 && specialKeys.includes(parts[0]);
const isKeyCombo =
parts.length > 1 &&
(parts[0] === "C" || // Control
parts[0] === "M" || // Meta/Alt
parts[0] === "S"); // Shift
if (isSpecialKey || isKeyCombo) {
// Send special key or key combination as-is
await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`);
} else {
// For regular text, send each character individually to ensure proper processing
// This handles both single characters (like 'q', 'f') and strings (like 'beam')
for (const char of fullCommand) {
await executeTmux(
`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
);
}
}
} else {
await executeTmux(
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
);
}
return commandId;
}
export async function checkCommandStatus(
commandId: string
): Promise<CommandExecution | null> {
const command = activeCommands.get(commandId);
if (!command) return null;
if (command.status !== "pending") return command;
const content = await capturePaneContent(command.paneId, 1000);
if (command.rawMode) {
command.result =
"Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead.";
return command;
}
// Find the last occurrence of the markers
const startIndex = content.lastIndexOf(startMarkerText);
const endIndex = content.lastIndexOf(endMarkerPrefix);
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
command.result = "Command output could not be captured properly";
return command;
}
// Extract exit code from the end marker line
const endLine = content.substring(endIndex).split("\n")[0];
const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`);
const exitCodeMatch = endLine.match(endMarkerRegex);
if (exitCodeMatch) {
const exitCode = parseInt(exitCodeMatch[1], 10);
command.status = exitCode === 0 ? "completed" : "error";
command.exitCode = exitCode;
// Extract output between the start and end markers
const outputStart = startIndex + startMarkerText.length;
const outputContent = content.substring(outputStart, endIndex).trim();
command.result = outputContent
.substring(outputContent.indexOf("\n") + 1)
.trim();
// Update in map
activeCommands.set(commandId, command);
}
return command;
}
// Get command by ID
export function getCommand(commandId: string): CommandExecution | null {
return activeCommands.get(commandId) || null;
}
// Get all active command IDs
export function getActiveCommandIds(): string[] {
return Array.from(activeCommands.keys());
}
// Clean up completed commands older than a certain time
export function cleanupOldCommands(maxAgeMinutes: number = 60): void {
const now = new Date();
for (const [id, command] of activeCommands.entries()) {
const ageMinutes =
(now.getTime() - command.startTime.getTime()) / (1000 * 60);
if (command.status !== "pending" && ageMinutes > maxAgeMinutes) {
activeCommands.delete(id);
}
}
}
function getEndMarkerText(): string {
return shellConfig.type === "fish"
? `${endMarkerPrefix}$status`
: `${endMarkerPrefix}$?`;
}
// New consolidated API functions
export type ListScope = "all" | "sessions" | "session" | "window" | "pane";
interface SessionWithWindows extends TmuxSession {
windowDetails?: WindowWithPanes[];
}
interface WindowWithPanes extends TmuxWindow {
paneDetails?: TmuxPane[];
}
export async function list({
scope,
target,
}: {
scope: ListScope;
target?: string;
}): Promise<
| SessionWithWindows[]
| TmuxSession[]
| TmuxWindow[]
| TmuxPane[]
| TmuxSession
| TmuxWindow
| TmuxPane
> {
if (scope === "all") {
const sessions = await listSessions();
const sessionsWithDetails: SessionWithWindows[] = [];
for (const session of sessions) {
const windows = await listWindows(session.id);
const windowsWithPanes: WindowWithPanes[] = [];
for (const window of windows) {
const panes = await listPanes(window.id);
windowsWithPanes.push({
...window,
paneDetails: panes,
});
}
sessionsWithDetails.push({
...session,
windowDetails: windowsWithPanes,
});
}
return sessionsWithDetails;
}
if (scope === "sessions") {
return listSessions();
}
if (scope === "session") {
if (!target) {
throw new Error("target is required for scope 'session'");
}
return listWindows(target);
}
if (scope === "window") {
if (!target) {
throw new Error("target is required for scope 'window'");
}
return listPanes(target);
}
if (scope === "pane") {
if (!target) {
throw new Error("target is required for scope 'pane'");
}
const windowId = await executeTmux(
`display-message -p -t '${target}' '#{window_id}'`
);
const panes = await listPanes(windowId);
const pane = panes.find((p) => p.id === target);
if (!pane) {
throw new Error(`Pane not found: ${target}`);
}
return pane;
}
throw new Error(`Invalid scope: ${scope}`);
}
export type KillScope = "session" | "window" | "pane";
export async function kill({
scope,
target,
}: {
scope: KillScope;
target: string;
}): Promise<void> {
if (scope === "session") {
return killSession(target);
}
if (scope === "window") {
return killWindow(target);
}
if (scope === "pane") {
return killPane(target);
}
throw new Error(`Invalid scope: ${scope}`);
}
export interface ShellCommandResult {
command: string;
status: "completed" | "error";
exitCode: number;
output: string;
}
export async function executeShellCommand({
paneId,
command,
timeout = 30000,
}: {
paneId: string;
command: string;
timeout?: number;
}): Promise<ShellCommandResult> {
const commandId = uuidv4();
const endMarkerText = getEndMarkerText();
const fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
activeCommands.set(commandId, {
id: commandId,
paneId,
command,
status: "pending",
startTime: new Date(),
rawMode: false,
});
await executeTmux(
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
);
// Poll for completion
const startTime = Date.now();
const pollInterval = 100;
while (Date.now() - startTime < timeout) {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
const result = await checkCommandStatus(commandId);
if (result && result.status !== "pending") {
// Cleanup
activeCommands.delete(commandId);
return {
command: result.command,
status: result.status,
exitCode: result.exitCode!,
output: result.result || "",
};
}
}
// Timeout
activeCommands.delete(commandId);
throw new Error(
`Command timed out after ${timeout}ms. Use capture-pane to check pane state.`
);
}
export async function sendKeys({
paneId,
keys,
repeat = 1,
return_output,
}: {
paneId: string;
keys: string;
repeat?: number;
return_output?: { lines?: number; wait?: number };
}): Promise<string | void> {
// Repeat the key press the specified number of times
for (let i = 0; i < repeat; i++) {
// Raw pass-through, no validation or processing
await executeTmux(`send-keys -t '${paneId}' ${keys}`);
}
// If return_output is requested, wait and capture pane content
if (return_output) {
await new Promise((resolve) =>
setTimeout(resolve, return_output.wait ?? 500)
);
const lines = return_output.lines || 200;
return capturePaneContent(paneId, lines, false);
}
}
export async function sendText({
paneId,
text,
pressEnter = false,
return_output,
}: {
paneId: string;
text: string;
pressEnter?: boolean;
return_output?: { lines?: number; wait?: number };
}): Promise<string | void> {
// Send each character with -l flag for literal interpretation
for (const char of text) {
await executeTmux(
`send-keys -l -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
);
}
if (pressEnter) {
await new Promise((resolve) => setTimeout(resolve, 300));
await executeTmux(`send-keys -t '${paneId}' Enter`);
}
// If return_output is requested, wait and capture pane content
if (return_output) {
if (return_output.wait) {
await new Promise((resolve) => setTimeout(resolve, return_output.wait));
}
const lines = return_output.lines || 200;
return capturePaneContent(paneId, lines, false);
}
}
@@ -0,0 +1,166 @@
/**
* OpenAI function calling schemas for terminal operations
* These tools are exposed to the LLM for terminal control
*/
export const terminalTools = [
{
type: "function",
function: {
name: "list_terminals",
description:
"List all available terminals in the voice-dev session. Returns terminal ID, name, working directory, and current command for each terminal.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
},
{
type: "function",
function: {
name: "create_terminal",
description:
"Create a new terminal (tmux window) with a specific name and working directory. Optionally execute an initial command after creation. Terminal names must be unique.",
parameters: {
type: "object",
properties: {
name: {
type: "string",
description:
"Unique name for the terminal. Should be descriptive of what the terminal is used for (e.g., 'web-dev', 'api-server', 'tests').",
},
workingDirectory: {
type: "string",
description:
"Absolute path to the working directory for this terminal. Can use ~ for home directory.",
},
initialCommand: {
type: "string",
description:
"Optional command to execute after creating the terminal (e.g., 'npm run dev', 'python -m venv venv').",
},
},
required: ["name", "workingDirectory"],
},
},
},
{
type: "function",
function: {
name: "capture_terminal",
description:
"Capture and return the output from a terminal. Returns the last N lines of terminal content. Useful for checking command results or monitoring running processes.",
parameters: {
type: "object",
properties: {
terminalId: {
type: "string",
description:
"The terminal ID (window ID format: @123) to capture output from. Get this from list_terminals.",
},
lines: {
type: "number",
description:
"Number of lines to capture from the terminal history. Defaults to 200.",
},
wait: {
type: "number",
description:
"Optional milliseconds to wait before capturing output. Useful when you just sent a command and want to wait for it to complete.",
},
},
required: ["terminalId"],
},
},
},
{
type: "function",
function: {
name: "send_text",
description:
"Type text into a terminal. Can optionally press Enter to execute the text as a command. Can optionally return the output after execution. This is the primary way to run commands in terminals.",
parameters: {
type: "object",
properties: {
terminalId: {
type: "string",
description:
"The terminal ID (window ID format: @123) to send text to. Get this from list_terminals.",
},
text: {
type: "string",
description:
"The text to type into the terminal. Can be a command, input for a running program, or any text.",
},
pressEnter: {
type: "boolean",
description:
"Whether to press Enter after typing the text. Set to true to execute commands. Defaults to false.",
},
return_output: {
type: "object",
description:
"Optional. If provided, will wait and return the terminal output after sending the text.",
properties: {
lines: {
type: "number",
description: "Number of lines to capture. Defaults to 200.",
},
wait: {
type: "number",
description:
"Milliseconds to wait before capturing output. Defaults to 500ms.",
},
},
},
},
required: ["terminalId", "text"],
},
},
},
{
type: "function",
function: {
name: "rename_terminal",
description:
"Rename an existing terminal. The new name must be unique among all terminals.",
parameters: {
type: "object",
properties: {
terminalId: {
type: "string",
description:
"The terminal ID (window ID format: @123) to rename. Get this from list_terminals.",
},
newName: {
type: "string",
description:
"The new unique name for the terminal. Should be descriptive of the terminal's purpose.",
},
},
required: ["terminalId", "newName"],
},
},
},
{
type: "function",
function: {
name: "kill_terminal",
description:
"Close and destroy a terminal. This will terminate any running processes in the terminal. Use with caution.",
parameters: {
type: "object",
properties: {
terminalId: {
type: "string",
description:
"The terminal ID (window ID format: @123) to kill. Get this from list_terminals.",
},
},
required: ["terminalId"],
},
},
},
] as const;
@@ -0,0 +1,336 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { createServer as createHTTPServer } from 'http';
import { readFile } from 'fs/promises';
import { v4 as uuidv4 } from 'uuid';
import { createServer as createViteServer } from 'vite';
import type { ViteDevServer } from 'vite';
import type { ServerConfig } from './types.js';
import { VoiceAssistantWebSocketServer } from './websocket-server.js';
import {
initializeDefaultSession,
listTerminals,
createTerminal,
sendText,
captureTerminal,
killTerminal,
} from './daemon/terminal-manager.js';
import { initializeOpenAI } from './agent/llm-openai.js';
import { initializeSTT, transcribeAudio } from './agent/stt-openai.js';
import { initializeTTS, synthesizeSpeech } from './agent/tts-openai.js';
import {
createConversation,
processUserMessage,
cleanupConversations,
} from './agent/orchestrator.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function createServer(config: ServerConfig) {
const app = express();
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Test endpoint for LLM integration (removed - using orchestrator now)
// Test endpoint for terminal operations
app.get('/api/test-terminal', async (_req, res) => {
try {
const testResults: string[] = [];
// 1. Initialize default session
testResults.push('1. Initializing default session...');
await initializeDefaultSession();
testResults.push(' ✓ Session initialized');
// 2. List existing terminals
testResults.push('2. Listing existing terminals...');
const beforeTerminals = await listTerminals();
testResults.push(` ✓ Found ${beforeTerminals.length} existing terminals`);
// 3. Create a test terminal
testResults.push('3. Creating test terminal...');
const terminal = await createTerminal({
name: 'test-terminal',
workingDirectory: '~',
initialCommand: undefined,
});
testResults.push(` ✓ Created terminal: ${terminal.id} (${terminal.name})`);
// 4. Send a command to the terminal
testResults.push('4. Sending command "echo hello world"...');
await sendText(terminal.id, 'echo "hello world"', true, { lines: 20, wait: 500 });
testResults.push(' ✓ Command sent');
// 5. Capture output
testResults.push('5. Capturing terminal output...');
const output = await captureTerminal(terminal.id, 20);
testResults.push(` ✓ Captured ${output.split('\n').length} lines`);
testResults.push(` Output preview: ${output.substring(0, 100)}...`);
// 6. List terminals again
testResults.push('6. Listing terminals again...');
const afterTerminals = await listTerminals();
testResults.push(` ✓ Found ${afterTerminals.length} terminals`);
const testTerminal = afterTerminals.find((t) => t.name === 'test-terminal');
if (testTerminal) {
testResults.push(` ✓ Found test-terminal: ${testTerminal.id}`);
}
// 7. Kill the test terminal
testResults.push('7. Killing test terminal...');
await killTerminal(terminal.id);
testResults.push(' ✓ Terminal killed');
// 8. Verify it's gone
testResults.push('8. Verifying terminal was removed...');
const finalTerminals = await listTerminals();
const stillExists = finalTerminals.find((t) => t.name === 'test-terminal');
if (!stillExists) {
testResults.push(' ✓ Terminal successfully removed');
} else {
testResults.push(' ✗ Terminal still exists!');
}
res.json({
success: true,
message: 'All terminal operations completed successfully',
results: testResults,
});
} catch (error: any) {
res.status(500).json({
success: false,
error: error.message,
stack: error.stack,
});
}
});
let vite: ViteDevServer | undefined;
if (config.isDev) {
// Development: Create Vite server in middleware mode
vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
// Use Vite's middleware to handle HMR, module transformation, etc.
app.use(vite.middlewares);
} else {
// Production: Serve static files from dist/ui
const uiPath = path.join(__dirname, '../ui');
app.use(express.static(uiPath));
}
// SPA fallback - serve index.html for all non-API routes
app.use('*', async (req, res, next) => {
if (req.originalUrl.startsWith('/api')) {
return res.status(404).json({ error: 'Not found' });
}
try {
const indexHtmlPath = config.isDev
? path.resolve(__dirname, '../../src/ui/index.html')
: path.join(__dirname, '../ui/index.html');
let html = await readFile(indexHtmlPath, 'utf-8');
// In development, transform the HTML with Vite
if (config.isDev && vite) {
html = await vite.transformIndexHtml(req.originalUrl, html);
}
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
if (config.isDev && vite) {
vite.ssrFixStacktrace(e as Error);
}
next(e);
}
});
return { app, vite };
}
async function main() {
const port = parseInt(process.env.PORT || '3000', 10);
const isDev = process.env.NODE_ENV !== 'production';
const config: ServerConfig = { port, isDev };
const { app, vite } = await createServer(config);
// Create HTTP server
const httpServer = createHTTPServer(app);
// Initialize WebSocket server
const wsServer = new VoiceAssistantWebSocketServer(httpServer);
// Initialize OpenAI client
const apiKey = process.env.OPENAI_API_KEY;
if (apiKey) {
initializeOpenAI(apiKey);
console.log('✓ OpenAI client initialized');
// Initialize STT (Whisper)
initializeSTT({ apiKey });
// Initialize TTS
const ttsVoice = (process.env.TTS_VOICE || 'alloy') as 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
const ttsModel = (process.env.TTS_MODEL || 'tts-1') as 'tts-1' | 'tts-1-hd';
initializeTTS({
apiKey,
voice: ttsVoice,
model: ttsModel,
responseFormat: 'mp3',
});
} else {
console.warn('⚠ OPENAI_API_KEY not set - LLM, STT, and TTS features will not work');
}
// Create default conversation
const defaultConversationId = createConversation();
console.log(`✓ Default conversation created: ${defaultConversationId}`);
// Helper function to process message and optionally generate TTS
async function processMessageWithOptionalTTS(message: string, enableTTS: boolean = false): Promise<string> {
const response = await processUserMessage({
conversationId: defaultConversationId,
message,
wsServer,
});
// Generate TTS for the response if enabled
if (enableTTS && apiKey) {
try {
console.log('[TTS] Generating speech for assistant response...');
const speechResult = await synthesizeSpeech(response);
// Convert audio buffer to base64
const base64Audio = speechResult.audio.toString('base64');
// Send audio to client via WebSocket
wsServer.broadcast({
type: 'audio_output',
payload: {
id: uuidv4(),
audio: base64Audio,
format: speechResult.format,
},
});
console.log(`[TTS] Sent audio to client via WebSocket (${speechResult.audio.length} bytes)`);
} catch (error: any) {
console.error('[TTS] Error generating speech:', error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
content: `TTS error: ${error.message}`,
});
}
}
return response;
}
// Wire orchestrator to WebSocket for text messages
wsServer.setMessageHandler(async (message: string) => {
try {
await processUserMessage({
conversationId: defaultConversationId,
message,
wsServer,
});
} catch (error: any) {
console.error('[Orchestrator] Error processing message:', error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
content: `Error: ${error.message}`,
});
}
});
// Wire audio handler to WebSocket for voice input (STT)
wsServer.setAudioHandler(async (audio: Buffer, format: string): Promise<string> => {
try {
// Transcribe audio using OpenAI Whisper
const result = await transcribeAudio(audio, format);
// Broadcast transcription result as activity log
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'transcript',
content: result.text,
metadata: {
language: result.language,
duration: result.duration,
},
});
// Process the transcribed text through the orchestrator WITH TTS enabled
// Since this came from voice input, respond with voice output
await processMessageWithOptionalTTS(result.text, true);
return result.text;
} catch (error: any) {
console.error('[STT] Error transcribing audio:', error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
content: `Transcription error: ${error.message}`,
});
throw error;
}
});
// Start conversation cleanup interval (every 10 minutes)
setInterval(() => {
cleanupConversations(60); // Clean up conversations older than 60 minutes
}, 10 * 60 * 1000);
// Initialize default tmux session for terminal control
try {
await initializeDefaultSession();
console.log('✓ Default tmux session initialized');
} catch (error) {
console.error('Failed to initialize tmux session:', error);
}
httpServer.listen(port, () => {
console.log(`\n✓ Voice Assistant server running on http://localhost:${port}`);
if (isDev) {
console.log(`✓ Vite dev server integrated (HMR enabled)\n`);
}
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('\nSIGTERM received, shutting down gracefully...');
wsServer.close();
if (vite) {
await vite.close();
}
httpServer.close(() => {
console.log('Server closed');
process.exit(0);
});
});
}
main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
@@ -0,0 +1,42 @@
// Shared server types
export interface ServerConfig {
port: number;
isDev: boolean;
}
export interface ActivityLogEntry {
id: string;
timestamp: Date;
type: 'transcript' | 'assistant' | 'tool_call' | 'tool_result' | 'error' | 'system';
content: string;
metadata?: Record<string, unknown>;
}
export interface WebSocketMessage {
type: 'activity_log' | 'status' | 'ping' | 'pong' | 'user_message' | 'assistant_chunk'
| 'audio_chunk' | 'audio_output' | 'recording_state' | 'transcription_result';
payload: unknown;
}
export interface AudioChunkPayload {
audio: string; // base64 encoded audio data
format: string; // 'webm', 'ogg', etc.
isLast: boolean; // true when recording stopped
}
export interface RecordingStatePayload {
isRecording: boolean;
}
export interface TranscriptionResultPayload {
text: string;
language?: string;
duration?: number;
}
export interface AudioOutputPayload {
audio: string; // base64 encoded audio data (complete)
format: string; // 'mp3'
id: string; // unique ID for queue management
}
@@ -0,0 +1,197 @@
import { WebSocketServer, WebSocket } from 'ws';
import { Server as HTTPServer } from 'http';
import type {
WebSocketMessage,
ActivityLogEntry,
AudioChunkPayload,
} from './types.js';
export class VoiceAssistantWebSocketServer {
private wss: WebSocketServer;
private clients: Map<WebSocket, string> = new Map(); // Map ws to client ID
private messageHandler?: (message: string) => Promise<void>;
private audioHandler?: (audio: Buffer, format: string) => Promise<string>;
private audioBuffers: Map<string, { chunks: Buffer[]; format: string }> = new Map();
private clientIdCounter: number = 0;
constructor(server: HTTPServer) {
this.wss = new WebSocketServer({ server, path: '/ws' });
this.wss.on('connection', (ws) => {
this.handleConnection(ws);
});
console.log('✓ WebSocket server initialized on /ws');
}
private handleConnection(ws: WebSocket): void {
// Generate unique client ID
const clientId = `client-${++this.clientIdCounter}`;
this.clients.set(ws, clientId);
console.log(`[WS] Client connected: ${clientId} (total: ${this.clients.size})`);
// Send welcome message
this.sendToClient(ws, {
type: 'status',
payload: { status: 'connected', message: 'WebSocket connection established' },
});
ws.on('message', (data) => {
this.handleMessage(ws, data);
});
ws.on('close', () => {
const clientId = this.clients.get(ws);
if (clientId) {
this.clients.delete(ws);
console.log(`[WS] Client disconnected: ${clientId} (total: ${this.clients.size})`);
}
});
ws.on('error', (error) => {
console.error('[WS] Client error:', error);
const clientId = this.clients.get(ws);
if (clientId) {
this.clients.delete(ws);
}
});
}
private async handleMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise<void> {
try {
const message = JSON.parse(data.toString()) as WebSocketMessage;
console.log(`[WS] Received message type: ${message.type}`);
switch (message.type) {
case 'ping':
this.sendToClient(ws, { type: 'pong', payload: {} });
break;
case 'user_message':
// Handle user message through orchestrator
const payload = message.payload as { message: string };
if (this.messageHandler) {
await this.messageHandler(payload.message);
} else {
console.warn('[WS] No message handler registered');
}
break;
case 'audio_chunk':
// Handle audio chunk for STT
await this.handleAudioChunk(ws, message.payload as AudioChunkPayload);
break;
default:
console.warn(`[WS] Unknown message type: ${message.type}`);
}
} catch (error) {
console.error('[WS] Failed to parse message:', error);
}
}
private sendToClient(ws: WebSocket, message: WebSocketMessage): void {
if (ws.readyState === 1) { // WebSocket.OPEN = 1
ws.send(JSON.stringify(message));
}
}
public broadcast(message: WebSocketMessage): void {
const payload = JSON.stringify(message);
this.clients.forEach((_clientId, client) => {
if (client.readyState === 1) { // WebSocket.OPEN = 1
client.send(payload);
}
});
}
public broadcastActivityLog(entry: ActivityLogEntry): void {
this.broadcast({
type: 'activity_log',
payload: entry,
});
}
public broadcastStatus(status: string, metadata?: Record<string, unknown>): void {
this.broadcast({
type: 'status',
payload: { status, ...metadata },
});
}
public setMessageHandler(handler: (message: string) => Promise<void>): void {
this.messageHandler = handler;
}
public setAudioHandler(handler: (audio: Buffer, format: string) => Promise<string>): void {
this.audioHandler = handler;
}
private async handleAudioChunk(ws: WebSocket, payload: AudioChunkPayload): Promise<void> {
try {
// Use a client-specific key for buffering (in case multiple clients)
const clientId = 'default'; // Could be enhanced with per-client tracking
// Decode base64 audio data
const audioBuffer = Buffer.from(payload.audio, 'base64');
if (!payload.isLast) {
// Buffer the chunk
if (!this.audioBuffers.has(clientId)) {
this.audioBuffers.set(clientId, { chunks: [], format: payload.format });
}
const buffer = this.audioBuffers.get(clientId)!;
buffer.chunks.push(audioBuffer);
console.log(`[WS] Buffered audio chunk (${audioBuffer.length} bytes, total chunks: ${buffer.chunks.length})`);
} else {
// Last chunk - process complete audio
const buffer = this.audioBuffers.get(clientId);
const allChunks = buffer ? [...buffer.chunks, audioBuffer] : [audioBuffer];
const format = buffer?.format || payload.format;
// Concatenate all chunks
const completeAudio = Buffer.concat(allChunks);
console.log(`[WS] Complete audio received (${completeAudio.length} bytes, ${allChunks.length} chunks)`);
// Clear buffer
this.audioBuffers.delete(clientId);
// Process audio through handler (STT)
if (this.audioHandler) {
this.broadcastActivityLog({
id: Date.now().toString(),
timestamp: new Date(),
type: 'system',
content: 'Transcribing audio...',
});
const transcript = await this.audioHandler(completeAudio, format);
// Send transcription result back to client
this.sendToClient(ws, {
type: 'transcription_result',
payload: { text: transcript },
});
} else {
console.warn('[WS] No audio handler registered');
}
}
} catch (error: any) {
console.error('[WS] Audio chunk handling error:', error);
this.broadcastActivityLog({
id: Date.now().toString(),
timestamp: new Date(),
type: 'error',
content: `Audio processing error: ${error.message}`,
});
}
}
public close(): void {
this.clients.forEach((_clientId, ws) => {
ws.close();
});
this.wss.close();
}
}
+391
View File
@@ -0,0 +1,391 @@
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
width: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background-color: #1a1a1a;
border-bottom: 1px solid #333;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.status-indicator {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.status-indicator.disconnected {
background-color: #444;
color: #999;
}
.status-indicator.connecting {
background-color: #ffa500;
color: #000;
animation: pulse 1.5s ease-in-out infinite;
}
.status-indicator.connected {
background-color: #22c55e;
color: #000;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.main {
display: flex;
flex: 1;
padding: 2rem;
gap: 2rem;
}
.chat-interface {
flex: 1;
background-color: #1a1a1a;
border-radius: 8px;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
max-height: calc(100vh - 120px);
}
.chat-interface h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid #333;
}
.ping-button {
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
background-color: #22c55e;
color: #000;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
}
.ping-button:hover:not(:disabled) {
background-color: #16a34a;
}
.ping-button:disabled {
background-color: #444;
color: #999;
cursor: not-allowed;
}
.connection-info {
color: #999;
font-size: 0.875rem;
text-align: center;
max-width: 400px;
}
.activity-log {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.log-entries {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.log-entry {
padding: 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
line-height: 1.4;
display: flex;
gap: 0.75rem;
}
.log-time {
font-family: monospace;
color: #666;
flex-shrink: 0;
}
.log-message {
flex: 1;
}
.log-entry.system {
background-color: #2a2a2a;
color: #999;
font-style: italic;
}
.log-entry.info {
background-color: #1e3a8a;
color: #93c5fd;
}
.log-entry.success {
background-color: #14532d;
color: #86efac;
}
.log-entry.error {
background-color: #7f1d1d;
color: #fca5a5;
}
.log-entry.user {
background-color: #1e293b;
color: #e2e8f0;
}
.log-entry.assistant {
background-color: #0f766e;
color: #ccfbf1;
}
.log-entry.tool {
background-color: #581c87;
color: #e9d5ff;
font-family: monospace;
font-size: 0.75rem;
}
.log-entry.streaming {
opacity: 0.8;
}
.streaming-indicator {
animation: dots 1.5s steps(3, end) infinite;
color: #666;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
.log-metadata {
margin-top: 0.5rem;
}
.log-metadata summary {
cursor: pointer;
color: #999;
font-size: 0.75rem;
}
.log-metadata pre {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #000;
border-radius: 4px;
overflow-x: auto;
font-size: 0.75rem;
}
.message-form {
display: flex;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #333;
}
.message-input {
flex: 1;
padding: 0.75rem 1rem;
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
outline: none;
}
.message-input:focus {
border-color: #22c55e;
}
.message-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.send-button {
padding: 0.75rem 1.5rem;
background-color: #22c55e;
color: #000;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.send-button:hover:not(:disabled) {
background-color: #16a34a;
}
.send-button:disabled {
background-color: #444;
color: #999;
cursor: not-allowed;
}
.test-controls {
padding-top: 0.5rem;
border-top: 1px solid #333;
display: flex;
justify-content: center;
}
/* Voice Controls */
.voice-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background-color: #2a2a2a;
border-radius: 6px;
margin-bottom: 1rem;
}
.voice-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 2rem;
background-color: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.voice-button:hover:not(:disabled) {
background-color: #2563eb;
transform: scale(1.02);
}
.voice-button:disabled {
background-color: #444;
color: #999;
cursor: not-allowed;
transform: none;
}
.voice-button.recording {
background-color: #ef4444;
animation: pulse-red 1.5s ease-in-out infinite;
}
.voice-button.recording:hover:not(:disabled) {
background-color: #dc2626;
}
.voice-button.processing {
background-color: #f59e0b;
}
@keyframes pulse-red {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.recording-indicator {
display: inline-block;
width: 12px;
height: 12px;
background-color: #fff;
border-radius: 50%;
animation: pulse 1s ease-in-out infinite;
}
.processing-indicator {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #fff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.microphone-icon {
font-size: 1.25rem;
}
.voice-error {
padding: 0.75rem;
background-color: #7f1d1d;
color: #fca5a5;
border-radius: 4px;
font-size: 0.875rem;
}
.permission-denied {
padding: 0.75rem;
background-color: #7c2d12;
color: #fed7aa;
border-radius: 4px;
font-size: 0.875rem;
}
.playing-indicator {
padding: 0.5rem;
background-color: #0f766e;
color: #ccfbf1;
border-radius: 4px;
font-size: 0.875rem;
text-align: center;
}
@media (max-width: 1024px) {
.main {
flex-direction: column;
}
}
+260
View File
@@ -0,0 +1,260 @@
import { useState, useEffect, useRef } from 'react';
import { useWebSocket } from './hooks/useWebSocket';
import { VoiceControls } from './components/VoiceControls';
import { createAudioPlayer } from './lib/audio-playback';
import './App.css';
interface LogEntry {
id: string;
timestamp: number;
type: 'system' | 'info' | 'success' | 'error' | 'user' | 'assistant' | 'tool';
message: string;
metadata?: Record<string, unknown>;
}
function App() {
const [logs, setLogs] = useState<LogEntry[]>([
{ id: '1', timestamp: Date.now(), type: 'system', message: 'System initialized' },
]);
const [userInput, setUserInput] = useState('');
const [currentAssistantMessage, setCurrentAssistantMessage] = useState('');
const [isProcessingAudio, setIsProcessingAudio] = useState(false);
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const audioPlayerRef = useRef(createAudioPlayer());
// WebSocket URL - use ws://localhost:3000/ws in dev, or construct from current host in prod
const wsUrl = `ws://${window.location.host}/ws`;
const ws = useWebSocket(wsUrl);
const addLog = (
type: LogEntry['type'],
message: string,
metadata?: Record<string, unknown>
) => {
console.trace("addLog", type, message, metadata);
setLogs((prev) => [
...prev,
{ id: Date.now().toString(), timestamp: Date.now(), type, message, metadata },
]);
};
useEffect(() => {
// Listen for status messages
const unsubStatus = ws.on('status', (payload: unknown) => {
const data = payload as { status: string; message?: string };
addLog('info', data.message || `Status: ${data.status}`);
});
// Listen for pong messages
const unsubPong = ws.on('pong', () => {
addLog('success', 'Received pong from server');
});
// Listen for activity log messages
const unsubActivity = ws.on('activity_log', (payload: unknown) => {
const data = payload as {
type: string;
content: string;
metadata?: Record<string, unknown>;
};
// Map activity log types to UI log types
let logType: LogEntry['type'] = 'info';
if (data.type === 'transcript') logType = 'user';
else if (data.type === 'assistant') logType = 'assistant';
else if (data.type === 'tool_call' || data.type === 'tool_result') logType = 'tool';
else if (data.type === 'error') logType = 'error';
addLog(logType, data.content, data.metadata);
});
// Listen for streaming assistant chunks
const unsubChunk = ws.on('assistant_chunk', (payload: unknown) => {
const data = payload as { chunk: string };
setCurrentAssistantMessage((prev) => prev + data.chunk);
});
// Listen for transcription results
const unsubTranscription = ws.on('transcription_result', (payload: unknown) => {
const data = payload as { text: string };
addLog('info', `Transcription: ${data.text}`);
setIsProcessingAudio(false);
});
// Listen for audio output (TTS)
const unsubAudioOutput = ws.on('audio_output', async (payload: unknown) => {
const data = payload as { audio: string; format: string; id: string };
try {
addLog('info', 'Playing assistant audio response...');
setIsPlayingAudio(true);
// Decode base64 audio
const binaryString = atob(data.audio);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Create blob with correct mime type
const mimeType = data.format === 'mp3' ? 'audio/mpeg' : `audio/${data.format}`;
const audioBlob = new Blob([bytes], { type: mimeType });
// Play audio
await audioPlayerRef.current.play(audioBlob);
setIsPlayingAudio(false);
addLog('success', 'Audio playback complete');
} catch (error: any) {
console.error('[App] Audio playback error:', error);
addLog('error', `Audio playback failed: ${error.message}`);
setIsPlayingAudio(false);
}
});
return () => {
unsubStatus();
unsubPong();
unsubActivity();
unsubChunk();
unsubTranscription();
unsubAudioOutput();
};
}, [ws]);
useEffect(() => {
if (ws.isConnected) {
addLog('success', 'WebSocket connected');
} else {
addLog('system', 'WebSocket disconnected');
}
}, [ws.isConnected]);
// Auto-scroll to bottom when new logs are added
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs, currentAssistantMessage]);
const handlePing = () => {
ws.sendPing();
addLog('info', 'Sent ping to server');
};
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!userInput.trim() || !ws.isConnected) return;
// Send message to server
ws.sendUserMessage(userInput);
// Clear input and reset streaming state
setUserInput('');
setCurrentAssistantMessage('');
};
const handleAudioRecorded = async (audioBlob: Blob, format: string) => {
if (!ws.isConnected) {
addLog('error', 'Cannot send audio - not connected to server');
return;
}
try {
setIsProcessingAudio(true);
addLog('info', 'Sending audio to server...');
// Convert blob to base64
const arrayBuffer = await audioBlob.arrayBuffer();
const base64Audio = btoa(
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
);
// Send audio chunk via WebSocket
ws.send({
type: 'audio_chunk',
payload: {
audio: base64Audio,
format: format,
isLast: true,
},
});
console.log(`[App] Sent audio: ${audioBlob.size} bytes, format: ${format}`);
} catch (error: any) {
console.error('[App] Error sending audio:', error);
addLog('error', `Failed to send audio: ${error.message}`);
setIsProcessingAudio(false);
}
};
return (
<div className="app">
<header className="header">
<h1>Voice Assistant</h1>
<div className={`status-indicator ${ws.isConnected ? 'connected' : 'disconnected'}`}>
{ws.isConnected ? 'connected' : 'disconnected'}
</div>
</header>
<main className="main">
<div className="chat-interface">
<h2>Chat with Assistant</h2>
<VoiceControls
onAudioRecorded={handleAudioRecorded}
isProcessing={isProcessingAudio}
isPlaying={isPlayingAudio}
/>
<div className="activity-log">
<div className="log-entries">
{logs.map((log) => (
<div key={log.id} className={`log-entry ${log.type}`}>
<span className="log-time">{new Date(log.timestamp).toLocaleTimeString()}</span>
<span className="log-message">{log.message}</span>
{log.metadata && (
<details className="log-metadata">
<summary>Details</summary>
<pre>{JSON.stringify(log.metadata, null, 2)}</pre>
</details>
)}
</div>
))}
{currentAssistantMessage && (
<div className="log-entry assistant streaming">
<span className="log-time">{new Date().toLocaleTimeString()}</span>
<span className="log-message">{currentAssistantMessage}</span>
<span className="streaming-indicator">...</span>
</div>
)}
<div ref={logEndRef} />
</div>
</div>
<form onSubmit={handleSendMessage} className="message-form">
<input
type="text"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
placeholder="Type a message to the assistant..."
disabled={!ws.isConnected}
className="message-input"
/>
<button type="submit" disabled={!ws.isConnected || !userInput.trim()} className="send-button">
Send
</button>
</form>
<div className="test-controls">
<button onClick={handlePing} disabled={!ws.isConnected} className="ping-button">
Send Ping (Test)
</button>
</div>
</div>
</main>
</div>
);
}
export default App;
@@ -0,0 +1,113 @@
import { useState, useEffect, useRef } from 'react';
import { createAudioRecorder, type AudioRecorder } from '../lib/audio-capture';
interface VoiceControlsProps {
onAudioRecorded: (audio: Blob, format: string) => void;
isProcessing: boolean;
isPlaying?: boolean;
}
export function VoiceControls({ onAudioRecorded, isProcessing, isPlaying }: VoiceControlsProps) {
const [isRecording, setIsRecording] = useState(false);
const [error, setError] = useState<string | null>(null);
const [permissionState, setPermissionState] = useState<'prompt' | 'granted' | 'denied'>('prompt');
const recorderRef = useRef<AudioRecorder | null>(null);
useEffect(() => {
// Initialize recorder
recorderRef.current = createAudioRecorder();
// Check microphone permission
if (navigator.permissions) {
navigator.permissions
.query({ name: 'microphone' as PermissionName })
.then((result) => {
setPermissionState(result.state);
result.onchange = () => {
setPermissionState(result.state);
};
})
.catch(() => {
setPermissionState('prompt');
});
}
}, []);
async function handleToggleRecording() {
const recorder = recorderRef.current;
if (!recorder) return;
try {
setError(null);
if (isRecording) {
// Stop recording
console.log('[VoiceControls] Stopping recording...');
const audioBlob = await recorder.stop();
setIsRecording(false);
// Get format from blob type
const format = audioBlob.type || 'audio/webm';
console.log(`[VoiceControls] Recording complete: ${audioBlob.size} bytes, format: ${format}`);
// Send to parent
onAudioRecorded(audioBlob, format);
} else {
// Start recording
console.log('[VoiceControls] Starting recording...');
await recorder.start();
setIsRecording(true);
setPermissionState('granted');
}
} catch (err: any) {
console.error('[VoiceControls] Recording error:', err);
setError(err.message || 'Failed to access microphone');
setIsRecording(false);
if (err.message.includes('Permission denied')) {
setPermissionState('denied');
}
}
}
const canRecord = !isProcessing && !isPlaying;
const buttonDisabled = !canRecord || permissionState === 'denied';
return (
<div className="voice-controls">
<button
onClick={handleToggleRecording}
disabled={buttonDisabled}
className={`voice-button ${isRecording ? 'recording' : ''} ${isProcessing ? 'processing' : ''}`}
title={isRecording ? 'Stop recording' : 'Start recording'}
>
{isRecording ? (
<>
<span className="recording-indicator" />
<span>Recording...</span>
</>
) : isProcessing ? (
<>
<span className="processing-indicator" />
<span>Processing...</span>
</>
) : (
<>
<span className="microphone-icon">🎤</span>
<span>Record</span>
</>
)}
</button>
{error && <div className="voice-error">{error}</div>}
{permissionState === 'denied' && (
<div className="permission-denied">
Microphone access denied. Please enable it in your browser settings.
</div>
)}
{isPlaying && <div className="playing-indicator">🔊 Playing response...</div>}
</div>
);
}
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState, useCallback } from 'react';
interface WebSocketMessage {
type: string;
payload: unknown;
}
type MessageHandler = (payload: unknown) => void;
export interface UseWebSocketReturn {
isConnected: boolean;
send: (message: WebSocketMessage) => void;
on: (type: string, handler: MessageHandler) => () => void;
sendPing: () => void;
sendUserMessage: (message: string) => void;
}
export function useWebSocket(url: string): UseWebSocketReturn {
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef<Map<string, Set<MessageHandler>>>(new Map());
const reconnectTimeoutRef = useRef<number>();
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
try {
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('[WS] Connected to server');
setIsConnected(true);
};
ws.onclose = () => {
console.log('[WS] Disconnected from server');
setIsConnected(false);
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = window.setTimeout(() => {
console.log('[WS] Attempting to reconnect...');
connect();
}, 3000);
};
ws.onerror = (error) => {
console.error('[WS] Error:', error);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
console.log(`[WS] Received message type: ${message.type}`, message.payload);
// Call all registered handlers for this message type
const handlers = handlersRef.current.get(message.type);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(message.payload);
} catch (err) {
console.error(`[WS] Error in handler for ${message.type}:`, err);
}
});
}
} catch (err) {
console.error('[WS] Failed to parse message:', err);
}
};
wsRef.current = ws;
} catch (err) {
console.error('[WS] Failed to create WebSocket:', err);
}
}, [url]);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
const send = useCallback((message: WebSocketMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('[WS] Cannot send message - not connected');
}
}, []);
const on = useCallback((type: string, handler: MessageHandler) => {
if (!handlersRef.current.has(type)) {
handlersRef.current.set(type, new Set());
}
handlersRef.current.get(type)!.add(handler);
// Return cleanup function
return () => {
const handlers = handlersRef.current.get(type);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
handlersRef.current.delete(type);
}
}
};
}, []);
const sendPing = useCallback(() => {
send({ type: 'ping', payload: {} });
}, [send]);
const sendUserMessage = useCallback(
(message: string) => {
send({ type: 'user_message', payload: { message } });
},
[send]
);
return {
isConnected,
send,
on,
sendPing,
sendUserMessage,
};
}
+31
View File
@@ -0,0 +1,31 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
min-height: 100vh;
}
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Voice Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,184 @@
export interface AudioCaptureConfig {
mimeType?: string;
audioBitsPerSecond?: number;
}
export interface AudioRecorder {
start(): Promise<void>;
stop(): Promise<Blob>;
isRecording(): boolean;
getSupportedMimeType(): string | null;
}
export function createAudioRecorder(config?: AudioCaptureConfig): AudioRecorder {
let mediaRecorder: MediaRecorder | null = null;
let audioChunks: Blob[] = [];
let stream: MediaStream | null = null;
let recording = false;
// Detect supported mime type
function getSupportedMimeType(): string | null {
const types = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/ogg',
'audio/mp4',
'audio/wav',
];
for (const type of types) {
if (MediaRecorder.isTypeSupported(type)) {
return type;
}
}
return null;
}
async function start(): Promise<void> {
if (recording) {
throw new Error('Already recording');
}
try {
// Request microphone permission with WebRTC audio processing constraints
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true, // Remove echo from speakers
noiseSuppression: true, // Remove background noise
autoGainControl: true, // Normalize volume
sampleRate: 16000, // Optimal for speech (Whisper works well with this)
channelCount: 1, // Mono audio (sufficient for voice)
},
video: false,
});
// Check which constraints were actually applied
const track = stream.getAudioTracks()[0];
const settings = track.getSettings();
console.log('[AudioRecorder] Audio settings:', settings);
// Try WebM/Opus first (best compression and quality for speech)
const mimeType = config?.mimeType ||
(MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: getSupportedMimeType());
if (!mimeType) {
throw new Error('No supported audio mime type found');
}
// Create MediaRecorder
const options: MediaRecorderOptions = {
mimeType,
};
if (config?.audioBitsPerSecond) {
options.audioBitsPerSecond = config.audioBitsPerSecond;
}
mediaRecorder = new MediaRecorder(stream, options);
audioChunks = [];
// Collect data chunks
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
console.log(`[AudioRecorder] Chunk received: ${event.data.size} bytes`);
}
};
// Start recording
mediaRecorder.start(100); // Collect chunks every 100ms
recording = true;
console.log('[AudioRecorder] Recording started with constraints:', {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 16000,
channelCount: 1,
mimeType,
});
} catch (error: any) {
console.error('[AudioRecorder] Failed to start recording:', error);
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
throw new Error(`Failed to start audio recording: ${error.message}`);
}
}
async function stop(): Promise<Blob> {
if (!recording || !mediaRecorder) {
throw new Error('Not recording');
}
return new Promise((resolve, reject) => {
if (!mediaRecorder) {
reject(new Error('MediaRecorder not initialized'));
return;
}
mediaRecorder.onstop = () => {
// Create blob from chunks
const mimeType = mediaRecorder?.mimeType || 'audio/webm';
const audioBlob = new Blob(audioChunks, { type: mimeType });
console.log('[AudioRecorder] Recording stopped:', {
size: audioBlob.size,
type: mimeType,
chunks: audioChunks.length,
});
// Clean up
if (stream) {
stream.getTracks().forEach((track) => {
track.stop();
console.log('[AudioRecorder] Stopped track:', track.label);
});
stream = null;
}
recording = false;
mediaRecorder = null;
audioChunks = [];
resolve(audioBlob);
};
mediaRecorder.onerror = (event: Event) => {
console.error('[AudioRecorder] MediaRecorder error:', event);
reject(new Error('MediaRecorder error during stop'));
};
// Stop recording
mediaRecorder.stop();
});
}
function isRecordingFunc(): boolean {
return recording;
}
return {
start,
stop,
isRecording: isRecordingFunc,
getSupportedMimeType,
};
}
export function checkMicrophonePermission(): Promise<PermissionState> {
// Check if browser supports permissions API
if (!navigator.permissions) {
return Promise.resolve('prompt' as PermissionState);
}
return navigator.permissions
.query({ name: 'microphone' as PermissionName })
.then((result) => result.state)
.catch(() => 'prompt' as PermissionState);
}
@@ -0,0 +1,116 @@
export interface AudioPlayer {
play(audioData: Blob): Promise<void>;
stop(): void;
isPlaying(): boolean;
clearQueue(): void;
}
export function createAudioPlayer(): AudioPlayer {
let currentAudio: HTMLAudioElement | null = null;
let playing = false;
let queue: Blob[] = [];
let isProcessingQueue = false;
async function play(audioData: Blob): Promise<void> {
// Add to queue
queue.push(audioData);
// Start processing queue if not already processing
if (!isProcessingQueue) {
processQueue();
}
}
async function processQueue(): Promise<void> {
if (isProcessingQueue || queue.length === 0) {
return;
}
isProcessingQueue = true;
while (queue.length > 0) {
const audioData = queue.shift()!;
await playAudio(audioData);
}
isProcessingQueue = false;
}
async function playAudio(audioData: Blob): Promise<void> {
return new Promise((resolve, reject) => {
try {
// Create blob URL
const audioUrl = URL.createObjectURL(audioData);
// Create audio element
const audio = new Audio(audioUrl);
currentAudio = audio;
playing = true;
console.log(`[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})`);
audio.onended = () => {
console.log('[AudioPlayer] Playback finished');
playing = false;
currentAudio = null;
// Clean up blob URL
URL.revokeObjectURL(audioUrl);
resolve();
};
audio.onerror = (error) => {
console.error('[AudioPlayer] Playback error:', error);
playing = false;
currentAudio = null;
// Clean up blob URL
URL.revokeObjectURL(audioUrl);
reject(new Error('Audio playback failed'));
};
// Start playback
audio.play().catch((error) => {
console.error('[AudioPlayer] Failed to start playback:', error);
playing = false;
currentAudio = null;
URL.revokeObjectURL(audioUrl);
reject(error);
});
} catch (error) {
console.error('[AudioPlayer] Error creating audio element:', error);
playing = false;
currentAudio = null;
reject(error);
}
});
}
function stop(): void {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
currentAudio = null;
}
playing = false;
queue = [];
isProcessingQueue = false;
}
function isPlayingFunc(): boolean {
return playing;
}
function clearQueue(): void {
queue = [];
}
return {
play,
stop,
isPlaying: isPlayingFunc,
clearQueue,
};
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />