"use client"; import { FileUIPart, getToolName, ToolUIPart, UIMessage } from "ai"; import { Check, Copy, Loader, Pencil, ChevronDownIcon, ChevronUp, RefreshCw, X, Trash2, ChevronRight, TriangleAlert, HammerIcon, EllipsisIcon, FileIcon, Download, } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"; import { Button } from "ui/button"; import { Badge } from "ui/badge"; import { Markdown } from "./markdown"; import { cn, safeJSONParse, truncateString } from "lib/utils"; import JsonView from "ui/json-view"; import { useMemo, useState, memo, useEffect, useRef, useCallback } from "react"; import { MessageEditor } from "./message-editor"; import type { UseChatHelpers } from "@ai-sdk/react"; import { useCopy } from "@/hooks/use-copy"; import { AnimatePresence, motion } from "framer-motion"; import { SelectModel } from "./select-model"; import { deleteMessageAction, deleteMessagesByChatIdAfterTimestampAction, } from "@/app/api/chat/actions"; import { toast } from "sonner"; import { safe } from "ts-safe"; import { ChatMetadata, ChatModel, ManualToolConfirmTag } from "app-types/chat"; import { useTranslations } from "next-intl"; import { extractMCPToolId } from "lib/ai/mcp/mcp-tool-id"; import { Separator } from "ui/separator"; import { TextShimmer } from "ui/text-shimmer"; import equal from "lib/equal"; import { VercelAIWorkflowToolStreamingResult, VercelAIWorkflowToolStreamingResultTag, } from "app-types/workflow"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; import { DefaultToolName, ImageToolName } from "lib/ai/tools"; import { Shortcut, getShortcutKeyList, isShortcutEvent, } from "lib/keyboard-shortcuts"; import { WorkflowInvocation } from "./tool-invocation/workflow-invocation"; import dynamic from "next/dynamic"; import { notify } from "lib/notify"; import { ModelProviderIcon } from "ui/model-provider-icon"; import { appStore } from "@/app/store"; import { BACKGROUND_COLORS, EMOJI_DATA } from "lib/const"; type MessagePart = UIMessage["parts"][number]; type TextMessagePart = Extract; type AssistMessagePart = Extract; interface UserMessagePartProps { part: TextMessagePart; isLast: boolean; message: UIMessage; setMessages?: UseChatHelpers["setMessages"]; sendMessage?: UseChatHelpers["sendMessage"]; status?: UseChatHelpers["status"]; isError?: boolean; readonly?: boolean; } interface AssistMessagePartProps { part: AssistMessagePart; isLast?: boolean; isLoading?: boolean; message: UIMessage; prevMessage?: UIMessage; showActions: boolean; threadId?: string; setMessages?: UseChatHelpers["setMessages"]; sendMessage?: UseChatHelpers["sendMessage"]; isError?: boolean; readonly?: boolean; } interface ToolMessagePartProps { part: ToolUIPart; messageId: string; showActions: boolean; isLast?: boolean; isManualToolInvocation?: boolean; addToolResult?: UseChatHelpers["addToolResult"]; isError?: boolean; setMessages?: UseChatHelpers["setMessages"]; readonly?: boolean; } const MAX_TEXT_LENGTH = 600; export const UserMessagePart = memo( function UserMessagePart({ part, isLast, status, message, setMessages, sendMessage, readonly, isError, }: UserMessagePartProps) { const { copied, copy } = useCopy(); const t = useTranslations(); const [mode, setMode] = useState<"view" | "edit">("view"); const [isDeleting, setIsDeleting] = useState(false); const [expanded, setExpanded] = useState(false); const ref = useRef(null); const scrolledRef = useRef(false); const isLongText = part.text.length > MAX_TEXT_LENGTH; const displayText = expanded || !isLongText ? part.text : truncateString(part.text, MAX_TEXT_LENGTH); const deleteMessage = useCallback(async () => { if (!setMessages) return; const ok = await notify.confirm({ title: "Delete Message", description: "Are you sure you want to delete this message?", }); if (!ok) return; safe(() => setIsDeleting(true)) .ifOk(() => deleteMessageAction(message.id)) .ifOk(() => setMessages((messages) => { const index = messages.findIndex((m) => m.id === message.id); if (index !== -1) { return messages.filter((_, i) => i !== index); } return messages; }), ) .ifFail((error) => toast.error(error.message)) .watch(() => setIsDeleting(false)) .unwrap(); }, [message.id]); useEffect(() => { if (status === "submitted" && isLast && !scrolledRef.current) { scrolledRef.current = true; ref.current?.scrollIntoView({ behavior: "smooth" }); } }, [status]); if (mode === "edit" && setMessages && sendMessage) { return (
); } return (
{isLongText && !expanded && (
)}

{displayText}

{isLongText && ( )}
{isLast && (
Copy {!readonly && ( <> Edit Delete Message )}
)}
); }, (prev, next) => { if (prev.part.text != next.part.text) return false; if (prev.isError != next.isError) return false; if (prev.isLast != next.isLast) return false; if (prev.status != next.status) return false; if (prev.message.id != next.message.id) return false; if (!equal(prev.part, next.part)) return false; return true; }, ); UserMessagePart.displayName = "UserMessagePart"; export const AssistMessagePart = memo(function AssistMessagePart({ part, showActions, message, prevMessage, isError, threadId, setMessages, readonly, sendMessage, }: AssistMessagePartProps) { const { copied, copy } = useCopy(); const [isLoading, setIsLoading] = useState(false); const agentList = appStore((state) => state.agentList); const [isDeleting, setIsDeleting] = useState(false); const ref = useRef(null); const metadata = message.metadata as ChatMetadata | undefined; const agent = useMemo(() => { return agentList.find((a) => a.id === metadata?.agentId); }, [metadata, agentList]); const deleteMessage = useCallback(async () => { if (!setMessages) return; const ok = await notify.confirm({ title: "Delete Message", description: "Are you sure you want to delete this message?", }); if (!ok) return; safe(() => setIsDeleting(true)) .ifOk(() => deleteMessageAction(message.id)) .ifOk(() => setMessages((messages) => { const index = messages.findIndex((m) => m.id === message.id); if (index !== -1) { return messages.filter((_, i) => i !== index); } return messages; }), ) .ifFail((error) => toast.error(error.message)) .watch(() => setIsDeleting(false)) .unwrap(); }, [message.id]); const handleModelChange = (model: ChatModel) => { if (!setMessages || !sendMessage || !prevMessage) return; safe(() => setIsLoading(true)) .ifOk(() => threadId ? deleteMessagesByChatIdAfterTimestampAction(message.id) : Promise.resolve(), ) .ifOk(() => setMessages((messages) => { const index = messages.findIndex((m) => m.id === prevMessage.id); if (index !== -1) { return [...messages.slice(0, index)]; } return messages; }), ) .ifOk(() => sendMessage(prevMessage, { body: { model, }, }), ) .ifFail((error) => toast.error(error.message)) .watch(() => setIsLoading(false)) .unwrap(); }; return (
{part.text}
{showActions && (
Copy {!readonly && ( <>
Change Model
Delete Message )} {metadata && (
{agent && ( <>

Agent

{agent.name[0]}
{agent.name}
)} {metadata.chatModel && ( <>

Model

{metadata.chatModel.provider}
{metadata.chatModel.model} {metadata.toolCount !== undefined && metadata.toolCount > 0 && ( • {metadata.toolCount} tools )}
)} {metadata.usage && ( <>

Token Usage { message.parts.filter( (v) => v.type != "step-start", ).length }{" "} Steps

High input token usage may occur when many tools are available.

{metadata.usage.inputTokens !== undefined && (
Input {metadata.usage.inputTokens.toLocaleString()}
)} {metadata.usage.outputTokens !== undefined && (
Output {metadata.usage.outputTokens.toLocaleString()}
)} {metadata.usage.totalTokens !== undefined && (
Total {metadata.usage.totalTokens.toLocaleString()}
)}
)}
)}
)}
); }); AssistMessagePart.displayName = "AssistMessagePart"; const variants = { collapsed: { height: 0, opacity: 0, marginTop: 0, marginBottom: 0, }, expanded: { height: "auto", opacity: 1, marginTop: "1rem", marginBottom: "0.5rem", }, }; export const ReasoningPart = memo(function ReasoningPart({ reasoningText, isThinking, }: { reasoningText: string; isThinking?: boolean; readonly?: boolean; }) { const [isExpanded, setIsExpanded] = useState(isThinking); useEffect(() => { if (!isThinking && isExpanded) { setIsExpanded(false); } }, [isThinking]); return (
{ setIsExpanded(!isExpanded); }} >
{isThinking ? ( Reasoned for a few seconds ) : (
Reasoned for a few seconds
)}
{isExpanded && ( {reasoningText || (isThinking ? "" : "Hmm, let's see...🤔")} )}
); }); ReasoningPart.displayName = "ReasoningPart"; const loading = memo(function Loading() { return (
); }); const PieChart = dynamic( () => import("./tool-invocation/pie-chart").then((mod) => mod.PieChart), { ssr: false, loading, }, ); const BarChart = dynamic( () => import("./tool-invocation/bar-chart").then((mod) => mod.BarChart), { ssr: false, loading, }, ); const LineChart = dynamic( () => import("./tool-invocation/line-chart").then((mod) => mod.LineChart), { ssr: false, loading, }, ); const InteractiveTable = dynamic( () => import("./tool-invocation/interactive-table").then( (mod) => mod.InteractiveTable, ), { ssr: false, loading, }, ); const WebSearchToolInvocation = dynamic( () => import("./tool-invocation/web-search").then( (mod) => mod.WebSearchToolInvocation, ), { ssr: false, loading, }, ); const CodeExecutor = dynamic( () => import("./tool-invocation/code-executor").then((mod) => mod.CodeExecutor), { ssr: false, loading, }, ); const ImageGeneratorToolInvocation = dynamic( () => import("./tool-invocation/image-generator").then( (mod) => mod.ImageGeneratorToolInvocation, ), { ssr: false, loading, }, ); // Local shortcuts for tool invocation approval/rejection const approveToolInvocationShortcut: Shortcut = { description: "approveToolInvocation", shortcut: { key: "Enter", command: true, }, }; const rejectToolInvocationShortcut: Shortcut = { description: "rejectToolInvocation", shortcut: { key: "Escape", command: true, }, }; export const ToolMessagePart = memo( ({ part, isLast, showActions, addToolResult, isError, messageId, setMessages, isManualToolInvocation, }: ToolMessagePartProps) => { const t = useTranslations(""); const { output, toolCallId, state, input, errorText } = part; const toolName = useMemo(() => getToolName(part), [part.type]); const isCompleted = useMemo(() => { return state.startsWith("output"); }, [state]); const [expanded, setExpanded] = useState(false); const { copied: copiedInput, copy: copyInput } = useCopy(); const { copied: copiedOutput, copy: copyOutput } = useCopy(); const [isDeleting, setIsDeleting] = useState(false); // Handle keyboard shortcuts for approve/reject actions useEffect(() => { // Only enable shortcuts when manual tool invocation buttons are shown if (!isManualToolInvocation) return; const handleKeyDown = (e: KeyboardEvent) => { const isApprove = isShortcutEvent(e, approveToolInvocationShortcut); const isReject = isShortcutEvent(e, rejectToolInvocationShortcut); if (!isApprove && !isReject) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (isApprove) { addToolResult?.({ tool: toolName, toolCallId, output: ManualToolConfirmTag.create({ confirm: true }), }); } if (isReject) { addToolResult?.({ tool: toolName, toolCallId, output: ManualToolConfirmTag.create({ confirm: false }), }); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isManualToolInvocation, isLast]); const deleteMessage = useCallback(async () => { const ok = await notify.confirm({ title: "Delete Message", description: "Are you sure you want to delete this message?", }); if (!ok) return; safe(() => setIsDeleting(true)) .ifOk(() => deleteMessageAction(messageId)) .ifOk(() => setMessages?.((messages) => { const index = messages.findIndex((m) => m.id === messageId); if (index !== -1) { return messages.filter((_, i) => i !== index); } return messages; }), ) .ifFail((error) => toast.error(error.message)) .watch(() => setIsDeleting(false)) .unwrap(); }, [messageId]); const onToolCallDirect = useCallback( (result: any) => { addToolResult?.({ tool: toolName, toolCallId, output: result, }); }, [addToolResult, toolCallId], ); const result = useMemo(() => { if (state == "output-error") { return errorText; } if (isCompleted) { return Array.isArray(output) ? { ...output, content: output.map((node) => { // mcp tools if (node?.type === "text" && typeof node?.text === "string") { const parsed = safeJSONParse(node.text); return { ...node, text: parsed.success ? parsed.value : node.text, }; } return node; }), } : output; } return null; }, [isCompleted, output, state, errorText]); const isWorkflowTool = useMemo( () => VercelAIWorkflowToolStreamingResultTag.isMaybe(result), [result], ); const CustomToolComponent = useMemo(() => { if ( toolName === DefaultToolName.WebSearch || toolName === DefaultToolName.WebContent ) { return ; } if (toolName === ImageToolName) { return ; } if (toolName === DefaultToolName.JavascriptExecution) { return ( ); } if (toolName === DefaultToolName.PythonExecution) { return ( ); } if (state === "output-available") { switch (toolName) { case DefaultToolName.CreatePieChart: return ( ); case DefaultToolName.CreateBarChart: return ( ); case DefaultToolName.CreateLineChart: return ( ); case DefaultToolName.CreateTable: return ( ); } } return null; }, [toolName, state, onToolCallDirect, result, input]); const { serverName: mcpServerName, toolName: mcpToolName } = useMemo(() => { return extractMCPToolId(toolName); }, [toolName]); const isExpanded = useMemo(() => { return expanded || result === null || isWorkflowTool; }, [expanded, result, isWorkflowTool]); const isExecuting = useMemo(() => { if (isWorkflowTool) return ( (result as VercelAIWorkflowToolStreamingResult)?.status == "running" ); return !isCompleted && isLast; }, [isWorkflowTool, isCompleted, result, isLast]); return (
{CustomToolComponent ? ( CustomToolComponent ) : (
setExpanded(!expanded)} >
{isExecuting ? ( ) : isError ? ( ) : isWorkflowTool ? ( {toolName.slice(0, 2).toUpperCase()} ) : ( )}
{isExecuting ? ( {mcpServerName} ) : ( mcpServerName )} {mcpToolName && ( <> {mcpToolName} )}
{ if (!isExpanded) { setExpanded(true); } }} >
Request
{copiedInput ? ( ) : ( )}
{isExpanded && (
)}
{!result ? null : isWorkflowTool ? ( ) : (
{ if (!isExpanded) { setExpanded(true); } }} >
Response
{copiedOutput ? ( ) : ( )}
{isExpanded && (
)}
)} {isManualToolInvocation && (
)}
{showActions && (
Delete Message
)}
)}
); }, (prev, next) => { if (prev.isError !== next.isError) return false; if (prev.isLast !== next.isLast) return false; if (prev.showActions !== next.showActions) return false; if (prev.isManualToolInvocation !== next.isManualToolInvocation) return false; if (prev.messageId !== next.messageId) return false; if (!equal(prev.part, next.part)) return false; return true; }, ); ToolMessagePart.displayName = "ToolMessagePart"; // File Message Part Component interface FileMessagePartProps { part: FileUIPart; // FileUIPart from AI SDK isUserMessage: boolean; } export const FileMessagePart = memo( ({ part, isUserMessage }: FileMessagePartProps) => { const isImage = part.mediaType?.startsWith("image/"); const fileExtension = part.filename?.split(".").pop()?.toUpperCase() || part.mediaType?.split("/").pop()?.toUpperCase() || "FILE"; const fileUrl = part.url; const filename = part.filename || part.url?.split("/").pop() || "Attachment"; const secondaryLabel = part.mediaType && part.mediaType !== "application/octet-stream" ? part.mediaType : undefined; if (isImage && fileUrl) { return (
{/* eslint-disable-next-line @next/next/no-img-element */} {part.filename {part.filename && (
{part.filename}
)}
); } // Non-image file return (

{filename}

{fileExtension} {secondaryLabel && ( {secondaryLabel} )}
{fileUrl && ( Download )}
); }, ); FileMessagePart.displayName = "FileMessagePart"; // Source URL (non-model) attachment renderer export function SourceUrlMessagePart({ part, isUserMessage, }: { part: { type: "source-url"; url: string; title?: string; mediaType?: string }; isUserMessage: boolean; }) { const name = part.title || part.url?.split("/").pop() || "attachment"; const ext = name.split(".").pop()?.toUpperCase() || "FILE"; const mediaType = part.mediaType && part.mediaType !== "application/octet-stream" ? part.mediaType : undefined; return (
{name}
{ext} {mediaType && ( {mediaType} )}
Open attachment
); }