feat: 完善 SSE 事件类型,添加完整 React 组件支持思考过程、工具调用、人工审核
Some checks failed
构建并部署 AI Agent 服务 / deploy (push) Failing after 6m9s
Some checks failed
构建并部署 AI Agent 服务 / deploy (push) Failing after 6m9s
This commit is contained in:
@@ -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", "")
|
||||
|
||||
# 处理思考过程
|
||||
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,
|
||||
"token": token_content,
|
||||
"reasoning_token": reasoning_token,
|
||||
"metadata": metadata
|
||||
"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
|
||||
|
||||
# 发送结束事件
|
||||
if current_node:
|
||||
yield {
|
||||
"type": "node_end",
|
||||
"node": current_node
|
||||
}
|
||||
yield {
|
||||
"type": "done"
|
||||
}
|
||||
413
frontend/FRONTEND_GUIDE.md
Normal file
413
frontend/FRONTEND_GUIDE.md
Normal file
@@ -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 (
|
||||
<div className="app">
|
||||
<ChatContainer
|
||||
model="zhipu"
|
||||
threadId="my-thread-123"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) => (
|
||||
<div className="my-custom-style">
|
||||
{/* 自定义思考区 */}
|
||||
<MyCustomReasoning content={message.reasoning} />
|
||||
|
||||
{/* 自定义工具卡片 */}
|
||||
{message.toolCalls.map(tc => (
|
||||
<MyCustomToolCall key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
|
||||
{/* 自定义审核卡片 */}
|
||||
{message.humanReview && (
|
||||
<MyCustomReview review={message.humanReview} />
|
||||
)}
|
||||
|
||||
{/* 自定义回答 */}
|
||||
<div className="my-answer">{message.content}</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## 六、后端修改说明
|
||||
|
||||
### 修改的文件
|
||||
|
||||
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 端点。
|
||||
379
frontend/src/components/ChatContainer.tsx
Normal file
379
frontend/src/components/ChatContainer.tsx
Normal 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;
|
||||
272
frontend/src/components/useChat.ts
Normal file
272
frontend/src/components/useChat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user