e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
230 lines
6.4 KiB
TypeScript
230 lines
6.4 KiB
TypeScript
import type { Route } from "./+types/api.chat";
|
|
import OpenAI from "openai";
|
|
|
|
type ChatCompletionMessageParam =
|
|
OpenAI.Chat.Completions.ChatCompletionMessageParam;
|
|
|
|
const openai = new OpenAI({
|
|
apiKey: process.env.OPENAI_API_KEY,
|
|
});
|
|
|
|
// Define available tools
|
|
const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get the current weather for a location",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
location: {
|
|
type: "string",
|
|
description: "The city name, e.g. San Francisco",
|
|
},
|
|
},
|
|
required: ["location"],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
// Simple tool execution
|
|
async function executeTool(
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
): Promise<string> {
|
|
if (name === "get_weather") {
|
|
const location = args.location as string;
|
|
// Simulate weather API call
|
|
const temp = Math.floor(Math.random() * 30) + 10;
|
|
const conditions = ["sunny", "cloudy", "rainy", "partly cloudy"];
|
|
const condition = conditions[Math.floor(Math.random() * conditions.length)];
|
|
return JSON.stringify({
|
|
location,
|
|
temperature: temp,
|
|
unit: "celsius",
|
|
condition,
|
|
});
|
|
}
|
|
return JSON.stringify({ error: "Unknown tool" });
|
|
}
|
|
|
|
export async function action({ request }: Route.ActionArgs) {
|
|
const { messages } = await request.json();
|
|
|
|
const openaiMessages: ChatCompletionMessageParam[] = messages.map(
|
|
(m: { role: string; content: string }) => ({
|
|
role: m.role as "user" | "assistant",
|
|
content: m.content,
|
|
}),
|
|
);
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
let continueLoop = true;
|
|
|
|
while (continueLoop) {
|
|
const response = await openai.chat.completions.create({
|
|
model: "gpt-5.4-nano",
|
|
messages: openaiMessages,
|
|
tools,
|
|
stream: true,
|
|
});
|
|
|
|
let currentToolCalls: {
|
|
id: string;
|
|
name: string;
|
|
arguments: string;
|
|
}[] = [];
|
|
let assistantContent = "";
|
|
|
|
for await (const chunk of response) {
|
|
const delta = chunk.choices[0]?.delta;
|
|
|
|
// Handle text content
|
|
if (delta?.content) {
|
|
assistantContent += delta.content;
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
`data: ${JSON.stringify({ type: "text", content: delta.content })}\n\n`,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Handle tool calls
|
|
if (delta?.tool_calls) {
|
|
for (const toolCall of delta.tool_calls) {
|
|
const index = toolCall.index;
|
|
|
|
if (!currentToolCalls[index]) {
|
|
currentToolCalls[index] = {
|
|
id: toolCall.id || "",
|
|
name: toolCall.function?.name || "",
|
|
arguments: "",
|
|
};
|
|
}
|
|
|
|
if (toolCall.id) {
|
|
currentToolCalls[index].id = toolCall.id;
|
|
}
|
|
if (toolCall.function?.name) {
|
|
currentToolCalls[index].name = toolCall.function.name;
|
|
}
|
|
if (toolCall.function?.arguments) {
|
|
currentToolCalls[index].arguments +=
|
|
toolCall.function.arguments;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If there are tool calls, execute them and continue the loop
|
|
if (currentToolCalls.length > 0) {
|
|
// Add assistant message with tool calls
|
|
openaiMessages.push({
|
|
role: "assistant",
|
|
content: assistantContent || null,
|
|
tool_calls: currentToolCalls.map((tc) => ({
|
|
id: tc.id,
|
|
type: "function" as const,
|
|
function: {
|
|
name: tc.name,
|
|
arguments: tc.arguments,
|
|
},
|
|
})),
|
|
});
|
|
|
|
// Execute each tool and add results
|
|
for (const toolCall of currentToolCalls) {
|
|
// Send tool call event to client
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
`data: ${JSON.stringify({
|
|
type: "tool_call",
|
|
id: toolCall.id,
|
|
name: toolCall.name,
|
|
arguments: toolCall.arguments,
|
|
})}\n\n`,
|
|
),
|
|
);
|
|
|
|
let parsedArgs: unknown;
|
|
try {
|
|
parsedArgs = JSON.parse(toolCall.arguments);
|
|
} catch (parseError) {
|
|
const message =
|
|
parseError instanceof Error
|
|
? parseError.message
|
|
: "Invalid tool arguments";
|
|
|
|
// Surface parsing failure to the client and continue gracefully
|
|
const errorResult = JSON.stringify({
|
|
error: message,
|
|
});
|
|
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
`data: ${JSON.stringify({
|
|
type: "tool_result",
|
|
id: toolCall.id,
|
|
result: errorResult,
|
|
})}\n\n`,
|
|
),
|
|
);
|
|
|
|
openaiMessages.push({
|
|
role: "tool",
|
|
tool_call_id: toolCall.id,
|
|
content: errorResult,
|
|
});
|
|
|
|
continue;
|
|
}
|
|
|
|
const args = parsedArgs as Record<string, unknown>;
|
|
const result = await executeTool(toolCall.name, args);
|
|
|
|
// Send tool result event to client
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
`data: ${JSON.stringify({
|
|
type: "tool_result",
|
|
id: toolCall.id,
|
|
result,
|
|
})}\n\n`,
|
|
),
|
|
);
|
|
|
|
// Add tool result to messages
|
|
openaiMessages.push({
|
|
role: "tool",
|
|
tool_call_id: toolCall.id,
|
|
content: result,
|
|
});
|
|
}
|
|
|
|
// Reset for next iteration
|
|
currentToolCalls = [];
|
|
} else {
|
|
// No tool calls, we're done
|
|
continueLoop = false;
|
|
}
|
|
}
|
|
|
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
},
|
|
});
|
|
}
|