[feature] adds web_search and fetch_url tool calls

This commit is contained in:
2026-03-02 16:13:34 -08:00
parent c47646a48c
commit d5b06ce22a
12 changed files with 951 additions and 48 deletions

View File

@@ -27,6 +27,7 @@ import {
type Message,
type SearchDetail,
type SearchSummary,
type ToolCallEvent,
} from "@/lib/api";
import { useSessionAuth } from "@/hooks/use-session-auth";
import { cn } from "@/lib/utils";
@@ -139,6 +140,54 @@ function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "las
};
}
type ToolLogMetadata = {
kind: "tool_call";
toolCallId?: string;
toolName?: string;
status?: "completed" | "failed";
summary?: string;
args?: Record<string, unknown>;
startedAt?: string;
completedAt?: string;
durationMs?: number;
error?: string | null;
resultPreview?: string | null;
};
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (record.kind !== "tool_call") return null;
return record as ToolLogMetadata;
}
function isToolCallLogMessage(message: Message) {
return asToolLogMetadata(message.metadata) !== null;
}
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
return {
id: `temp-tool-${event.toolCallId}`,
createdAt: event.completedAt ?? new Date().toISOString(),
role: "tool",
content: event.summary,
name: event.name,
metadata: {
kind: "tool_call",
toolCallId: event.toolCallId,
toolName: event.name,
status: event.status,
summary: event.summary,
args: event.args,
startedAt: event.startedAt,
completedAt: event.completedAt,
durationMs: event.durationMs,
error: event.error ?? null,
resultPreview: event.resultPreview ?? null,
} satisfies ToolLogMetadata,
};
}
type ModelComboboxProps = {
options: string[];
value: string;
@@ -707,6 +756,7 @@ export default function App() {
role: "user",
content,
name: null,
metadata: null,
};
const optimisticAssistantMessage: Message = {
@@ -715,6 +765,7 @@ export default function App() {
role: "assistant",
content: "",
name: null,
metadata: null,
};
setPendingChatState({
@@ -758,11 +809,13 @@ export default function App() {
}
const requestMessages: CompletionRequestMessage[] = [
...baseChat.messages.map((message) => ({
...baseChat.messages
.filter((message) => !isToolCallLogMessage(message))
.map((message) => ({
role: message.role,
content: message.content,
...(message.name ? { name: message.name } : {}),
})),
})),
{
role: "user",
content,
@@ -813,6 +866,35 @@ export default function App() {
if (payload.chatId !== chatId) return;
setPendingChatState((current) => (current ? { ...current, chatId: payload.chatId } : current));
},
onToolCall: (payload) => {
setPendingChatState((current) => {
if (!current) return current;
if (
current.messages.some(
(message) =>
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
)
) {
return current;
}
const toolMessage = buildOptimisticToolMessage(payload);
const assistantIndex = current.messages.findIndex(
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
);
if (assistantIndex < 0) {
return { ...current, messages: current.messages.concat(toolMessage) };
}
return {
...current,
messages: [
...current.messages.slice(0, assistantIndex),
toolMessage,
...current.messages.slice(assistantIndex),
],
};
});
},
onDelta: (payload) => {
if (!payload.text) return;
setPendingChatState((current) => {

View File

@@ -1,6 +1,7 @@
import { cn } from "@/lib/utils";
import type { Message } from "@/lib/api";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { Globe2, Link2, Wrench } from "lucide-preact";
type Props = {
messages: Message[];
@@ -8,6 +9,33 @@ type Props = {
isSending: boolean;
};
type ToolLogMetadata = {
kind: "tool_call";
toolName?: string;
status?: "completed" | "failed";
summary?: string;
};
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (record.kind !== "tool_call") return null;
return record as ToolLogMetadata;
}
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
return `Ran tool '${toolName}'.`;
}
function getToolIconName(toolName: string | null | undefined) {
const lowered = toolName?.toLowerCase() ?? "";
if (lowered.includes("search")) return "search";
if (lowered.includes("url") || lowered.includes("fetch") || lowered.includes("http")) return "fetch";
return "generic";
}
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
@@ -16,6 +44,28 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
<div className="mx-auto max-w-3xl space-y-6">
{messages.map((message) => {
const toolLogMetadata = asToolLogMetadata(message.metadata);
if (message.role === "tool" && toolLogMetadata) {
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
const isFailed = toolLogMetadata.status === "failed";
return (
<div key={message.id} className="flex justify-start">
<div
className={cn(
"inline-flex max-w-[85%] items-center gap-2 rounded-md border px-3 py-2 text-xs leading-5",
isFailed
? "border-rose-500/40 bg-rose-950/20 text-rose-200"
: "border-cyan-500/35 bg-cyan-950/20 text-cyan-100"
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span>{getToolSummary(message, toolLogMetadata)}</span>
</div>
</div>
);
}
const isUser = message.role === "user";
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
return (

View File

@@ -23,6 +23,20 @@ export type Message = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name: string | null;
metadata: unknown | null;
};
export type ToolCallEvent = {
toolCallId: string;
name: string;
status: "completed" | "failed";
summary: string;
args: Record<string, unknown>;
startedAt: string;
completedAt: string;
durationMs: number;
error?: string;
resultPreview?: string;
};
export type ChatDetail = {
@@ -113,6 +127,7 @@ type CompletionResponse = {
type CompletionStreamHandlers = {
onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void;
onToolCall?: (payload: ToolCallEvent) => void;
onDelta?: (payload: { text: string }) => void;
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
onError?: (payload: { message: string }) => void;
@@ -415,6 +430,7 @@ export async function runCompletionStream(
}
if (eventName === "meta") handlers.onMeta?.(payload);
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
else if (eventName === "delta") handlers.onDelta?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload);