Files
paseo/packages/voice-assistant/src/server/agent/llm-openai.ts
T
Mohamed Boudra d7a387d911 ```
refactor: stream text segments for TTS preparation

Refactor LLM streaming to emit complete text segments before tool calls for future TTS integration. Moves from step-based callbacks to chunk-based streaming with text buffering and segment flushing at tool boundaries.
```
2025-10-19 17:22:43 +02:00

208 lines
5.5 KiB
TypeScript

import { stepCountIs, streamText, tool } from "ai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { z } from "zod";
import {
listTerminals,
createTerminal,
captureTerminal,
sendText,
renameTerminal,
killTerminal,
} from "../daemon/terminal-manager.js";
import invariant from "tiny-invariant";
/**
* 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",
inputSchema: z.object({}),
execute: async () => {
const terminals = await listTerminals();
return { terminals };
},
}),
create_terminal: tool({
description:
"Create a new terminal with specified name and working directory",
inputSchema: 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",
inputSchema: 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",
inputSchema: 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",
inputSchema: 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",
inputSchema: 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;
onTextSegment?: (segment: 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> {
invariant(process.env.OPENROUTER_API_KEY, "OPENROUTER_API_KEY is required");
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
const result = await streamText({
model: openrouter("anthropic/claude-haiku-4.5"),
messages: params.messages,
tools: terminalTools,
onChunk: (chuk) => {
// if (params.onChunk) {
// params.onChunk(chunk);
// }
},
stopWhen: stepCountIs(10),
});
let fullText = "";
let textBuffer = "";
function flushTextBuffer() {
if (textBuffer.length > 0 && params.onTextSegment) {
params.onTextSegment(textBuffer);
}
textBuffer = "";
}
// Stream all events from fullStream
for await (const part of result.fullStream) {
if (part.type === "text-delta") {
// Accumulate text in buffer
textBuffer += part.text;
fullText += part.text;
// Stream individual deltas to caller
if (params.onChunk) {
params.onChunk(part.text);
}
} else if (part.type === "tool-call") {
// Flush accumulated text as a segment before tool call
flushTextBuffer();
// Emit tool call event
if (params.onToolCall) {
params.onToolCall(part.toolName, part.input);
}
} else if (part.type === "tool-result") {
// Emit tool result event
if (params.onToolResult) {
params.onToolResult(part.toolName, part.output);
}
}
}
// Flush any remaining text at the end
flushTextBuffer();
if (params.onFinish) {
params.onFinish(fullText);
}
return fullText;
}