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() {