feat: 完善 SSE 事件类型,添加完整 React 组件支持思考过程、工具调用、人工审核
Some checks failed
构建并部署 AI Agent 服务 / deploy (push) Failing after 6m9s

This commit is contained in:
2026-04-26 16:05:44 +08:00
parent 92863e86dc
commit 7a769fab14
4 changed files with 1157 additions and 12 deletions

View File

@@ -0,0 +1,379 @@
import React, { useState } from 'react';
import { Message, useChat, ToolCall, HumanReview } from './useChat';
interface ReasoningSectionProps {
content: string;
}
export const ReasoningSection: React.FC<ReasoningSectionProps> = ({ content }) => {
const [isExpanded, setIsExpanded] = useState(true);
if (!content) return null;
return (
<div className="my-3">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 text-sm mb-1"
>
<span className="text-lg">💭</span>
<span></span>
<span className="text-xs">{isExpanded ? '▼' : '▶'}</span>
</button>
{isExpanded && (
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<p className="text-gray-600 italic whitespace-pre-wrap">{content}</p>
</div>
)}
</div>
);
};
interface ToolCallCardProps {
toolCall: ToolCall;
}
export const ToolCallCard: React.FC<ToolCallCardProps> = ({ toolCall }) => {
const [showArgs, setShowArgs] = useState(false);
const [showResult, setShowResult] = useState(false);
const getStatusIcon = () => {
switch (toolCall.status) {
case 'pending': return '⏳';
case 'running': return '🔄';
case 'success': return '✅';
case 'error': return '❌';
default: return '⚙️';
}
};
const getStatusColor = () => {
switch (toolCall.status) {
case 'success': return 'border-green-300 bg-green-50';
case 'error': return 'border-red-300 bg-red-50';
case 'running': return 'border-blue-300 bg-blue-50';
default: return 'border-gray-300 bg-gray-50';
}
};
return (
<div className={`my-3 border rounded-lg p-3 ${getStatusColor()}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg"></span>
<span className="font-medium text-gray-800">{toolCall.tool}</span>
<span className="text-xl">{getStatusIcon()}</span>
</div>
<div className="flex gap-2">
{Object.keys(toolCall.args).length > 0 && (
<button
onClick={() => setShowArgs(!showArgs)}
className="text-xs text-blue-600 hover:text-blue-800"
>
{showArgs ? '▼' : '▶'}
</button>
)}
{toolCall.result && (
<button
onClick={() => setShowResult(!showResult)}
className="text-xs text-green-600 hover:text-green-800"
>
{showResult ? '▼' : '▶'}
</button>
)}
</div>
</div>
{showArgs && Object.keys(toolCall.args).length > 0 && (
<div className="mt-2 bg-white rounded p-2 text-sm">
<pre className="text-gray-700 whitespace-pre-wrap overflow-x-auto">
{JSON.stringify(toolCall.args, null, 2)}
</pre>
</div>
)}
{showResult && toolCall.result && (
<div className="mt-2 bg-white rounded p-2 text-sm">
<pre className="text-gray-700 whitespace-pre-wrap overflow-x-auto">
{toolCall.result}
</pre>
</div>
)}
</div>
);
};
interface HumanReviewCardProps {
review: HumanReview;
onAction: (action: 'approve' | 'reject' | 'modify', comment?: string, modifiedContent?: string) => void;
}
export const HumanReviewCard: React.FC<HumanReviewCardProps> = ({ review, onAction }) => {
const [comment, setComment] = useState('');
const [modifiedContent, setModifiedContent] = useState(review.content);
const [showModify, setShowModify] = useState(false);
const getStatusIcon = () => {
switch (review.status) {
case 'pending': return '⏳';
case 'approved': return '✅';
case 'rejected': return '❌';
case 'modified': return '✏️';
default: return '👤';
}
};
const getStatusText = () => {
switch (review.status) {
case 'pending': return '待审核';
case 'approved': return '已通过';
case 'rejected': return '已拒绝';
case 'modified': return '已修改';
default: return '未知';
}
};
return (
<div className="my-3 border border-yellow-300 bg-yellow-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-xl">👤</span>
<span className="font-medium text-gray-800"></span>
<span className="text-xl">{getStatusIcon()}</span>
<span className="text-sm text-yellow-700">{getStatusText()}</span>
</div>
</div>
<div className="bg-white rounded p-3 mb-3">
<p className="text-sm text-gray-500 mb-1"></p>
<p className="text-gray-800 whitespace-pre-wrap">{review.content}</p>
</div>
{review.status === 'pending' && (
<div className="space-y-3">
<div>
<label className="text-sm text-gray-600 mb-1 block"></label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full border rounded p-2 text-sm"
rows={2}
placeholder="请输入审核意见..."
/>
</div>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => onAction('approve', comment)}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
</button>
<button
onClick={() => onAction('reject', comment)}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
</button>
<button
onClick={() => setShowModify(!showModify)}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
</button>
</div>
{showModify && (
<div className="bg-white rounded p-3 border">
<label className="text-sm text-gray-600 mb-1 block"></label>
<textarea
value={modifiedContent}
onChange={(e) => setModifiedContent(e.target.value)}
className="w-full border rounded p-2 text-sm"
rows={4}
/>
<button
onClick={() => onAction('modify', comment, modifiedContent)}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
</button>
</div>
)}
</div>
)}
{(review.status === 'approved' || review.status === 'rejected' || review.status === 'modified') && (
<div className="bg-white rounded p-3">
<p className="text-sm text-gray-500 mb-1"></p>
{review.comment && <p className="text-gray-600">{review.comment}</p>}
{review.modifiedContent && (
<p className="text-gray-600 mt-2">{review.modifiedContent}</p>
)}
</div>
)}
</div>
);
};
interface AssistantMessageProps {
message: Message;
onReviewAction?: (review: HumanReview, action: 'approve' | 'reject' | 'modify', comment?: string, modifiedContent?: string) => void;
}
export const AssistantMessage: React.FC<AssistantMessageProps> = ({ message, onReviewAction }) => {
return (
<div className="flex gap-3 my-4">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-medium shrink-0">
AI
</div>
<div className="flex-1">
<ReasoningSection content={message.reasoning} />
{message.toolCalls.map(toolCall => (
<ToolCallCard key={toolCall.id} toolCall={toolCall} />
))}
{message.humanReview && onReviewAction && (
<HumanReviewCard
review={message.humanReview}
onAction={(action, comment, modifiedContent) =>
onReviewAction(message.humanReview!, action, comment, modifiedContent)
}
/>
)}
{message.content && (
<div className="bg-blue-50 rounded-lg p-3 border border-blue-100">
<p className="text-gray-800 whitespace-pre-wrap">{message.content}</p>
{message.isLoading && (
<span className="inline-block w-2 h-5 bg-blue-400 animate-pulse ml-1 align-middle"></span>
)}
</div>
)}
</div>
</div>
);
};
interface UserMessageProps {
message: Message;
}
export const UserMessage: React.FC<UserMessageProps> = ({ message }) => {
return (
<div className="flex gap-3 my-4 justify-end">
<div className="flex-1 max-w-[80%]">
<div className="bg-gray-800 text-white rounded-lg p-3">
<p className="whitespace-pre-wrap">{message.content}</p>
</div>
</div>
<div className="w-8 h-8 bg-gray-500 rounded-full flex items-center justify-center text-white font-medium shrink-0">
</div>
</div>
);
};
interface ChatInputProps {
onSend: (text: string) => void;
disabled: boolean;
}
export const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled }) => {
const [input, setInput] = useState('');
const handleSend = () => {
if (input.trim() && !disabled) {
onSend(input.trim());
setInput('');
}
};
return (
<div className="border-t border-gray-200 p-4">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
disabled={disabled}
placeholder="发消息或按住说话..."
className="flex-1 border rounded-lg p-3 disabled:bg-gray-100"
/>
<button
onClick={handleSend}
disabled={disabled}
className="px-4 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300"
>
</button>
</div>
<div className="flex gap-2 mt-2">
<button className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm">
<span>🔍</span>
</button>
<button className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm">
<span>🌐</span>
</button>
</div>
</div>
);
};
interface ChatContainerProps {
model?: string;
threadId?: string;
}
export const ChatContainer: React.FC<ChatContainerProps> = ({ model = 'zhipu', threadId: propThreadId }) => {
const [threadId] = useState(() => propThreadId || Date.now().toString());
const { messages, isLoading, sendMessage, handleReviewAction } = useChat();
const handleSend = (text: string) => {
sendMessage(text, threadId, model);
};
const handleReview = (
review: HumanReview,
action: 'approve' | 'reject' | 'modify',
comment?: string,
modifiedContent?: string
) => {
handleReviewAction(review.reviewId, action, 'user', comment, modifiedContent);
};
return (
<div className="flex flex-col h-screen max-w-4xl mx-auto">
<div className="border-b border-gray-200 p-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800">AI Agent </h1>
<div className="text-sm text-gray-500">{model}</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{messages.map(message => (
message.role === 'user' ? (
<UserMessage key={message.id} message={message} />
) : (
<AssistantMessage
key={message.id}
message={message}
onReviewAction={handleReview}
/>
)
))}
{messages.length === 0 && (
<div className="text-center text-gray-500 mt-20">
<p className="text-lg"> AI Agent </p>
<p className="text-sm mt-2"></p>
</div>
)}
</div>
<ChatInput onSend={handleSend} disabled={isLoading} />
</div>
);
};
export default ChatContainer;

View File

@@ -0,0 +1,272 @@
import { useState, useEffect, useRef, useCallback } from 'react';
export interface SSEEvent {
type: string;
[key: string]: any;
}
export interface ToolCall {
id: string;
tool: string;
args: any;
status: 'pending' | 'running' | 'success' | 'error';
result?: string;
}
export interface HumanReview {
reviewId: string;
content: string;
status: 'pending' | 'approved' | 'rejected' | 'modified';
comment?: string;
modifiedContent?: string;
}
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
reasoning: string;
toolCalls: ToolCall[];
humanReview?: HumanReview;
isLoading: boolean;
timestamp: Date;
}
const API_BASE = 'http://localhost:8079';
export class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = API_BASE) {
this.baseUrl = baseUrl;
}
async* chatStream(message: string, threadId: string, model: string, userId: string = 'default_user'): AsyncGenerator<SSEEvent> {
const response = await fetch(`${this.baseUrl}/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message, thread_id: threadId, model, user_id: userId }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No reader available');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
const event = JSON.parse(data);
yield event;
} catch (e) {
console.error('Failed to parse SSE event:', e);
}
}
}
}
}
async approveReview(reviewId: string, reviewer: string, comment: string = ''): Promise<boolean> {
const response = await fetch(`${this.baseUrl}/reviews/${reviewId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ review_id: reviewId, reviewer, comment }),
});
return response.ok;
}
async rejectReview(reviewId: string, reviewer: string, comment: string = ''): Promise<boolean> {
const response = await fetch(`${this.baseUrl}/reviews/${reviewId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ review_id: reviewId, reviewer, comment }),
});
return response.ok;
}
async modifyReview(reviewId: string, reviewer: string, modifiedContent: string, comment: string = ''): Promise<boolean> {
const response = await fetch(`${this.baseUrl}/reviews/${reviewId}/modify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ review_id: reviewId, reviewer, modified_content: modifiedContent, comment }),
});
return response.ok;
}
}
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [apiClient] = useState(() => new ApiClient());
const currentMessageRef = useRef<Message | null>(null);
const addMessage = useCallback((role: 'user' | 'assistant', content: string = '') => {
const message: Message = {
id: Date.now().toString(),
role,
content,
reasoning: '',
toolCalls: [],
isLoading: role === 'assistant',
timestamp: new Date(),
};
setMessages(prev => [...prev, message]);
currentMessageRef.current = message;
return message;
}, []);
const updateCurrentMessage = useCallback((updates: Partial<Message>) => {
if (!currentMessageRef.current) return;
setMessages(prev => prev.map(msg =>
msg.id === currentMessageRef.current!.id
? { ...msg, ...updates }
: msg
));
currentMessageRef.current = { ...currentMessageRef.current, ...updates };
}, []);
const sendMessage = useCallback(async (text: string, threadId: string, model: string = 'zhipu') => {
setIsLoading(true);
addMessage('user', text);
addMessage('assistant', '');
try {
for await (const event of apiClient.chatStream(text, threadId, model)) {
switch (event.type) {
case 'node_start':
console.log('Node started:', event.node);
break;
case 'node_end':
console.log('Node ended:', event.node);
break;
case 'reasoning':
updateCurrentMessage({
reasoning: (currentMessageRef.current?.reasoning || '') + event.content
});
break;
case 'tool_call_start':
updateCurrentMessage({
toolCalls: [
...(currentMessageRef.current?.toolCalls || []),
{
id: event.id,
tool: event.tool,
args: event.args,
status: 'running' as const
}
]
});
break;
case 'tool_call_end':
updateCurrentMessage({
toolCalls: (currentMessageRef.current?.toolCalls || []).map(tc =>
tc.id === event.id
? { ...tc, status: 'success' as const, result: event.result }
: tc
)
});
break;
case 'llm_token':
updateCurrentMessage({
content: (currentMessageRef.current?.content || '') + event.content
});
break;
case 'human_review_request':
updateCurrentMessage({
humanReview: {
reviewId: event.review_id,
content: event.content,
status: 'pending' as const
}
});
break;
case 'done':
updateCurrentMessage({ isLoading: false });
break;
}
}
} catch (error) {
console.error('Chat stream error:', error);
updateCurrentMessage({
content: '抱歉,发生了错误,请重试。',
isLoading: false
});
} finally {
setIsLoading(false);
}
}, [addMessage, updateCurrentMessage, apiClient]);
const handleReviewAction = useCallback(async (
reviewId: string,
action: 'approve' | 'reject' | 'modify',
reviewer: string,
comment?: string,
modifiedContent?: string
) => {
try {
let success = false;
switch (action) {
case 'approve':
success = await apiClient.approveReview(reviewId, reviewer, comment);
break;
case 'reject':
success = await apiClient.rejectReview(reviewId, reviewer, comment);
break;
case 'modify':
if (modifiedContent) {
success = await apiClient.modifyReview(reviewId, reviewer, modifiedContent, comment);
}
break;
}
if (success) {
updateCurrentMessage({
humanReview: {
...currentMessageRef.current!.humanReview!,
status: action as any,
comment,
modifiedContent
}
});
}
} catch (error) {
console.error('Review action failed:', error);
}
}, [updateCurrentMessage, apiClient]);
return {
messages,
isLoading,
sendMessage,
handleReviewAction,
};
}