Files
paseo/packages/voice-assistant/src/server/agent/orchestrator.ts
T

304 lines
8.9 KiB
TypeScript

import { v4 as uuidv4 } from "uuid";
import { readFile } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
import { getSystemPrompt } from "./system-prompt.js";
import { streamLLM, type Message } from "./llm-openai.js";
import { generateTTSAndWaitForPlayback } from "./tts-manager.js";
import type { VoiceAssistantWebSocketServer } from "../websocket-server.js";
import type { ArtifactPayload } from "../types.js";
const execAsync = promisify(exec);
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
*/
export function createConversation(): string {
const id = uuidv4();
conversations.set(id, {
id,
messages: [],
createdAt: new Date(),
lastActivity: new Date(),
});
return id;
}
/**
* Get conversation by ID
*/
export function getConversation(id: string): ConversationContext | null {
return conversations.get(id) || null;
}
/**
* Delete a conversation by ID
*/
export function deleteConversation(id: string): void {
conversations.delete(id);
}
/**
* 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;
abortSignal?: AbortSignal;
}): 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();
// 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
let assistantResponse = "";
// Track pending TTS playback promise outside of streamLLM scope
let pendingTTS: Promise<void> | null = null;
try {
// Stream LLM response with tool execution
assistantResponse = await streamLLM({
systemPrompt: getSystemPrompt(),
messages: conversation.messages,
abortSignal: params.abortSignal,
onTextSegment: (segment) => {
// Create TTS promise (don't await it yet)
if (params.wsServer && params.enableTTS) {
pendingTTS = generateTTSAndWaitForPlayback(segment, params.wsServer);
}
// Broadcast complete text segments
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: "assistant",
content: segment,
});
}
},
onChunk: async (chunk) => {
params.wsServer?.broadcast({
type: "assistant_chunk",
payload: { chunk },
});
},
onToolCall: async (toolCallId, toolName, args) => {
if (pendingTTS) {
console.log("Waiting for pending TTS to finish to execute", toolName);
await pendingTTS;
pendingTTS = null;
}
// Handle present_artifact tool specially
if (toolName === "present_artifact" && params.wsServer) {
const artifactId = uuidv4();
// Resolve source to content
let content: string;
let isBase64 = false;
try {
if (args.source.type === "file") {
const fileBuffer = await readFile(args.source.path);
content = fileBuffer.toString("base64");
isBase64 = true;
} else if (args.source.type === "command_output") {
const { stdout } = await execAsync(args.source.command, { encoding: 'buffer' });
content = stdout.toString("base64");
isBase64 = true;
} else if (args.source.type === "text") {
content = args.source.text;
isBase64 = false;
} else {
content = "[Unknown source type]";
isBase64 = false;
}
} catch (error) {
console.error("Failed to resolve artifact source:", error);
content = `[Error resolving source: ${error instanceof Error ? error.message : String(error)}]`;
isBase64 = false;
}
const artifact: ArtifactPayload = {
type: args.type,
id: artifactId,
title: args.title,
content,
isBase64,
};
// Broadcast artifact to client
params.wsServer.broadcast({
type: "artifact",
payload: artifact,
});
// Broadcast as activity log entry so it appears in the feed
params.wsServer.broadcastActivityLog({
id: artifactId,
timestamp: new Date(),
type: "system",
content: `${args.type} artifact: ${args.title}`,
metadata: { artifactId, artifactType: args.type },
});
}
// Broadcast tool call to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: toolCallId,
timestamp: new Date(),
type: "tool_call",
content: `Calling ${toolName}`,
metadata: { toolCallId, toolName, arguments: args },
});
}
},
onToolResult: (toolCallId, toolName, result) => {
// Broadcast tool result to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: toolCallId,
timestamp: new Date(),
type: "tool_result",
content: `Tool ${toolName} completed`,
metadata: { toolCallId, toolName, result },
});
}
},
onToolError: async (toolCallId, toolName, error) => {
// Broadcast tool error to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: toolCallId,
timestamp: new Date(),
type: "error",
content: `Tool ${toolName} failed: ${
error instanceof Error ? error.message : String(error)
}`,
metadata: { toolCallId, toolName, error },
});
}
},
onError: async (error) => {
// Broadcast general stream error to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Stream error: ${
error instanceof Error ? error.message : String(error)
}`,
});
}
},
onFinish: async () => {
// Don't wait for TTS here - we'll handle it after adding to history
},
});
// Add assistant response to context IMMEDIATELY after stream completes
// This ensures partial responses are saved even if TTS fails or is interrupted
conversation.messages.push({
role: "assistant",
content: assistantResponse,
});
// Now wait for any pending TTS, but don't fail the entire operation if it times out
if (pendingTTS) {
try {
await pendingTTS;
} catch (ttsError) {
// TTS failed but message is already in history - just log the error
console.error("TTS playback failed (message already saved):", ttsError);
}
}
} catch (error) {
// If stream itself failed or was aborted, still save any partial response to history
if (assistantResponse) {
conversation.messages.push({
role: "assistant",
content: assistantResponse,
});
}
// Broadcast error to WebSocket
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Error: ${
error instanceof Error ? error.message : String(error)
}`,
});
}
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,
})),
};
}