Show tool calls separately from message

This commit is contained in:
Nicolas Mowen
2026-02-13 06:26:24 -07:00
parent eb9f16b4fa
commit 3e97f9e985
5 changed files with 129 additions and 85 deletions

View File

@@ -4,5 +4,7 @@
"processing": "Processing...",
"toolsUsed": "Used: {{tools}}",
"showTools": "Show tools ({{count}})",
"hideTools": "Hide tools"
"hideTools": "Hide tools",
"call": "Call",
"result": "Result"
}

View File

@@ -1,66 +1,9 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import ReactMarkdown from "react-markdown";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
export type ToolCall = {
name: string;
arguments?: Record<string, unknown>;
response?: string;
};
type AssistantMessageProps = {
content: string;
toolCalls?: ToolCall[];
};
export function AssistantMessage({
content,
toolCalls,
}: AssistantMessageProps) {
const { t } = useTranslation(["views/chat"]);
const [open, setOpen] = useState(false);
const hasToolCalls = toolCalls && toolCalls.length > 0;
return (
<div className="flex flex-col gap-2">
<ReactMarkdown>{content}</ReactMarkdown>
{hasToolCalls && (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-auto py-1 text-xs text-muted-foreground hover:text-foreground"
>
{open
? t("hideTools")
: t("showTools", { count: toolCalls.length })}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="mt-2 space-y-2 border-l-2 border-muted-foreground/30 pl-3">
{toolCalls.map((tc, idx) => (
<li key={idx} className="text-xs">
<span className="font-medium text-muted-foreground">
{tc.name}
</span>
{tc.response != null && tc.response !== "" && (
<pre className="mt-1 max-h-32 overflow-auto rounded bg-muted/50 p-2 text-[10px]">
{tc.response}
</pre>
)}
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
)}
</div>
);
export function AssistantMessage({ content }: AssistantMessageProps) {
return <ReactMarkdown>{content}</ReactMarkdown>;
}

View File

@@ -0,0 +1,77 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronRight } from "lucide-react";
type ToolCallBubbleProps = {
name: string;
arguments?: Record<string, unknown>;
response?: string;
side: "left" | "right";
};
export function ToolCallBubble({
name,
arguments: args,
response,
side,
}: ToolCallBubbleProps) {
const { t } = useTranslation(["views/chat"]);
const [open, setOpen] = useState(false);
const isLeft = side === "left";
const normalizedName = name
.replace(/_/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
return (
<div
className={
isLeft
? "self-start rounded-lg bg-muted px-3 py-2"
: "self-end rounded-lg bg-primary px-3 py-2 text-primary-foreground"
}
>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-auto w-full justify-start gap-2 p-0 text-xs hover:bg-transparent"
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span className="font-medium">
{isLeft ? t("call") : t("result")} {normalizedName}
</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 space-y-2">
{isLeft && args && Object.keys(args).length > 0 && (
<div className="text-xs">
<div className="font-medium text-muted-foreground">Arguments:</div>
<pre className="mt-1 max-h-32 overflow-auto rounded bg-muted/50 p-2 text-[10px]">
{JSON.stringify(args, null, 2)}
</pre>
</div>
)}
{!isLeft && response && response !== "" && (
<div className="text-xs">
<div className="font-medium opacity-80">Response:</div>
<pre className="mt-1 max-h-32 overflow-auto rounded bg-primary/20 p-2 text-[10px]">
{response}
</pre>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View File

@@ -4,16 +4,9 @@ import { FaArrowUpLong } from "react-icons/fa6";
import { useTranslation } from "react-i18next";
import { useState, useCallback } from "react";
import axios from "axios";
import {
AssistantMessage,
type ToolCall,
} from "@/components/chat/AssistantMessage";
type ChatMessage = {
role: "user" | "assistant";
content: string;
toolCalls?: ToolCall[];
};
import { AssistantMessage } from "@/components/chat/AssistantMessage";
import { ToolCallBubble } from "@/components/chat/ToolCallBubble";
import type { ChatMessage, ToolCall } from "@/types/chat";
export default function ChatPage() {
const { t } = useTranslation(["views/chat"]);
@@ -62,22 +55,40 @@ export default function ChatPage() {
<div className="flex size-full flex-col items-center p-2">
<div className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto xl:w-[50%]">
{messages.map((msg, i) => (
<div
key={i}
className={
msg.role === "user"
? "self-end rounded-lg bg-primary px-3 py-2 text-primary-foreground"
: "self-start rounded-lg bg-muted px-3 py-2"
}
>
{msg.role === "assistant" ? (
<AssistantMessage
content={msg.content}
toolCalls={msg.toolCalls}
/>
) : (
msg.content
<div key={i} className="flex flex-col gap-2">
{msg.role === "assistant" && msg.toolCalls && (
<>
{msg.toolCalls.map((tc, tcIdx) => (
<div key={tcIdx} className="flex flex-col gap-2">
<ToolCallBubble
name={tc.name}
arguments={tc.arguments}
side="left"
/>
{tc.response && (
<ToolCallBubble
name={tc.name}
response={tc.response}
side="right"
/>
)}
</div>
))}
</>
)}
<div
className={
msg.role === "user"
? "self-end rounded-lg bg-primary px-3 py-2 text-primary-foreground"
: "self-start rounded-lg bg-muted px-3 py-2"
}
>
{msg.role === "assistant" ? (
<AssistantMessage content={msg.content} />
) : (
msg.content
)}
</div>
</div>
))}
{isLoading && (

11
web/src/types/chat.ts Normal file
View File

@@ -0,0 +1,11 @@
export type ToolCall = {
name: string;
arguments?: Record<string, unknown>;
response?: string;
};
export type ChatMessage = {
role: "user" | "assistant";
content: string;
toolCalls?: ToolCall[];
};