## T3: OpenClaw 直连通道
- channels/openclaw.py: OpenClawChannel
- send: POST /api/v1/chat/completions
- poll_reply: GET /api/v1/chat/completions/{msg_id}
- health_check: GET /api/health
- 默认从 settings 读取 upstream/auth_token,channel_config 可覆盖
- channels/factory.py: 注册 ChannelType.OPENCLAW → OpenClawChannel
- Targets.tsx: 通道类型下拉新增「HTTP 通用」和「OpenClaw」选项
- 8 个 OpenClawChannel 单元测试(发送/轮询/超时/健康检查/默认配置)
## T4: 前端 Bundle 优化
- App.tsx: 7 个页面改为 React.lazy 懒加载 + Suspense fallback(Spin)
- vite.config.ts: 精细化 manualChunks
- vendor-antd / vendor-monaco / vendor-charts 独立拆分
- 主 index 68KB → 7KB,页面按需加载
- 无循环依赖警告
## 测试
- 178/178 全绿,覆盖率维持 77%
Co-Authored-By: Claude <noreply@anthropic.com>
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
"""OpenClaw message channel.
|
|
|
|
Connects directly to an OpenClaw instance as an evaluation target.
|
|
Uses the OpenClaw chat API to send messages and poll for replies.
|
|
|
|
Configuration keys (in channel_config, all optional — defaults come from settings):
|
|
base_url Override OpenClaw upstream URL (defaults to AGENTEVAL_OPENCLAW_UPSTREAM)
|
|
auth_token Override auth token (defaults to AGENTEVAL_OPENCLAW_AUTH_TOKEN)
|
|
model Model name for chat completions (default: "doubao-seed-2.0")
|
|
poll_interval Seconds between polls (default 1.0)
|
|
timeout Seconds before poll gives up (default 30.0)
|
|
"""
|
|
|
|
import asyncio
|
|
import uuid
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from agenteval.channels.base import ChannelHealth, EvalChannel, Reply, SendResult
|
|
from agenteval.config import get_settings
|
|
|
|
|
|
class OpenClawChannel(EvalChannel):
|
|
"""Message channel backed by an OpenClaw chat API."""
|
|
|
|
def __init__(self, config: dict[str, Any]):
|
|
settings = get_settings()
|
|
self.base_url: str = config.get("base_url", settings.openclaw_upstream).rstrip("/")
|
|
self.auth_token: str = config.get("auth_token", settings.openclaw_auth_token)
|
|
self.model: str = config.get("model", "doubao-seed-2.0")
|
|
self._poll_interval: float = float(config.get("poll_interval", 1.0))
|
|
|
|
self._client = httpx.AsyncClient(
|
|
headers={
|
|
"Authorization": f"Bearer {self.auth_token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
timeout=30,
|
|
)
|
|
|
|
async def close(self) -> None:
|
|
await self._client.aclose()
|
|
|
|
async def health_check(self) -> ChannelHealth:
|
|
try:
|
|
resp = await self._client.get(f"{self.base_url}/api/health")
|
|
resp.raise_for_status()
|
|
return ChannelHealth(ok=True, message=f"OpenClaw {resp.status_code}")
|
|
except Exception as exc:
|
|
return ChannelHealth(ok=False, message=str(exc))
|
|
|
|
async def send(self, content: str, **kwargs: Any) -> SendResult:
|
|
"""Send a chat message to OpenClaw."""
|
|
payload = {
|
|
"model": self.model,
|
|
"messages": [{"role": "user", "content": content}],
|
|
"stream": False,
|
|
}
|
|
try:
|
|
resp = await self._client.post(
|
|
f"{self.base_url}/api/v1/chat/completions",
|
|
json=payload,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
# Extract the assistant message ID from the response
|
|
msg_id = data.get("id") or str(uuid.uuid4())
|
|
return SendResult(ok=True, question_msg_id=msg_id, raw_response=data)
|
|
except Exception as exc:
|
|
return SendResult(ok=False, error=str(exc))
|
|
|
|
async def poll_reply(
|
|
self,
|
|
question_msg_id: str,
|
|
timeout: float = 30.0,
|
|
poll_interval: float = 1.0,
|
|
) -> Optional[Reply]:
|
|
"""Poll the chat completions endpoint until a reply is available.
|
|
|
|
Uses the conversation ID from the send response to track the thread.
|
|
"""
|
|
interval = poll_interval or self._poll_interval
|
|
deadline = asyncio.get_event_loop().time() + timeout
|
|
|
|
# Re-send with the same conversation to get the latest reply
|
|
while asyncio.get_event_loop().time() < deadline:
|
|
try:
|
|
# Get the thread/messages from the conversation
|
|
resp = await self._client.get(
|
|
f"{self.base_url}/api/v1/chat/completions/{question_msg_id}",
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
choice = (data.get("choices") or [{}])[0]
|
|
reply_content = choice.get("message", {}).get("content", "")
|
|
if reply_content:
|
|
return Reply(
|
|
question_msg_id=question_msg_id,
|
|
content=reply_content,
|
|
raw_message={"text": reply_content, "_raw": data},
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
await asyncio.sleep(interval)
|
|
|
|
return None
|