## 新增功能 - 文件管理模块:分类树 + 文件上传/下载/删除 - 文件上传支持拖拽(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>
138 lines
5.5 KiB
Python
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
|