mirror of
https://github.com/csunny/DB-GPT.git
synced 2026-07-17 10:16:49 +00:00
Co-authored-by: lusain <lusain1990@gmail.com> Co-authored-by: alan.cl <1165243776@qq.com> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
696 lines
25 KiB
TypeScript
696 lines
25 KiB
TypeScript
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);
|