diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 12992d07..5da40c7d 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -1,26 +1,33 @@ import type { Message } from "@langchain/langgraph-sdk"; -import { FileIcon } from "lucide-react"; +import { FileIcon, Loader2Icon } from "lucide-react"; import { useParams } from "next/navigation"; -import { memo, useMemo, useState, type ImgHTMLAttributes } from "react"; +import { memo, useMemo, 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, - type UploadedFile, + 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"; @@ -40,7 +47,7 @@ export function MessageListItem({ const isHuman = message.type === "human"; return ( - - - - - + {!isLoading && ( + + + + + + )} ); } @@ -121,37 +130,67 @@ function MessageContent_({ const rawContent = extractContentFromMessage(message); const reasoningContent = extractReasoningContentFromMessage(message); - const { contentToParse, uploadedFiles } = useMemo(() => { - if (!isLoading && reasoningContent && !rawContent) { - return { - contentToParse: reasoningContent, - uploadedFiles: [] as UploadedFile[], - }; + + 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).files as FileInMessage[]; + } + return null; } - if (isHuman && rawContent) { - const { files, cleanContent: contentWithoutFiles } = - parseUploadedFiles(rawContent); - return { contentToParse: contentWithoutFiles, uploadedFiles: files }; + return files as FileInMessage[]; + }, [message.additional_kwargs?.files, rawContent]); + + const contentToDisplay = useMemo(() => { + if (isHuman) { + return rawContent ? stripUploadedFilesTag(rawContent) : ""; } - return { - contentToParse: rawContent ?? "", - uploadedFiles: [] as UploadedFile[], - }; - }, [isLoading, rawContent, reasoningContent, isHuman]); + return rawContent ?? ""; + }, [rawContent, isHuman]); const filesList = - uploadedFiles.length > 0 && thread_id ? ( - + files && files.length > 0 && thread_id ? ( + ) : 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 = contentToParse ? ( + const messageResponse = contentToDisplay ? ( - {contentToParse} + {contentToDisplay} ) : null; return ( @@ -170,7 +209,7 @@ function MessageContent_({ {filesList} {files.map((file, index) => ( - @@ -254,47 +300,49 @@ function UploadedFilesList({ } /** - * Single uploaded file card component + * Single file card that handles FileInMessage (supports uploading state) */ -function UploadedFileCard({ +function RichFileCard({ file, threadId, }: { - file: UploadedFile; + file: FileInMessage; threadId: string; }) { - const [isMaterializing, setIsMaterializing] = useState(false); - const [materializeMessage, setMaterializeMessage] = useState( - null, - ); - - if (!threadId) return null; - + const { t } = useI18n(); + const isUploading = file.status === "uploading"; const isImage = isImageFile(file.filename); - const isYaml = isYamlFile(file.filename); - const fileUrl = resolveArtifactURL(file.path, threadId); - const handleMaterializeYaml = async () => { - if (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 (isUploading) { + return ( + + + + + {file.filename} + + + + + {getFileTypeLabel(file.filename)} + + + {t.uploads.uploading} + + + + ); + } + + if (!file.path) return null; + + const fileUrl = resolveArtifactURL(file.path, threadId); if (isImage) { return ( @@ -307,14 +355,14 @@ function UploadedFileCard({ ); } return ( - + {getFileTypeLabel(file.filename)} - {file.size} + + {formatBytes(file.size)} + - {/* 注释掉测试按钮,后续根据需求再决定是否保留 */} - {/* {isYaml && ( - - { - void handleMaterializeYaml(); - }} - disabled={isMaterializing} - > - {isMaterializing ? "解析中..." : "一键导入为 Skill 目录"} - - {materializeMessage && ( - - {materializeMessage} - - )} - - )} */} ); }