diff --git a/backend/app/agent/service.py b/backend/app/agent/service.py index 8c0a5dc..0c86c90 100644 --- a/backend/app/agent/service.py +++ b/backend/app/agent/service.py @@ -109,6 +109,9 @@ class AIAgentService: input_state = {"messages": [{"role": "user", "content": message}]} context = GraphContext(user_id=user_id) + current_node = None + tool_calls_in_progress = {} + async for chunk in graph.astream( input_state, config=config, @@ -123,35 +126,113 @@ class AIAgentService: if chunk_type == "messages": message_chunk, metadata = chunk["data"] node_name = metadata.get("langgraph_node", "unknown") + + # 检测节点变化,发送节点开始事件 + if node_name != current_node: + if current_node: + yield { + "type": "node_end", + "node": current_node + } + yield { + "type": "node_start", + "node": node_name + } + current_node = node_name + + # 处理消息内容 token_content = getattr(message_chunk, 'content', str(message_chunk)) reasoning_token = "" if hasattr(message_chunk, 'additional_kwargs'): reasoning_token = message_chunk.additional_kwargs.get("reasoning_content", "") - processed_event = { - "type": "llm_token", - "node": node_name, - "token": token_content, - "reasoning_token": reasoning_token, - "metadata": metadata - } + # 处理思考过程 + if reasoning_token: + processed_event = { + "type": "reasoning", + "node": node_name, + "content": reasoning_token + } + # 处理工具调用 + elif hasattr(message_chunk, 'tool_calls') and message_chunk.tool_calls: + for tool_call in message_chunk.tool_calls: + tool_call_id = tool_call.get("id", "") + tool_name = tool_call.get("name", "") + tool_args = tool_call.get("args", {}) + + # 记录工具调用开始 + if tool_call_id not in tool_calls_in_progress: + tool_calls_in_progress[tool_call_id] = { + "name": tool_name, + "args": tool_args + } + yield { + "type": "tool_call_start", + "tool": tool_name, + "args": tool_args, + "id": tool_call_id + } + # 处理普通 token + elif token_content: + processed_event = { + "type": "llm_token", + "node": node_name, + "content": token_content + } + elif chunk_type == "updates": updates_data = chunk["data"] serialized_data = self._serialize_value(updates_data) + + # 检查是否有人工审核请求 + if "review_pending" in serialized_data and serialized_data["review_pending"]: + review_id = serialized_data.get("review_id", "") + content_to_review = serialized_data.get("content_to_review", "") + yield { + "type": "human_review_request", + "review_id": review_id, + "content": content_to_review + } + + # 检查是否有工具结果 + if "messages" in serialized_data: + for msg in serialized_data["messages"]: + # 检测工具结果消息 + if msg.get("role") == "tool": + tool_call_id = msg.get("tool_call_id", "") + tool_name = msg.get("name", "") + tool_output = msg.get("content", "") + + if tool_call_id in tool_calls_in_progress: + yield { + "type": "tool_call_end", + "tool": tool_name, + "id": tool_call_id, + "result": tool_output + } + del tool_calls_in_progress[tool_call_id] + processed_event = { "type": "state_update", "data": serialized_data } - if "messages" in serialized_data: - processed_event["messages"] = serialized_data["messages"] + elif chunk_type == "custom": serialized_data = self._serialize_value(chunk["data"]) processed_event = { "type": "custom", "data": serialized_data } - else: - continue if processed_event: - yield processed_event \ No newline at end of file + yield processed_event + + # 发送结束事件 + if current_node: + yield { + "type": "node_end", + "node": current_node + } + yield { + "type": "done" + } \ No newline at end of file diff --git a/frontend/FRONTEND_GUIDE.md b/frontend/FRONTEND_GUIDE.md new file mode 100644 index 0000000..257c4de --- /dev/null +++ b/frontend/FRONTEND_GUIDE.md @@ -0,0 +1,413 @@ +# AI Agent 前端展示 - 完整实现文档 + +## 一、概述 + +本文档描述了 AI Agent 对话系统的前端展示实现,包括: +- 思考过程展示 +- 工具调用与结果展示 +- 最终回答流式渲染 +- 人工介入确认显示 + +## 二、后端 SSE 事件类型 + +### 事件类型汇总 + +| 事件类型 | 说明 | 数据结构 | +|---------|------|---------| +| `node_start` | 节点开始执行 | `{ type: "node_start", node: string }` | +| `node_end` | 节点执行结束 | `{ type: "node_end", node: string }` | +| `reasoning` | 思考过程 token | `{ type: "reasoning", node: string, content: string }` | +| `tool_call_start` | 工具调用开始 | `{ type: "tool_call_start", tool: string, args: any, id: string }` | +| `tool_call_end` | 工具调用结束 | `{ type: "tool_call_end", tool: string, id: string, result: string }` | +| `llm_token` | 最终回答 token | `{ type: "llm_token", node: string, content: string }` | +| `human_review_request` | 人工审核请求 | `{ type: "human_review_request", review_id: string, content: string }` | +| `state_update` | 状态更新 | `{ type: "state_update", data: any }` | +| `custom` | 自定义事件 | `{ type: "custom", data: any }` | +| `done` | 对话完成 | `{ type: "done" }` | + +### 事件处理流程 + +``` +用户消息 + ↓ +[node_start] llm_call + ↓ +[reasoning] 思考过程流式输出 + ↓ +[tool_call_start] 工具调用开始 + ↓ +[node_end] llm_call + ↓ +[node_start] tool_node + ↓ +[tool_call_end] 工具调用完成,返回结果 + ↓ +[node_end] tool_node + ↓ +[node_start] llm_call + ↓ +[llm_token] 最终回答流式输出 + ↓ +[node_end] llm_call + ↓ +[human_review_request] 人工审核请求(如有) + ↓ +[done] +``` + +## 三、前端组件结构 + +### 组件树 + +``` +ChatContainer (主容器) +├── useChat (自定义 Hook) +│ ├── ApiClient (API 客户端) +│ └── 状态管理 +├── UserMessage (用户消息) +└── AssistantMessage (AI 消息) + ├── ReasoningSection (思考过程) + ├── ToolCallCard[] (工具调用卡片) + ├── HumanReviewCard (人工审核卡片) + └── 最终回答内容 +``` + +## 四、视觉设计规范 + +### 1. 思考区(Reasoning Section) + +```tsx +// 设计规范 +{ + icon: '💭', + style: { + background: 'bg-gray-50', + border: 'border-gray-200', + text: 'text-gray-600 italic', + }, + interaction: { + collapsible: true, + streaming: true, + } +} +``` + +### 2. 工具调用区(Tool Call Card) + +```tsx +// 设计规范 +{ + icon: '⚙️', + statusIcons: { + pending: '⏳', + running: '🔄', + success: '✅', + error: '❌', + }, + colors: { + pending: 'border-gray-300 bg-gray-50', + running: 'border-blue-300 bg-blue-50', + success: 'border-green-300 bg-green-50', + error: 'border-red-300 bg-red-50', + }, + features: { + argsCollapsible: true, + resultCollapsible: true, + } +} +``` + +### 3. 最终回答区(Final Answer) + +```tsx +// 设计规范 +{ + style: { + background: 'bg-blue-50', + border: 'border-blue-100', + }, + interaction: { + streaming: true, + cursorBlink: true, + }, + actions: { + copy: true, + feedback: true, + } +} +``` + +### 4. 人工审核区(Human Review Card) + +```tsx +// 设计规范 +{ + icon: '👤', + style: { + background: 'bg-yellow-50', + border: 'border-yellow-300', + }, + actions: { + approve: true, + reject: true, + modify: true, + }, + fields: { + contentToReview: true, + comment: true, + modifiedContent: true, + } +} +``` + +## 五、使用示例 + +### 基本使用 + +```tsx +import React from 'react'; +import ChatContainer from './components/ChatContainer'; + +function App() { + return ( +
+ +
+ ); +} + +export default App; +``` + +### 自定义 API 客户端 + +```tsx +import { ApiClient } from './components/useChat'; + +const customClient = new ApiClient('http://my-custom-backend:8080'); + +// 流式对话 +async function streamChat() { + for await (const event of customClient.chatStream( + '你好', + 'thread-1', + 'zhipu' + )) { + console.log('Event:', event); + } +} + +// 审核操作 +await customClient.approveReview('review-123', 'user@example.com', '内容正确'); +await customClient.rejectReview('review-123', 'user@example.com', '内容有误'); +await customClient.modifyReview( + 'review-123', + 'user@example.com', + '修改后的内容', + '调整了措辞' +); +``` + +### 自定义样式 + +```tsx +import { AssistantMessage } from './components/ChatContainer'; + +// 自定义组件 +const CustomAssistantMessage = ({ message }) => ( +
+ {/* 自定义思考区 */} + + + {/* 自定义工具卡片 */} + {message.toolCalls.map(tc => ( + + ))} + + {/* 自定义审核卡片 */} + {message.humanReview && ( + + )} + + {/* 自定义回答 */} +
{message.content}
+
+); +``` + +## 六、后端修改说明 + +### 修改的文件 + +1. `backend/app/agent/service.py` - 补充完整 SSE 事件类型 + +### 新增的事件处理逻辑 + +```python +# 节点开始/结束事件 +if node_name != current_node: + if current_node: + yield { "type": "node_end", "node": current_node } + yield { "type": "node_start", "node": node_name } + current_node = node_name + +# 思考过程事件 +if reasoning_token: + yield { "type": "reasoning", "node": node_name, "content": reasoning_token } + +# 工具调用开始事件 +if tool_call_id not in tool_calls_in_progress: + yield { + "type": "tool_call_start", + "tool": tool_name, + "args": tool_args, + "id": tool_call_id + } + +# 工具调用结束事件 +if msg.get("role") == "tool": + yield { + "type": "tool_call_end", + "tool": tool_name, + "id": tool_call_id, + "result": tool_output + } + +# 人工审核请求事件 +if "review_pending" in serialized_data and serialized_data["review_pending"]: + yield { + "type": "human_review_request", + "review_id": review_id, + "content": content_to_review + } +``` + +## 七、状态管理 + +### Message 接口 + +```typescript +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + reasoning: string; + toolCalls: ToolCall[]; + humanReview?: HumanReview; + isLoading: boolean; + timestamp: Date; +} +``` + +### ToolCall 接口 + +```typescript +interface ToolCall { + id: string; + tool: string; + args: any; + status: 'pending' | 'running' | 'success' | 'error'; + result?: string; +} +``` + +### HumanReview 接口 + +```typescript +interface HumanReview { + reviewId: string; + content: string; + status: 'pending' | 'approved' | 'rejected' | 'modified'; + comment?: string; + modifiedContent?: string; +} +``` + +## 八、文件清单 + +### 后端文件 + +| 文件 | 说明 | +|------|------| +| `backend/app/agent/service.py` | 补充完整 SSE 事件类型 | +| `backend/app/agent_subgraphs/common/human_review.py` | 人工审核功能(已有) | + +### 前端文件 + +| 文件 | 说明 | +|------|------| +| `frontend/src/components/useChat.ts` | 自定义 Hook + API 客户端 | +| `frontend/src/components/ChatContainer.tsx` | 完整 UI 组件 | + +### 文档文件 + +| 文件 | 说明 | +|------|------| +| `backend/docs/RAG_EVALUATION_GUIDE.md` | RAG 评估指南 | +| `frontend/FRONTEND_GUIDE.md` | 本文档 | + +## 九、测试方法 + +### 测试用例 1:简单对话 + +输入: +``` +你好,请介绍一下你自己 +``` + +预期输出: +``` +[思考过程] 我需要介绍自己... +[最终回答] 你好!我是 AI Agent... +``` + +### 测试用例 2:工具调用 + +输入: +``` +请帮我查询北京的天气 +``` + +预期输出: +``` +[思考过程] 用户要查询天气... +[tool_call_start] get_weather { city: "北京" } +[tool_call_end] 结果: "北京, 晴天, 25°C" +[最终回答] 北京今天天气是晴,气温 25°C... +``` + +### 测试用例 3:人工审核 + +输入: +``` +请生成一份重要邮件内容 +``` + +预期输出: +``` +[思考过程] 用户要生成邮件... +[最终回答] 邮件内容... +[human_review_request] 需要审核... +[审核卡片] 通过 / 拒绝 / 修改 +``` + +## 十、常见问题 + +### Q: 如何自定义样式? + +A: 复制组件文件,修改 className 或样式对象即可。 + +### Q: 如何添加新的事件类型? + +A: 1. 在后端添加 yield 语句,2. 在前端 useChat 中添加 case 分支,3. 在 UI 中添加展示组件。 + +### Q: 如何处理多个工具调用? + +A: ToolCallCard 组件支持数组,每个工具调用独立显示。 + +### Q: 如何集成现有项目? + +A: 1. 复制后端修改,2. 复制前端组件,3. 根据项目调整 API 端点。 \ No newline at end of file diff --git a/frontend/src/components/ChatContainer.tsx b/frontend/src/components/ChatContainer.tsx new file mode 100644 index 0000000..8b6d138 --- /dev/null +++ b/frontend/src/components/ChatContainer.tsx @@ -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 = ({ content }) => { + const [isExpanded, setIsExpanded] = useState(true); + + if (!content) return null; + + return ( +
+ + {isExpanded && ( +
+

{content}

+
+ )} +
+ ); +}; + +interface ToolCallCardProps { + toolCall: ToolCall; +} + +export const ToolCallCard: React.FC = ({ 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 ( +
+
+
+ ⚙️ + {toolCall.tool} + {getStatusIcon()} +
+
+ {Object.keys(toolCall.args).length > 0 && ( + + )} + {toolCall.result && ( + + )} +
+
+ + {showArgs && Object.keys(toolCall.args).length > 0 && ( +
+
+            {JSON.stringify(toolCall.args, null, 2)}
+          
+
+ )} + + {showResult && toolCall.result && ( +
+
+            {toolCall.result}
+          
+
+ )} +
+ ); +}; + +interface HumanReviewCardProps { + review: HumanReview; + onAction: (action: 'approve' | 'reject' | 'modify', comment?: string, modifiedContent?: string) => void; +} + +export const HumanReviewCard: React.FC = ({ 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 ( +
+
+
+ 👤 + 人工审核确认 + {getStatusIcon()} + {getStatusText()} +
+
+ +
+

待审核内容:

+

{review.content}

+
+ + {review.status === 'pending' && ( +
+
+ +