import React, { useMemo, useState, useEffect } from 'react'; import { Collapse, Tag } from 'antd'; import { CheckCircle, Loader, XCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { NodeComponentProps } from 'markstream-react'; const MCP_NODE_TEXT_CHAR_LIMIT = 20_000; const MCP_NODE_TRUNCATED_NOTICE = 'MCP output truncated for rendering'; function safeGetAttr(attrs: any, key: string): string | undefined { if (!attrs) return undefined; if (Array.isArray(attrs)) { for (const item of attrs) { if (Array.isArray(item)) { const [k, v] = item; if (k === key || k === `data-${key}`) { if (v == null) return undefined; return typeof v === 'object' ? JSON.stringify(v) : String(v); } } else if (item && typeof item === 'object' && 'name' in item) { if (item.name === key || item.name === `data-${key}`) { const v = item.value; if (v == null) return undefined; return typeof v === 'object' ? JSON.stringify(v) : String(v); } } } return undefined; } if (typeof attrs === 'object') { for (const k of [key, `data-${key}`]) { const v = attrs[k]; if (v == null) continue; return typeof v === 'object' ? JSON.stringify(v) : String(v); } } return undefined; } function appendBoundedText(current: string, next: string, limit: number): { text: string; truncated: boolean } { if (!next) return { text: current, truncated: false }; let text = current; let remaining = limit - Array.from(current).length; if (remaining <= 0) { return { text, truncated: true }; } for (const char of next) { if (remaining <= 0) { return { text, truncated: true }; } text += char; remaining -= 1; } return { text, truncated: false }; } function extractText(children: any[] | undefined, limit = MCP_NODE_TEXT_CHAR_LIMIT): { text: string; truncated: boolean } { if (!children || children.length === 0) return { text: '', truncated: false }; let text = ''; for (const child of children) { let next = ''; if (typeof child === 'string') { next = child; } else if (child?.content != null) { next = typeof child.content === 'object' ? JSON.stringify(child.content) : String(child.content); } else if (child?.children) { const nested = extractText(child.children, limit - Array.from(text).length); text += nested.text; if (nested.truncated) { return { text, truncated: true }; } continue; } const appended = appendBoundedText(text, next, limit); text = appended.text; if (appended.truncated) { return { text, truncated: true }; } } return { text, truncated: false }; } export function McpContainerNode(props: NodeComponentProps) { const { node, ctx, renderNode, indexKey } = props; if (node.name !== 'mcp') { return (
{Array.isArray(node.children) && ctx && renderNode ? node.children.map((child: any, i: number) => ( {renderNode(child, `${String(indexKey ?? 'vmr-container')}-${i}`, ctx)} )) : null}
); } return ; } const monoStyle: React.CSSProperties = { display: 'block', fontSize: 12, maxHeight: 300, overflow: 'auto', whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', padding: '4px 0', }; function McpToolCard({ node }: { node: any }) { const { t } = useTranslation(); const serverName = safeGetAttr(node.attrs, 'name') ?? 'MCP'; const toolName = safeGetAttr(node.attrs, 'tool') ?? 'unknown'; const rawArgs = safeGetAttr(node.attrs, 'arguments'); const isLoading = Boolean(node.loading); const [activeKey, setActiveKey] = useState(isLoading ? ['1'] : []); useEffect(() => { if (isLoading) { setActiveKey(['1']); } else { setActiveKey([]); } }, [isLoading]); const resultText = useMemo(() => { if (isLoading) return ''; const result = extractText(node.children); if (!result.truncated) return result.text; return `${result.text}\n\n[${MCP_NODE_TRUNCATED_NOTICE}]`; }, [isLoading, node.children]); const isError = useMemo(() => { if (isLoading) return false; const trimmed = resultText.trim(); return ( trimmed.startsWith('Error:') || trimmed.startsWith('Error executing tool:') || trimmed.startsWith('错误') ); }, [isLoading, resultText]); const status = isLoading ? 'running' : isError ? 'error' : 'success'; const statusIcon = useMemo(() => { if (status === 'running') return ; if (status === 'error') return ; return ; }, [status]); const statusColor = status === 'running' ? 'processing' : status === 'error' ? 'error' : 'success'; const statusLabel = status === 'running' ? t('chat.tool.running') : status === 'error' ? t('chat.tool.error') : t('chat.tool.success'); const decodedArgs = useMemo(() => { if (!rawArgs) return null; try { const decoded = rawArgs.includes('%') ? decodeURIComponent(rawArgs) : rawArgs; return JSON.stringify(JSON.parse(decoded), null, 2); } catch { return rawArgs; } }, [rawArgs]); const headerLabel = ( {statusLabel} {serverName} {toolName} ); const hasContent = Boolean(decodedArgs) || Boolean(!isLoading && resultText); return ( setActiveKey(keys as string[])} items={hasContent ? [{ key: '1', label: headerLabel, children: ( <> {decodedArgs && (
{t('chat.tool.input')}
{decodedArgs}
)} {!isLoading && resultText && (
{t('chat.tool.output')}
{resultText}
)} ), }] : [{ key: '1', label: headerLabel, children: null, }]} />
); }