feat: replace raw JSON tool display with collapsible summary cards in chat UI
This commit is contained in:
parent
4c5e151822
commit
7c19c72058
|
|
@ -67,7 +67,7 @@ export function ChatPanel() {
|
|||
<ScrollArea className="flex-1 p-3" ref={scrollRef}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
<MessageBubble key={msg.id} message={msg} messages={messages} />
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
import type { ChatMessage } from "@/agent/types";
|
||||
import {
|
||||
isTranscriptData,
|
||||
formatTimestamp,
|
||||
} from "./transcript-utils";
|
||||
import type { ChatMessage, ToolCall } from "@/agent/types";
|
||||
import { isTranscriptData, formatTimestamp } from "./transcript-utils";
|
||||
import type { TranscriptData } from "./transcript-utils";
|
||||
import { ToolCallCard } from "./tool-call-card";
|
||||
import { ToolResultCard } from "./tool-result-card";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<ChatMessage["role"], string> = {
|
||||
user: "You",
|
||||
assistant: "Assistant",
|
||||
tool_result: "Tool",
|
||||
system: "System",
|
||||
};
|
||||
function findToolCallName(
|
||||
messages: ChatMessage[],
|
||||
toolCallId?: string,
|
||||
): string | null {
|
||||
if (!toolCallId) return null;
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant" && msg.toolCalls) {
|
||||
const match = msg.toolCalls.find((tc: ToolCall) => tc.id === toolCallId);
|
||||
if (match) return match.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function TranscriptCard({ data }: { data: TranscriptData }) {
|
||||
return (
|
||||
|
|
@ -46,39 +53,62 @@ function TranscriptCard({ data }: { data: TranscriptData }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
export function MessageBubble({ message, messages }: MessageBubbleProps) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
// For tool_result messages, try to parse as transcript
|
||||
if (message.role === "tool_result") {
|
||||
const toolName = findToolCallName(messages, message.toolCallId);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message.content);
|
||||
if (isTranscriptData(parsed)) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-md px-3 py-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{ROLE_LABELS.tool_result}
|
||||
</span>
|
||||
<div className="px-3 py-0.5">
|
||||
<TranscriptCard data={parsed} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to plain text
|
||||
// not JSON
|
||||
}
|
||||
|
||||
if (toolName) {
|
||||
return (
|
||||
<div className="px-3 py-0.5">
|
||||
<ToolResultCard name={toolName} content={message.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 py-0.5">
|
||||
<ToolResultCard name="Tool" content={message.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasToolCalls =
|
||||
message.role === "assistant" &&
|
||||
message.toolCalls &&
|
||||
message.toolCalls.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-md px-3 py-2",
|
||||
"flex flex-col gap-1.5 rounded-md px-3 py-2",
|
||||
isUser ? "bg-secondary" : "bg-transparent",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{ROLE_LABELS[message.role]}
|
||||
</span>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
{message.content && (
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
)}
|
||||
{hasToolCalls && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{message.toolCalls?.map((tc: ToolCall) => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { formatToolCall } from "./tool-formatters";
|
||||
import type { ToolCall } from "@/agent/types";
|
||||
|
||||
interface ToolCallCardProps {
|
||||
toolCall: ToolCall;
|
||||
}
|
||||
|
||||
export function ToolCallCard({ toolCall }: ToolCallCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const summary = formatToolCall(toolCall.name, toolCall.args);
|
||||
|
||||
return (
|
||||
<div className="group/tool flex flex-col rounded-md border border-border/50 bg-muted/30 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="Expand"
|
||||
className={cn(
|
||||
"size-3 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium text-foreground/80">{summary.label}</span>
|
||||
<span className="text-muted-foreground">{summary.description}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-border/50 border-t px-2.5 py-1.5">
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">
|
||||
{JSON.stringify(toolCall.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { formatTimestamp } from "./transcript-utils";
|
||||
|
||||
export interface ToolSummary {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function formatToolCall(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): ToolSummary {
|
||||
const formatter = TOOL_CALL_FORMATTERS[name];
|
||||
if (formatter) return formatter(args);
|
||||
|
||||
return {
|
||||
label: formatToolName(name),
|
||||
description: summarizeArgs(args),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatToolResult(
|
||||
name: string,
|
||||
raw: string,
|
||||
): ToolSummary | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && "error" in parsed) {
|
||||
return {
|
||||
label: formatToolName(name),
|
||||
description: String(parsed.error),
|
||||
};
|
||||
}
|
||||
const formatter = TOOL_RESULT_FORMATTERS[name];
|
||||
if (formatter) return formatter(parsed);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function formatSeconds(s: unknown): string {
|
||||
if (typeof s !== "number" || !Number.isFinite(s)) return "?";
|
||||
return formatTimestamp(s);
|
||||
}
|
||||
|
||||
function summarizeArgs(args: Record<string, unknown>): string {
|
||||
const entries = Object.entries(args);
|
||||
if (entries.length === 0) return "No arguments";
|
||||
return entries
|
||||
.map(([key, val]) => {
|
||||
if (Array.isArray(val)) {
|
||||
return `${key}: [${val.length} items]`;
|
||||
}
|
||||
if (typeof val === "object" && val !== null) {
|
||||
return `${key}: {...}`;
|
||||
}
|
||||
return `${key}: ${String(val)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
const TOOL_CALL_FORMATTERS: Record<
|
||||
string,
|
||||
(args: Record<string, unknown>) => ToolSummary
|
||||
> = {
|
||||
split: (args) => {
|
||||
const times = args.times as number[] | undefined;
|
||||
if (!times || times.length === 0)
|
||||
return { label: "Split", description: "No times specified" };
|
||||
const formatted = times.map((t) => formatSeconds(t)).join(", ");
|
||||
return {
|
||||
label: "Split",
|
||||
description: `at ${formatted}`,
|
||||
};
|
||||
},
|
||||
load_context: (args) => {
|
||||
const targetType = String(args.targetType ?? "unknown");
|
||||
const id = args.id ?? args.assetId ?? args.elementId;
|
||||
if (targetType === "asset") {
|
||||
return {
|
||||
label: "Load Asset",
|
||||
description: id ? String(id) : "Loading asset...",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Load Context",
|
||||
description: id ? `${targetType} ${String(id)}` : targetType,
|
||||
};
|
||||
},
|
||||
list_timeline: () => ({
|
||||
label: "List Timeline",
|
||||
description: "Fetching timeline tracks",
|
||||
}),
|
||||
list_project_assets: (args) => {
|
||||
const type = args.type ?? "all";
|
||||
const filter = args.filter ?? "all";
|
||||
const parts: string[] = [];
|
||||
if (type !== "all") parts.push(String(type));
|
||||
if (filter !== "all") parts.push(String(filter));
|
||||
return {
|
||||
label: "List Assets",
|
||||
description:
|
||||
parts.length > 0 ? parts.join(", ") : "Fetching project assets",
|
||||
};
|
||||
},
|
||||
transcribe_video: (args) => {
|
||||
const lang = args.language ? String(args.language) : null;
|
||||
return {
|
||||
label: "Transcribe",
|
||||
description: lang ? `language: ${lang}` : "Transcribing audio",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const TOOL_RESULT_FORMATTERS: Record<
|
||||
string,
|
||||
(parsed: unknown) => ToolSummary | null
|
||||
> = {
|
||||
split: (parsed) => {
|
||||
const data = parsed as {
|
||||
success?: boolean;
|
||||
affectedElements?: string[];
|
||||
} | null;
|
||||
if (!data) return null;
|
||||
const count = data.affectedElements?.length ?? 0;
|
||||
return {
|
||||
label: "Split",
|
||||
description: data.success
|
||||
? `${count} element${count !== 1 ? "s" : ""} affected`
|
||||
: "Failed",
|
||||
};
|
||||
},
|
||||
load_context: (parsed) => {
|
||||
const data = parsed as {
|
||||
status?: string;
|
||||
cached?: boolean;
|
||||
context?: { kind?: string; assetName?: string };
|
||||
} | null;
|
||||
if (!data) return null;
|
||||
const assetName = data.context?.assetName;
|
||||
const cached = data.cached ? " (cached)" : "";
|
||||
return {
|
||||
label: "Load Context",
|
||||
description: assetName
|
||||
? `${assetName}${cached}`
|
||||
: `${data.status ?? "loaded"}${cached}`,
|
||||
};
|
||||
},
|
||||
list_timeline: (parsed) => {
|
||||
const data = parsed as { tracks?: unknown[] } | null;
|
||||
if (!data) return null;
|
||||
const count = data.tracks?.length ?? 0;
|
||||
return {
|
||||
label: "Timeline",
|
||||
description: `${count} track${count !== 1 ? "s" : ""}`,
|
||||
};
|
||||
},
|
||||
list_project_assets: (parsed) => {
|
||||
const data = parsed as { assets?: unknown[] } | null;
|
||||
if (!data) return null;
|
||||
const count = data.assets?.length ?? 0;
|
||||
return {
|
||||
label: "Assets",
|
||||
description: `${count} asset${count !== 1 ? "s" : ""}`,
|
||||
};
|
||||
},
|
||||
transcribe_video: (parsed) => {
|
||||
const data = parsed as {
|
||||
assetName?: string;
|
||||
language?: string;
|
||||
segmentCount?: number;
|
||||
duration?: number;
|
||||
} | null;
|
||||
if (!data) return null;
|
||||
return {
|
||||
label: "Transcript",
|
||||
description: `${data.assetName ?? "audio"} · ${data.segmentCount ?? 0} segments`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { formatToolResult } from "./tool-formatters";
|
||||
|
||||
interface ToolResultCardProps {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ToolResultCard({ name, content }: ToolResultCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const summary = formatToolResult(name, content);
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<div className="group/result flex flex-col rounded-md border border-border/50 bg-muted/20 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="Expand"
|
||||
className={cn(
|
||||
"size-3 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium text-foreground/80">{summary.label}</span>
|
||||
<span className="text-muted-foreground">{summary.description}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-border/50 border-t px-2.5 py-1.5">
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue