chore: upgrade Vite to v7 and update OpenAI services

- Upgrade vite from ^5.0.0 to ^7.1.10 with new Node.js requirements
- Add dotenv package for environment variable management
- Update STT to use gpt-4o-transcribe model
- Remove verbose_json format from STT response
- Format code with consistent style (quotes, spacing)
- Add HMR WebSocket configuration for development
- Improve UI layout with overflow handling
- Reorganize chat interface component order
- Add user message broadcasting to activity log
```
This commit is contained in:
Mohamed Boudra
2025-10-19 16:57:25 +02:00
parent c3049b8fa0
commit 81bdba0d48
8 changed files with 105 additions and 85 deletions
+2 -1
View File
@@ -16,6 +16,7 @@
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@openrouter/ai-sdk-provider": "^1.2.0", "@openrouter/ai-sdk-provider": "^1.2.0",
"ai": "^5.0.76", "ai": "^5.0.76",
"dotenv": "^17.2.3",
"express": "^4.18.2", "express": "^4.18.2",
"openai": "^4.20.0", "openai": "^4.20.0",
"tiny-invariant": "^1.3.3", "tiny-invariant": "^1.3.3",
@@ -36,6 +37,6 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"tsx": "^4.6.0", "tsx": "^4.6.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.0.0" "vite": "^7.1.10"
} }
} }
@@ -173,6 +173,8 @@ export async function streamLLM(params: StreamLLMParams): Promise<string> {
let fullText = ""; let fullText = "";
result.fullStream;
// Stream text chunks to the caller // Stream text chunks to the caller
for await (const chunk of result.textStream) { for await (const chunk of result.textStream) {
fullText += chunk; fullText += chunk;
@@ -1,7 +1,7 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from "uuid";
import { getSystemPrompt } from './system-prompt.js'; import { getSystemPrompt } from "./system-prompt.js";
import { streamLLM, type Message } from './llm-openai.js'; import { streamLLM, type Message } from "./llm-openai.js";
import type { VoiceAssistantWebSocketServer } from '../websocket-server.js'; import type { VoiceAssistantWebSocketServer } from "../websocket-server.js";
interface ConversationContext { interface ConversationContext {
id: string; id: string;
@@ -23,7 +23,7 @@ export function createConversation(): string {
const id = uuidv4(); const id = uuidv4();
conversations.set(id, { conversations.set(id, {
id, id,
messages: [{ role: 'system', content: getSystemPrompt() }], messages: [{ role: "system", content: getSystemPrompt() }],
createdAt: new Date(), createdAt: new Date(),
lastActivity: new Date(), lastActivity: new Date(),
}); });
@@ -49,12 +49,12 @@ export async function processUserMessage(params: {
}): Promise<string> { }): Promise<string> {
const conversation = conversations.get(params.conversationId); const conversation = conversations.get(params.conversationId);
if (!conversation) { if (!conversation) {
throw new Error('Conversation not found'); throw new Error("Conversation not found");
} }
// Add user message to context // Add user message to context
conversation.messages.push({ conversation.messages.push({
role: 'user', role: "user",
content: params.message, content: params.message,
}); });
conversation.lastActivity = new Date(); conversation.lastActivity = new Date();
@@ -62,7 +62,7 @@ export async function processUserMessage(params: {
// Note: User message is already broadcast by the caller (e.g., after STT in index.ts) // Note: User message is already broadcast by the caller (e.g., after STT in index.ts)
// No need to broadcast again here to avoid duplication // No need to broadcast again here to avoid duplication
let assistantResponse = ''; let assistantResponse = "";
try { try {
// Stream LLM response with tool execution // Stream LLM response with tool execution
@@ -72,7 +72,7 @@ export async function processUserMessage(params: {
// Broadcast streaming chunks to WebSocket // Broadcast streaming chunks to WebSocket
if (params.wsServer) { if (params.wsServer) {
params.wsServer.broadcast({ params.wsServer.broadcast({
type: 'assistant_chunk', type: "assistant_chunk",
payload: { chunk }, payload: { chunk },
}); });
} }
@@ -83,7 +83,7 @@ export async function processUserMessage(params: {
params.wsServer.broadcastActivityLog({ params.wsServer.broadcastActivityLog({
id: uuidv4(), id: uuidv4(),
timestamp: new Date(), timestamp: new Date(),
type: 'tool_call', type: "tool_call",
content: `Calling ${toolName}`, content: `Calling ${toolName}`,
metadata: { toolName, arguments: args }, metadata: { toolName, arguments: args },
}); });
@@ -95,7 +95,7 @@ export async function processUserMessage(params: {
params.wsServer.broadcastActivityLog({ params.wsServer.broadcastActivityLog({
id: uuidv4(), id: uuidv4(),
timestamp: new Date(), timestamp: new Date(),
type: 'tool_result', type: "tool_result",
content: `Tool ${toolName} completed`, content: `Tool ${toolName} completed`,
metadata: { toolName, result }, metadata: { toolName, result },
}); });
@@ -107,7 +107,7 @@ export async function processUserMessage(params: {
params.wsServer.broadcastActivityLog({ params.wsServer.broadcastActivityLog({
id: uuidv4(), id: uuidv4(),
timestamp: new Date(), timestamp: new Date(),
type: 'assistant', type: "assistant",
content: fullText, content: fullText,
}); });
} }
@@ -116,16 +116,16 @@ export async function processUserMessage(params: {
// Add assistant response to context // Add assistant response to context
conversation.messages.push({ conversation.messages.push({
role: 'assistant', role: "assistant",
content: assistantResponse, content: assistantResponse,
}); });
} catch (error: any) { } catch (error) {
// Broadcast error to WebSocket // Broadcast error to WebSocket
if (params.wsServer) { if (params.wsServer) {
params.wsServer.broadcastActivityLog({ params.wsServer.broadcastActivityLog({
id: uuidv4(), id: uuidv4(),
timestamp: new Date(), timestamp: new Date(),
type: 'error', type: "error",
content: `Error: ${error.message}`, content: `Error: ${error.message}`,
}); });
} }
@@ -141,7 +141,8 @@ export async function processUserMessage(params: {
export function cleanupConversations(maxAgeMinutes: number = 60): void { export function cleanupConversations(maxAgeMinutes: number = 60): void {
const now = new Date(); const now = new Date();
for (const [id, conv] of conversations.entries()) { for (const [id, conv] of conversations.entries()) {
const ageMinutes = (now.getTime() - conv.lastActivity.getTime()) / (1000 * 60); const ageMinutes =
(now.getTime() - conv.lastActivity.getTime()) / (1000 * 60);
if (ageMinutes > maxAgeMinutes) { if (ageMinutes > maxAgeMinutes) {
conversations.delete(id); conversations.delete(id);
} }
@@ -153,7 +154,11 @@ export function cleanupConversations(maxAgeMinutes: number = 60): void {
*/ */
export function getConversationStats(): { export function getConversationStats(): {
total: number; total: number;
conversations: Array<{ id: string; messageCount: number; lastActivity: Date }>; conversations: Array<{
id: string;
messageCount: number;
lastActivity: Date;
}>;
} { } {
return { return {
total: conversations.size, total: conversations.size,
@@ -1,12 +1,12 @@
import OpenAI from 'openai'; import OpenAI from "openai";
import { writeFile, unlink } from 'fs/promises'; import { writeFile, unlink } from "fs/promises";
import { join } from 'path'; import { join } from "path";
import { tmpdir } from 'os'; import { tmpdir } from "os";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from "uuid";
export interface STTConfig { export interface STTConfig {
apiKey: string; apiKey: string;
model?: 'whisper-1'; model?: "whisper-1";
} }
export interface TranscriptionResult { export interface TranscriptionResult {
@@ -23,7 +23,7 @@ export function initializeSTT(sttConfig: STTConfig): void {
openaiClient = new OpenAI({ openaiClient = new OpenAI({
apiKey: sttConfig.apiKey, apiKey: sttConfig.apiKey,
}); });
console.log('✓ STT (OpenAI Whisper) initialized'); console.log("✓ STT (OpenAI Whisper) initialized");
} }
export async function transcribeAudio( export async function transcribeAudio(
@@ -31,7 +31,7 @@ export async function transcribeAudio(
format: string format: string
): Promise<TranscriptionResult> { ): Promise<TranscriptionResult> {
if (!openaiClient || !config) { if (!openaiClient || !config) {
throw new Error('STT not initialized. Call initializeSTT() first.'); throw new Error("STT not initialized. Call initializeSTT() first.");
} }
const startTime = Date.now(); const startTime = Date.now();
@@ -46,26 +46,29 @@ export async function transcribeAudio(
tempFilePath = join(tmpdir(), `audio-${uuidv4()}.${ext}`); tempFilePath = join(tmpdir(), `audio-${uuidv4()}.${ext}`);
await writeFile(tempFilePath, audioBuffer); await writeFile(tempFilePath, audioBuffer);
console.log(`[STT] Transcribing audio file: ${tempFilePath} (${audioBuffer.length} bytes)`); console.log(
`[STT] Transcribing audio file: ${tempFilePath} (${audioBuffer.length} bytes)`
);
// Call OpenAI Whisper API // Call OpenAI Whisper API
const response = await openaiClient.audio.transcriptions.create({ const response = await openaiClient.audio.transcriptions.create({
file: await import('fs').then((fs) => fs.createReadStream(tempFilePath!)), file: await import("fs").then((fs) => fs.createReadStream(tempFilePath!)),
model: config.model || 'whisper-1', model: config.model || "gpt-4o-transcribe",
response_format: 'verbose_json', // Get language and duration info response_format: "json", // Get language and duration info
}); });
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
console.log(`[STT] Transcription complete in ${duration}ms: "${response.text}"`); console.log(
`[STT] Transcription complete in ${duration}ms: "${response.text}"`
);
return { return {
text: response.text, text: response.text,
language: response.language,
duration: duration, duration: duration,
}; };
} catch (error: any) { } catch (error: any) {
console.error('[STT] Transcription error:', error); console.error("[STT] Transcription error:", error);
throw new Error(`STT transcription failed: ${error.message}`); throw new Error(`STT transcription failed: ${error.message}`);
} finally { } finally {
// Clean up temporary file // Clean up temporary file
@@ -83,16 +86,16 @@ function getFileExtension(format: string): string {
// Map mime types or format strings to file extensions // Map mime types or format strings to file extensions
const formatLower = format.toLowerCase(); const formatLower = format.toLowerCase();
if (formatLower.includes('webm')) return 'webm'; if (formatLower.includes("webm")) return "webm";
if (formatLower.includes('ogg')) return 'ogg'; if (formatLower.includes("ogg")) return "ogg";
if (formatLower.includes('mp3')) return 'mp3'; if (formatLower.includes("mp3")) return "mp3";
if (formatLower.includes('wav')) return 'wav'; if (formatLower.includes("wav")) return "wav";
if (formatLower.includes('m4a')) return 'm4a'; if (formatLower.includes("m4a")) return "m4a";
if (formatLower.includes('mp4')) return 'mp4'; if (formatLower.includes("mp4")) return "mp4";
if (formatLower.includes('flac')) return 'flac'; if (formatLower.includes("flac")) return "flac";
// Default to webm (common browser format) // Default to webm (common browser format)
return 'webm'; return "webm";
} }
export function isSTTInitialized(): boolean { export function isSTTInitialized(): boolean {
@@ -1,11 +1,11 @@
import OpenAI from 'openai'; import OpenAI from "openai";
import { Readable } from 'stream'; import { Readable } from "stream";
export interface TTSConfig { export interface TTSConfig {
apiKey: string; apiKey: string;
model?: 'tts-1' | 'tts-1-hd'; model?: "tts-1" | "tts-1-hd";
voice?: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'; voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer";
responseFormat?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'; responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm";
} }
export interface SpeechResult { export interface SpeechResult {
@@ -18,51 +18,61 @@ let config: TTSConfig | null = null;
export function initializeTTS(ttsConfig: TTSConfig): void { export function initializeTTS(ttsConfig: TTSConfig): void {
config = { config = {
model: 'tts-1', model: "tts-1",
voice: 'alloy', voice: "alloy",
responseFormat: 'mp3', responseFormat: "mp3",
...ttsConfig, ...ttsConfig,
}; };
openaiClient = new OpenAI({ openaiClient = new OpenAI({
apiKey: ttsConfig.apiKey, apiKey: ttsConfig.apiKey,
}); });
console.log(`✓ TTS (OpenAI) initialized with voice: ${config.voice}, model: ${config.model}`); console.log(
`✓ TTS (OpenAI) initialized with voice: ${config.voice}, model: ${config.model}`
);
} }
export async function synthesizeSpeech(text: string): Promise<SpeechResult> { export async function synthesizeSpeech(text: string): Promise<SpeechResult> {
if (!openaiClient || !config) { if (!openaiClient || !config) {
throw new Error('TTS not initialized. Call initializeTTS() first.'); throw new Error("TTS not initialized. Call initializeTTS() first.");
} }
if (!text || text.trim().length === 0) { if (!text || text.trim().length === 0) {
throw new Error('Cannot synthesize empty text'); throw new Error("Cannot synthesize empty text");
} }
const startTime = Date.now(); const startTime = Date.now();
try { try {
console.log(`[TTS] Synthesizing speech: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); console.log(
`[TTS] Synthesizing speech: "${text.substring(0, 50)}${
text.length > 50 ? "..." : ""
}"`
);
// Call OpenAI TTS API with streaming // Call OpenAI TTS API with streaming
const response = await openaiClient.audio.speech.create({ const response = await openaiClient.audio.speech.create({
model: config.model!, model: config.model!,
voice: config.voice!, voice: config.voice!,
input: text, input: text,
response_format: config.responseFormat as 'mp3' | 'opus' | 'aac' | 'flac', response_format: config.responseFormat as "mp3" | "opus" | "aac" | "flac",
}); });
// Response is a stream, convert to buffer // Response is a stream, convert to buffer
const audioBuffer = await streamToBuffer(response.body as unknown as Readable); const audioBuffer = await streamToBuffer(
response.body as unknown as Readable
);
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
console.log(`[TTS] Speech synthesis complete in ${duration}ms (${audioBuffer.length} bytes)`); console.log(
`[TTS] Speech synthesis complete in ${duration}ms (${audioBuffer.length} bytes)`
);
return { return {
audio: audioBuffer, audio: audioBuffer,
format: config.responseFormat || 'mp3', format: config.responseFormat || "mp3",
}; };
} catch (error: any) { } catch (error: any) {
console.error('[TTS] Speech synthesis error:', error); console.error("[TTS] Speech synthesis error:", error);
throw new Error(`TTS synthesis failed: ${error.message}`); throw new Error(`TTS synthesis failed: ${error.message}`);
} }
} }
@@ -71,15 +81,15 @@ async function streamToBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
stream.on('data', (chunk) => { stream.on("data", (chunk) => {
chunks.push(Buffer.from(chunk)); chunks.push(Buffer.from(chunk));
}); });
stream.on('end', () => { stream.on("end", () => {
resolve(Buffer.concat(chunks)); resolve(Buffer.concat(chunks));
}); });
stream.on('error', (error) => { stream.on("error", (error) => {
reject(error); reject(error);
}); });
}); });
+15 -4
View File
@@ -1,3 +1,4 @@
import "dotenv/config";
import express from "express"; import express from "express";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
@@ -20,7 +21,6 @@ import {
captureTerminal, captureTerminal,
killTerminal, killTerminal,
} from "./daemon/terminal-manager.js"; } from "./daemon/terminal-manager.js";
import { initializeOpenAI } from "./agent/llm-openai.js";
import { initializeSTT, transcribeAudio } from "./agent/stt-openai.js"; import { initializeSTT, transcribeAudio } from "./agent/stt-openai.js";
import { initializeTTS, synthesizeSpeech } from "./agent/tts-openai.js"; import { initializeTTS, synthesizeSpeech } from "./agent/tts-openai.js";
import { import {
@@ -137,8 +137,12 @@ async function createServer(httpServer: HttpServer, config: ServerConfig) {
server: { server: {
middlewareMode: true, middlewareMode: true,
allowedHosts: ["localhost", "mohameds-macbook-pro.tail8fe838.ts.net"], allowedHosts: ["localhost", "mohameds-macbook-pro.tail8fe838.ts.net"],
port: 3000, hmr: {
hmr: false, server: httpServer,
port: config.port,
protocol: "wss",
path: "/hmr",
},
}, },
appType: "custom", appType: "custom",
}); });
@@ -201,7 +205,6 @@ async function main() {
// Initialize OpenAI client // Initialize OpenAI client
const apiKey = process.env.OPENAI_API_KEY; const apiKey = process.env.OPENAI_API_KEY;
if (apiKey) { if (apiKey) {
initializeOpenAI(apiKey);
console.log("✓ OpenAI client initialized"); console.log("✓ OpenAI client initialized");
// Initialize STT (Whisper) // Initialize STT (Whisper)
@@ -282,6 +285,14 @@ async function main() {
// Wire orchestrator to WebSocket for text messages // Wire orchestrator to WebSocket for text messages
wsServer.setMessageHandler(async (message: string) => { wsServer.setMessageHandler(async (message: string) => {
try { try {
// Broadcast user's text message as activity log
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: "transcript",
content: message,
});
await processUserMessage({ await processUserMessage({
conversationId: defaultConversationId, conversationId: defaultConversationId,
message, message,
+3 -2
View File
@@ -55,8 +55,9 @@
.main { .main {
display: flex; display: flex;
flex: 1; flex: 1;
padding: 2rem;
gap: 2rem; gap: 2rem;
max-height: 100vh;
overflow: hidden;
} }
.chat-interface { .chat-interface {
@@ -67,7 +68,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
max-height: calc(100vh - 120px); height: 100%;
} }
.chat-interface h2 { .chat-interface h2 {
+5 -18
View File
@@ -40,7 +40,6 @@ function App() {
message: string, message: string,
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
) => { ) => {
console.trace("addLog", type, message, metadata);
setLogs((prev) => [ setLogs((prev) => [
...prev, ...prev,
{ {
@@ -231,14 +230,6 @@ function App() {
<main className="main"> <main className="main">
<div className="chat-interface"> <div className="chat-interface">
<h2>Chat with Assistant</h2>
<VoiceControls
onAudioRecorded={handleAudioRecorded}
isProcessing={isProcessingAudio}
isPlaying={isPlayingAudio}
/>
<div className="activity-log"> <div className="activity-log">
<div className="log-entries"> <div className="log-entries">
{logs.map((log) => ( {logs.map((log) => (
@@ -286,15 +277,11 @@ function App() {
</button> </button>
</form> </form>
<div className="test-controls"> <VoiceControls
<button onAudioRecorded={handleAudioRecorded}
onClick={handlePing} isProcessing={isProcessingAudio}
disabled={!ws.isConnected} isPlaying={isPlayingAudio}
className="ping-button" />
>
Send Ping (Test)
</button>
</div>
</div> </div>
</main> </main>
</div> </div>