import { createRoot } from "react-dom/client"; import { useStream } from "@langchain/langgraph-sdk/react"; import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui"; import { Client } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk"; // UIMessage is not exported directly — use a local shape type UIMsgLocal = { id: string; type: string; name: string; props: Record; metadata?: { message_id?: string } }; import { useState, useRef, useEffect, useCallback } from "react"; import ComponentMap from "./agent-uis/index.tsx"; import "./index.css"; import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown, Sparkles, Loader2, Clock } from "lucide-react"; import { cn } from "@/lib/utils"; import { ThreadSidebar, type ThreadItem, updateThreadLastActive } from "@/components/ThreadSidebar.tsx"; import { ThemeProvider } from "next-themes"; import ThemeToggle from "@/components/ThemeToggle.tsx"; import MessageBubble from "@/components/MessageBubble.tsx"; import CanvasPanel, { type CanvasDoc } from "@/components/CanvasPanel.tsx"; import FileUploadButton, { type SelectedFile } from "@/components/FileUploadButton.tsx"; import FileAttachmentPreview from "@/components/FileAttachmentPreview.tsx"; import ToolCallStatus from "@/components/ToolCallStatus.tsx"; import { ExecutionLogPanel } from "@/components/ExecutionLogPanel.tsx"; const LANGGRAPH_URL = import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024"; // ─── Card deduplication by card_id ────────────────────────────────────────── // When the backend pushes loading then complete cards with the same card_id, // keep only the last (most recent) card per card_id. Cards without card_id pass through. function deduplicateUiItems(items: UIMsgLocal[]): UIMsgLocal[] { const seen = new Map(); // Walk forward to find the last index for each card_id items.forEach((item, idx) => { const cardId = item.props?.card_id as string | undefined; if (cardId) seen.set(cardId, idx); }); return items.filter((item, idx) => { const cardId = item.props?.card_id as string | undefined; if (!cardId) return true; return seen.get(cardId) === idx; }); } // ─── Tool groups ──────────────────────────────────────────────────────────── const TOOL_GROUPS = [ { key: "knowledge", label: "知识库", icon: BookOpen, tools: ["kb_search"] }, { key: "tickets", label: "工单", icon: Ticket, tools: ["ticket_list", "ticket_detail"] }, { key: "search", label: "搜索", icon: Search, tools: ["web_search", "google_search"] }, { key: "sandbox", label: "代码", icon: Terminal, tools: ["sandbox_run"] }, ] as const; type ToolKey = (typeof TOOL_GROUPS)[number]["key"]; type ModelMode = "flash" | "auto" | "pro"; const MODEL_OPTIONS: { value: ModelMode; label: string; icon: React.FC<{ className?: string }> }[] = [ { value: "flash", label: "Flash", icon: Zap }, { value: "auto", label: "Auto", icon: Bot }, { value: "pro", label: "Pro", icon: Cpu }, ]; // ─── Quick prompts ─────────────────────────────────────────────────────────── const QUICK_PROMPTS = [ { icon: BookOpen, label: "搜索知识库", prompt: "帮我搜索知识库中关于" }, { icon: Ticket, label: "查看最新工单", prompt: "查看最新的工单列表" }, { icon: Search, label: "搜索网络", prompt: "帮我搜索" }, { icon: Terminal, label: "运行代码", prompt: "用 Python 写一段代码" }, ]; // ─── LangGraph Client (for thread management) ─────────────────────────────── const client = new Client({ apiUrl: LANGGRAPH_URL }); // ─── Copy button for AI messages ──────────────────────────────────────────── function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const handleCopy = () => { navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; return ( ); } function App() { const [input, setInput] = useState(""); const bottomRef = useRef(null); const textareaRef = useRef(null); const scrollContainerRef = useRef(null); const [showScrollBtn, setShowScrollBtn] = useState(false); const isComposingRef = useRef(false); // Tool & model state const [activeTools, setActiveTools] = useState>(new Set()); const [modelMode, setModelMode] = useState("auto"); // Thread sidebar state const [threads, setThreads] = useState([]); const [currentThreadId, setCurrentThreadId] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(false); // Historical messages fetched when switching to an existing thread const [historicalMessages, setHistoricalMessages] = useState([]); const [historicalUi, setHistoricalUi] = useState([]); // Canvas panel state const [canvasDoc, setCanvasDoc] = useState(null); // File attachment state const [attachedFile, setAttachedFile] = useState(null); // Source label from card action buttons (e.g. "来自 知识库检索") const [sourceLabel, setSourceLabel] = useState(null); // Config panel open/close state const [configOpen, setConfigOpen] = useState(false); const configPanelRef = useRef(null); // Pending retry tool name — set by soc:retry-tool, consumed on next submit const pendingRetryToolRef = useRef(null); // ── Concurrent submit state ─────────────────────────────────────────────── type ConcurrentStatus = "idle" | "generating" | "queued" | "stopping" | "cancelling" | "auto-sending"; // 完整 payload 快照类型 interface SubmitPayload { text: string; attachedFile: { name: string; mimeType: string; base64: string; size: number } | null; activeTools: Set; modelMode: ModelMode; } const [showConcurrentDialog, setShowConcurrentDialog] = useState(false); const [concurrentStatus, setConcurrentStatus] = useState("idle"); const pendingQueueRef = useRef void>>([]); const isPollingRef = useRef(false); const wasStoppedRef = useRef(false); const [pendingQueueCount, setPendingQueueCount] = useState(0); const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({ apiUrl: LANGGRAPH_URL, assistantId: "agent", messagesKey: "messages", threadId: currentThreadId ?? undefined, }); // Auto-scroll to bottom only when user is near the bottom useEffect(() => { const el = scrollContainerRef.current; if (!el) return; const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200; if (isNearBottom) { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); } }, [thread.messages]); // Listen for open-canvas events from Gen-UI cards useEffect(() => { const handler = (e: Event) => setCanvasDoc((e as CustomEvent).detail); window.addEventListener("open-canvas", handler); return () => window.removeEventListener("open-canvas", handler); }, []); // Sync concurrent status with thread loading state useEffect(() => { if (!thread.isLoading && concurrentStatus !== "cancelling" && concurrentStatus !== "queued") { setConcurrentStatus("idle"); } }, [thread.isLoading, concurrentStatus]); // Listen for retry-tool events from error-result cards useEffect(() => { const handler = (e: Event) => { const ce = e as CustomEvent<{ toolName: string }>; if (ce.detail.toolName) { pendingRetryToolRef.current = ce.detail.toolName; } }; window.addEventListener("soc:retry-tool", handler); return () => window.removeEventListener("soc:retry-tool", handler); }, []); // Cleanup queue on unmount useEffect(() => { return () => { pendingQueueRef.current = []; isPollingRef.current = false; }; }, []); // Listen for prefill-input events from ActionBar useEffect(() => { const handler = (e: Event) => { const ce = e as CustomEvent<{ text?: string; prefix?: string; sourceLabel?: string; taskType?: string; sourceCardId?: string }>; if (ce.detail.prefix) { // Prefix mode: prepend context label to current input setInput((prev) => { const base = prev.trim(); return base ? `${ce.detail.prefix}${base}` : ce.detail.prefix!; }); } else if (ce.detail.text !== undefined) { setInput(ce.detail.text); } // Show source label tag above textarea if provided if (ce.detail.sourceLabel) { setSourceLabel(ce.detail.sourceLabel); } setTimeout(() => textareaRef.current?.focus(), 50); }; window.addEventListener("soc:prefill-input", handler); return () => window.removeEventListener("soc:prefill-input", handler); }, []); // Close config panel when clicking outside or pressing Escape useEffect(() => { if (!configOpen) return; function handleMouseDown(e: MouseEvent) { if (configPanelRef.current && !configPanelRef.current.contains(e.target as Node)) { setConfigOpen(false); } } function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") setConfigOpen(false); } document.addEventListener("mousedown", handleMouseDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("mousedown", handleMouseDown); document.removeEventListener("keydown", handleKeyDown); }; }, [configOpen]); // Load threads on mount useEffect(() => { client.threads .search({ limit: 50 }) .then((list: any) => setThreads(list as ThreadItem[])) .catch(() => {/* graceful degradation — sidebar stays empty */}); }, []); // ── Thread actions ────────────────────────────────────────────────────── const handleNewThread = useCallback(async () => { // Save draft for current thread before switching if (currentThreadId) { try { localStorage.setItem(`draft_${currentThreadId}`, input); } catch { /* ignore */ } } setInput(""); try { const t = await client.threads.create(); setThreads((prev) => [t as ThreadItem, ...prev]); setCurrentThreadId(t.thread_id); } catch { // If create fails, just clear threadId so useStream creates one implicitly setCurrentThreadId(null); } setSidebarOpen(false); }, [currentThreadId, input]); const handleSelectThread = useCallback(async (threadId: string) => { // Save draft for current thread if (currentThreadId) { try { localStorage.setItem(`draft_${currentThreadId}`, input); } catch { /* ignore */ } } // Restore draft for the new thread try { const saved = localStorage.getItem(`draft_${threadId}`) ?? ""; setInput(saved); } catch { setInput(""); } // Clear historical messages before switching setHistoricalMessages([]); setHistoricalUi([]); setCurrentThreadId(threadId); setSidebarOpen(false); // Fetch existing thread state so history is visible immediately try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const state = await (client.threads as any).getState(threadId); if (state?.values?.messages) { setHistoricalMessages(state.values.messages as Message[]); } if (state?.values?.ui) { setHistoricalUi(state.values.ui as UIMsgLocal[]); } } catch { /* graceful degradation */ } }, [currentThreadId, input]); const handleDeleteThread = useCallback(async (threadId: string) => { try { await client.threads.delete(threadId); } catch { // ignore errors } setThreads((prev) => prev.filter((t) => t.thread_id !== threadId)); if (currentThreadId === threadId) { setCurrentThreadId(null); } }, [currentThreadId]); // Auto-resize textarea useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 200) + "px"; }, [input]); // ── Tool toggle ───────────────────────────────────────────────────────── function toggleTool(key: ToolKey) { setActiveTools((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); } // ── Submit ────────────────────────────────────────────────────────────── async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const text = input.trim(); if (!text && !attachedFile) return; if (thread.isLoading) { // 立即 snapshot const snapshot: SubmitPayload = { text, attachedFile: attachedFile ? { name: attachedFile.name, mimeType: attachedFile.mimeType, base64: attachedFile.base64, size: attachedFile.size } : null, activeTools: new Set(activeTools), modelMode, }; setInput(""); setAttachedFile(null); pendingQueueRef.current.push(() => doSubmit(snapshot)); setPendingQueueCount(pendingQueueRef.current.length); setShowConcurrentDialog(true); return; } const payload: SubmitPayload = { text, attachedFile: attachedFile ? { name: attachedFile.name, mimeType: attachedFile.mimeType, base64: attachedFile.base64, size: attachedFile.size } : null, activeTools: new Set(activeTools), modelMode, }; setConcurrentStatus("generating"); doSubmit(payload); } async function doSubmit(payload: SubmitPayload) { const { text, attachedFile: file, activeTools: tools, modelMode: mode } = payload; setInput(""); setSourceLabel(null); if (currentThreadId) { try { localStorage.removeItem(`draft_${currentThreadId}`); } catch { /* ignore */ } updateThreadLastActive(currentThreadId); } const enabledTools = tools.size > 0 ? TOOL_GROUPS.filter((g) => tools.has(g.key)).flatMap((g) => [...g.tools]) : []; setAttachedFile(null); const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; // eslint-disable-next-line @typescript-eslint/no-explicit-any let messageContent: any; if (file && IMAGE_TYPES.includes(file.mimeType)) { messageContent = [ { type: "image_url", image_url: { url: `data:${file.mimeType};base64,${file.base64}` } }, { type: "text", text: text || "请分析这张图片" }, ]; } else if (file) { messageContent = [{ type: "text", text: `[附件: ${file.name}]\n\n${text || "请分析这个文件"}` }]; } else { messageContent = text; } const isFirstMessage = activeMessages.length === 0; const retryTool = pendingRetryToolRef.current; pendingRetryToolRef.current = null; thread.submit( { messages: [{ type: "human", content: messageContent }] }, { config: { configurable: { enabledTools, modelMode: mode, ...(retryTool ? { retryTool } : {}), ...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}), }, }, }, ); if (isFirstMessage && currentThreadId) { const titleText = text.slice(0, 20); client.threads.update(currentThreadId, { metadata: { title: titleText } }).catch(() => {}); try { localStorage.setItem(`title_${currentThreadId}`, titleText); } catch { /* ignore */ } setThreads((prev) => prev.map((t) => t.thread_id === currentThreadId ? { ...t, metadata: { ...t.metadata, title: titleText } } : t, ), ); } } // ── Concurrent dialog handlers ──────────────────────────────────────────── function handleConcurrentInterrupt() { setShowConcurrentDialog(false); setConcurrentStatus("stopping"); wasStoppedRef.current = true; thread.stop(); setTimeout(() => { setConcurrentStatus("cancelling"); const waitForStop = () => { if (!thread.isLoading) { wasStoppedRef.current = false; const next = pendingQueueRef.current.shift(); if (next) { setPendingQueueCount(pendingQueueRef.current.length); setConcurrentStatus("generating"); next(); } else { setConcurrentStatus("idle"); } } else { setTimeout(waitForStop, 200); } }; waitForStop(); }, 150); } function handleConcurrentQueue() { setShowConcurrentDialog(false); setConcurrentStatus("queued"); if (isPollingRef.current) return; isPollingRef.current = true; const poll = () => { if (pendingQueueRef.current.length === 0) { isPollingRef.current = false; setPendingQueueCount(0); setConcurrentStatus("idle"); return; } if (thread.isLoading) { setTimeout(poll, 500); return; } const next = pendingQueueRef.current.shift()!; setPendingQueueCount(pendingQueueRef.current.length); setConcurrentStatus("auto-sending"); next(); setTimeout(poll, 500); }; setTimeout(poll, 500); } function handleConcurrentCancel() { setShowConcurrentDialog(false); setConcurrentStatus(thread.isLoading ? "generating" : "idle"); pendingQueueRef.current = []; setPendingQueueCount(0); isPollingRef.current = false; } // ── Regenerate last AI message ─────────────────────────────────────────── function handleRegenerate() { // Find last human message const humanMsgs = activeMessages.filter((m) => m.type === "human"); const lastHuman = humanMsgs[humanMsgs.length - 1]; if (!lastHuman || thread.isLoading) return; const enabledTools = activeTools.size > 0 ? TOOL_GROUPS.filter((g) => activeTools.has(g.key)).flatMap((g) => [...g.tools]) : []; thread.submit( { messages: [{ type: "human", content: lastHuman.content }] }, { config: { configurable: { enabledTools, modelMode }, }, }, ); } // ── Drag and drop ──────────────────────────────────────────────────────── function handleDragOver(e: React.DragEvent) { e.preventDefault(); } function handleDrop(e: React.DragEvent) { e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result as string; const base64 = dataUrl.split(",")[1]; setAttachedFile({ name: file.name, mimeType: file.type, base64, size: file.size }); }; reader.readAsDataURL(file); } // ── Paste image ────────────────────────────────────────────────────────── function handlePaste(e: React.ClipboardEvent) { const items = Array.from(e.clipboardData.items); const imageItem = items.find((item) => item.type.startsWith("image/")); if (!imageItem) return; const file = imageItem.getAsFile(); if (!file) return; const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result as string; const base64 = dataUrl.split(",")[1]; setAttachedFile({ name: file.name || "pasted-image.png", mimeType: file.type, base64, size: file.size }); }; reader.readAsDataURL(file); } // ── Active messages: prefer streaming messages, fall back to historical ── const activeMessages: Message[] = thread.messages.length > 0 ? thread.messages : historicalMessages; // ── Collect completed tool call IDs ───────────────────────────────────── const completedToolIds = new Set( activeMessages .filter((m) => m.type === "tool") .map((m) => (m as any).tool_call_id as string) .filter(Boolean), ); // ── Collect failed tool call IDs ───────────────────────────────────────── const failedToolIds = new Set( activeMessages .filter((m) => m.type === "tool" && (m as any).status === "error") .map((m) => (m as any).tool_call_id as string) .filter(Boolean), ); // ── Read execution_log for partial/fallback status ──────────────────────── const executionLog = ((thread.values as any)?.execution_log ?? []) as Array<{ tool: string; status: string; durationMs?: number; retryCount?: number; fallbackFrom?: string; resultCount?: number; summary?: string; inputSummary?: string; }>; // ── Find last AI message index ────────────────────────────────────────── const lastAiIdx = activeMessages.reduce((last, m, i) => (m.type === "ai" ? i : last), -1); return (
{/* ── Mobile sidebar overlay backdrop ── */} {sidebarOpen && (
setSidebarOpen(false)} /> )} {/* ── Left sidebar ── */}
{/* ── Right main area ── */}
{/* Header */}
{/* Hamburger for mobile */} 运营大脑 {thread.isLoading && ( 思考中… )}
{/* Messages */}
{ const el = scrollContainerRef.current; if (!el) return; setShowScrollBtn(el.scrollHeight - el.scrollTop - el.clientHeight > 200); }} > {activeMessages.length === 0 && (

你好,有什么可以帮你的?

可以查询知识库、工单、搜索网络或执行代码。

{/* Quick prompts */}
{QUICK_PROMPTS.map(({ icon: Icon, label, prompt }) => ( ))}
)} {activeMessages.map((message, idx) => { // Render UI cards attached to this message // eslint-disable-next-line @typescript-eslint/no-explicit-any const allUi: UIMsgLocal[] = thread.messages.length > 0 ? ((thread.values as any)?.ui ?? []) : historicalUi; const uiItemsRaw = deduplicateUiItems( allUi.filter( (ui: UIMsgLocal) => ui.metadata?.message_id === message.id, ) as UIMsgLocal[] ); // Sort by card type priority first, then by sort_key for deterministic order const CARD_TYPE_PRIORITY: Record = { "error-result": 100, "chart-result": 200, "knowledge-result": 300, "ticket-summary": 400, "ticket-detail": 450, "search-result": 500, "sandbox-result": 600, "canvas-doc": 700, "reply-draft": 750, "next-actions": 900, }; const uiItems = [...uiItemsRaw].sort((a, b) => { const priorityA = CARD_TYPE_PRIORITY[a.name] ?? 500; const priorityB = CARD_TYPE_PRIORITY[b.name] ?? 500; if (priorityA !== priorityB) return priorityA - priorityB; const sa = (a.props?.sort_key as number) ?? 0; const sb = (b.props?.sort_key as number) ?? 0; return sa - sb; }); if (message.type === "human") { const humanText = typeof message.content === "string" ? message.content : Array.isArray(message.content) ? (message.content as any[]).filter((p) => p.type === "text").map((p) => p.text).join("") : ""; return (
{/* Edit button — appears on hover, refills input */} {humanText && !thread.isLoading && ( )}
{Array.isArray(message.content) ? (
{(message.content as any[]).map((part, pi) => { if (part.type === "image_url") { const url = part.image_url?.url ?? part.image_url; return ( 附件图片 ); } if (part.type === "text") { return {part.text}; } return null; })}
) : ( typeof message.content === "string" ? message.content : JSON.stringify(message.content) )}
); } if (message.type === "ai") { const textContent = typeof message.content === "string" ? message.content : Array.isArray(message.content) ? message.content .filter((c) => c.type === "text") .map((c) => ("text" in c ? c.text : "")) .join("") : ""; const toolCalls: { name?: string; id?: string }[] = (message as any).tool_calls ?? []; const isLastAi = idx === lastAiIdx; return (
{/* Text reply — rendered first so user sees conclusion before evidence */} {textContent && (
{thread.isLoading && isLastAi && (
)} {/* Tool call status (with inline expand/collapse for UI cards) */} {toolCalls.length > 0 && ( )} {/* UI cards not matched to any tool call (standalone) */} {(() => { const standaloneUiItems = uiItems.filter((ui) => !toolCalls.some((tc) => { const nameMap: Record = { kb_search: "knowledge-result", ticket_list: "ticket-summary", ticket_detail: "ticket-detail", web_search: "search-result", google_search: "search-result", sandbox_run: "sandbox-result", }; return tc.name && ui.name === nameMap[tc.name]; })); if (standaloneUiItems.length === 0) return null; const cards = standaloneUiItems.map((ui: UIMsgLocal) => (
)); if (standaloneUiItems.length >= 2) { return (
综合分析 · {standaloneUiItems.length} 项结果 {cards}
); } return <>{cards}; })()} {/* Execution log panel */} {!thread.isLoading && ( )} {/* Regenerate button — only on last AI message, only when not loading */} {isLastAi && !thread.isLoading && (
)}
); } return null; })} {/* Loading placeholder: show 3-dot bounce when waiting for first AI tokens after a human message */} {(() => { const lastMsg = activeMessages[activeMessages.length - 1]; const showLoadingDots = thread.isLoading && lastMsg?.type === "human"; return showLoadingDots ? (
) : null; })()} {/* Streaming UI cards not yet attached to a completed message */} {thread.isLoading && deduplicateUiItems( (thread.values?.ui ?? []).filter((ui: UIMsgLocal) => { const attachedToExisting = activeMessages.some( (m) => m.id === ui.metadata?.message_id, ); return !attachedToExisting; }) ) .map((ui: UIMsgLocal) => (
))}
{/* Scroll-to-bottom floating button */} {showScrollBtn && (
)}
{/* Config summary bar + expandable panel */} {(() => { const modelLabel = modelMode === "flash" ? "Flash" : modelMode === "pro" ? "Pro" : "Auto"; const activeToolLabels = TOOL_GROUPS .filter((g) => activeTools.has(g.key)) .map((g) => g.label); const summary = activeToolLabels.length === 0 ? modelLabel : activeToolLabels.length <= 2 ? `${modelLabel} · ${activeToolLabels.join(" + ")}` : `${modelLabel} · ${activeToolLabels.length} 个工具`; return (
{/* Summary bar */} {/* Expanded panel — opens upward */} {configOpen && (
{/* Model mode row */}
模型模式
{MODEL_OPTIONS.map(({ value, label, icon: Icon }) => ( ))}
{/* Tool toggles row */}
工具
{/* Auto chip */} {TOOL_GROUPS.map(({ key, label, icon: Icon }) => { const isOn = activeTools.has(key); return ( ); })}
)}
); })()} {/* Stream status bar */} {concurrentStatus !== "idle" && (
{(concurrentStatus === "generating" || concurrentStatus === "auto-sending") && } {concurrentStatus === "queued" && } {concurrentStatus === "stopping" && } {concurrentStatus === "cancelling" && } {concurrentStatus === "queued" ? ( <> {pendingQueueCount > 1 ? `${pendingQueueCount} 条消息已排队,等待当前回复完成` : "下一条消息已排队,等待当前回复完成"} ) : ( {concurrentStatus === "generating" && "正在生成回复..."} {concurrentStatus === "stopping" && "正在停止上一轮..."} {concurrentStatus === "cancelling" && "等待当前任务停止..."} {concurrentStatus === "auto-sending" && "正在自动发送下一条..."} )}
)} {/* Input */}
{attachedFile && ( setAttachedFile(null)} /> )} {/* Source label: shown when user clicks a card action button */} {sourceLabel && (
来自 {sourceLabel}
)}