Files
clawgo/webui/src/pages/Chat.tsx
2026-03-03 10:36:53 +08:00

225 lines
9.1 KiB
TypeScript

import React, { useState, useRef, useEffect } 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 { ChatItem } from '../types';
const Chat: React.FC = () => {
const { t } = useTranslation();
const { q, sessions } = useAppContext();
const [chat, setChat] = useState<ChatItem[]>([]);
const [msg, setMsg] = useState('');
const [fileSelected, setFileSelected] = useState(false);
const [sessionKey, setSessionKey] = useState('main');
const chatEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chat]);
const loadHistory = async () => {
try {
const qs = q ? `${q}&session=${encodeURIComponent(sessionKey)}` : `?session=${encodeURIComponent(sessionKey)}`;
const r = await fetch(`/webui/api/chat/history${qs}`);
if (!r.ok) return;
const j = await r.json();
const arr = Array.isArray(j.messages) ? j.messages : [];
const mapped: ChatItem[] = arr.map((m: any) => {
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' ? 'User' : role === 'tool' ? 'Exec' : role === 'system' ? 'System' : 'Agent';
if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
role = 'exec';
label = 'Exec';
text = `${text}\n[tool calls: ${m.tool_calls.map((x: any) => x?.function?.name || x?.name).filter(Boolean).join(', ')}]`;
}
if (baseRole === 'tool') {
text = `[tool output]\n${text}`;
}
return { role, text, label };
});
setChat(mapped);
} 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}]` : '');
setChat((prev) => [...prev, { role: 'user', text: userText, label: 'User' }]);
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 = '';
setChat((prev) => [...prev, { role: 'assistant', text: '', label: 'Agent' }]);
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
assistantText += chunk;
setChat((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', text: assistantText, label: 'Agent' };
return next;
});
}
// refresh full persisted history (includes tool/internal traces)
loadHistory();
} catch (e) {
setChat((prev) => [...prev, { role: 'system', text: 'Error: Failed to get response from server.', label: 'System' }]);
}
}
useEffect(() => {
loadHistory();
}, [q, sessionKey]);
useEffect(() => {
if (!sessions || sessions.length === 0) return;
if (!sessions.some(s => s.key === sessionKey)) {
setSessionKey(sessions[0].key);
}
}, [sessions]);
return (
<div className="flex h-full">
<div className="flex-1 flex flex-col bg-zinc-950/50">
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<h2 className="text-sm text-zinc-300 font-medium">Session</h2>
<select value={sessionKey} onChange={(e)=>setSessionKey(e.target.value)} className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200">
{(sessions || []).map((s:any)=> <option key={s.key} value={s.key}>{s.key}</option>)}
</select>
</div>
<button onClick={loadHistory} className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-zinc-800 hover:bg-zinc-700"><RefreshCw className="w-3 h-3"/>Reload History</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{chat.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-zinc-500 space-y-4">
<div className="w-16 h-16 rounded-2xl bg-zinc-900 flex items-center justify-center border border-zinc-800">
<MessageSquare className="w-8 h-8 text-zinc-600" />
</div>
<p className="text-sm font-medium">{t('startConversation')}</p>
</div>
) : (
chat.map((m, i) => {
const isUser = m.role === 'user';
const isExec = m.role === 'tool' || m.role === 'exec';
const isSystem = m.role === 'system';
const avatar = isUser ? 'U' : isExec ? 'E' : isSystem ? 'S' : 'A';
const avatarClass = isUser
? 'bg-indigo-600/90 text-white'
: isExec
? 'bg-amber-600/80 text-white'
: isSystem
? 'bg-zinc-700 text-zinc-100'
: 'bg-emerald-600/80 text-white';
const bubbleClass = isUser
? 'bg-indigo-600 text-white rounded-br-sm'
: isExec
? 'bg-amber-500/10 text-amber-100 rounded-bl-sm border border-amber-500/30'
: isSystem
? 'bg-zinc-700/40 text-zinc-100 rounded-bl-sm border border-zinc-600/40'
: 'bg-zinc-800/80 text-zinc-200 rounded-bl-sm border border-zinc-700/50';
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
key={i}
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
>
<div className={`flex items-start gap-2 max-w-[96%] ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`w-7 h-7 mt-1 rounded-full text-xs font-bold flex items-center justify-center shrink-0 ${avatarClass}`}>{avatar}</div>
<div className={`max-w-[92%] rounded-2xl px-4 py-3 shadow-sm ${bubbleClass}`}>
<div className="text-[11px] opacity-80 mb-1">{m.label || (isUser ? 'User' : isExec ? 'Exec' : isSystem ? 'System' : 'Agent')}</div>
<p className="whitespace-pre-wrap text-[14px] leading-relaxed">{m.text}</p>
</div>
</div>
</motion.div>
);
})
)}
<div ref={chatEndRef} />
</div>
<div className="p-4 bg-zinc-950 border-t border-zinc-800">
<div className="max-w-4xl mx-auto relative flex items-center">
<input
type="file"
id="file"
className="hidden"
onChange={(e) => setFileSelected(!!e.target.files?.[0])}
/>
<label
htmlFor="file"
className={`absolute left-3 p-2 rounded-full cursor-pointer transition-colors ${
fileSelected ? 'text-indigo-400 bg-indigo-500/10' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
}`}
>
<Paperclip className="w-5 h-5" />
</label>
<input
value={msg}
onChange={(e) => setMsg(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder={t('typeMessage')}
className="w-full bg-zinc-900 border border-zinc-800 rounded-full pl-14 pr-14 py-3.5 text-[15px] focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-all placeholder:text-zinc-500 shadow-sm"
/>
<button
onClick={send}
disabled={!msg.trim() && !fileSelected}
className="absolute right-2 p-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-full transition-colors shadow-sm"
>
<Send className="w-4 h-4 ml-0.5" />
</button>
</div>
</div>
</div>
</div>
);
};
export default Chat;