v0.4-t3t4: OpenClaw 通道 + 前端 bundle 优化
## 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>
This commit is contained in:
parent
e0b69fa2b9
commit
12c1732617
@ -2,6 +2,7 @@
|
||||
|
||||
from agenteval.channels.base import EvalChannel
|
||||
from agenteval.channels.http import HttpChannel
|
||||
from agenteval.channels.openclaw import OpenClawChannel
|
||||
from agenteval.channels.tutu import TutuApiChannel
|
||||
from agenteval.models import ChannelType, EvalTarget
|
||||
|
||||
@ -12,6 +13,7 @@ class ChannelFactory:
|
||||
_mapping: dict[ChannelType, type[EvalChannel]] = {
|
||||
ChannelType.TUTU_API: TutuApiChannel,
|
||||
ChannelType.HTTP: HttpChannel,
|
||||
ChannelType.OPENCLAW: OpenClawChannel,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
108
backend/agenteval/channels/openclaw.py
Normal file
108
backend/agenteval/channels/openclaw.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""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
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { lazy, Suspense, useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Layout, Menu } from 'antd'
|
||||
import { Layout, Menu, Spin } from 'antd'
|
||||
import type { MenuProps } from 'antd'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
@ -11,18 +11,34 @@ import {
|
||||
RobotOutlined,
|
||||
FolderOpenOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import HomePage from './pages/Home'
|
||||
import TargetsPage from './pages/Targets'
|
||||
import ScenariosPage from './pages/Scenarios'
|
||||
import RunsPage from './pages/Runs'
|
||||
import ReportsPage from './pages/Reports'
|
||||
import OpenClawPage from './pages/OpenClaw'
|
||||
import FilesPage from './pages/Files'
|
||||
import TabBar from './components/TabBar'
|
||||
import { useTabStore, type TabItem } from './stores/tabStore'
|
||||
import { colors } from './tokens'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
// Lazy-load pages to split heavy dependencies (@ant-design/charts, Monaco Editor)
|
||||
// into separate chunks. The keep-alive tab pattern still works — each page is
|
||||
// loaded on first access and then stays mounted.
|
||||
const HomePage = lazy(() => import('./pages/Home'))
|
||||
const TargetsPage = lazy(() => import('./pages/Targets'))
|
||||
const ScenariosPage = lazy(() => import('./pages/Scenarios'))
|
||||
const RunsPage = lazy(() => import('./pages/Runs'))
|
||||
const ReportsPage = lazy(() => import('./pages/Reports'))
|
||||
const OpenClawPage = lazy(() => import('./pages/OpenClaw'))
|
||||
const FilesPage = lazy(() => import('./pages/Files'))
|
||||
|
||||
function PageLoader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
}>
|
||||
{children}
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
interface RouteConfig {
|
||||
path: string
|
||||
name: string
|
||||
@ -31,13 +47,13 @@ interface RouteConfig {
|
||||
}
|
||||
|
||||
const routeConfigs: RouteConfig[] = [
|
||||
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, component: () => <HomePage /> },
|
||||
{ path: '/targets', name: '评测对象', icon: <AimOutlined />, component: () => <TargetsPage /> },
|
||||
{ path: '/scenarios', name: '评测场景', icon: <FileTextOutlined />, component: () => <ScenariosPage /> },
|
||||
{ path: '/runs', name: '评测执行', icon: <PlayCircleOutlined />, component: () => <RunsPage /> },
|
||||
{ path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <ReportsPage /> },
|
||||
{ path: '/openclaw', name: 'AI 助手', icon: <RobotOutlined />, component: () => <OpenClawPage /> },
|
||||
{ path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <FilesPage /> },
|
||||
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, component: () => <PageLoader><HomePage /></PageLoader> },
|
||||
{ path: '/targets', name: '评测对象', icon: <AimOutlined />, component: () => <PageLoader><TargetsPage /></PageLoader> },
|
||||
{ path: '/scenarios', name: '评测场景', icon: <FileTextOutlined />, component: () => <PageLoader><ScenariosPage /></PageLoader> },
|
||||
{ path: '/runs', name: '评测执行', icon: <PlayCircleOutlined />, component: () => <PageLoader><RunsPage /></PageLoader> },
|
||||
{ path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <PageLoader><ReportsPage /></PageLoader> },
|
||||
{ path: '/openclaw', name: 'AI 助手', icon: <RobotOutlined />, component: () => <PageLoader><OpenClawPage /></PageLoader> },
|
||||
{ path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <PageLoader><FilesPage /></PageLoader> },
|
||||
]
|
||||
|
||||
const componentMap: Record<string, () => ReactNode> = {}
|
||||
|
||||
@ -198,6 +198,8 @@ export default function TargetsPage() {
|
||||
<Form.Item name="channel_type" label="通道类型" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ value: 'tutu-api', label: 'Tutu API' },
|
||||
{ value: 'http', label: 'HTTP 通用' },
|
||||
{ value: 'openclaw', label: 'OpenClaw' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@ -25,9 +25,19 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
// Monaco Editor — only used by Scenarios page
|
||||
if (id.includes('@monaco-editor') || id.includes('monaco-editor')) {
|
||||
return 'vendor-monaco'
|
||||
}
|
||||
// @ant-design/charts — only used by Home page
|
||||
if (id.includes('@ant-design/charts')) {
|
||||
return 'vendor-charts'
|
||||
}
|
||||
// Ant Design core — used everywhere
|
||||
if (id.includes('antd') || id.includes('@ant-design/icons')) {
|
||||
return 'vendor-antd'
|
||||
}
|
||||
// React + remaining libs — used everywhere, merged to avoid circular imports
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor'
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Unit tests for HttpChannel and async rule evaluation."""
|
||||
"""Unit tests for HttpChannel, OpenClawChannel, and async rule evaluation."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from agenteval.channels.http import HttpChannel, _get_path
|
||||
from agenteval.channels.openclaw import OpenClawChannel
|
||||
from agenteval.channels.base import SendResult
|
||||
from agenteval.evaluation.rules.keyword import KeywordMatchRule
|
||||
from agenteval.evaluation.rules.response_time import ResponseTimeRule
|
||||
@ -211,3 +212,80 @@ async def test_rules_empty_dialog_fail():
|
||||
result = await rule.evaluate(case, [])
|
||||
assert result.passed is False
|
||||
assert "无回复" in result.reason
|
||||
|
||||
|
||||
# ── OpenClawChannel ──────────────────────────────────────────────────────
|
||||
|
||||
def _make_openclaw_channel(**extra) -> OpenClawChannel:
|
||||
config = {"base_url": "http://mock-openclaw:18789", "auth_token": "test-token", **extra}
|
||||
with patch("agenteval.channels.openclaw.get_settings") as mock_settings:
|
||||
mock_settings.return_value.openclaw_upstream = "http://default:18789"
|
||||
mock_settings.return_value.openclaw_auth_token = "default-token"
|
||||
return OpenClawChannel(config)
|
||||
|
||||
|
||||
async def test_openclaw_health_check_ok():
|
||||
ch = _make_openclaw_channel()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
with patch.object(ch._client, "get", new=AsyncMock(return_value=mock_resp)):
|
||||
result = await ch.health_check()
|
||||
assert result.ok is True
|
||||
assert "OpenClaw" in result.message
|
||||
|
||||
|
||||
async def test_openclaw_health_check_fail():
|
||||
ch = _make_openclaw_channel()
|
||||
with patch.object(ch._client, "get", new=AsyncMock(side_effect=Exception("conn refused"))):
|
||||
result = await ch.health_check()
|
||||
assert result.ok is False
|
||||
|
||||
|
||||
async def test_openclaw_send_ok():
|
||||
ch = _make_openclaw_channel()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json = MagicMock(return_value={"id": "chat-msg-1", "choices": [{"message": {"content": "reply"}}]})
|
||||
with patch.object(ch._client, "post", new=AsyncMock(return_value=mock_resp)):
|
||||
result = await ch.send("hello")
|
||||
assert result.ok is True
|
||||
assert result.question_msg_id == "chat-msg-1"
|
||||
|
||||
|
||||
async def test_openclaw_send_failure():
|
||||
ch = _make_openclaw_channel()
|
||||
with patch.object(ch._client, "post", new=AsyncMock(side_effect=Exception("timeout"))):
|
||||
result = await ch.send("hi")
|
||||
assert result.ok is False
|
||||
|
||||
|
||||
async def test_openclaw_poll_reply_found():
|
||||
ch = _make_openclaw_channel()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json = MagicMock(return_value={"choices": [{"message": {"content": "assistant reply"}}]})
|
||||
with patch.object(ch._client, "get", new=AsyncMock(return_value=mock_resp)):
|
||||
reply = await ch.poll_reply("chat-msg-1", timeout=5.0)
|
||||
assert reply is not None
|
||||
assert reply.content == "assistant reply"
|
||||
|
||||
|
||||
async def test_openclaw_poll_reply_timeout():
|
||||
ch = _make_openclaw_channel(poll_interval=0.05)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
mock_resp.json = MagicMock(return_value={})
|
||||
with patch.object(ch._client, "get", new=AsyncMock(return_value=mock_resp)):
|
||||
reply = await ch.poll_reply("chat-msg-1", timeout=0.15, poll_interval=0.05)
|
||||
assert reply is None
|
||||
|
||||
|
||||
async def test_openclaw_uses_default_settings():
|
||||
"""When config has no base_url or auth_token, fall back to settings defaults."""
|
||||
with patch("agenteval.channels.openclaw.get_settings") as mock_settings:
|
||||
mock_settings.return_value.openclaw_upstream = "http://default:18789"
|
||||
mock_settings.return_value.openclaw_auth_token = "default-token"
|
||||
ch = OpenClawChannel({})
|
||||
assert ch.base_url == "http://default:18789"
|
||||
assert ch.auth_token == "default-token"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user