## 新增功能 - 文件管理模块:分类树 + 文件上传/下载/删除 - 文件上传支持拖拽(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>
145 lines
4.7 KiB
Python
145 lines
4.7 KiB
Python
"""Reverse proxy for OpenClaw, rewriting headers for iframe embedding.
|
|
|
|
OpenClaw is configured with trusted-proxy auth mode, so the proxy sends
|
|
an x-forwarded-user header instead of a Bearer token. All connection
|
|
parameters come from :mod:`agenteval.config.settings` — no hardcoded hosts.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import httpx
|
|
import websockets
|
|
from fastapi import APIRouter, Request, WebSocket
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from agenteval.config import get_settings
|
|
|
|
|
|
def get_http_upstream() -> str:
|
|
return get_settings().openclaw_upstream
|
|
|
|
|
|
def get_ws_upstream() -> str:
|
|
return get_settings().openclaw_ws_upstream
|
|
|
|
|
|
def get_proxy_user() -> str:
|
|
return get_settings().openclaw_proxy_user
|
|
|
|
|
|
def get_auth_token() -> str:
|
|
return get_settings().openclaw_auth_token
|
|
|
|
|
|
_client = httpx.AsyncClient(follow_redirects=True, timeout=60.0)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _rewrite_headers(headers: httpx.Headers) -> dict[str, str]:
|
|
"""Strip iframe-blocking headers and rewrite CSP."""
|
|
result: dict[str, str] = {}
|
|
for key, value in headers.items():
|
|
lower = key.lower()
|
|
if lower in ("x-frame-options", "transfer-encoding", "content-encoding", "content-length"):
|
|
continue
|
|
if lower == "content-security-policy":
|
|
value = value.replace("frame-ancestors 'none'", "frame-ancestors 'self'")
|
|
value = value.replace("script-src 'self'", "script-src 'self' 'unsafe-inline'")
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
|
async def http_proxy(request: Request, path: str):
|
|
url = f"{get_http_upstream()}/{path}"
|
|
if request.query_params:
|
|
url += f"?{request.query_params}"
|
|
|
|
body = await request.body()
|
|
headers = dict(request.headers)
|
|
headers.pop("host", None)
|
|
headers.pop("referer", None)
|
|
headers.pop("origin", None)
|
|
headers["x-forwarded-user"] = get_proxy_user()
|
|
headers["authorization"] = f"Bearer {get_auth_token()}"
|
|
|
|
resp = await _client.request(
|
|
method=request.method,
|
|
url=url,
|
|
content=body,
|
|
headers=headers,
|
|
)
|
|
|
|
return StreamingResponse(
|
|
content=iter([resp.content]),
|
|
status_code=resp.status_code,
|
|
headers=_rewrite_headers(resp.headers),
|
|
)
|
|
|
|
|
|
async def ws_bridge(client_ws: WebSocket, upstream_url: str):
|
|
"""Bidirectional WebSocket bridge between client and upstream."""
|
|
await client_ws.accept()
|
|
|
|
settings = get_settings()
|
|
auth_token = settings.openclaw_auth_token
|
|
connect_kwargs: dict = {
|
|
"additional_headers": {
|
|
"x-forwarded-user": settings.openclaw_proxy_user,
|
|
"authorization": f"Bearer {auth_token}",
|
|
},
|
|
"max_size": 10 * 1024 * 1024,
|
|
}
|
|
if settings.openclaw_ws_origin:
|
|
connect_kwargs["origin"] = settings.openclaw_ws_origin
|
|
|
|
try:
|
|
async with websockets.connect(upstream_url, **connect_kwargs) as upstream_ws:
|
|
|
|
async def client_to_upstream():
|
|
try:
|
|
while True:
|
|
msg = await client_ws.receive_text()
|
|
try:
|
|
payload = json.loads(msg)
|
|
if (
|
|
isinstance(payload, dict)
|
|
and payload.get("method") == "connect.authenticate"
|
|
and isinstance(payload.get("payload"), dict)
|
|
):
|
|
params = payload["payload"].setdefault("params", {})
|
|
params.setdefault("auth", {})
|
|
params["auth"].setdefault("token", auth_token)
|
|
msg = json.dumps(payload)
|
|
except (ValueError, TypeError, AttributeError):
|
|
pass
|
|
await upstream_ws.send(msg)
|
|
except Exception:
|
|
pass
|
|
|
|
async def upstream_to_client():
|
|
try:
|
|
async for msg in upstream_ws:
|
|
if isinstance(msg, str):
|
|
await client_ws.send_text(msg)
|
|
else:
|
|
await client_ws.send_bytes(msg)
|
|
except Exception:
|
|
pass
|
|
|
|
done, pending = await asyncio.wait(
|
|
[asyncio.create_task(client_to_upstream()), asyncio.create_task(upstream_to_client())],
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
for task in pending:
|
|
task.cancel()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
await client_ws.close()
|
|
except Exception:
|
|
pass
|