Files
DB-GPT/web/new-components/chat/content/OpenCodeSessionTurn.tsx
Aries-ckt ef83851b31 🎉 DB-GPT V0.8.0 - Beta Testing (#2988)
Co-authored-by: lusain <lusain1990@gmail.com>
Co-authored-by: alan.cl <1165243776@qq.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 11:55:42 +08:00

696 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>;
output?: string;
error?: string;
metadata?: Record<string, unknown>;
};
}
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<string, string> = {
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, unknown>): 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 (
<Tooltip title={copied ? t('copy_success') : t('copy_to_clipboard')}>
<button
className={classNames(
'flex-shrink-0 p-1.5 rounded-md transition-all duration-200',
'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300',
'hover:bg-gray-100 dark:hover:bg-gray-700',
copied && 'text-green-500 hover:text-green-500',
className,
)}
onClick={handleCopy}
>
{copied ? <CheckOutlined className='text-sm' /> : <CopyOutlined className='text-sm' />}
</button>
</Tooltip>
);
};
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 (
<div className='flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-tr from-[#31afff] to-[#1677ff] text-xs text-white font-medium shadow-sm'>
{user?.nick_name?.charAt(0) || 'U'}
</div>
);
}
return (
<Image
className='rounded-full border border-gray-200 object-contain bg-white inline-block shadow-sm'
width={28}
height={28}
src={user?.avatar_url}
alt={user?.nick_name || 'User'}
/>
);
};
interface ToolPartDisplayProps {
part: ToolPart;
defaultOpen?: boolean;
}
const ToolPartDisplay: React.FC<ToolPartDisplayProps> = ({ 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<string, unknown>).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: <name> - <description>" 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 (
<BasicTool
icon={iconName}
trigger={{
title,
subtitle,
action: isRunning ? (
<Spin size='small' indicator={<LoadingOutlined spin />} />
) : hasError ? (
<span className='text-xs text-red-500'>Error</span>
) : null,
}}
defaultOpen={defaultOpen}
className={classNames({
'border-l-2 border-l-blue-500': isRunning,
'border-l-2 border-l-red-500': hasError,
})}
>
{(hasOutput || hasError) && (
<div className='py-2'>
{hasError && part.state.error && <ErrorDisplay error={part.state.error} toolName={title} className='mb-2' />}
{hasOutput && (
<div className='relative'>
{skillInfo ? (
<div className='rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden bg-white dark:bg-[#1a1b1e]'>
<div className='px-5 py-4'>
<div className='flex items-center gap-2.5 mb-2'>
<div className='flex-shrink-0 w-9 h-9 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 flex items-center justify-center'>
<ToolIcon name='brain' size='medium' className='text-indigo-500' />
</div>
<div className='min-w-0 flex-1'>
<div className='text-sm font-semibold text-gray-800 dark:text-gray-200 truncate'>
{skillInfo.name}
</div>
<div className='text-[11px] text-gray-400 dark:text-gray-500'>Skill</div>
</div>
</div>
{skillInfo.description && (
<p className='text-sm text-gray-600 dark:text-gray-400 leading-relaxed mt-2'>
{skillInfo.description}
</p>
)}
</div>
</div>
) : isReActOutput ? (
<ReActThinking content={normalizeText(part.state.output)} round={roundNumber} compact={true} />
) : (
<div className='group'>
<pre className='text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-words max-h-60 overflow-auto bg-gray-50 dark:bg-gray-800 p-2 rounded'>
{normalizeText(part.state.output)}
</pre>
<div className='absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity'>
<CopyButton text={normalizeText(part.state.output)} />
</div>
</div>
)}
</div>
)}
</div>
)}
</BasicTool>
);
};
// Animated spinner with pulsing effect
const Spinner: React.FC<{ className?: string }> = ({ className }) => (
<div className={classNames('oc-spinner inline-flex items-center justify-center', className)}>
<svg className='animate-spin' viewBox='0 0 20 20' fill='none' width='16' height='16'>
<circle
cx='10'
cy='10'
r='8'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeDasharray='50.265'
strokeDashoffset='25'
className='opacity-25'
/>
<circle
cx='10'
cy='10'
r='8'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeDasharray='50.265'
strokeDashoffset='37.5'
className='opacity-75'
/>
</svg>
</div>
);
// Thinking dots animation
const ThinkingDots: React.FC = () => (
<div className='flex gap-1 items-center'>
<div
className='w-2 h-2 rounded-full bg-blue-500 animate-bounce'
style={{ animationDelay: '0ms', animationDuration: '1s' }}
/>
<div
className='w-2 h-2 rounded-full bg-blue-500 animate-bounce'
style={{ animationDelay: '150ms', animationDuration: '1s' }}
/>
<div
className='w-2 h-2 rounded-full bg-blue-500 animate-bounce'
style={{ animationDelay: '300ms', animationDuration: '1s' }}
/>
</div>
);
// Pulse animation for working state
const PulseIndicator: React.FC = () => (
<span className='relative flex h-2 w-2'>
<span className='animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75'></span>
<span className='relative inline-flex rounded-full h-2 w-2 bg-blue-500'></span>
</span>
);
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 <FileExcelOutlined className='text-green-600 text-lg' />;
}
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext) || mimeType?.includes('image')) {
return <FileImageOutlined className='text-pink-500 text-lg' />;
}
if (['ppt', 'pptx'].includes(ext)) {
return <FilePptOutlined className='text-orange-500 text-lg' />;
}
return <FileTextOutlined className='text-blue-500 text-lg' />;
};
const FileAttachmentCard: React.FC<{ file: FileAttachment }> = ({ file }) => {
const fileTypeLabel = getFileTypeLabel(file.name, file.type);
const formattedSize = formatFileSize(file.size);
return (
<div className='inline-flex items-center gap-3 px-3 py-2 bg-white dark:bg-[#1f2024] border border-gray-200 dark:border-gray-700 rounded-xl mb-2 max-w-sm shadow-sm'>
<div className='w-9 h-9 bg-green-50 dark:bg-green-900/30 rounded-lg flex items-center justify-center flex-shrink-0'>
<FileIconComponent fileName={file.name} mimeType={file.type} />
</div>
<div className='min-w-0 flex-1'>
<div className='font-medium text-sm text-gray-800 dark:text-gray-200 truncate'>{file.name}</div>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{fileTypeLabel} · {formattedSize}
</div>
</div>
</div>
);
};
const OpenCodeSessionTurn: React.FC<OpenCodeSessionTurnProps> = ({
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(/<table(\w*=[^>]+)>/gi, '<table $1>').replace(/<tr(\w*=[^>]+)>/gi, '<tr $1>');
};
const getStepsButtonText = () => {
if (isWorking) {
return (
<span className='flex items-center gap-2'>
<PulseIndicator />
<span>{displayStatus}</span>
</span>
);
}
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 ? (
<div data-slot='steps-section' className='flex flex-col'>
{thinkingContent && (
<div
data-slot='thinking-content'
className='mb-3 px-4 py-3 rounded-xl bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 border border-amber-200/50 dark:border-amber-800/50'
>
<div className='flex items-center gap-2 text-amber-600 dark:text-amber-400 text-xs font-medium mb-2'>
<Spinner className='text-amber-500' />
<span>{t('thinking')}</span>
</div>
<div className='text-sm text-amber-800 dark:text-amber-200 whitespace-pre-wrap leading-relaxed'>
{thinkingContent}
</div>
</div>
)}
{showSteps && (isWorking || hasSteps) && (
<div className='flex flex-col'>
<button
data-slot='steps-trigger'
className={classNames(
'oc-steps-trigger',
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all duration-200',
'bg-gray-50 hover:bg-gray-100 dark:bg-gray-800/60 dark:hover:bg-gray-700/60',
'text-gray-600 dark:text-gray-400',
'text-left w-auto inline-flex',
'border border-transparent',
isWorking && 'border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/20',
)}
onClick={() => setStepsExpanded(!stepsExpanded)}
>
{isWorking && <Spinner className='text-blue-500' />}
<span className='flex-1 text-left truncate max-w-md'>{getStepsButtonText()}</span>
{duration && (
<>
<span className='text-gray-400'>·</span>
<span className='text-gray-400 tabular-nums'>{duration}</span>
</>
)}
{hasSteps && !isWorking && (
<ToolIcon
name={stepsExpanded ? 'chevron-down' : 'chevron-right'}
size='small'
className='text-gray-400 transition-transform duration-200'
/>
)}
</button>
{stepsExpanded && hasSteps && (
<div
data-slot='steps-content'
className='mt-2 flex flex-col gap-1.5 pl-3 border-l-2 border-gray-200 dark:border-gray-700 animate-fadeIn'
>
{toolParts.map((part, index) => (
<ToolPartDisplay
key={part.id}
part={part}
defaultOpen={
part.state.status === 'error' || (index === toolParts.length - 1 && part.state.status === 'running')
}
/>
))}
</div>
)}
</div>
)}
</div>
) : null;
return (
<div data-component='opencode-session-turn' className={classNames('oc-session-turn', className)}>
<div data-slot='session-turn-content' className='flex flex-col gap-4 py-4'>
<div data-slot='user-message' className='flex gap-3 group'>
<div className='flex-shrink-0 mt-0.5'>
<UserIcon />
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-start justify-between'>
<div className='flex-1 min-w-0'>
{attachedFile && <FileAttachmentCard file={attachedFile} />}
<div className='text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words leading-relaxed'>
{userMessage}
</div>
{startTime && <div className='mt-1 text-xs text-gray-400'>{formatTimestamp(startTime)}</div>}
</div>
<CopyButton text={userMessage} className='opacity-0 group-hover:opacity-100 ml-2' />
</div>
</div>
</div>
{(isWorking || assistantMessage || hasSteps || thinkingContent) && (
<>
<div data-slot='assistant-section' className='flex gap-3'>
<div className='flex-shrink-0 mt-0.5'>
<RobotIcon model={modelName || ''} />
</div>
<div className='flex-1 min-w-0 flex flex-col gap-2'>
{stepsPlacement === 'inside' && stepsBlock}
{assistantMessage && (
<div
data-slot='assistant-response'
className='group relative bg-white dark:bg-[rgba(255,255,255,0.08)] p-4 rounded-2xl rounded-tl-none shadow-sm border border-gray-100 dark:border-gray-800'
>
<div className='prose prose-sm dark:prose-invert max-w-none'>
<GPTVis components={markdownComponents as any} {...(markdownPlugins as any)}>
{preprocessLaTeX(formatMarkdownVal(assistantMessage))}
</GPTVis>
</div>
{endTime && (
<div className='mt-2 pt-2 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between'>
<span className='text-xs text-gray-400'>
{formatTimestamp(endTime)}
{duration && <span className='ml-2'>· {duration}</span>}
</span>
<CopyButton text={assistantMessage} className='opacity-0 group-hover:opacity-100' />
</div>
)}
{!endTime && (
<div className='absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity'>
<CopyButton text={assistantMessage} />
</div>
)}
</div>
)}
{isWorking && !assistantMessage && !thinkingContent && (
<div
data-slot='loading-placeholder'
className='bg-white dark:bg-[rgba(255,255,255,0.08)] p-4 rounded-2xl rounded-tl-none border border-gray-100 dark:border-gray-800'
>
<div className='flex items-center gap-3'>
<ThinkingDots />
<span className='text-sm text-gray-500 dark:text-gray-400'>{displayStatus || t('thinking')}</span>
</div>
</div>
)}
</div>
</div>
{stepsPlacement === 'outside' && stepsBlock && (
<div data-slot='assistant-steps-outside' className='flex gap-3'>
<div className='flex-shrink-0 mt-0.5 w-7' />
<div className='flex-1 min-w-0'>{stepsBlock}</div>
</div>
)}
</>
)}
</div>
</div>
);
};
export default memo(OpenCodeSessionTurn);