aura-web/src/components/ChatPreview.tsx

198 lines
6.3 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Button, Input, Space, App as AntApp, Empty, Spin } from 'antd';
import ReactMarkdown from 'react-markdown';
import {
Agent,
ChatMessage,
streamChat,
RetrievedSnippet,
ToolCallTrace
} from '../api';
interface Props {
agent: Agent;
agentId?: string;
}
interface StreamingState {
active: boolean;
text: string;
retrieved: RetrievedSnippet[];
toolCalls: ToolCallTrace[];
}
export default function ChatPreview({ agent, agentId }: Props) {
const { message: msg } = AntApp.useApp();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [streaming, setStreaming] = useState<StreamingState>({
active: false,
text: '',
retrieved: [],
toolCalls: []
});
const bodyRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const scrollBottom = () => {
requestAnimationFrame(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight, behavior: 'smooth' });
});
};
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
const handleSend = async () => {
const text = input.trim();
if (!text || !agentId || sending) return;
setInput('');
setSending(true);
const tempUser: ChatMessage = {
id: 'tmp-' + Date.now(),
role: 'user',
content: text,
createdAt: Date.now()
};
setMessages((m) => [...m, tempUser]);
setStreaming({ active: true, text: '', retrieved: [], toolCalls: [] });
scrollBottom();
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
await streamChat(
agentId,
text,
{
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
onDelta: (chunk) =>
setStreaming((s) => {
const next = { ...s, text: s.text + chunk };
scrollBottom();
return next;
}),
onToolCall: (data) =>
setStreaming((s) => ({
...s,
toolCalls: [...s.toolCalls, { name: data.name, args: data.args, result: { pending: true } }]
})),
onToolResult: (data) =>
setStreaming((s) => {
const list = [...s.toolCalls];
for (let i = list.length - 1; i >= 0; i--) {
if (list[i].name === data.name && (list[i].result as any)?.pending) {
list[i] = { ...list[i], result: data.result };
break;
}
}
return { ...s, toolCalls: list };
}),
onDone: (data) => {
setMessages((m) => [...m.filter((x) => x.id !== tempUser.id), data.user, data.assistant]);
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
scrollBottom();
},
onError: (errMsg) => {
msg.error('预览失败:' + errMsg);
setMessages((m) => m.filter((x) => x.id !== tempUser.id));
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
}
},
ctrl.signal
);
} catch (e: any) {
if (e?.name !== 'AbortError') {
msg.error('请求失败:' + (e?.message ?? e));
}
setMessages((m) => m.filter((x) => x.id !== tempUser.id));
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
} finally {
setSending(false);
}
};
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
return (
<div className="flex flex-col h-full bg-gray-50 border-l">
<div className="p-4 border-b bg-white flex items-center gap-3">
<div
className="w-8 h-8 rounded-full text-white flex items-center justify-center font-bold overflow-hidden"
style={{ background: agent.avatar || '#0891b2' }}
>
{isImageUrl(agent.avatar) ? (
<img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(agent?.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div className="font-semibold text-gray-800"></div>
</div>
<div ref={bodyRef} className="flex-1 overflow-auto p-4 space-y-4">
{messages.length === 0 && !streaming.active ? (
<Empty description="在这里测试你的智能体" style={{ marginTop: 100 }} />
) : (
<div className="flex flex-col gap-4">
{messages.map((m) => (
<div key={m.id} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`bubble ${m.role === 'user' ? 'user' : 'assistant'}`}>
<ReactMarkdown>{m.content}</ReactMarkdown>
</div>
</div>
))}
{streaming.active && (
<div className="flex justify-start">
<div className="bubble assistant">
{streaming.text ? (
<ReactMarkdown>{streaming.text + '▍'}</ReactMarkdown>
) : (
<span className="text-gray-400">...</span>
)}
</div>
</div>
)}
</div>
)}
</div>
<div className="p-4 bg-white border-t">
<div className="flex gap-2">
<Input.TextArea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入消息测试..."
autoSize={{ minRows: 1, maxRows: 4 }}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
disabled={sending || !agentId}
className="rounded-lg"
/>
<Button
type="primary"
onClick={handleSend}
disabled={!agentId}
loading={sending}
style={{ backgroundColor: '#0891b2', height: 'auto' }}
>
</Button>
</div>
{!agentId && (
<div className="text-xs text-gray-400 mt-1"></div>
)}
</div>
</div>
);
}