import markdownComponents, { markdownPlugins, preprocessLaTeX } from '@/components/chat/chat-content/config'; import { STORAGE_USERINFO_KEY } from '@/utils/constants/index'; import { CheckOutlined, CopyOutlined, LoadingOutlined } from '@ant-design/icons'; import { GPTVis } from '@antv/gpt-vis'; import { Spin, Tooltip, message } from 'antd'; import classNames from 'classnames'; import Image from 'next/image'; import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ToolIcon, getStatusText, getToolIconName } from '../icons/ToolIcon'; import { BasicTool } from '../tools/BasicTool'; import { ErrorDisplay, ReActThinking } from './ReActThinking'; import RobotIcon from './RobotIcon'; import { FileExcelOutlined, FileImageOutlined, FilePptOutlined, FileTextOutlined } from '@ant-design/icons'; export type ToolStatus = 'pending' | 'running' | 'completed' | 'error'; export interface ToolPart { id: string; type: 'tool'; tool: string; callID?: string; state: { status: ToolStatus; input?: Record; output?: string; error?: string; metadata?: Record; }; } export interface TextPart { id: string; type: 'text'; text: string; synthetic?: boolean; } export interface ReasoningPart { id: string; type: 'reasoning'; text: string; } export type MessagePart = ToolPart | TextPart | ReasoningPart; export interface FileAttachment { name: string; size: number; type: string; } export interface OpenCodeSessionTurnProps { userMessage: string; assistantMessage?: string; parts?: MessagePart[]; isWorking?: boolean; startTime?: number; endTime?: number; onCopy?: (text: string) => void; showSteps?: boolean; defaultStepsExpanded?: boolean; modelName?: string; thinkingContent?: string; currentStatus?: string; stepsPlacement?: 'inside' | 'outside'; className?: string; attachedFile?: FileAttachment; } function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; } function formatTimestamp(timestamp: number): string { const date = new Date(timestamp); const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); return `${hours}:${minutes}`; } function getFilename(path: string | undefined): string { if (!path) return ''; const parts = path.split('/'); return parts[parts.length - 1] || path; } function getToolTitle(tool: string): string { const titleMap: Record = { read: 'Read File', list: 'List Directory', glob: 'Find Files', grep: 'Search Content', bash: 'Run Command', edit: 'Edit File', write: 'Write File', task: 'Delegate Task', todowrite: 'Update Todo', todoread: 'Read Todo', webfetch: 'Fetch Web', question: 'Ask Question', apply_patch: 'Apply Patch', skill: 'Load Skill', }; return titleMap[tool] || tool; } function getToolSubtitle(tool: string, input?: Record): string | undefined { if (!input) return undefined; switch (tool) { case 'read': case 'edit': case 'write': return input.filePath ? getFilename(input.filePath as string) : undefined; case 'glob': return input.pattern as string | undefined; case 'grep': return input.pattern as string | undefined; case 'bash': return input.description as string | undefined; case 'task': return input.description as string | undefined; case 'webfetch': return input.url as string | undefined; case 'list': return input.path ? getFilename(input.path as string) : undefined; default: // Try common input keys return (input.value || input.name || input.query) as string | undefined; } } // Copy button with success state const CopyButton: React.FC<{ text: string; className?: string }> = ({ text, className }) => { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const handleCopy = useCallback( async (e: React.MouseEvent) => { e.stopPropagation(); try { await navigator.clipboard.writeText(text); setCopied(true); message.success(t('copy_to_clipboard_success')); setTimeout(() => setCopied(false), 2000); } catch { message.error(t('copy_to_clipboard_failed')); } }, [text, t], ); return ( ); }; const UserIcon: React.FC = () => { const userStr = typeof window !== 'undefined' ? localStorage.getItem(STORAGE_USERINFO_KEY) : null; const user = userStr ? JSON.parse(userStr) : {}; if (!user.avatar_url) { return (
{user?.nick_name?.charAt(0) || 'U'}
); } return ( {user?.nick_name ); }; interface ToolPartDisplayProps { part: ToolPart; defaultOpen?: boolean; } const ToolPartDisplay: React.FC = ({ part, defaultOpen = false }) => { const iconName = getToolIconName(part.tool); const title = getToolTitle(part.tool); const subtitle = getToolSubtitle(part.tool, part.state.input); const isRunning = part.state.status === 'running'; const hasError = part.state.status === 'error'; const hasOutput = !!part.state.output; // Check if output contains ReAct format (Thought/Action/Observation) const isReActOutput = useMemo(() => { if (!part.state.output) return false; const output = part.state.output; return /(?:Thought|Action|Observation|思考|动作|观察)\s*[::]/i.test(output); }, [part.state.output]); // Extract round number from title if present (e.g., "Delegate Task ReAct Round 1") const roundNumber = useMemo(() => { const match = title.match(/Round\s*(\d+)/i); return match ? parseInt(match[1], 10) : undefined; }, [title]); // Parse skill output into name and description for card display const normalizeText = (value: unknown): string => { if (typeof value === 'string') return value; if (value && typeof value === 'object') { const todoValue = (value as Record).TODO; if (typeof todoValue === 'string') return todoValue; try { return JSON.stringify(value); } catch { return String(value); } } return value == null ? '' : String(value); }; const skillInfo = useMemo(() => { if (part.tool !== 'skill' || !part.state.output) return null; const output = normalizeText(part.state.output); // Try "Skill: - " format on first line const firstLine = output.split('\n')[0]; const match = firstLine.match(/^Skill:\s*(.+?)\s+-\s+(.+)$/); if (match) { return { name: match[1].trim(), description: match[2].trim() }; } // Try frontmatter format const fmMatch = output.match(/^---\n([\s\S]*?)\n---/); if (fmMatch) { const nameMatch = fmMatch[1].match(/^name:\s*(.+)$/m); const descMatch = fmMatch[1].match(/^description:\s*(.+)$/m); if (nameMatch) { return { name: nameMatch[1].trim(), description: descMatch ? descMatch[1].trim() : '', }; } } // Fallback: first heading + first paragraph const headingMatch = output.match(/^#\s+(.+)$/m); const paraMatch = output.match(/^(?!#|---|\s*$)(.+)/m); if (headingMatch) { return { name: headingMatch[1].trim(), description: paraMatch ? paraMatch[1].trim() : '', }; } return null; }, [part.tool, part.state.output]); return ( } /> ) : hasError ? ( Error ) : null, }} defaultOpen={defaultOpen} className={classNames({ 'border-l-2 border-l-blue-500': isRunning, 'border-l-2 border-l-red-500': hasError, })} > {(hasOutput || hasError) && (
{hasError && part.state.error && } {hasOutput && (
{skillInfo ? (
{skillInfo.name}
Skill
{skillInfo.description && (

{skillInfo.description}

)}
) : isReActOutput ? ( ) : (
                    {normalizeText(part.state.output)}
                  
)}
)}
)}
); }; // Animated spinner with pulsing effect const Spinner: React.FC<{ className?: string }> = ({ className }) => (
); // Thinking dots animation const ThinkingDots: React.FC = () => (
); // Pulse animation for working state const PulseIndicator: React.FC = () => ( ); const formatFileSize = (bytes: number): string => { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; }; const getFileTypeLabel = (fileName: string, mimeType?: string): string => { const ext = fileName.toLowerCase().split('.').pop() || ''; if (['xlsx', 'xls'].includes(ext) || mimeType?.includes('spreadsheet') || mimeType?.includes('excel')) { return '电子表格'; } if (ext === 'csv' || mimeType?.includes('csv')) { return '电子表格'; } if (ext === 'pdf' || mimeType?.includes('pdf')) return 'PDF'; if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext) || mimeType?.includes('image')) return '图片'; if (['doc', 'docx'].includes(ext) || mimeType?.includes('word')) return 'Word 文档'; if (['txt', 'md'].includes(ext) || mimeType?.includes('text')) return '文本文件'; if (['json'].includes(ext)) return 'JSON'; return '文件'; }; const FileIconComponent: React.FC<{ fileName: string; mimeType?: string }> = ({ fileName, mimeType }) => { const ext = fileName.toLowerCase().split('.').pop() || ''; if ( ['xlsx', 'xls', 'csv'].includes(ext) || mimeType?.includes('spreadsheet') || mimeType?.includes('excel') || mimeType?.includes('csv') ) { return ; } if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext) || mimeType?.includes('image')) { return ; } if (['ppt', 'pptx'].includes(ext)) { return ; } return ; }; const FileAttachmentCard: React.FC<{ file: FileAttachment }> = ({ file }) => { const fileTypeLabel = getFileTypeLabel(file.name, file.type); const formattedSize = formatFileSize(file.size); return (
{file.name}
{fileTypeLabel} · {formattedSize}
); }; const OpenCodeSessionTurn: React.FC = ({ userMessage, assistantMessage, parts = [], isWorking = false, startTime, endTime, onCopy, showSteps = true, defaultStepsExpanded = false, modelName, thinkingContent, currentStatus, stepsPlacement = 'inside', className, attachedFile, }) => { const { t } = useTranslation(); const [stepsExpanded, setStepsExpanded] = useState(defaultStepsExpanded); const [elapsedTime, setElapsedTime] = useState(0); useEffect(() => { if (!isWorking || !startTime) return; const updateElapsed = () => { setElapsedTime(Date.now() - startTime); }; updateElapsed(); const timer = setInterval(updateElapsed, 1000); return () => clearInterval(timer); }, [isWorking, startTime]); const duration = useMemo(() => { if (endTime && startTime) { return formatDuration(endTime - startTime); } if (isWorking && startTime) { return formatDuration(elapsedTime); } return null; }, [startTime, endTime, isWorking, elapsedTime]); const toolParts = useMemo(() => { return parts.filter((p): p is ToolPart => { if (p.type !== 'tool') return false; const title = getToolTitle(p.tool); if (title.match(/^ReAct Round \d+$/i)) return false; const action = (p.state.metadata?.action as string) ?? ''; if (action.toLowerCase() === 'terminate') return false; return true; }); }, [parts]); const hasSteps = toolParts.length > 0; const displayStatus = useMemo(() => { if (currentStatus) return currentStatus; if (!isWorking) return null; const runningPart = toolParts.find(p => p.state.status === 'running'); if (runningPart) { return getStatusText(runningPart.tool); } return t('thinking') || 'Thinking...'; }, [isWorking, toolParts, currentStatus, t]); const formatMarkdownVal = (val: string) => { return val.replace(/]+)>/gi, '').replace(/]+)>/gi, ''); }; const getStepsButtonText = () => { if (isWorking) { return ( {displayStatus} ); } if (stepsExpanded) return `Hide ${toolParts.length} step${toolParts.length > 1 ? 's' : ''}`; return `Show ${toolParts.length} step${toolParts.length > 1 ? 's' : ''}`; }; const stepsAndThinking = (showSteps && (isWorking || hasSteps)) || thinkingContent; const stepsBlock = stepsAndThinking ? (
{thinkingContent && (
{t('thinking')}
{thinkingContent}
)} {showSteps && (isWorking || hasSteps) && (
{stepsExpanded && hasSteps && (
{toolParts.map((part, index) => ( ))}
)}
)}
) : null; return (
{attachedFile && }
{userMessage}
{startTime &&
{formatTimestamp(startTime)}
}
{(isWorking || assistantMessage || hasSteps || thinkingContent) && ( <>
{stepsPlacement === 'inside' && stepsBlock} {assistantMessage && (
{preprocessLaTeX(formatMarkdownVal(assistantMessage))}
{endTime && (
{formatTimestamp(endTime)} {duration && · {duration}}
)} {!endTime && (
)}
)} {isWorking && !assistantMessage && !thinkingContent && (
{displayStatus || t('thinking')}
)}
{stepsPlacement === 'outside' && stepsBlock && (
{stepsBlock}
)} )}
); }; export default memo(OpenCodeSessionTurn);