diff --git a/backend/agenteval/channels/factory.py b/backend/agenteval/channels/factory.py
index efb6dce..3a17f12 100644
--- a/backend/agenteval/channels/factory.py
+++ b/backend/agenteval/channels/factory.py
@@ -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
diff --git a/backend/agenteval/channels/openclaw.py b/backend/agenteval/channels/openclaw.py
new file mode 100644
index 0000000..163441e
--- /dev/null
+++ b/backend/agenteval/channels/openclaw.py
@@ -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
diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx
index b123b0a..0164157 100644
--- a/frontend/web/src/App.tsx
+++ b/frontend/web/src/App.tsx
@@ -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 (
+
+
+
+ }>
+ {children}
+
+ )
+}
+
interface RouteConfig {
path: string
name: string
@@ -31,13 +47,13 @@ interface RouteConfig {
}
const routeConfigs: RouteConfig[] = [
- { path: '/', name: '仪表盘', icon: , component: () => },
- { path: '/targets', name: '评测对象', icon: , component: () => },
- { path: '/scenarios', name: '评测场景', icon: , component: () => },
- { path: '/runs', name: '评测执行', icon: , component: () => },
- { path: '/reports', name: '评测报告', icon: , component: () => },
- { path: '/openclaw', name: 'AI 助手', icon: , component: () => },
- { path: '/files', name: '原始文件', icon: , component: () => },
+ { path: '/', name: '仪表盘', icon: , component: () => },
+ { path: '/targets', name: '评测对象', icon: , component: () => },
+ { path: '/scenarios', name: '评测场景', icon: , component: () => },
+ { path: '/runs', name: '评测执行', icon: , component: () => },
+ { path: '/reports', name: '评测报告', icon: , component: () => },
+ { path: '/openclaw', name: 'AI 助手', icon: , component: () => },
+ { path: '/files', name: '原始文件', icon: , component: () => },
]
const componentMap: Record ReactNode> = {}
diff --git a/frontend/web/src/pages/Targets.tsx b/frontend/web/src/pages/Targets.tsx
index 8fd03b1..26c747a 100644
--- a/frontend/web/src/pages/Targets.tsx
+++ b/frontend/web/src/pages/Targets.tsx
@@ -198,6 +198,8 @@ export default function TargetsPage() {
diff --git a/frontend/web/vite.config.ts b/frontend/web/vite.config.ts
index ecc50bc..6808b57 100644
--- a/frontend/web/vite.config.ts
+++ b/frontend/web/vite.config.ts
@@ -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'
}
@@ -35,4 +45,4 @@ export default defineConfig({
},
},
},
-})
+})
\ No newline at end of file
diff --git a/tests/unit/test_http_channel_and_rules.py b/tests/unit/test_http_channel_and_rules.py
index 315c2ce..bf57422 100644
--- a/tests/unit/test_http_channel_and_rules.py
+++ b/tests/unit/test_http_channel_and_rules.py
@@ -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"