Wire mic through Whisper-compatible transcriptions on LLM_BASE_URL. Photos upload to MinIO and reach the vision model as base64 image_url parts.
128 lines
4.0 KiB
TypeScript
128 lines
4.0 KiB
TypeScript
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
|
|
import { buildVisionContentParts, textFromMessageContent } from "@/lib/llm/content";
|
|
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS } from "../tools";
|
|
import type { ClientChatMessage } from "../messages";
|
|
import { describeToolActivity } from "../tool-labels";
|
|
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
|
|
import { resolveAssistantImageDataUrls } from "./resolve-images";
|
|
import { thinkingLabel, type AgentProgressEvent } from "./progress";
|
|
|
|
const MAX_TOOL_ROUNDS = 8;
|
|
|
|
export type AgentToolCallSummary = {
|
|
name: string;
|
|
status: number;
|
|
};
|
|
|
|
export type AgentChatResult = {
|
|
message: ClientChatMessage;
|
|
toolCalls: AgentToolCallSummary[];
|
|
};
|
|
|
|
export type AgentProgressHandler = (event: AgentProgressEvent) => void;
|
|
|
|
async function toLlmUserMessage(message: ClientChatMessage): Promise<ChatMessage> {
|
|
if (message.role === "assistant") {
|
|
return { role: "assistant", content: message.content };
|
|
}
|
|
|
|
const imageUrls =
|
|
message.attachments?.filter((attachment) => attachment.type === "image").map((a) => a.url) ??
|
|
[];
|
|
|
|
if (imageUrls.length === 0) {
|
|
return { role: "user", content: message.content };
|
|
}
|
|
|
|
const dataUrls = await resolveAssistantImageDataUrls(imageUrls);
|
|
return {
|
|
role: "user",
|
|
content: buildVisionContentParts(message.content, dataUrls),
|
|
};
|
|
}
|
|
|
|
export async function runAgentChat(options: {
|
|
messages: ClientChatMessage[];
|
|
request: Request;
|
|
systemPrompt?: string;
|
|
llm?: LlmClient;
|
|
executeTool?: ToolExecutor;
|
|
onProgress?: AgentProgressHandler;
|
|
}): Promise<AgentChatResult> {
|
|
const llm = options.llm ?? createLlmClient();
|
|
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
|
|
const onProgress = options.onProgress;
|
|
const systemPrompt = options.systemPrompt ?? AGENT_SYSTEM_PROMPT;
|
|
|
|
const userMessages = await Promise.all(
|
|
options.messages.map((message) => toLlmUserMessage(message)),
|
|
);
|
|
|
|
const transcript: ChatMessage[] = [{ role: "system", content: systemPrompt }, ...userMessages];
|
|
|
|
const toolCalls: AgentToolCallSummary[] = [];
|
|
|
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
onProgress?.({ type: "thinking", label: thinkingLabel(round), round });
|
|
|
|
const completion = await llm.chatCompletion({
|
|
messages: transcript,
|
|
tools: AGENT_TOOLS,
|
|
});
|
|
|
|
const assistantMessage = completion.message;
|
|
transcript.push(assistantMessage);
|
|
|
|
if (!assistantMessage.tool_calls?.length) {
|
|
onProgress?.({ type: "responding", label: "Writing a reply…" });
|
|
return {
|
|
message: {
|
|
role: "assistant",
|
|
content:
|
|
textFromMessageContent(assistantMessage.content).trim() ||
|
|
"I couldn't generate a response.",
|
|
},
|
|
toolCalls,
|
|
};
|
|
}
|
|
|
|
for (const toolCall of assistantMessage.tool_calls) {
|
|
const label = describeToolActivity(toolCall.function.name, toolCall.function.arguments);
|
|
onProgress?.({ type: "tool", name: toolCall.function.name, label });
|
|
|
|
let result: string;
|
|
let status: number;
|
|
|
|
try {
|
|
result = await executeTool(toolCall.function.name, toolCall.function.arguments);
|
|
const parsed = JSON.parse(result) as { status?: number };
|
|
status = typeof parsed.status === "number" ? parsed.status : 200;
|
|
} catch (err) {
|
|
status = 500;
|
|
result = JSON.stringify({
|
|
status: 500,
|
|
body: { error: err instanceof Error ? err.message : "Tool execution failed" },
|
|
});
|
|
}
|
|
|
|
toolCalls.push({ name: toolCall.function.name, status });
|
|
|
|
transcript.push({
|
|
role: "tool",
|
|
tool_call_id: toolCall.id,
|
|
name: toolCall.function.name,
|
|
content: result,
|
|
});
|
|
}
|
|
}
|
|
|
|
onProgress?.({ type: "responding", label: "Wrapping up…" });
|
|
return {
|
|
message: {
|
|
role: "assistant",
|
|
content: "I hit the tool-call limit for this request. Please try a simpler question.",
|
|
},
|
|
toolCalls,
|
|
};
|
|
}
|