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}>
|
<ScrollArea className="flex-1 p-3" ref={scrollRef}>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<MessageBubble key={msg.id} message={msg} />
|
<MessageBubble key={msg.id} message={msg} messages={messages} />
|
||||||
))}
|
))}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,30 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import type { ChatMessage } from "@/agent/types";
|
import type { ChatMessage, ToolCall } from "@/agent/types";
|
||||||
import {
|
import { isTranscriptData, formatTimestamp } from "./transcript-utils";
|
||||||
isTranscriptData,
|
|
||||||
formatTimestamp,
|
|
||||||
} from "./transcript-utils";
|
|
||||||
import type { TranscriptData } from "./transcript-utils";
|
import type { TranscriptData } from "./transcript-utils";
|
||||||
|
import { ToolCallCard } from "./tool-call-card";
|
||||||
|
import { ToolResultCard } from "./tool-result-card";
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
|
messages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_LABELS: Record<ChatMessage["role"], string> = {
|
function findToolCallName(
|
||||||
user: "You",
|
messages: ChatMessage[],
|
||||||
assistant: "Assistant",
|
toolCallId?: string,
|
||||||
tool_result: "Tool",
|
): string | null {
|
||||||
system: "System",
|
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 }) {
|
function TranscriptCard({ data }: { data: TranscriptData }) {
|
||||||
return (
|
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";
|
const isUser = message.role === "user";
|
||||||
|
|
||||||
// For tool_result messages, try to parse as transcript
|
|
||||||
if (message.role === "tool_result") {
|
if (message.role === "tool_result") {
|
||||||
|
const toolName = findToolCallName(messages, message.toolCallId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(message.content);
|
const parsed = JSON.parse(message.content);
|
||||||
if (isTranscriptData(parsed)) {
|
if (isTranscriptData(parsed)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 rounded-md px-3 py-2">
|
<div className="px-3 py-0.5">
|
||||||
<span className="text-muted-foreground text-xs font-medium">
|
|
||||||
{ROLE_LABELS.tool_result}
|
|
||||||
</span>
|
|
||||||
<TranscriptCard data={parsed} />
|
<TranscriptCard data={parsed} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
isUser ? "bg-secondary" : "bg-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground text-xs font-medium">
|
{message.content && (
|
||||||
{ROLE_LABELS[message.role]}
|
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||||
</span>
|
)}
|
||||||
<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>
|
</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