198 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|