import { useQuery } from '@tanstack/react-query';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import MarkdownRenderer from '../components/MarkdownRenderer';
import AgentBayLivePanel, { LivePreviewState } from '../components/AgentBayLivePanel';
import ModelSwitcher from '../components/ModelSwitcher';
import { agentApi, enterpriseApi, tenantApi, uploadFileWithProgress } from '../services/api';
import { IconPaperclip, IconSend } from '@tabler/icons-react';
import { formatFileSize } from '../utils/formatFileSize';
import { useAuthStore } from '../stores';
import { useDropZone } from '../hooks/useDropZone';
import { useToast } from '../components/Toast/ToastProvider';
const Icons = {
bot: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="12" height="10" rx="2" />
<circle cx="7" cy="10" r="1" fill="currentColor" stroke="none" />
<circle cx="11" cy="10" r="1" fill="currentColor" stroke="none" />
<path d="M9 2v3M6 2h6" />
</svg>
),
user: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="5.5" r="2.5" />
<path d="M3 14v-1a4 4 0 018 0v1" />
</svg>
),
chat: (
<svg width="28" height="28" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3a1 1 0 011-1h10a1 1 0 011 1v7a1 1 0 01-1 1H5l-3 3V3z" />
<path d="M5 5.5h6M5 8h4" />
</svg>
),
tool: (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.5 10.5L14 14M4.5 2a2.5 2.5 0 00-1.8 4.2l5.1 5.1A2.5 2.5 0 1012 7.2L6.8 2.2A2.5 2.5 0 004.5 2z" />
</svg>
),
};
interface ToolCall {
name: string;
args: any;
result?: string;
}
interface Message {
role: 'user' | 'assistant';
content: string;
fileName?: string;
toolCalls?: ToolCall[];
thinking?: string;
imageUrl?: string;
timestamp?: string;
_isToolGroup?: boolean;
}
const PULSE_STYLE_ID = 'cw-tool-pulse-style';
if (typeof document !== 'undefined' && !document.getElementById(PULSE_STYLE_ID)) {
const s = document.createElement('style');
s.id = PULSE_STYLE_ID;
s.textContent = `
@keyframes cw-pulse-led {
0%, 100% { opacity: 1; transform: scale(1); box-shadow: 0 0 0 0 rgba(107,114,128,0.45); }
50% { opacity: 0.55; transform: scale(1.5); box-shadow: 0 0 0 4px rgba(107,114,128,0); }
}
.cw-running-led { animation: cw-pulse-led 1.4s ease-in-out infinite; }
`;
document.head.appendChild(s);
}
function ChatToolChain({ toolCalls }: { toolCalls: ToolCall[] }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const count = toolCalls.length;
const activeIdx = (() => {
for (let i = toolCalls.length - 1; i >= 0; i--) {
if (!toolCalls[i].result) return i;
}
return -1;
})();
const isRunning = activeIdx >= 0;
const activeTool = isRunning ? toolCalls[activeIdx] : null;
return (
<div style={{
borderRadius: '8px',
background: isRunning ? 'color-mix(in srgb, var(--bg-secondary) 72%, var(--bg-primary))' : 'var(--bg-primary)',
border: `1px solid ${isRunning ? 'var(--border-default)' : 'var(--border-subtle)'}`,
fontSize: '12px',
overflow: 'hidden',
marginBottom: '6px',
transition: 'border-color 0.3s ease',
}}>
{/* ── Header / toggle row ── */}
<button
onClick={() => setExpanded(v => !v)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
width: '100%', display: 'flex', alignItems: 'center', gap: '6px',
padding: '7px 10px',
color: 'var(--text-secondary)',
}}
>
{/* Left label: title + running-tool indicator */}
<span style={{ flex: 1, textAlign: 'left', display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
<span style={{ fontWeight: 500, flexShrink: 0, color: 'var(--text-primary)' }}>{t('agent.chat.toolCallChain')}</span>
<span style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}>·</span>
{isRunning && activeTool ? (
<>
{/* Pulse LED: breathing dot while a tool runs */}
<span
className="cw-running-led"
style={{
display: 'inline-block',
width: '6px', height: '6px',
borderRadius: '50%',
background: 'var(--text-tertiary)',
flexShrink: 0,
}}
/>
{/* Currently-running tool name */}
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: '11px',
color: 'var(--text-secondary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{activeTool.name}
</span>
</>
) : (
/* Static green dot when all tools are done */
<span style={{
display: 'inline-block',
width: '6px', height: '6px',
borderRadius: '50%',
background: '#22c55e',
flexShrink: 0,
opacity: 0.85,
}} />
)}
</span>
{/* Count badge */}
<span style={{
background: 'var(--bg-secondary)', color: 'var(--text-secondary)',
borderRadius: '10px', padding: '1px 7px',
fontSize: '10px', fontWeight: 600, flexShrink: 0,
}}>
{count}
</span>
{/* Expand chevron */}
<span style={{
fontSize: '10px', color: 'var(--text-tertiary)',
transition: 'transform 0.2s', display: 'inline-block',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
flexShrink: 0,
}}>▶</span>
</button>
{}
{!expanded && count > 0 && (
<div style={{ padding: '0 10px 7px 10px', display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{toolCalls.map((tc, i) => {
const running = !tc.result;
return (
<span key={i} style={{
background: running ? 'var(--bg-secondary)' : 'var(--bg-primary)',
border: '1px solid var(--border-subtle)',
borderRadius: '4px', padding: '1px 6px',
fontSize: '10px', color: 'var(--text-secondary)',
fontFamily: 'var(--font-mono)',
display: 'inline-flex', alignItems: 'center', gap: '4px',
}}>
{running && (
<span
className="cw-running-led"
style={{
display: 'inline-block',
width: '4px', height: '4px',
borderRadius: '50%',
background: 'var(--text-tertiary)',
flexShrink: 0,
}}
/>
)}
{tc.name}
</span>
);
})}
</div>
)}
{}
{expanded && (
<div style={{ borderTop: '1px solid var(--border-subtle)' }}>
{toolCalls.map((tc, i) => {
const running = !tc.result;
return (
<div key={i} style={{
padding: '7px 10px',
borderBottom: i < toolCalls.length - 1 ? '1px solid var(--border-subtle)' : 'none',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', marginBottom: '4px' }}>
{/* Status dot: amber + pulse = running; green = done */}
<span
className={running ? 'cw-running-led' : undefined}
style={{
display: 'inline-block',
width: '5px', height: '5px',
borderRadius: '50%',
background: running ? '#f59e0b' : '#22c55e',
flexShrink: 0,
}}
/>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', fontWeight: 600 }}>
{tc.name}
</span>
{running && (
<span style={{ fontSize: '10px', color: 'var(--text-tertiary)', marginLeft: 'auto' }}>
{t('common.loading')}
</span>
)}
</div>
{tc.args && Object.keys(tc.args).length > 0 && (
<div style={{
fontFamily: 'var(--font-mono)', fontSize: '10px',
color: 'var(--text-tertiary)', whiteSpace: 'pre-wrap',
wordBreak: 'break-all', maxHeight: '80px', overflowY: 'auto',
background: 'var(--bg-secondary)', borderRadius: '4px',
padding: '4px 6px', marginBottom: tc.result ? '4px' : 0,
}}>
{JSON.stringify(tc.args, null, 2)}
</div>
)}
{tc.result && (
<div style={{
fontSize: '10px', color: 'var(--text-secondary)',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
maxHeight: '80px', overflowY: 'auto',
borderTop: '1px solid var(--border-subtle)', paddingTop: '4px',
}}>
{tc.result.length > 500 ? tc.result.slice(0, 500) + '…' : tc.result}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
export default function Chat() {
const { t, i18n } = useTranslation();
const toast = useToast();
const { id } = useParams<{ id: string }>();
const token = useAuthStore((s) => s.token);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [connected, setConnected] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{
name: string;
percent: number;
previewUrl?: string;
sizeBytes: number;
} | null>(null);
const [streaming, setStreaming] = useState(false);
const [isWaiting, setIsWaiting] = useState(false);
const [attachedFile, setAttachedFile] = useState<{ name: string; text: string; path?: string; imageUrl?: string } | null>(null);
const [liveState, setLiveState] = useState<LivePreviewState>({});
const [livePanelVisible, setLivePanelVisible] = useState(false);
const [wsSessionId, setWsSessionId] = useState<string>('');
const wsRef = useRef<WebSocket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
const userPinnedAwayFromBottomRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pendingToolCalls = useRef<ToolCall[]>([]);
const streamContent = useRef('');
const thinkingContent = useRef('');
const historyLoaded = useRef(false);
const onboardingKickoffSent = useRef(false);
const { data: agent } = useQuery({
queryKey: ['agent', id],
queryFn: () => agentApi.get(id!),
enabled: !!id,
});
const { data: myTenant } = useQuery({
queryKey: ['tenant', 'me'],
queryFn: () => tenantApi.me(),
staleTime: 5 * 60 * 1000,
refetchOnMount: 'always',
});
const [overrideModelId, setOverrideModelId] = useState<string | null>(null);
useEffect(() => {
if (agent?.primary_model_id && agent.primary_model_id !== overrideModelId) {
setOverrideModelId(agent.primary_model_id);
}
}, [agent?.primary_model_id, wsSessionId]);
const handleModelChange = useCallback(async (newModelId: string | null) => {
setOverrideModelId(newModelId);
}, []);
const { data: llmModels = [], isLoading: llmModelsLoading } = useQuery({
queryKey: ['llm-models'],
queryFn: () => enterpriseApi.llmModels(),
enabled: !!agent,
refetchOnMount: 'always',
});
const enabledLlmModels = (llmModels as any[]).filter((m: any) => m.enabled);
const effectiveChatModelId = overrideModelId
|| agent?.primary_model_id
|| myTenant?.default_model_id
|| enabledLlmModels[0]?.id
|| null;
const effectiveModelReady = !!effectiveChatModelId && enabledLlmModels.some((m: any) => m.id === effectiveChatModelId);
const supportsVision = !!effectiveChatModelId && llmModels.some(
(m: any) => m.id === effectiveChatModelId && m.supports_vision
);
const parseMessage = (msg: Message): Message => {
if (msg.role !== 'user') return msg;
let result = { ...msg };
const newFmt = result.content.match(/^\[file:([^\]]+)\]\n?/);
if (newFmt) {
result = { ...result, fileName: newFmt[1], content: result.content.slice(newFmt[0].length).trim() };
} else {
const chanFmt = result.content.match(/^\[\u6587\u4ef6\u5df2\u4e0a\u4f20: (?:workspace\/uploads\/)?([^\]\n]+)\]/);
if (chanFmt) {
const raw = chanFmt[1]; const fileName = raw.split('/').pop() || raw;
result = { ...result, fileName, content: result.content.slice(chanFmt[0].length).trim() };
} else {
const oldFmt = result.content.match(/^\[File: ([^\]]+)\]/);
if (oldFmt) {
const fileName = oldFmt[1];
const qMatch = result.content.match(/\nQuestion: ([\s\S]+)$/);
result = { ...result, fileName, content: qMatch ? qMatch[1].trim() : '' };
}
}
}
const imgDataPattern = /\[image_data:(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\]/;
const imgMatch = result.content.match(imgDataPattern);
if (imgMatch) {
result = {
...result,
imageUrl: result.imageUrl || imgMatch[1],
content: result.content
.replace(/\[image_data:data:image\/[^;]+;base64,[A-Za-z0-9+/=]+\]\n?/g, '')
.trim(),
};
}
return result;
};
useEffect(() => {
if (!id || !token) return;
fetch(`/api/chat/${id}/history`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.json())
.then((history: any[]) => {
if (history.length > 0) {
const processed: Message[] = [];
for (const h of history) {
if (h.role === 'tool_call') {
const tc: ToolCall = {
name: h.toolName || h.tool_name || '',
args: h.toolArgs || h.tool_args || {},
result: h.toolResult || h.tool_result || '',
};
const last = processed[processed.length - 1];
if (last && last._isToolGroup) {
last.toolCalls = [...(last.toolCalls || []), tc];
} else if (last && last.role === 'assistant' && !(last.content && last.content.trim())) {
last._isToolGroup = true;
last.toolCalls = [...(last.toolCalls || []), tc];
} else {
processed.push({
role: 'assistant', content: '', toolCalls: [tc],
timestamp: h.created_at || undefined,
_isToolGroup: true,
});
}
} else {
const msg = parseMessage({ role: h.role, content: h.content, fileName: h.fileName, toolCalls: h.toolCalls, thinking: h.thinking, imageUrl: h.imageUrl });
msg.timestamp = h.created_at || undefined;
processed.push(msg);
}
}
setMessages(processed);
}
})
.catch(() => { })
.finally(() => { historyLoaded.current = true; });
}, [id, token]);
useEffect(() => {
if (onboardingKickoffSent.current) return;
if (!connected || !wsRef.current) return;
if (!agent || agent.onboarded_for_me !== false) return;
if (!historyLoaded.current) return;
if (llmModelsLoading || !effectiveModelReady || !effectiveChatModelId) return;
if (messages.length > 0) return;
onboardingKickoffSent.current = true;
setIsWaiting(true);
setStreaming(true);
wsRef.current.send(JSON.stringify({
content: '',
kind: 'onboarding_trigger',
model_id: effectiveChatModelId,
}));
}, [connected, agent, messages.length, llmModelsLoading, effectiveModelReady, effectiveChatModelId]);
useEffect(() => {
if (!id || !token) return;
let cancelled = false;
const connect = () => {
if (cancelled) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const lang = (i18n.language || 'en').toLowerCase().startsWith('zh') ? 'zh' : 'en';
const wsUrl = `${protocol}//${window.location.host}/ws/chat/${id}?token=${token}&lang=${lang}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
if (cancelled) {
ws.close();
return;
}
setConnected(true);
wsRef.current = ws;
};
ws.onclose = () => {
if (!cancelled) {
setConnected(false);
setTimeout(() => connect(), 2000);
}
};
ws.onerror = () => {
if (!cancelled) setConnected(false);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (['thinking', 'chunk', 'tool_call', 'done', 'error', 'quota_exceeded'].includes(data.type)) {
setIsWaiting(false);
}
if (['error', 'quota_exceeded'].includes(data.type)) {
setStreaming(false);
}
if (data.type === 'connected' && data.session_id) {
setWsSessionId(data.session_id);
return;
}
if (data.type === 'agentbay_live') {
console.log('[LivePreview] Received:', data.env, 'url:', data.screenshot_url?.substring(0, 60));
setLiveState(prev => {
const next = { ...prev };
if ((data.env === 'desktop' || data.env === 'browser') && data.screenshot_url) {
const imgUrl = data.screenshot_url + '&_t=' + Date.now();
if (data.env === 'desktop') next.desktop = { screenshotUrl: imgUrl };
else next.browser = { screenshotUrl: imgUrl };
} else if (data.env === 'code' && data.output) {
const existing = prev.code?.output || '';
next.code = { output: existing + (existing ? '\n---\n' : '') + data.output };
}
return next;
});
setLivePanelVisible(true);
return;
}
if (data.type === 'thinking') {
thinkingContent.current += data.content;
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
const updated = [...prev];
updated[updated.length - 1] = { ...last, thinking: thinkingContent.current };
return updated;
}
return [...prev, { role: 'assistant', content: '', thinking: thinkingContent.current, timestamp: new Date().toISOString() }];
});
} else if (data.type === 'chunk') {
streamContent.current += data.content;
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
const updated = [...prev];
updated[updated.length - 1] = { ...last, content: streamContent.current };
return updated;
}
return [...prev, { role: 'assistant', content: streamContent.current, timestamp: new Date().toISOString() }];
});
} else if (data.type === 'tool_call') {
console.log('[ToolCall]', data.name, data.status);
if (data.status === 'running') {
const tc: ToolCall = { name: data.name, args: data.args || {} };
pendingToolCalls.current.push(tc);
const now = new Date().toISOString();
setMessages(prev => {
let msgs = [...prev];
while (msgs.length > 0) {
const last = msgs[msgs.length - 1];
if (last.role === 'assistant' && !last._isToolGroup && !(last.content && last.content.trim())) {
msgs.pop();
} else break;
}
for (let i = msgs.length - 1; i >= Math.max(0, msgs.length - 5); i--) {
if (msgs[i].role === 'user') break;
if (msgs[i]._isToolGroup) {
msgs[i] = { ...msgs[i], toolCalls: [...(msgs[i].toolCalls || []), tc], timestamp: now };
return msgs;
}
}
return [...msgs, { role: 'assistant', content: '', toolCalls: [tc], timestamp: now, _isToolGroup: true }];
});
} else if (data.status === 'done') {
streamContent.current = '';
thinkingContent.current = '';
const newCall: ToolCall = { name: data.name, args: data.args, result: data.result || '' };
const idx = pendingToolCalls.current.findIndex(tc => tc.name === data.name && !tc.result);
if (idx >= 0) {
pendingToolCalls.current[idx] = newCall;
} else {
pendingToolCalls.current.push(newCall);
}
const now = new Date().toISOString();
setMessages(prev => {
let msgs = [...prev];
while (msgs.length > 0) {
const last = msgs[msgs.length - 1];
if (last.role === 'assistant' && !last._isToolGroup && !(last.content && last.content.trim())) {
msgs.pop();
} else break;
}
for (let i = msgs.length - 1; i >= Math.max(0, msgs.length - 5); i--) {
if (msgs[i].role === 'user') break;
if (msgs[i]._isToolGroup) {
const existing = (msgs[i].toolCalls || []).map(tc =>
tc.name === data.name && !tc.result ? newCall : tc
);
const hasIt = existing.some(tc => tc.name === data.name && tc.result);
msgs[i] = { ...msgs[i], toolCalls: hasIt ? existing : [...existing, newCall], timestamp: now };
return msgs;
}
}
return [...msgs, { role: 'assistant', content: '', toolCalls: [newCall], timestamp: now, _isToolGroup: true }];
});
if (data.live_preview) {
const lp = data.live_preview;
setLiveState(prev => {
const next = { ...prev };
if ((lp.env === 'desktop' || lp.env === 'browser') && lp.screenshot_url) {
const imgUrl = lp.screenshot_url + '&_t=' + Date.now();
if (lp.env === 'desktop') next.desktop = { screenshotUrl: imgUrl };
else next.browser = { screenshotUrl: imgUrl };
} else if (lp.env === 'code' && lp.output) {
const existing = prev.code?.output || '';
next.code = { output: existing + (existing ? '\n---\n' : '') + lp.output };
}
return next;
});
setLivePanelVisible(true);
}
}
} else if (data.type === 'done') {
const toolCalls = pendingToolCalls.current.length > 0 ? [...pendingToolCalls.current] : undefined;
const thinking = thinkingContent.current || undefined;
pendingToolCalls.current = [];
streamContent.current = '';
thinkingContent.current = '';
setStreaming(false);
setMessages(prev => {
const updated = [...prev];
if (updated.length > 0 && updated[updated.length - 1].role === 'assistant') {
updated[updated.length - 1] = { role: 'assistant', content: data.content, toolCalls, thinking };
} else {
updated.push({ role: 'assistant', content: data.content, toolCalls, thinking });
}
return updated;
});
} else {
setMessages(prev => [...prev, { role: data.role, content: data.content }]);
}
};
};
connect();
return () => {
cancelled = true;
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [id, token]);
useEffect(() => {
if (connected) {
setTimeout(() => textareaRef.current?.focus(), 50);
}
}, [connected]);
const handleMessagesScroll = () => {
const el = messagesContainerRef.current;
if (!el) return;
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isNearBottomRef.current = distFromBottom < 120;
userPinnedAwayFromBottomRef.current = distFromBottom > 220;
};
const handleMessagesWheelCapture = (event: React.WheelEvent<HTMLDivElement>) => {
const el = messagesContainerRef.current;
if (!el) return;
if (event.deltaY < 0 && el.scrollTop > 0) {
userPinnedAwayFromBottomRef.current = true;
isNearBottomRef.current = false;
}
};
useEffect(() => {
if (userPinnedAwayFromBottomRef.current || !isNearBottomRef.current) return;
const el = messagesContainerRef.current;
if (!el) return;
requestAnimationFrame(() => {
if (userPinnedAwayFromBottomRef.current) return;
el.scrollTop = el.scrollHeight;
});
}, [messages]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
setUploadProgress({ name: file.name, percent: 0, previewUrl, sizeBytes: file.size });
try {
const { promise } = uploadFileWithProgress(
'/chat/upload',
file,
(pct) => {
setUploadProgress((prev) =>
prev ? { ...prev, percent: pct >= 101 ? 100 : pct } : null,
);
},
id ? { agent_id: id } : undefined,
);
const data = await promise;
setAttachedFile({
name: data.filename,
text: data.extracted_text,
path: data.workspace_path,
imageUrl: data.image_data_url || undefined,
});
} catch (err: any) {
if (err?.message !== 'Upload cancelled') {
toast.error(t('agent.upload.failed'), { details: String(err?.message || '') });
}
} finally {
if (previewUrl) URL.revokeObjectURL(previewUrl);
setUploadProgress(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const sendMessage = () => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
if (!input.trim() && !attachedFile) return;
pendingToolCalls.current = [];
streamContent.current = '';
thinkingContent.current = '';
userPinnedAwayFromBottomRef.current = false;
isNearBottomRef.current = true;
setIsWaiting(true);
setStreaming(true);
let userMsg = input.trim();
let contentForLLM = userMsg;
if (attachedFile) {
if (attachedFile.imageUrl && supportsVision) {
const imageMarker = `[image_data:${attachedFile.imageUrl}]`;
contentForLLM = userMsg
? `${imageMarker}\n${userMsg}`
: `${imageMarker}\n请分析这张图片`;
userMsg = userMsg || `[图片] ${attachedFile.name}`;
} else if (attachedFile.imageUrl) {
const wsPath = attachedFile.path || '';
contentForLLM = userMsg
? `[图片文件已上传: ${attachedFile.name},保存在 ${wsPath}]\n\n${userMsg}`
: `[图片文件已上传: ${attachedFile.name},保存在 ${wsPath}]\n请描述或处理这个图片文件。你可以使用 read_document 工具读取它。`;
userMsg = userMsg || `[图片] ${attachedFile.name}`;
} else {
const wsPath = attachedFile.path || '';
const codePath = wsPath.replace(/^workspace\//, '');
const fileLoc = wsPath ? `\nFile location: ${wsPath} (for read_file/read_document tools)\nIn execute_code, use relative path: "${codePath}" (working directory is workspace/)` : '';
const fileContext = `[文件: ${attachedFile.name}]${fileLoc}\n\n${attachedFile.text}`;
contentForLLM = userMsg
? `${fileContext}\n\n用户问题: ${userMsg}`
: `请阅读并分析以下文件内容:\n\n${fileContext}`;
userMsg = userMsg || `[${t('agent.chat.attachment')}] ${attachedFile.name}`;
}
}
setMessages((prev) => [...prev, {
role: 'user',
content: userMsg,
fileName: attachedFile?.name,
imageUrl: attachedFile?.imageUrl,
timestamp: new Date().toISOString(),
}]);
wsRef.current.send(JSON.stringify({ content: contentForLLM, display_content: userMsg, file_name: attachedFile?.name || '', model_id: effectiveChatModelId }));
setInput('');
setAttachedFile(null);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && !isWaiting && !streaming) {
e.preventDefault();
sendMessage();
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
};
const hasLiveData = !!(liveState.desktop || liveState.browser || liveState.code);
const handleDroppedFiles = useCallback(async (files: File[]) => {
const file = files[0];
if (!file) return;
const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
setUploadProgress({ name: file.name, percent: 0, previewUrl, sizeBytes: file.size });
try {
const { promise } = uploadFileWithProgress(
'/chat/upload',
file,
(pct) => {
setUploadProgress((prev) =>
prev ? { ...prev, percent: pct >= 101 ? 100 : pct } : null,
);
},
id ? { agent_id: id } : undefined,
);
const data = await promise;
setAttachedFile({
name: data.filename,
text: data.extracted_text,
path: data.workspace_path,
imageUrl: data.image_data_url || undefined,
});
} catch (err: any) {
if (err?.message !== 'Upload cancelled') {
toast.error(t('agent.upload.failed'), { details: String(err?.message || '') });
}
} finally {
if (previewUrl) URL.revokeObjectURL(previewUrl);
setUploadProgress(null);
}
}, [id, t]);
const { isDragging: isChatDragging, dropZoneProps: chatDropProps } = useDropZone({
onDrop: handleDroppedFiles,
disabled: !connected || !!uploadProgress || isWaiting || streaming,
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div className="page-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ width: '36px', height: '36px', borderRadius: 'var(--radius-md)', background: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-tertiary)' }}>
{Icons.bot}
</div>
<div>
<h1 className="page-title" style={{ fontSize: '18px' }}>{agent?.name || '...'}</h1>
<div style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className={`status-dot ${connected ? 'running' : 'stopped'}`} />
<span style={{ color: 'var(--text-tertiary)' }}>{connected ? t('agent.chat.connected') : t('agent.chat.disconnected')}</span>
</div>
</div>
</div>
</div>
<div className={`chat-container ${hasLiveData ? 'chat-with-live-panel' : ''}`} {...chatDropProps} style={{ position: 'relative' }}>
{/* Drop overlay */}
{isChatDragging && (
<div className="drop-zone-overlay">
<div className="drop-zone-overlay__icon"><IconPaperclip size={28} stroke={1.8} /></div>
<div className="drop-zone-overlay__text">{t('agent.upload.dropToAttach', 'Drop file to attach')}</div>
</div>
)}
{/* Wrap chat area in a column so it coexists with the live panel in flex-row */}
<div className="chat-main">
<div
ref={messagesContainerRef}
className="chat-messages"
onScroll={handleMessagesScroll}
onWheelCapture={handleMessagesWheelCapture}
>
{messages.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--text-tertiary)' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'center' }}>{Icons.chat}</div>
<div>{t('agent.chat.startConversation', { name: agent?.name || t('nav.newAgent') })}</div>
<div style={{ fontSize: '12px', marginTop: '8px', opacity: 0.7 }}>{t('agent.chat.fileSupport')}</div>
</div>
)}
{messages.filter(m => {
// Skip empty assistant messages (stream placeholders)
if (m.role === 'assistant' && !m._isToolGroup && !(m.content && m.content.trim()) && !m.toolCalls?.length && !m.thinking) return false;
return true;
}).map((msg, i) => (
msg._isToolGroup ? (
/* Tool call group — compact display without avatar bubble */
<div key={i} style={{ marginLeft: '48px', marginBottom: '8px' }}>
{msg.toolCalls && msg.toolCalls.length > 0 && (
<ChatToolChain toolCalls={msg.toolCalls} />
)}
</div>
) :
<div key={i} className={`chat-message ${msg.role}`}>
<div className="chat-avatar" style={{ color: 'var(--text-tertiary)' }}>
{msg.role === 'user' ? Icons.user : Icons.bot}
</div>
<div className="chat-bubble">
{msg.fileName && (() => {
const fe = msg.fileName!.split('.').pop()?.toLowerCase() ?? '';
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(fe);
if (isImage && msg.imageUrl) {
return (<div style={{ marginBottom: '4px' }}>
<img src={msg.imageUrl} alt={msg.fileName} style={{ maxWidth: '240px', maxHeight: '180px', borderRadius: '8px', border: '1px solid var(--border-subtle)' }} />
</div>);
}
return (<div style={{ display: 'inline-flex', alignItems: 'center', gap: '5px', background: 'rgba(0,0,0,0.08)', borderRadius: '6px', padding: '4px 8px', marginBottom: msg.content ? '4px' : '0', fontSize: '11px', border: '1px solid var(--border-subtle)', color: 'var(--text-secondary)' }}><IconPaperclip size={13} stroke={1.8} /><span style={{ fontWeight: 500, color: 'var(--text-primary)', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{msg.fileName}</span></div>);
})()}
{msg.thinking && (
<details className="thinking-panel">
<summary className="thinking-summary">
<span className="thinking-status-dot" />
{streaming && i === messages.length - 1 && !msg.content
? t('agent.chat.thinkingLabel', '思考中')
: t('agent.chat.thoughtLabel', '已思考')}
</summary>
<div className="thinking-content">
{msg.thinking}
</div>
</details>
)}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<ChatToolChain toolCalls={msg.toolCalls} />
)}
{msg.role === 'assistant' ? (
streaming && !msg.content && !msg.thinking && i === messages.length - 1 ? (
<div className="thinking-indicator">
<div className="thinking-dots">
<span /><span /><span />
</div>
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}>{t('agent.chat.thinking', 'Thinking...')}</span>
</div>
) : (
<MarkdownRenderer content={msg.content} />
)
) : (
<MarkdownRenderer content={msg.content} />
)}
{msg.timestamp && (
<div style={{ fontSize: '10px', color: 'var(--text-tertiary)', marginTop: '4px', opacity: 0.7 }}>
{new Date(msg.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
)}
</div>
</div>
))}
{(isWaiting || (streaming && (messages.length === 0 || messages[messages.length - 1].role === 'user'))) && (
<div className="chat-message assistant">
<div className="chat-avatar" style={{ color: 'var(--text-tertiary)' }}>
{Icons.bot}
</div>
<div className="chat-bubble">
<div className="thinking-indicator">
<div className="thinking-dots">
<span /><span /><span />
</div>
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}>{t('agent.chat.thinking', 'Thinking...')}</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="chat-input-area">
<div className="chat-composer">
{(uploadProgress || (attachedFile && !uploadProgress)) && (
<div className="chat-composer-attachments">
{uploadProgress && (
<div className="chat-file-pill">
<div
className="chat-file-pill__fill"
style={{ width: `${uploadProgress.percent}%` }}
/>
<div className="chat-file-pill__row">
{uploadProgress.previewUrl ? (
<img className="chat-file-pill__thumb" src={uploadProgress.previewUrl} alt="" />
) : (
<span className="chat-file-pill__icon">
<IconPaperclip size={14} stroke={1.75} />
</span>
)}
<span className="chat-file-pill__name">{uploadProgress.name}</span>
<span className="chat-file-pill__size">{formatFileSize(uploadProgress.sizeBytes)}</span>
<span className="chat-file-pill__pct">{uploadProgress.percent}%</span>
</div>
</div>
)}
{attachedFile && !uploadProgress && (
<div className="chat-file-pill">
<div className="chat-file-pill__row">
{attachedFile.imageUrl ? (
<img className="chat-file-pill__thumb" src={attachedFile.imageUrl} alt="" />
) : (
<span className="chat-file-pill__icon">
<IconPaperclip size={14} stroke={1.75} />
</span>
)}
<span className="chat-file-pill__name">{attachedFile.name}</span>
<button
type="button"
className="chat-file-pill__remove"
onClick={() => setAttachedFile(null)}
title={t('common.close', 'Close')}
>
×
</button>
</div>
</div>
)}
</div>
)}
<div className="chat-composer-input-block">
<textarea
ref={textareaRef}
className="chat-input"
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={t('chat.placeholder')}
disabled={!connected}
rows={1}
/>
</div>
<div className="chat-composer-toolbar">
<input type="file" ref={fileInputRef} onChange={handleFileSelect} style={{ display: 'none' }} />
<button
type="button"
className="chat-composer-btn"
onClick={() => fileInputRef.current?.click()}
disabled={!connected || !!uploadProgress || isWaiting || streaming}
title={t('agent.workspace.uploadFile')}
>
<IconPaperclip size={16} stroke={1.75} />
</button>
<ModelSwitcher
value={overrideModelId}
onChange={handleModelChange}
tenantDefaultId={myTenant?.default_model_id || null}
disabled={!connected}
/>
<div style={{ flex: 1 }} />
{(streaming || isWaiting) ? (
<button
type="button"
className="btn btn-stop-generation"
onClick={() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'abort' }));
setStreaming(false);
setIsWaiting(false);
}
}}
title={t('chat.stop', 'Stop')}
>
<span className="stop-icon" />
</button>
) : (
<button
type="button"
className="btn btn-primary chat-composer-send"
onClick={sendMessage}
disabled={!connected || (!input.trim() && !attachedFile)}
title={t('chat.send')}
>
<IconSend size={16} stroke={1.75} />
</button>
)}
</div>
</div>
</div>
</div>
{/* AgentBay Live Preview Panel */}
{hasLiveData && (
<AgentBayLivePanel
liveState={liveState}
visible={livePanelVisible}
onToggle={() => setLivePanelVisible(v => !v)}
agentId={id}
sessionId={wsSessionId}
onLiveUpdate={(env, screenshotDataUri) => {
// Update live preview with the latest screenshot from Take Control
setLiveState(prev => ({
...prev,
[env]: { screenshotUrl: screenshotDataUri },
}));
}}
/>
)}
</div>
</div>
);
}