后端: - 飞书通道 HTTP client 增加 connect/全局超时,token 刷新与 reaction 叠加紧超时,消除无限挂起风险 - SQLite WAL 下设置 synchronous=NORMAL(per-connection with_init),6 处批量写入循环改用 prepare_cached 复用预编译语句 - web_fetch 复用长生命周期 HTTP client,响应体改为流式限长读取(上限=字符限额x4,封顶 32MB),替代全量读完再截断 前端: - App.tsx effect/callback 依赖由 subAgentView 对象改为 subAgentTaskId 原始值,修复子代理流式期间 token 统计 500ms 定时器被每帧重置永不触发的问题 - MessageBubble/ToolDetailModal 的 ReactMarkdown components 与 remarkPlugins 提升为模块级常量,避免流式渲染期间每帧重建
968 lines
35 KiB
TypeScript
968 lines
35 KiB
TypeScript
import { useState, useEffect, memo } from 'react';
|
||
import {
|
||
CheckCircle,
|
||
ChevronRight,
|
||
ChevronDown,
|
||
Clock,
|
||
Loader,
|
||
Loader2,
|
||
XCircle,
|
||
Copy,
|
||
Check,
|
||
Brain,
|
||
Maximize2,
|
||
Download,
|
||
File,
|
||
FileText,
|
||
Image,
|
||
Music,
|
||
Video,
|
||
X,
|
||
} from 'lucide-react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import type { Components } from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol';
|
||
import { ToolDetailModal } from './ToolDetailModal';
|
||
|
||
// 模块级常量:保持引用稳定,避免流式渲染期间每帧重建
|
||
// (react-markdown 收到新的 components/plugins 引用会重走内部映射与解析)。
|
||
const REMARK_PLUGINS = [remarkGfm];
|
||
|
||
const MARKDOWN_COMPONENTS: Components = {
|
||
// 自定义代码块渲染
|
||
code({ className, children, ...props }) {
|
||
const isInline = !className;
|
||
if (isInline) {
|
||
return (
|
||
<code
|
||
className="bg-[var(--overlay-code)] px-1.5 py-0.5 rounded text-[var(--accent-cyan)] font-mono text-xs"
|
||
{...props}
|
||
>
|
||
{children}
|
||
</code>
|
||
);
|
||
}
|
||
return (
|
||
<pre className="bg-[var(--overlay-dim-heavy)] rounded-lg p-3 overflow-x-auto my-2">
|
||
<code className={`${className} font-mono text-xs`} {...props}>
|
||
{children}
|
||
</code>
|
||
</pre>
|
||
);
|
||
},
|
||
// 标题样式
|
||
h1: ({ children }) => (
|
||
<h1 className="text-xl font-bold text-[var(--text-primary)] mb-2 mt-4">{children}</h1>
|
||
),
|
||
h2: ({ children }) => (
|
||
<h2 className="text-lg font-bold text-[var(--text-primary)] mb-2 mt-3">{children}</h2>
|
||
),
|
||
h3: ({ children }) => (
|
||
<h3 className="text-base font-bold text-[var(--text-primary)] mb-1 mt-2">{children}</h3>
|
||
),
|
||
// 段落
|
||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||
// 列表
|
||
ul: ({ children }) => (
|
||
<ul className="list-disc list-outside mb-2 space-y-1 pl-5">{children}</ul>
|
||
),
|
||
ol: ({ children }) => (
|
||
<ol className="list-decimal list-outside mb-2 space-y-1 pl-5">{children}</ol>
|
||
),
|
||
li: ({ children }) => <li className="[&>p]:m-0">{children}</li>,
|
||
// 链接
|
||
a: ({ href, children }) => (
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-[var(--accent-cyan)] hover:underline"
|
||
>
|
||
{children}
|
||
</a>
|
||
),
|
||
// 表格
|
||
table: ({ children }) => (
|
||
<table className="w-full border-collapse mb-2 text-xs">{children}</table>
|
||
),
|
||
thead: ({ children }) => <thead className="bg-[var(--overlay-subtle)]">{children}</thead>,
|
||
th: ({ children }) => (
|
||
<th className="border border-[var(--border-color)] px-2 py-1 text-left font-semibold">
|
||
{children}
|
||
</th>
|
||
),
|
||
td: ({ children }) => (
|
||
<td className="border border-[var(--border-color)] px-2 py-1">{children}</td>
|
||
),
|
||
// 引用块
|
||
blockquote: ({ children }) => (
|
||
<blockquote className="border-l-2 border-[var(--accent-cyan)]/50 pl-3 my-2 text-[var(--text-secondary)]">
|
||
{children}
|
||
</blockquote>
|
||
),
|
||
// 分隔线
|
||
hr: () => <hr className="border-[var(--border-color)] my-3" />,
|
||
// 加粗和斜体
|
||
strong: ({ children }) => (
|
||
<strong className="font-bold text-[var(--text-primary)]">{children}</strong>
|
||
),
|
||
em: ({ children }) => <em className="italic text-[var(--text-secondary)]">{children}</em>,
|
||
};
|
||
|
||
// 状态图标组件
|
||
function StatusIcon({
|
||
status,
|
||
size = 14,
|
||
}: {
|
||
status: string;
|
||
size?: number;
|
||
}) {
|
||
const iconClass = `transition-all duration-300`;
|
||
|
||
switch (status) {
|
||
case 'calling':
|
||
case 'running':
|
||
return (
|
||
<Loader2
|
||
className={`${iconClass} animate-spin`}
|
||
style={{ width: size, height: size }}
|
||
strokeWidth={2.5}
|
||
/>
|
||
);
|
||
case 'result':
|
||
case 'success':
|
||
case 'completed':
|
||
return (
|
||
<CheckCircle
|
||
className={`${iconClass} animate-scale-in`}
|
||
style={{ width: size, height: size }}
|
||
strokeWidth={2.5}
|
||
/>
|
||
);
|
||
case 'failed':
|
||
return (
|
||
<XCircle
|
||
className={`${iconClass} animate-scale-in`}
|
||
style={{ width: size, height: size }}
|
||
strokeWidth={2.5}
|
||
/>
|
||
);
|
||
case 'timeout':
|
||
return (
|
||
<Clock
|
||
className={`${iconClass} animate-scale-in`}
|
||
style={{ width: size, height: size }}
|
||
strokeWidth={2.5}
|
||
/>
|
||
);
|
||
case 'pending':
|
||
case 'interrupted':
|
||
case 'cancelled':
|
||
return (
|
||
<Loader
|
||
className={`${iconClass} animate-spin`}
|
||
style={{ width: size, height: size }}
|
||
strokeWidth={2.5}
|
||
/>
|
||
);
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
interface MessageBubbleProps {
|
||
message: ChatMessage;
|
||
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void;
|
||
showThinking?: boolean;
|
||
/** 主代理当前生效模型(Task 卡片差异显示:子代理模型不同才展示模型徽章) */
|
||
effectiveModel?: { provider: string; model: string } | null;
|
||
}
|
||
|
||
function getAttachmentIcon(mediaType: string) {
|
||
switch (mediaType) {
|
||
case 'image':
|
||
return <Image className="h-4 w-4" />;
|
||
case 'audio':
|
||
return <Music className="h-4 w-4" />;
|
||
case 'video':
|
||
return <Video className="h-4 w-4" />;
|
||
case 'file':
|
||
return <FileText className="h-4 w-4" />;
|
||
default:
|
||
return <File className="h-4 w-4" />;
|
||
}
|
||
}
|
||
|
||
function getFileName(path: string): string {
|
||
const parts = path.replace(/\\/g, '/').split('/');
|
||
return parts[parts.length - 1] || path;
|
||
}
|
||
|
||
function formatTime(timestamp: number) {
|
||
return new Date(timestamp * 1000).toLocaleTimeString('zh-CN', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function formatDuration(ms: number): string {
|
||
if (ms < 1000) {
|
||
return `${ms}ms`;
|
||
}
|
||
if (ms < 60000) {
|
||
return `${(ms / 1000).toFixed(1)}s`;
|
||
}
|
||
const minutes = Math.floor(ms / 60000);
|
||
const seconds = Math.floor((ms % 60000) / 1000);
|
||
return `${minutes}m ${seconds}s`;
|
||
}
|
||
|
||
/**
|
||
* base64 → Blob 分块解码。
|
||
* 旧实现为 atob 全量字符串 + 装箱数字数组 + 单个巨型 Uint8Array,
|
||
* 50MB 附件下载瞬时占用数百 MB;分块构造后 Blob 直接接收分片,
|
||
* 峰值内存约为 atob 字符串 + 单个分块大小。
|
||
*/
|
||
function base64ToBlob(base64: string, mimeType: string): Blob {
|
||
const byteChars = atob(base64);
|
||
const total = byteChars.length;
|
||
const CHUNK_SIZE = 0x8000; // 32K
|
||
const parts: Uint8Array<ArrayBuffer>[] = [];
|
||
for (let offset = 0; offset < total; offset += CHUNK_SIZE) {
|
||
const slice = byteChars.slice(offset, offset + CHUNK_SIZE);
|
||
parts.push(Uint8Array.from(slice, (c) => c.charCodeAt(0)));
|
||
}
|
||
return new Blob(parts, { type: mimeType });
|
||
}
|
||
|
||
function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
||
const fileName = attachment.file_name || getFileName(attachment.path);
|
||
|
||
const handleDownload = (e: React.MouseEvent) => {
|
||
if (!attachment.content_base64) return;
|
||
|
||
e.preventDefault();
|
||
const mimeType = attachment.mime_type || 'application/octet-stream';
|
||
const blob = base64ToBlob(attachment.content_base64, mimeType);
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = fileName;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const canDownload = !!attachment.content_base64;
|
||
|
||
return (
|
||
<div
|
||
onClick={canDownload ? handleDownload : undefined}
|
||
className={`flex items-center gap-2 rounded-lg bg-[var(--overlay-hover)] border border-[var(--border-color)] px-3 py-2 text-xs transition-colors group ${
|
||
canDownload
|
||
? 'hover:bg-[var(--overlay-subtle)] hover:border-[var(--accent-cyan)]/30 cursor-pointer'
|
||
: ''
|
||
}`}
|
||
title={canDownload ? `下载 ${fileName}` : attachment.path}
|
||
>
|
||
<span
|
||
className={`text-[var(--text-secondary)] transition-colors ${canDownload ? 'group-hover:text-[var(--accent-cyan)]' : ''}`}
|
||
>
|
||
{getAttachmentIcon(attachment.media_type)}
|
||
</span>
|
||
<span
|
||
className={`text-[var(--text-secondary)] truncate max-w-[200px] transition-colors ${canDownload ? 'group-hover:text-[var(--text-primary)]' : ''}`}
|
||
title={attachment.path}
|
||
>
|
||
{fileName}
|
||
</span>
|
||
<span className="text-[var(--text-muted)] ml-auto shrink-0">{attachment.media_type}</span>
|
||
{canDownload && (
|
||
<Download className="h-3 w-3 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)] transition-colors" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ImageLightbox({
|
||
src,
|
||
mimeType,
|
||
fileName,
|
||
onClose,
|
||
}: {
|
||
src: string;
|
||
mimeType: string;
|
||
fileName?: string;
|
||
onClose: () => void;
|
||
}) {
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
document.addEventListener('keydown', handleKeyDown);
|
||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||
}, [onClose]);
|
||
|
||
const handleDownload = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
const blob = base64ToBlob(src, mimeType);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = fileName || `image.${mimeType.split('/')[1] || 'png'}`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm animate-fade-in"
|
||
onClick={onClose}
|
||
>
|
||
{/* 顶部工具栏 */}
|
||
<div className="absolute top-4 right-4 flex items-center gap-2 z-10">
|
||
<button
|
||
onClick={handleDownload}
|
||
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white transition-colors"
|
||
aria-label="下载图片"
|
||
title="下载图片"
|
||
>
|
||
<Download className="h-5 w-5" />
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white transition-colors"
|
||
aria-label="关闭"
|
||
title="关闭"
|
||
>
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
<img
|
||
src={`data:${mimeType};base64,${src}`}
|
||
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
alt="图片预览"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CopyButton({ text, className = '' }: { text: string; className?: string }) {
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const handleCopy = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
navigator.clipboard
|
||
.writeText(text)
|
||
.then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1500);
|
||
})
|
||
.catch(() => {
|
||
// fallback silently
|
||
});
|
||
};
|
||
|
||
if (!text) return null;
|
||
|
||
return (
|
||
<button
|
||
onClick={handleCopy}
|
||
className={`opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-[var(--overlay-subtle)] flex-shrink-0 ${className}`}
|
||
title="复制"
|
||
>
|
||
{copied ? (
|
||
<Check className="h-3 w-3 text-[var(--accent-green)]" />
|
||
) : (
|
||
<Copy className="h-3 w-3 text-[var(--text-muted)]" />
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function ThinkingSection({ content }: { content: string }) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
return (
|
||
<div className="rounded-lg border border-[var(--border-color)] bg-[var(--overlay-hover)] overflow-hidden">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setExpanded(!expanded);
|
||
}}
|
||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-subtle)] transition-colors cursor-pointer select-none"
|
||
>
|
||
<Brain className="h-3.5 w-3.5 text-[var(--accent-purple)] flex-shrink-0" />
|
||
<span className="font-medium text-[var(--accent-purple)]">Thinking</span>
|
||
{expanded ? (
|
||
<ChevronDown className="h-3 w-3 ml-auto flex-shrink-0" />
|
||
) : (
|
||
<ChevronRight className="h-3 w-3 ml-auto flex-shrink-0" />
|
||
)}
|
||
{!expanded && (
|
||
<span className="text-[var(--text-muted)] truncate flex-1 text-left">
|
||
{content.slice(0, 60)}
|
||
{content.length > 60 ? '…' : ''}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{expanded && (
|
||
<div className="px-3 py-2 border-t border-[var(--border-color)] animate-thinking-reveal">
|
||
<div className="text-xs text-[var(--text-secondary)] whitespace-pre-wrap leading-relaxed max-h-64 overflow-y-auto scrollbar-thin">
|
||
{content}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function parseTaskResult(content: string): TaskToolResult | null {
|
||
if (!content) return null;
|
||
try {
|
||
const parsed = JSON.parse(content);
|
||
if (
|
||
parsed &&
|
||
typeof parsed.status === 'string' &&
|
||
typeof parsed.output === 'string' &&
|
||
typeof parsed.summary === 'string' &&
|
||
typeof parsed.task_id === 'string'
|
||
) {
|
||
return parsed as TaskToolResult;
|
||
}
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export const MessageBubble = memo(function MessageBubble({
|
||
message,
|
||
onNavigateToSubAgent,
|
||
showThinking = true,
|
||
effectiveModel,
|
||
}: MessageBubbleProps) {
|
||
const isUser = message.role === 'user';
|
||
const isTool = message.role === 'tool';
|
||
const isMergedTool = message.type === 'merged_tool';
|
||
const [toolExpanded, setToolExpanded] = useState(false);
|
||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||
const [lightboxImage, setLightboxImage] = useState<{
|
||
base64: string;
|
||
mimeType: string;
|
||
fileName?: string;
|
||
} | null>(null);
|
||
|
||
const lightboxElement = lightboxImage ? (
|
||
<ImageLightbox
|
||
src={lightboxImage.base64}
|
||
mimeType={lightboxImage.mimeType}
|
||
fileName={lightboxImage.fileName}
|
||
onClose={() => setLightboxImage(null)}
|
||
/>
|
||
) : null;
|
||
|
||
if (isMergedTool) {
|
||
const status = message.status || 'calling';
|
||
const hasResult = !!message.resultContent;
|
||
const hasArgs = message.arguments !== undefined && message.arguments !== null;
|
||
|
||
const statusConfig = {
|
||
calling: {
|
||
dot: 'bg-[var(--accent-cyan)] animate-pulse',
|
||
fullBorder: 'border-[var(--accent-cyan)]/30',
|
||
iconColor: 'text-[var(--accent-cyan)]',
|
||
avatarBg: 'bg-[var(--accent-cyan)]/10',
|
||
avatarIcon: 'text-[var(--accent-cyan)]',
|
||
},
|
||
result: {
|
||
dot: 'bg-[var(--accent-green)]',
|
||
fullBorder: 'border-[var(--accent-green)]/30',
|
||
iconColor: 'text-[var(--accent-green)]',
|
||
avatarBg: 'bg-[var(--accent-green)]/10',
|
||
avatarIcon: 'text-[var(--accent-green)]',
|
||
},
|
||
pending: {
|
||
dot: 'bg-[var(--accent-amber)] animate-pulse',
|
||
fullBorder: 'border-[var(--accent-amber)]/30',
|
||
iconColor: 'text-[var(--accent-amber)]',
|
||
avatarBg: 'bg-[var(--accent-amber)]/10',
|
||
avatarIcon: 'text-[var(--accent-amber)]',
|
||
},
|
||
}[status];
|
||
|
||
const formatJSON = (text: string): string => {
|
||
try {
|
||
return JSON.stringify(JSON.parse(text), null, 2);
|
||
} catch {
|
||
return text;
|
||
}
|
||
};
|
||
|
||
function stripToolResultPrefix(text: string): string {
|
||
const lines = text.split('\n');
|
||
if (lines[0]?.startsWith('工具结果')) {
|
||
let start = 1;
|
||
while (start < lines.length && lines[start].trim() === '') {
|
||
start++;
|
||
}
|
||
return lines.slice(start).join('\n');
|
||
}
|
||
return text;
|
||
}
|
||
|
||
const argsPreview = hasArgs ? JSON.stringify(message.arguments).slice(0, 500) : '';
|
||
|
||
const displayContent = hasResult ? stripToolResultPrefix(message.resultContent!) : '';
|
||
|
||
const isTaskTool = message.toolName === 'task';
|
||
const taskResult = isTaskTool && hasResult ? parseTaskResult(displayContent) : null;
|
||
const subagentType =
|
||
((message.arguments as Record<string, unknown> | null)?.subagent_type as string) || 'general';
|
||
const taskDescription =
|
||
((message.arguments as Record<string, unknown> | null)?.description as string) || '';
|
||
const taskPrompt =
|
||
((message.arguments as Record<string, unknown> | null)?.prompt as string) || '';
|
||
|
||
// task tool 专用的状态配色
|
||
// 支持的状态:
|
||
// - running: 异步子代理刚 spawn,占位结果
|
||
// - success/completed: 子代理执行成功
|
||
// - failed: 子代理执行失败
|
||
// - timeout: 子代理执行超时
|
||
// - cancelled: 子代理被用户取消
|
||
// - interrupted: 子代理因服务器重启被中断
|
||
const taskStatusConfig: Record<string, { dot: string; borderColor: string; iconColor: string }> = {
|
||
running: {
|
||
dot: 'bg-[var(--accent-amber)] animate-pulse',
|
||
borderColor: 'border-[var(--accent-amber)]/40',
|
||
iconColor: 'text-[var(--accent-amber)]',
|
||
},
|
||
success: {
|
||
dot: 'bg-[var(--accent-green)]',
|
||
borderColor: 'border-[var(--accent-green)]/40',
|
||
iconColor: 'text-[var(--accent-green)]',
|
||
},
|
||
completed: {
|
||
dot: 'bg-[var(--accent-green)]',
|
||
borderColor: 'border-[var(--accent-green)]/40',
|
||
iconColor: 'text-[var(--accent-green)]',
|
||
},
|
||
failed: {
|
||
dot: 'bg-[rgb(242,90,90)]',
|
||
borderColor: 'border-[rgb(242,90,90)]/40',
|
||
iconColor: 'text-[rgb(242,90,90)]',
|
||
},
|
||
timeout: {
|
||
dot: 'bg-[var(--accent-amber)]',
|
||
borderColor: 'border-[var(--accent-amber)]/40',
|
||
iconColor: 'text-[var(--accent-amber)]',
|
||
},
|
||
cancelled: {
|
||
dot: 'bg-[var(--text-muted)]',
|
||
borderColor: 'border-[var(--border-color)]',
|
||
iconColor: 'text-[var(--text-muted)]',
|
||
},
|
||
interrupted: {
|
||
dot: 'bg-[var(--accent-amber)]',
|
||
borderColor: 'border-[var(--accent-amber)]/40',
|
||
iconColor: 'text-[var(--accent-amber)]',
|
||
},
|
||
};
|
||
|
||
// 安全获取 task 状态配色,未知状态回退到默认(避免 undefined.borderColor 崩溃)
|
||
const taskStyle = taskResult ? (taskStatusConfig[taskResult.status] ?? taskStatusConfig.failed) : null;
|
||
|
||
// 子代理模型徽章:结果携带模型信息,且(无主代理参照 或 model/provider 任一不同)时展示。
|
||
// provider 不同即使 model 同名也显示——不同 provider 的同名模型在路由/计费上已不同。
|
||
const modelDiffers =
|
||
!effectiveModel ||
|
||
effectiveModel.model !== taskResult?.model ||
|
||
effectiveModel.provider !== taskResult?.provider;
|
||
const subagentModelLabel =
|
||
taskResult?.model && modelDiffers
|
||
? effectiveModel && effectiveModel.provider === taskResult.provider
|
||
? taskResult.model
|
||
: `${taskResult.provider ?? '?'}/${taskResult.model}`
|
||
: null;
|
||
|
||
return (
|
||
<div data-message-id={message.id} className="animate-slide-in">
|
||
<div
|
||
onClick={() => setToolExpanded(!toolExpanded)}
|
||
className={`group w-full cursor-pointer rounded-xl border bg-[var(--bg-secondary)]/60 transition-colors duration-300 hover:bg-[var(--bg-secondary)] ${
|
||
taskStyle ? taskStyle.borderColor : statusConfig.fullBorder
|
||
}`}
|
||
>
|
||
{/* Header row */}
|
||
<div className="flex items-center gap-2 px-3 py-2">
|
||
<span
|
||
className={`inline-block h-2 w-2 rounded-full flex-shrink-0 transition-colors duration-500 ${
|
||
taskStyle ? taskStyle.dot : statusConfig.dot
|
||
}`}
|
||
/>
|
||
<span className="text-sm font-medium text-[var(--text-secondary)] truncate">
|
||
{isTaskTool
|
||
? `${message.toolName || 'Tool'}${taskDescription ? ` · ${taskDescription}` : ''}`
|
||
: message.toolName || 'Tool'}
|
||
</span>
|
||
{subagentModelLabel && (
|
||
<span
|
||
className="flex-shrink-0 rounded-full border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-1.5 py-px text-[10px] leading-4 text-[var(--text-muted)] max-w-[160px] truncate"
|
||
title={`子代理模型:${taskResult?.provider ?? '?'} / ${taskResult?.model}`}
|
||
>
|
||
{subagentModelLabel}
|
||
</span>
|
||
)}
|
||
<span
|
||
className={`flex-shrink-0 transition-all duration-300 ${
|
||
taskStyle ? taskStyle.iconColor : statusConfig.iconColor
|
||
}`}
|
||
>
|
||
{taskResult ? (
|
||
<StatusIcon status={taskResult.status} />
|
||
) : (
|
||
<StatusIcon status={status} />
|
||
)}
|
||
</span>
|
||
{status === 'result' && message.durationMs != null && (
|
||
<span className="text-xs text-[var(--text-muted)] flex-shrink-0 tabular-nums ml-1">
|
||
⏱️ {formatDuration(message.durationMs)}
|
||
</span>
|
||
)}
|
||
{hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />}
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setShowDetailModal(true);
|
||
}}
|
||
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-[var(--overlay-subtle)] flex-shrink-0"
|
||
title="放大查看"
|
||
>
|
||
<Maximize2 className="h-3 w-3 text-[var(--text-muted)]" />
|
||
</button>
|
||
<span className="ml-auto flex-shrink-0">
|
||
{toolExpanded ? (
|
||
<ChevronDown className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||
) : (
|
||
<ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 模型思考内容(工具调用时展示) */}
|
||
{showThinking && message.reasoningContent && !toolExpanded && (
|
||
<div className="px-3 pb-1 mb-2">
|
||
<ThinkingSection content={message.reasoningContent} />
|
||
</div>
|
||
)}
|
||
{/* Collapsed preview */}
|
||
{!toolExpanded && (
|
||
<>
|
||
{hasArgs && argsPreview && !taskResult && (
|
||
<div
|
||
className="px-3 pb-1 text-xs text-[var(--text-muted)] font-mono line-clamp-3"
|
||
style={{
|
||
display: '-webkit-box',
|
||
WebkitLineClamp: 3,
|
||
WebkitBoxOrient: 'vertical',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{argsPreview}
|
||
</div>
|
||
)}
|
||
{taskResult && taskResult.summary && (
|
||
<div className="px-3 pb-1 text-xs text-[var(--text-secondary)] line-clamp-2">
|
||
{taskResult.summary}
|
||
</div>
|
||
)}
|
||
{taskResult && (
|
||
<div className="px-3 pb-1">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onNavigateToSubAgent?.(
|
||
taskResult.task_id,
|
||
taskDescription || '子智能体任务',
|
||
subagentType,
|
||
);
|
||
}}
|
||
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
|
||
>
|
||
<span>查看完整会话</span>
|
||
<span>→</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
{isTaskTool && message.navigateToTaskId && !taskResult && (
|
||
<div className="px-3 pb-1">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onNavigateToSubAgent?.(
|
||
message.navigateToTaskId!,
|
||
taskDescription || '子智能体任务',
|
||
subagentType,
|
||
);
|
||
}}
|
||
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
|
||
>
|
||
<span>查看实时进度</span>
|
||
<span>→</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
{isTaskTool && !taskResult && !message.navigateToTaskId && (
|
||
<div className="px-3 pb-2 text-xs text-[var(--text-muted)]">
|
||
子智能体正在执行...
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Expanded */}
|
||
{toolExpanded && (
|
||
<div className="border-t border-[var(--border-color)] px-3 py-2 space-y-2">
|
||
{/* 模型思考内容(展开时也展示) */}
|
||
{showThinking && message.reasoningContent && (
|
||
<div className="mb-2">
|
||
<ThinkingSection content={message.reasoningContent} />
|
||
</div>
|
||
)}
|
||
{taskResult ? (
|
||
<>
|
||
{taskPrompt && (
|
||
<div>
|
||
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">
|
||
任务指令
|
||
</div>
|
||
<pre className="text-xs text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-lg p-2 overflow-x-auto max-h-32 overflow-y-auto">
|
||
{taskPrompt}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{taskResult.summary && (
|
||
<div
|
||
className={`rounded-lg px-3 py-2 ${
|
||
taskResult.status === 'success'
|
||
? 'bg-[var(--accent-green)]/10 border border-[var(--accent-green)]/30'
|
||
: taskResult.status === 'failed'
|
||
? 'bg-[rgb(242,90,90)]/10 border border-[rgb(242,90,90)]/30'
|
||
: 'bg-[var(--accent-amber)]/10 border border-[var(--accent-amber)]/30'
|
||
}`}
|
||
>
|
||
<div className="text-xs font-medium text-[var(--text-muted)] mb-0.5">
|
||
摘要
|
||
</div>
|
||
<div className="text-sm text-[var(--text-secondary)]">
|
||
{taskResult.summary}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">输出</div>
|
||
<div className="markdown-content text-sm leading-relaxed bg-[var(--overlay-dim)] rounded-lg p-3 max-h-96 overflow-y-auto">
|
||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>
|
||
{taskResult.output}
|
||
</ReactMarkdown>
|
||
</div>
|
||
</div>
|
||
<div className="text-xs text-[var(--text-muted)] font-mono select-all">
|
||
task_id: {taskResult.task_id}
|
||
</div>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onNavigateToSubAgent?.(
|
||
taskResult.task_id,
|
||
taskDescription || '子智能体任务',
|
||
subagentType,
|
||
);
|
||
}}
|
||
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
|
||
>
|
||
<span>查看完整会话</span>
|
||
<span>→</span>
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{hasArgs && (
|
||
<div>
|
||
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">
|
||
参数
|
||
</div>
|
||
<pre className="text-xs text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-lg p-2 overflow-x-auto">
|
||
{JSON.stringify(message.arguments, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{hasResult && (
|
||
<div>
|
||
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">
|
||
结果
|
||
</div>
|
||
<pre className="text-xs text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-lg p-2 overflow-x-auto max-h-48 overflow-y-auto">
|
||
{formatJSON(displayContent)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{isTaskTool && message.navigateToTaskId && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onNavigateToSubAgent?.(
|
||
message.navigateToTaskId!,
|
||
taskDescription || '子智能体任务',
|
||
subagentType,
|
||
);
|
||
}}
|
||
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
|
||
>
|
||
<span>查看实时进度</span>
|
||
<span>→</span>
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{lightboxElement}
|
||
{showDetailModal && (
|
||
<ToolDetailModal
|
||
toolName={message.toolName || 'Tool'}
|
||
status={status}
|
||
statusLabel={
|
||
status === 'calling'
|
||
? '执行中'
|
||
: status === 'result'
|
||
? '已完成'
|
||
: status === 'pending'
|
||
? '待确认'
|
||
: status
|
||
}
|
||
arguments={message.arguments}
|
||
resultContent={message.resultContent || ''}
|
||
callContent={message.callContent || ''}
|
||
durationMs={message.durationMs}
|
||
onClose={() => setShowDetailModal(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 隐藏无可见内容的助手消息(无文本,且无思考或思考被关闭)
|
||
const hasVisibleContent =
|
||
!!(message.content && message.content.trim()) || (showThinking && message.reasoningContent);
|
||
if (!isUser && !isTool && !isMergedTool && !hasVisibleContent) {
|
||
return null;
|
||
}
|
||
|
||
|
||
|
||
return (
|
||
<div
|
||
data-message-id={message.id}
|
||
className={`animate-slide-in group ${isUser ? 'flex flex-col items-end' : ''}`}
|
||
>
|
||
{!isUser && (
|
||
<div className="mb-1 flex items-center gap-2">
|
||
{isTool && message.toolName ? (
|
||
<span className="text-xs font-medium text-[var(--text-secondary)]">
|
||
{message.toolName}
|
||
</span>
|
||
) : null}
|
||
<span className="text-xs text-[var(--text-muted)] opacity-0 transition-opacity group-hover:opacity-100">
|
||
{formatTime(message.timestamp)}
|
||
</span>
|
||
<CopyButton text={message.content} />
|
||
</div>
|
||
)}
|
||
<div
|
||
className={
|
||
isUser
|
||
? 'max-w-[min(525px,82%)] rounded-[22px] bg-[var(--bubble-user)] px-4 py-2.5 text-left'
|
||
: 'w-full min-w-0'
|
||
}
|
||
>
|
||
{isUser ? (
|
||
// 用户消息保持纯文本(右对齐柔和气泡)
|
||
<div className="whitespace-pre-wrap text-[15px] leading-6 text-[var(--text-primary)]">
|
||
{message.content}
|
||
</div>
|
||
) : (
|
||
// 模型思考内容(仅助手消息,非工具消息)
|
||
<>
|
||
{showThinking && !isTool && message.reasoningContent && (
|
||
<div className={message.content.trim() ? 'mb-3' : ''}>
|
||
<ThinkingSection content={message.reasoningContent} />
|
||
</div>
|
||
)}
|
||
{/* AI 和工具消息使用 Markdown 渲染 */}
|
||
{message.content.trim() && (
|
||
<div className="markdown-content text-[15px] leading-6">
|
||
<ReactMarkdown
|
||
remarkPlugins={REMARK_PLUGINS}
|
||
components={MARKDOWN_COMPONENTS}
|
||
>
|
||
{message.content}
|
||
</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
{message.attachments && message.attachments.length > 0 && (
|
||
<div className="mt-2 space-y-2">
|
||
{message.attachments.some(
|
||
(att) => att.media_type === 'image' && att.content_base64,
|
||
) && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{message.attachments
|
||
.filter((att) => att.media_type === 'image' && att.content_base64)
|
||
.map((att, idx) => (
|
||
<img
|
||
key={`img-${idx}`}
|
||
src={`data:${att.mime_type || 'image/png'};base64,${att.content_base64}`}
|
||
alt={att.file_name || att.path || '图片'}
|
||
className="max-w-64 max-h-48 rounded-xl object-cover cursor-pointer border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/40 hover:scale-[1.02] transition-all duration-200 shadow-md"
|
||
onClick={() =>
|
||
setLightboxImage({
|
||
base64: att.content_base64!,
|
||
mimeType: att.mime_type || 'image/png',
|
||
fileName: att.file_name || att.path,
|
||
})
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{message.attachments
|
||
.filter((att) => att.media_type !== 'image' || !att.content_base64)
|
||
.map((att, idx) => (
|
||
<AttachmentCard key={`att-${idx}`} attachment={att} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isUser && (
|
||
<div className="mt-1 flex items-center gap-2 px-1">
|
||
<span className="text-xs text-[var(--text-muted)] opacity-0 transition-opacity group-hover:opacity-100">
|
||
{formatTime(message.timestamp)}
|
||
</span>
|
||
<CopyButton text={message.content} />
|
||
</div>
|
||
)}
|
||
{lightboxElement}
|
||
</div>
|
||
);
|
||
});
|