180 lines
8.5 KiB
TypeScript
180 lines
8.5 KiB
TypeScript
import { cn } from "@/lib/utils";
|
|
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
|
import { getMessageAttachments, type Message } from "@/lib/api";
|
|
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
|
import { Globe2, Link2, Wrench } from "lucide-preact";
|
|
|
|
type Props = {
|
|
messages: Message[];
|
|
isLoading: boolean;
|
|
isSending: boolean;
|
|
};
|
|
|
|
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 getToolSummary(message: Message, metadata: ToolLogMetadata) {
|
|
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
|
|
if (metadata.status === "failed" && typeof metadata.error === "string" && metadata.error.trim()) {
|
|
return `Tool failed: ${metadata.error.trim()}`;
|
|
}
|
|
if (typeof metadata.resultPreview === "string" && metadata.resultPreview.trim()) return metadata.resultPreview.trim();
|
|
if (message.content.trim()) return message.content.trim();
|
|
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
|
|
return `Ran tool '${toolName}'.`;
|
|
}
|
|
|
|
function getToolLabel(message: Message, metadata: ToolLogMetadata) {
|
|
const raw = metadata.toolName?.trim() || message.name?.trim();
|
|
if (!raw) return "Tool call";
|
|
return raw
|
|
.replace(/_/g, " ")
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)
|
|
.join(" ");
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
function formatDuration(durationMs: unknown) {
|
|
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) return null;
|
|
return `${Math.round(durationMs)} ms`;
|
|
}
|
|
|
|
function formatToolTimestamp(...values: Array<string | null | undefined>) {
|
|
const value = values.find((candidate) => candidate && !Number.isNaN(new Date(candidate).getTime()));
|
|
if (!value) return null;
|
|
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
|
|
}
|
|
|
|
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFailed: boolean) {
|
|
return [
|
|
isFailed ? "Failed" : "Completed",
|
|
formatDuration(metadata.durationMs),
|
|
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" • ");
|
|
}
|
|
|
|
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
|
|
|
return (
|
|
<>
|
|
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
|
<div className="mx-auto max-w-4xl 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";
|
|
const toolSummary = getToolSummary(message, toolLogMetadata);
|
|
const toolLabel = getToolLabel(message, toolLogMetadata);
|
|
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed);
|
|
return (
|
|
<div key={message.id} className="flex justify-start">
|
|
<div
|
|
className={cn(
|
|
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
|
isFailed
|
|
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
|
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
|
|
)}
|
|
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
|
isFailed ? "border-rose-400/34 bg-rose-400/13 text-rose-300" : "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1 space-y-1">
|
|
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>
|
|
{toolSummary}
|
|
</span>
|
|
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
|
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : "text-cyan-200/90")}>
|
|
{toolLabel}
|
|
</span>
|
|
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isUser = message.role === "user";
|
|
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
|
const attachments = getMessageAttachments(message.metadata);
|
|
return (
|
|
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
|
<div
|
|
className={cn(
|
|
"max-w-[85%] space-y-3",
|
|
isUser
|
|
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
|
: "text-base leading-7 text-violet-50"
|
|
)}
|
|
>
|
|
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
|
{isPendingAssistant ? (
|
|
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
|
|
</span>
|
|
) : message.content.trim() ? (
|
|
<MarkdownContent
|
|
markdown={message.content}
|
|
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{isSending && !hasPendingAssistant ? (
|
|
<div className="flex justify-start">
|
|
<div className="max-w-[85%] text-base leading-7 text-violet-50">
|
|
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|