import type { Message } from "@langchain/langgraph-sdk"; import { FileIcon, Loader2Icon } from "lucide-react"; import { memo, useMemo, useState, type ImgHTMLAttributes } from "react"; import rehypeKatex from "rehype-katex"; import { Loader } from "@/components/ai-elements/loader"; import { Message as AIElementMessage, MessageContent as AIElementMessageContent, MessageResponse as AIElementMessageResponse, MessageToolbar, } from "@/components/ai-elements/message"; import { Reasoning, ReasoningContent, ReasoningTrigger, } from "@/components/ai-elements/reasoning"; import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { resolveArtifactURL } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import { extractContentFromMessage, extractReasoningContentFromMessage, parseUploadedFiles, stripUploadedFilesTag, type FileInMessage, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { materializeSkillYaml } from "@/core/skills"; import { humanMessagePlugins } from "@/core/streamdown"; import { cn } from "@/lib/utils"; import { CopyButton } from "../copy-button"; import { MarkdownContent } from "./markdown-content"; export function MessageListItem({ className, message, isLoading, threadId, }: { className?: string; message: Message; isLoading?: boolean; threadId: string; }) { const isHuman = message.type === "human"; return ( {!isLoading && (
)}
); } /** * Custom image component that handles artifact URLs */ function MessageImage({ src, alt, threadId, maxWidth = "90%", ...props }: React.ImgHTMLAttributes & { threadId: string; maxWidth?: string; }) { if (!src) return null; const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`); if (typeof src !== "string") { return {alt}; } const url = src.startsWith("/mnt/") && threadId ? resolveArtifactURL(src, threadId) : src; return ( {alt} ); } function MessageContent_({ className, message, isLoading = false, threadId, }: { className?: string; message: Message; isLoading?: boolean; threadId: string; }) { const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const isHuman = message.type === "human"; const components = useMemo( () => ({ img: (props: ImgHTMLAttributes) => ( ), }), [threadId], ); const rawContent = extractContentFromMessage(message); const reasoningContent = extractReasoningContentFromMessage(message); const files = useMemo(() => { const files = message.additional_kwargs?.files; if (!Array.isArray(files) || files.length === 0) { if (rawContent.includes("")) { // If the content contains the tag, we return the parsed files from the content for backward compatibility. return parseUploadedFiles(rawContent); } return null; } return files as FileInMessage[]; }, [message.additional_kwargs?.files, rawContent]); const contentToDisplay = useMemo(() => { if (isHuman) { return rawContent ? stripUploadedFilesTag(rawContent) : ""; } return rawContent ?? ""; }, [rawContent, isHuman]); const filesList = files && files.length > 0 && threadId ? ( ) : null; // Uploading state: mock AI message shown while files upload if (message.additional_kwargs?.element === "task") { return (
{contentToDisplay}
); } // Reasoning-only AI message (no main response content yet) if (!isHuman && reasoningContent && !rawContent) { return ( {reasoningContent} ); } if (isHuman) { const messageResponse = contentToDisplay ? ( {contentToDisplay} ) : null; return (
{filesList} {messageResponse && ( {messageResponse} )}
); } return ( {filesList} ); } /** * Get file extension and check helpers */ const getFileExt = (filename: string) => filename.split(".").pop()?.toLowerCase() ?? ""; const FILE_TYPE_MAP: Record = { json: "JSON", csv: "CSV", txt: "TXT", md: "Markdown", py: "Python", js: "JavaScript", ts: "TypeScript", tsx: "TSX", jsx: "JSX", html: "HTML", css: "CSS", xml: "XML", yaml: "YAML", yml: "YAML", pdf: "PDF", png: "PNG", jpg: "JPG", jpeg: "JPEG", gif: "GIF", svg: "SVG", zip: "ZIP", tar: "TAR", gz: "GZ", }; const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]; function getFileTypeLabel(filename: string): string { const ext = getFileExt(filename); return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || "FILE"); } function isImageFile(filename: string): boolean { return IMAGE_EXTENSIONS.includes(getFileExt(filename)); } function isYamlFile(filename: string): boolean { const ext = getFileExt(filename); return ext === "yaml" || ext === "yml"; } /** * Format bytes to human-readable size string */ function formatBytes(bytes: number | string): string { const numericBytes = typeof bytes === "string" ? Number(bytes) : bytes; if (!Number.isFinite(numericBytes)) return "—"; const safeBytes = Math.max(0, numericBytes); if (safeBytes === 0) return "—"; const kb = safeBytes / 1024; if (kb < 1024) return `${kb.toFixed(1)} KB`; return `${(kb / 1024).toFixed(1)} MB`; } /** * List of files from additional_kwargs.files (with optional upload status) */ function RichFilesList({ files, threadId, }: { files: FileInMessage[]; threadId: string; }) { if (files.length === 0) return null; return (
{files.map((file, index) => ( ))}
); } /** * Single file card that handles FileInMessage (supports uploading state) */ function RichFileCard({ file, threadId, }: { file: FileInMessage; threadId: string; }) { const { t } = useI18n(); const isUploading = file.status === "uploading"; const isImage = isImageFile(file.filename); const isYaml = isYamlFile(file.filename); const [isMaterializing, setIsMaterializing] = useState(false); const [materializeMessage, setMaterializeMessage] = useState( null, ); if (isUploading) { return (
{file.filename}
{getFileTypeLabel(file.filename)} {t.uploads.uploading}
); } if (!file.path) return null; const fileUrl = resolveArtifactURL(file.path, threadId); const handleMaterializeYaml = async () => { if (!isYaml || isMaterializing) return; setIsMaterializing(true); setMaterializeMessage(null); try { const result = await materializeSkillYaml({ thread_id: threadId, path: file.path!, target_dir: "/mnt/user-data/uploads/skill", clear_target: true, }); setMaterializeMessage( `已创建 ${result.created_files} 个文件 / ${result.created_directories} 个目录`, ); } catch (error) { const message = error instanceof Error ? error.message : "解析失败"; setMaterializeMessage(`失败: ${message}`); } finally { setIsMaterializing(false); } }; if (isImage) { return ( {file.filename} ); } return (
{file.filename}
{getFileTypeLabel(file.filename)} {formatBytes(file.size)}
{isYaml && (
{materializeMessage && ( {materializeMessage} )}
)}
); } const MessageContent = memo(MessageContent_);