import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Paperclip, Send, MessageSquare, RefreshCw } from 'lucide-react'; import { motion } from 'motion/react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; import { ChatItem } from '../types'; type StreamItem = { kind?: string; at?: number; task_id?: string; label?: string; agent_id?: string; event_type?: string; message?: string; message_type?: string; content?: string; from_agent?: string; to_agent?: string; reply_to?: string; message_id?: string; status?: string; }; type RenderedChatItem = ChatItem & { id: string; actorKey?: string; actorName?: string; avatarText?: string; avatarClassName?: string; metaLine?: string; isReadonlyGroup?: boolean; }; type RegistryAgent = { agent_id?: string; display_name?: string; role?: string; enabled?: boolean; transport?: string; }; type RuntimeTask = { id?: string; agent_id?: string; status?: string; updated?: number; created?: number; waiting_for_reply?: boolean; }; type AgentRuntimeBadge = { status: 'running' | 'waiting' | 'failed' | 'completed' | 'idle'; text: string; }; function formatAgentName(agentID?: string): string { const normalized = String(agentID || '').trim(); if (!normalized) return 'Unknown Agent'; if (normalized === 'main') return 'Main Agent'; return normalized .split(/[-_.:]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } function avatarSeed(key?: string): string { const palette = [ 'bg-emerald-600/80 text-white', 'bg-sky-600/80 text-white', 'bg-violet-600/80 text-white', 'bg-amber-600/80 text-white', 'bg-rose-600/80 text-white', 'bg-cyan-600/80 text-white', 'bg-fuchsia-600/80 text-white', ]; const source = String(key || 'agent'); let hash = 0; for (let i = 0; i < source.length; i += 1) { hash = (hash * 31 + source.charCodeAt(i)) | 0; } return palette[Math.abs(hash) % palette.length]; } function avatarText(name?: string): string { const parts = String(name || '') .split(/\s+/) .filter(Boolean); if (parts.length === 0) return 'A'; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase(); } function messageActorKey(item: StreamItem): string { return String(item.from_agent || item.agent_id || item.to_agent || 'subagent').trim() || 'subagent'; } function collectActors(items: StreamItem[]): string[] { const set = new Set(); items.forEach((item) => { [item.agent_id, item.from_agent, item.to_agent].forEach((value) => { const v = String(value || '').trim(); if (v) set.add(v); }); }); return Array.from(set).sort((a, b) => a.localeCompare(b)); } const Chat: React.FC = () => { const { t } = useTranslation(); const { q, sessions } = useAppContext(); const ui = useUI(); const [mainChat, setMainChat] = useState([]); const [subagentStream, setSubagentStream] = useState([]); const [registryAgents, setRegistryAgents] = useState([]); const [msg, setMsg] = useState(''); const [fileSelected, setFileSelected] = useState(false); const [chatTab, setChatTab] = useState<'main' | 'subagents'>('main'); const [sessionKey, setSessionKey] = useState(''); const [selectedStreamAgents, setSelectedStreamAgents] = useState([]); const [dispatchAgentID, setDispatchAgentID] = useState(''); const [dispatchTask, setDispatchTask] = useState(''); const [dispatchLabel, setDispatchLabel] = useState(''); const [runtimeTasks, setRuntimeTasks] = useState([]); const chatEndRef = useRef(null); const chatScrollRef = useRef(null); const shouldAutoScrollRef = useRef(true); const historyRequestRef = useRef(0); const isNearBottom = () => { const container = chatScrollRef.current; if (!container) return true; const threshold = 64; return container.scrollHeight - container.scrollTop - container.clientHeight < threshold; }; const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { chatEndRef.current?.scrollIntoView({ behavior }); }; useEffect(() => { if (shouldAutoScrollRef.current) { scrollToBottom(chatTab === 'main' ? 'smooth' : 'auto'); } }, [chatTab, mainChat, subagentStream]); const loadHistory = async (requestedSessionKey?: string) => { const targetSessionKey = String(requestedSessionKey || sessionKey || '').trim(); if (!targetSessionKey) return; const requestID = ++historyRequestRef.current; try { shouldAutoScrollRef.current = isNearBottom() || chatTab !== 'main'; const qs = q ? `${q}&session=${encodeURIComponent(targetSessionKey)}` : `?session=${encodeURIComponent(targetSessionKey)}`; const r = await fetch(`/webui/api/chat/history${qs}`); if (!r.ok) return; const j = await r.json(); if (requestID !== historyRequestRef.current) return; const arr = Array.isArray(j.messages) ? j.messages : []; const mapped: RenderedChatItem[] = arr.map((m: any, index: number) => { const baseRole = String(m.role || 'assistant'); let role: ChatItem['role'] = 'assistant'; if (baseRole === 'user') role = 'user'; else if (baseRole === 'tool') role = 'tool'; else if (baseRole === 'system') role = 'system'; let text = m.content || ''; let label = role === 'user' ? t('user') : role === 'tool' ? t('exec') : role === 'system' ? t('system') : t('agent'); if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) { role = 'exec'; label = t('exec'); text = `${text}\n[tool calls: ${m.tool_calls.map((x: any) => x?.function?.name || x?.name).filter(Boolean).join(', ')}]`; } if (baseRole === 'tool') { text = `[${t('toolOutput')}]\n${text}`; } const actorName = role === 'user' ? t('user') : role === 'tool' || role === 'exec' ? t('exec') : role === 'system' ? t('system') : t('agent'); const avatarClassName = role === 'user' ? 'bg-indigo-600/90 text-white' : role === 'tool' || role === 'exec' ? 'bg-amber-600/80 text-white' : role === 'system' ? 'bg-zinc-700 text-zinc-100' : 'bg-emerald-600/80 text-white'; return { id: `${targetSessionKey}-${index}`, role, text, label, actorName, avatarText: role === 'user' ? 'U' : role === 'tool' || role === 'exec' ? 'E' : role === 'system' ? 'S' : 'A', avatarClassName, }; }); setMainChat(mapped); } catch (e) { console.error(e); } }; const loadSubagentGroup = async () => { try { shouldAutoScrollRef.current = isNearBottom() || chatTab !== 'subagents'; const r = await fetch(`/webui/api/subagents_runtime${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'stream_all', limit: 300, task_limit: 36 }), }); if (!r.ok) return; const j = await r.json(); const arr = Array.isArray(j?.result?.items) ? j.result.items : []; setSubagentStream(arr); } catch (e) { console.error(e); } }; const loadRegistryAgents = async () => { try { const r = await fetch(`/webui/api/subagents_runtime${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'registry' }), }); if (!r.ok) return; const j = await r.json(); const items = Array.isArray(j?.result?.items) ? j.result.items : []; const filtered = items.filter((item: RegistryAgent) => item?.agent_id && item.enabled !== false); setRegistryAgents(filtered); if (!dispatchAgentID && filtered.length > 0) { setDispatchAgentID(String(filtered[0].agent_id || '')); } } catch (e) { console.error(e); } }; const loadRuntimeTasks = async () => { try { const r = await fetch(`/webui/api/subagents_runtime${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'list' }), }); if (!r.ok) return; const j = await r.json(); const items = Array.isArray(j?.result?.items) ? j.result.items : []; setRuntimeTasks(items); } catch (e) { console.error(e); } }; async function send() { if (!msg.trim() && !fileSelected) return; let media = ''; const input = document.getElementById('file') as HTMLInputElement | null; const f = input?.files?.[0]; if (f) { const fd = new FormData(); fd.append('file', f); try { const ur = await fetch(`/webui/api/upload${q}`, { method: 'POST', body: fd }); const uj = await ur.json(); media = uj.path || ''; } catch (e) { console.error('L0053', e); } } const userText = msg + (media ? `\n[Attached File: ${f?.name}]` : ''); shouldAutoScrollRef.current = true; setMainChat((prev) => [...prev, { id: `local-user-${Date.now()}`, role: 'user', text: userText, label: t('user'), actorName: t('user'), avatarText: 'U', avatarClassName: 'bg-indigo-600/90 text-white', }]); const currentMsg = msg; setMsg(''); setFileSelected(false); if (input) input.value = ''; try { const response = await fetch(`/webui/api/chat/stream${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session: sessionKey, message: currentMsg, media }), }); if (!response.ok || !response.body) throw new Error('Chat request failed'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let assistantText = ''; setMainChat((prev) => [...prev, { id: `local-assistant-${Date.now()}`, role: 'assistant', text: '', label: t('agent'), actorName: t('agent'), avatarText: 'A', avatarClassName: 'bg-emerald-600/80 text-white', }]); while (true) { const { value, done } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); assistantText += chunk; setMainChat((prev) => { const next = [...prev]; next[next.length - 1] = { ...next[next.length - 1], text: assistantText, }; return next; }); } loadHistory(); } catch (e) { setMainChat((prev) => [...prev, { id: `local-system-${Date.now()}`, role: 'system', text: t('chatServerError'), label: t('system'), actorName: t('system'), avatarText: 'S', avatarClassName: 'bg-zinc-700 text-zinc-100', }]); } } useEffect(() => { shouldAutoScrollRef.current = true; if (chatTab === 'main') { if (sessionKey) { loadHistory(sessionKey); } return; } loadSubagentGroup(); loadRegistryAgents(); loadRuntimeTasks(); }, [q, chatTab, sessionKey]); useEffect(() => { if (chatTab !== 'subagents') return; const timer = window.setInterval(() => { loadSubagentGroup(); loadRuntimeTasks(); }, 5000); return () => window.clearInterval(timer); }, [q, chatTab]); const userSessions = (sessions || []).filter((s: any) => !String(s?.key || '').startsWith('subagent:')); useEffect(() => { if (chatTab !== 'main') return; if (!userSessions.length) return; if (!sessionKey || !userSessions.some((s: any) => s.key === sessionKey)) { setSessionKey(userSessions[0].key); } }, [chatTab, sessionKey, userSessions]); const streamActors = useMemo(() => collectActors(subagentStream), [subagentStream]); useEffect(() => { setSelectedStreamAgents((prev) => prev.filter((agent) => streamActors.includes(agent))); }, [streamActors]); const renderedSubagentChat = useMemo(() => { const replyIndex = new Map(); subagentStream.forEach((item) => { const messageID = String(item.message_id || '').trim(); if (!messageID) return; replyIndex.set(messageID, { actor: formatAgentName(item.from_agent || item.agent_id), messageType: String(item.message_type || 'message'), }); }); return subagentStream .filter((item) => { if (selectedStreamAgents.length === 0) return true; const actors = [item.agent_id, item.from_agent, item.to_agent] .map((value) => String(value || '').trim()) .filter(Boolean); return actors.some((actor) => selectedStreamAgents.includes(actor)); }) .map((item, index) => { const actorKey = messageActorKey(item); const actorName = formatAgentName(actorKey); let metaLine = ''; if (item.kind === 'message') { const replyMeta = String(item.reply_to || '').trim() ? replyIndex.get(String(item.reply_to || '').trim()) : null; if (replyMeta) { metaLine = `${t('replyTo')}: ${replyMeta.actor}`; } else if (item.from_agent && item.to_agent && item.from_agent === item.to_agent) { metaLine = t('selfRefresh'); } else if (item.to_agent) { metaLine = `${t('toAgent')}: ${formatAgentName(item.to_agent)}`; } if (item.message_type) { metaLine = metaLine ? `${metaLine} · ${item.message_type}` : String(item.message_type); } } else { metaLine = item.event_type === 'resumed' || item.event_type === 'recovered' ? t('selfRefresh') : String(item.event_type || t('internalEvent')); } const text = item.kind === 'event' ? (item.message || '') : (item.content || ''); const label = item.kind === 'event' ? `${actorName} · ${item.event_type || t('internalEvent')}` : actorName; return { id: `${item.kind || 'item'}-${item.message_id || item.task_id || item.at || index}-${index}`, role: 'assistant', text: text || t('empty'), label, actorKey, actorName, avatarText: avatarText(actorName), avatarClassName: avatarSeed(actorKey), metaLine, isReadonlyGroup: true, }; }); }, [selectedStreamAgents, subagentStream, t]); const displayedChat = chatTab === 'main' ? mainChat : renderedSubagentChat; const runtimeBadgeByAgent = useMemo>(() => { const latest = new Map(); runtimeTasks.forEach((task) => { const agentID = String(task.agent_id || '').trim(); if (!agentID) return; const current = latest.get(agentID); const taskTime = Math.max(Number(task.updated || 0), Number(task.created || 0)); const currentTime = current ? Math.max(Number(current.updated || 0), Number(current.created || 0)) : 0; if (!current || taskTime >= currentTime) { latest.set(agentID, task); } }); const out: Record = {}; latest.forEach((task, agentID) => { if (task.waiting_for_reply) { out[agentID] = { status: 'waiting', text: t('statusWaiting') }; return; } switch (String(task.status || '').trim()) { case 'running': out[agentID] = { status: 'running', text: t('statusRunning') }; break; case 'failed': out[agentID] = { status: 'failed', text: t('statusError') }; break; case 'completed': out[agentID] = { status: 'completed', text: t('statusSuccess') }; break; default: out[agentID] = { status: 'idle', text: t('idle') }; } }); return out; }, [runtimeTasks, t]); const dispatchSubagentTask = async () => { const task = dispatchTask.trim(); const agentID = dispatchAgentID.trim(); if (!task || !agentID) return; await ui.withLoading(async () => { const r = await fetch(`/webui/api/subagents_runtime${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'spawn', agent_id: agentID, task, label: dispatchLabel.trim(), origin_channel: 'webui', origin_chat_id: 'subagent-group', }), }); if (!r.ok) { throw new Error(await r.text()); } setDispatchTask(''); setDispatchLabel(''); await Promise.all([loadSubagentGroup(), loadRegistryAgents(), loadRuntimeTasks()]); await ui.notify({ title: t('saved'), message: t('subagentTaskDispatched') }); }, t('creating')).catch(async (err) => { await ui.notify({ title: t('requestFailed'), message: err instanceof Error ? err.message : String(err) }); }); }; const toggleStreamAgent = (agent: string) => { setSelectedStreamAgents((prev) => ( prev.includes(agent) ? prev.filter((item) => item !== agent) : [...prev, agent] )); }; return (
{chatTab === 'main' && ( )}
{chatTab === 'subagents' && (
{streamActors.map((agent) => ( ))}
)}
{chatTab === 'subagents' && (
{t('subagentDispatch')}
{t('subagentDispatchHint')}