AgentEvalTool/backend/agenteval/channels/tutu.py
sinohqb a77cd83e6a v0.2.0-dev: 文件管理 + 页面布局统一 + 6 个 bug 修复
## 新增功能
- 文件管理模块:分类树 + 文件上传/下载/删除
- 文件上传支持拖拽(Dragger)+ 手动上传(customRequest 模式)

## 页面布局统一(参照评测执行页)
- 仪表盘/评测对象/评测场景/评测报告 全部改为全高 flex 布局
- 统一内联页头样式(h2 + 竖线分隔 + 描述)
- 表格撑满高度、overflow 处理
- 每页添加刷新按钮

## Bug 修复
- 分类树操作按钮 hover 不可见(CSS 规则缺失)
- 文件上传失败(multipart boundary 缺失)
- LLM API 响应 content blocks 数组格式支持(_extract_content_from_api_response)
- response_time_max_ms 被静默忽略(隐式规则传空 params)
- 空 messages 导致 IndexError 崩溃
- poll_reply 异常中止整个 run(缺 try/catch)
- engine finally 未关闭 session
- 3 个页面 UTC 时间戳解析偏差 8 小时

## 后端
- EvalEngine: poll_reply 异常保护、空 dialog 保护、session 关闭
- LLM API 响应解析支持 content-block-array 格式
- 隐式 response_time 规则正确传递 max_ms 参数

## 前端
- api.ts: 移除手动 Content-Type(让浏览器自动添加 boundary)
- Files.tsx: customRequest 替代 beforeUpload、布局优化
- index.css: 分类树 hover 规则
- Targets/Scenarios/Home/Reports: 全高布局改造
- 3 个页面时间戳改用 formatDateTime()(修复 UTC 偏差)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 15:25:22 +08:00

138 lines
5.5 KiB
Python

"""Tutu API message channel implementation."""
import asyncio
import json
import time
from typing import Any, Optional
import httpx
from agenteval.channels.base import ChannelHealth, EvalChannel, Reply, SendResult
class TutuApiChannel(EvalChannel):
"""Message channel backed by the Tutu chat API.
Uses a single ``httpx.AsyncClient`` per instance to reuse TCP connections
across the many small poll requests during an evaluation run.
"""
def __init__(self, config: dict[str, Any]):
self.base_url = config["base_url"].rstrip("/")
self.token = config["token"]
self.tenant = config["tenant"]
self.chat_channel_id = config["chat_channel_id"]
self.chat_contact_id = config["chat_contact_id"]
self.sender_type = config.get("sender_type", "WORK_WE_CUSTOMER")
self.contact_type = config.get("contact_type", "EXTERNAL")
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=15.0)
return self._client
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
def _build_url(self, path: str) -> str:
return f"{self.base_url}/api/{self.tenant}/{path}"
def _build_headers(self, accept: str = "*/*") -> dict[str, str]:
return {
"accept": accept,
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
async def health_check(self) -> ChannelHealth:
"""Send a lightweight request to verify connectivity."""
client = await self._get_client()
try:
url = self._build_url("v1/chat/message")
params = {
"chatChannelId": self.chat_channel_id,
"chatContactId": self.chat_contact_id,
"page": 0,
"size": 1,
}
resp = await client.get(url, headers=self._build_headers(), params=params)
if resp.status_code == 200:
return ChannelHealth(ok=True, message="通道正常")
return ChannelHealth(ok=False, message=f"HTTP {resp.status_code}: {resp.text[:200]}")
except Exception as exc:
return ChannelHealth(ok=False, message=f"请求异常: {exc}")
async def send(self, content: str, **kwargs: Any) -> SendResult:
"""Send a text message to the configured chat contact."""
payload = {
"chatChannelId": self.chat_channel_id,
"chatContactType": self.contact_type,
"chatContactId": self.chat_contact_id,
"msgType": kwargs.get("msg_type", "text"),
"msgBody": json.dumps({"content": content}, ensure_ascii=False),
"actualSenderType": self.sender_type,
"sender": {"type": self.contact_type},
}
client = await self._get_client()
try:
url = self._build_url("v1/chat/message/sendMsg")
resp = await client.post(url, headers=self._build_headers(), json=payload)
if resp.status_code != 200:
return SendResult(ok=False, error=f"HTTP {resp.status_code}: {resp.text[:500]}")
data = resp.json()
# The reply references the sent message via metadata.questionMsgId == sent msgId.
question_msg_id = data.get("msgId")
return SendResult(ok=True, question_msg_id=question_msg_id, raw_response=data)
except Exception as exc:
return SendResult(ok=False, error=f"发送异常: {exc}")
async def poll_reply(
self,
question_msg_id: str,
timeout: float = 30.0,
poll_interval: float = 1.0,
) -> Optional[Reply]:
"""Poll chat history until a reply matching the questionMsgId arrives."""
deadline = time.time() + timeout
seen_ids: set[str] = set()
client = await self._get_client()
while time.time() < deadline:
try:
url = self._build_url("v1/chat/message")
params = {
"chatChannelId": self.chat_channel_id,
"chatContactId": self.chat_contact_id,
"page": 0,
"size": 20,
}
resp = await client.get(url, headers=self._build_headers(), params=params)
if resp.status_code == 200:
data = resp.json()
records = data.get("data", []) if isinstance(data, dict) else []
for msg in records:
msg_id = msg.get("id") or msg.get("msgId")
if not msg_id or msg_id in seen_ids:
continue
seen_ids.add(msg_id)
meta = msg.get("metadata", {})
if meta.get("questionMsgId") == question_msg_id:
return Reply(
question_msg_id=question_msg_id,
content=msg.get("msgBody"),
sender_name=msg.get("senderName") or msg.get("actualSenderName"),
msg_time=msg.get("msgTime"),
raw_message=msg,
)
except Exception:
pass
await asyncio.sleep(poll_interval)
return None