diff --git a/.gitignore b/.gitignore
index 3eca5a8..debc3c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,7 +51,6 @@ node_modules
outputs/
/production/
/smoke_test/
-/smoke_live_mock/
# Local tooling state
.omc/
diff --git a/.omc/state/hud-state.json b/.omc/state/hud-state.json
index 8aae63d..7cd49f1 100644
--- a/.omc/state/hud-state.json
+++ b/.omc/state/hud-state.json
@@ -1,6 +1,6 @@
{
- "timestamp": "2026-03-26T17:14:45.135Z",
+ "timestamp": "2026-03-27T04:53:52.906Z",
"backgroundTasks": [],
- "sessionStartTimestamp": "2026-03-26T17:13:16.686Z",
- "sessionId": "83f172c1-eb0f-4418-87a5-b9d4b6ce5b61"
+ "sessionStartTimestamp": "2026-03-27T04:53:21.944Z",
+ "sessionId": "cbb9004e-771b-4e82-95d4-cea6d9753642"
}
\ No newline at end of file
diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json
index c44d454..ebd970d 100644
--- a/.omc/state/hud-stdin-cache.json
+++ b/.omc/state/hud-stdin-cache.json
@@ -1 +1 @@
-{"session_id":"83f172c1-eb0f-4418-87a5-b9d4b6ce5b61","transcript_path":"/Users/cillin/.claude/projects/-Users-cillin-workspeace-evotraders/83f172c1-eb0f-4418-87a5-b9d4b6ce5b61.jsonl","cwd":"/Users/cillin/workspeace/evotraders","model":{"id":"MiniMax-M2.7-highspeed","display_name":"MiniMax-M2.7-highspeed"},"workspace":{"current_dir":"/Users/cillin/workspeace/evotraders","project_dir":"/Users/cillin/workspeace/evotraders","added_dirs":[]},"version":"2.1.78","output_style":{"name":"default"},"cost":{"total_cost_usd":98.95595149999994,"total_duration_ms":43461876,"total_api_duration_ms":7482894,"total_lines_added":2289,"total_lines_removed":1132},"context_window":{"total_input_tokens":949049,"total_output_tokens":356074,"context_window_size":200000,"current_usage":{"input_tokens":507,"output_tokens":72,"cache_creation_input_tokens":346,"cache_read_input_tokens":82368},"used_percentage":42,"remaining_percentage":58},"exceeds_200k_tokens":false}
\ No newline at end of file
+{"session_id":"cbb9004e-771b-4e82-95d4-cea6d9753642","transcript_path":"/Users/cillin/.claude/projects/-Users-cillin-workspeace-evotraders/cbb9004e-771b-4e82-95d4-cea6d9753642.jsonl","cwd":"/Users/cillin/workspeace/evotraders","model":{"id":"MiniMax-M2.7-highspeed","display_name":"MiniMax-M2.7-highspeed"},"workspace":{"current_dir":"/Users/cillin/workspeace/evotraders","project_dir":"/Users/cillin/workspeace/evotraders","added_dirs":[]},"version":"2.1.78","output_style":{"name":"default"},"cost":{"total_cost_usd":0.660433,"total_duration_ms":168502,"total_api_duration_ms":37670,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":14416,"total_output_tokens":1705,"context_window_size":200000,"current_usage":{"input_tokens":461,"output_tokens":214,"cache_creation_input_tokens":0,"cache_read_input_tokens":53991},"used_percentage":27,"remaining_percentage":73},"exceeds_200k_tokens":false}
\ No newline at end of file
diff --git a/.omc/state/idle-notif-cooldown.json b/.omc/state/idle-notif-cooldown.json
index 4b87c5f..4e6a598 100644
--- a/.omc/state/idle-notif-cooldown.json
+++ b/.omc/state/idle-notif-cooldown.json
@@ -1,3 +1,3 @@
{
- "lastSentAt": "2026-03-27T03:08:22.675Z"
+ "lastSentAt": "2026-03-27T04:55:49.635Z"
}
\ No newline at end of file
diff --git a/.omc/state/subagent-tracking.json b/.omc/state/subagent-tracking.json
deleted file mode 100644
index 3f33a62..0000000
--- a/.omc/state/subagent-tracking.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "agents": [
- {
- "agent_id": "ace758bdbd117358d",
- "agent_type": "Explore",
- "started_at": "2026-03-26T17:16:09.450Z",
- "parent_mode": "none",
- "status": "completed",
- "completed_at": "2026-03-26T17:17:33.704Z",
- "duration_ms": 84254
- }
- ],
- "total_spawned": 1,
- "total_completed": 1,
- "total_failed": 0,
- "last_updated": "2026-03-27T03:08:25.014Z"
-}
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index c6b3726..e508e76 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,6 @@ uv pip install -e .
evotraders backtest --start 2025-11-01 --end 2025-12-01 # 回测模式
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # 带记忆回测
evotraders live # 实盘交易
-evotraders live --mock # 模拟/测试模式
evotraders live -t 22:30 # 定时每日交易
evotraders frontend # 启动可视化界面
@@ -28,7 +27,7 @@ evotraders frontend # 启动可视化界面
./start-dev.sh # 启动全部 4 个微服务 (agent, runtime, trading, news)
# Gateway WebSocket 服务器
-python backend/main.py --mode live --config-name mock --mock
+python backend/main.py --mode live --config-name live
# 单独启动微服务
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
@@ -189,7 +188,6 @@ backend/
│ ├── schema.py # 数据 schema
│ ├── historical_price_manager.py # 历史价格管理
│ ├── polling_price_manager.py # 轮询价格管理
-│ ├── mock_price_manager.py # Mock 价格管理
│ ├── news_alignment.py # 新闻对齐
│ ├── polygon_client.py # Polygon.io 客户端
│ └── ret_data_updater.py # 离线数据更新
diff --git a/backend/services/gateway.py b/backend/services/gateway.py
index 44473ad..ff7adb9 100644
--- a/backend/services/gateway.py
+++ b/backend/services/gateway.py
@@ -499,11 +499,29 @@ class Gateway:
await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data)
elif msg_type == "get_openclaw_workspace_file":
await gateway_openclaw_handlers.handle_get_openclaw_workspace_file(self, websocket, data)
+ elif msg_type == "openclaw_resolve_session":
+ await gateway_openclaw_handlers.handle_openclaw_resolve_session(self, websocket, data)
+ elif msg_type == "openclaw_create_session":
+ await gateway_openclaw_handlers.handle_openclaw_create_session(self, websocket, data)
+ elif msg_type == "openclaw_send_message":
+ await gateway_openclaw_handlers.handle_openclaw_send_message(self, websocket, data)
+ elif msg_type == "openclaw_subscribe_session":
+ await gateway_openclaw_handlers.handle_openclaw_subscribe_session(self, websocket, data)
+ elif msg_type == "openclaw_unsubscribe_session":
+ await gateway_openclaw_handlers.handle_openclaw_unsubscribe_session(self, websocket, data)
+ elif msg_type == "openclaw_reset_session":
+ await gateway_openclaw_handlers.handle_openclaw_reset_session(self, websocket, data)
+ elif msg_type == "openclaw_delete_session":
+ await gateway_openclaw_handlers.handle_openclaw_delete_session(self, websocket, data)
except websockets.ConnectionClosed:
pass
except json.JSONDecodeError:
pass
+ finally:
+ subscriber_map = getattr(self, "_openclaw_session_subscribers", None)
+ if isinstance(subscriber_map, dict):
+ subscriber_map.pop(websocket, None)
async def _handle_get_stock_history(
self,
diff --git a/backend/services/gateway_openclaw_handlers.py b/backend/services/gateway_openclaw_handlers.py
index bb3f733..e4cb958 100644
--- a/backend/services/gateway_openclaw_handlers.py
+++ b/backend/services/gateway_openclaw_handlers.py
@@ -13,6 +13,63 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
+def _ensure_session_bridge(gateway) -> None:
+ """Forward OpenClaw session events into EvoTraders frontend websockets."""
+ if getattr(gateway, "_openclaw_session_bridge_ready", False):
+ return
+
+ async def _forward(event) -> None:
+ payload = event.payload or {}
+ session_key = str(payload.get("sessionKey") or payload.get("key") or "").strip()
+ if not session_key:
+ return
+
+ subscriber_map = getattr(gateway, "_openclaw_session_subscribers", {})
+ targets = [
+ ws
+ for ws, session_keys in list(subscriber_map.items())
+ if session_key in session_keys
+ ]
+ if not targets:
+ return
+
+ message = json.dumps(
+ {
+ "type": "openclaw_session_event",
+ "event": event.event,
+ "session_key": session_key,
+ "payload": payload,
+ }
+ )
+ stale = []
+ for ws in targets:
+ try:
+ await ws.send(message)
+ except Exception:
+ stale.append(ws)
+
+ for ws in stale:
+ try:
+ subscriber_map.pop(ws, None)
+ except Exception:
+ pass
+
+ def _handler(event) -> None:
+ try:
+ import asyncio
+
+ asyncio.create_task(_forward(event))
+ except Exception as exc:
+ logger.debug("OpenClaw session bridge skipped event: %s", exc)
+
+ client = _get_ws_client(gateway)
+ client.add_event_handler(_handler)
+ gateway._openclaw_session_bridge_ready = True
+ gateway._openclaw_session_bridge_handler = _handler
+ if not hasattr(gateway, "_openclaw_session_subscribers"):
+ gateway._openclaw_session_subscribers = {}
+
+
def _get_ws_client(gateway) -> "OpenClawWebSocketClient":
"""Get the OpenClaw WebSocket client from gateway."""
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient
@@ -44,10 +101,18 @@ async def handle_get_openclaw_sessions(gateway, websocket, data: dict) -> None:
async def handle_get_openclaw_session_detail(gateway, websocket, data: dict) -> None:
session_key = data.get("session_key", "")
- result = await _ws_call(gateway, "sessions.list", {"limit": 1, "agentId": session_key.split(":")[1] if session_key else None})
+ result = await _ws_call(gateway, "sessions.list", {"limit": 200, "includeLastMessage": True})
+ session = None
+ if isinstance(result, dict):
+ for item in result.get("sessions", []) or []:
+ if not isinstance(item, dict):
+ continue
+ if item.get("key") == session_key or item.get("sessionKey") == session_key:
+ session = item
+ break
await websocket.send(json.dumps({
"type": "openclaw_session_detail_loaded",
- "data": result,
+ "data": {"session": session, "error": None if session else f"session '{session_key}' not found"},
"session_key": session_key,
}))
@@ -55,14 +120,202 @@ async def handle_get_openclaw_session_detail(gateway, websocket, data: dict) ->
async def handle_get_openclaw_session_history(gateway, websocket, data: dict) -> None:
session_key = data.get("session_key", "")
limit = data.get("limit", 20)
- result = await _ws_call(gateway, "sessions.list", {"limit": limit})
+ try:
+ from backend.services.openclaw_cli import OpenClawCliService
+
+ result = OpenClawCliService().get_session_history_model(session_key, limit=limit)
+ payload = {
+ "session_key": result.session_key,
+ "session_id": result.session_id,
+ "history": result.events,
+ "events": result.events,
+ "raw_text": result.raw_text,
+ }
+ except Exception as exc:
+ payload = {"error": str(exc)[:200], "history": []}
await websocket.send(json.dumps({
"type": "openclaw_session_history_loaded",
- "data": result,
+ "data": payload,
"session_key": session_key,
}))
+async def handle_openclaw_resolve_session(gateway, websocket, data: dict) -> None:
+ params = {}
+ agent_id = str(data.get("agent_id") or "").strip()
+ label = str(data.get("label") or "").strip()
+ channel = str(data.get("channel") or "").strip()
+ if agent_id:
+ params["agentId"] = agent_id
+ if label:
+ params["label"] = label
+ if channel:
+ params["channel"] = channel
+ params["includeGlobal"] = bool(data.get("include_global", True))
+ result = await _ws_call(gateway, "sessions.resolve", params)
+ await websocket.send(json.dumps({"type": "openclaw_session_resolved", "data": result}))
+
+
+async def handle_openclaw_create_session(gateway, websocket, data: dict) -> None:
+ params = {}
+ agent_id = str(data.get("agent_id") or "").strip()
+ label = str(data.get("label") or "").strip()
+ model = str(data.get("model") or "").strip()
+ initial_message = str(data.get("initial_message") or "").strip()
+ if agent_id:
+ params["agentId"] = agent_id
+ if label:
+ params["label"] = label
+ if model:
+ params["model"] = model
+ if initial_message:
+ params["message"] = initial_message
+ result = await _ws_call(gateway, "sessions.create", params)
+ await websocket.send(json.dumps({"type": "openclaw_session_created", "data": result}))
+
+
+async def handle_openclaw_send_message(gateway, websocket, data: dict) -> None:
+ session_key = str(data.get("session_key") or "").strip()
+ message = str(data.get("message") or "").strip()
+ thinking = str(data.get("thinking") or "").strip()
+ if not session_key or not message:
+ await websocket.send(
+ json.dumps(
+ {
+ "type": "openclaw_message_sent",
+ "data": {"error": "session_key and message are required"},
+ }
+ )
+ )
+ return
+
+ params = {"key": session_key, "message": message}
+ if thinking:
+ params["thinking"] = thinking
+ result = await _ws_call(gateway, "sessions.send", params)
+ await websocket.send(
+ json.dumps(
+ {
+ "type": "openclaw_message_sent",
+ "data": result,
+ "session_key": session_key,
+ }
+ )
+ )
+
+
+async def handle_openclaw_subscribe_session(gateway, websocket, data: dict) -> None:
+ session_key = str(data.get("session_key") or "").strip()
+ if not session_key:
+ await websocket.send(
+ json.dumps(
+ {
+ "type": "openclaw_session_subscribed",
+ "data": {"error": "session_key is required"},
+ }
+ )
+ )
+ return
+
+ _ensure_session_bridge(gateway)
+ result = await _ws_call(gateway, "sessions.messages.subscribe", {"key": session_key})
+ if not isinstance(result, dict) or not result.get("error"):
+ subscriber_map = getattr(gateway, "_openclaw_session_subscribers", {})
+ subscriber_map.setdefault(websocket, set()).add(session_key)
+ gateway._openclaw_session_subscribers = subscriber_map
+ await websocket.send(
+ json.dumps(
+ {
+ "type": "openclaw_session_subscribed",
+ "data": result,
+ "session_key": session_key,
+ }
+ )
+ )
+
+
+async def handle_openclaw_unsubscribe_session(gateway, websocket, data: dict) -> None:
+ session_key = str(data.get("session_key") or "").strip()
+ if not session_key:
+ await websocket.send(
+ json.dumps(
+ {
+ "type": "openclaw_session_unsubscribed",
+ "data": {"error": "session_key is required"},
+ }
+ )
+ )
+ return
+
+ result = await _ws_call(gateway, "sessions.messages.unsubscribe", {"key": session_key})
+ subscriber_map = getattr(gateway, "_openclaw_session_subscribers", {})
+ session_keys = subscriber_map.get(websocket)
+ if isinstance(session_keys, set):
+ session_keys.discard(session_key)
+ if not session_keys:
+ subscriber_map.pop(websocket, None)
+ gateway._openclaw_session_subscribers = subscriber_map
+ await websocket.send(
+ json.dumps(
+ {
+ "type": "openclaw_session_unsubscribed",
+ "data": result,
+ "session_key": session_key,
+ }
+ )
+ )
+
+
+async def handle_openclaw_reset_session(gateway, websocket, data: dict) -> None:
+ session_key = str(data.get("session_key") or "").strip()
+ if not session_key:
+ await websocket.send(
+ json.dumps(
+ {
+ "type": "openclaw_session_reset",
+ "data": {"error": "session_key is required"},
+ }
+ )
+ )
+ return
+
+ result = await _ws_call(gateway, "sessions.reset", {"key": session_key})
+ await websocket.send(
+ json.dumps(
+ {
+ "type": "openclaw_session_reset",
+ "data": result,
+ "session_key": session_key,
+ }
+ )
+ )
+
+
+async def handle_openclaw_delete_session(gateway, websocket, data: dict) -> None:
+ session_key = str(data.get("session_key") or "").strip()
+ if not session_key:
+ await websocket.send(
+ json.dumps(
+ {
+ "type": "openclaw_session_deleted",
+ "data": {"error": "session_key is required"},
+ }
+ )
+ )
+ return
+
+ result = await _ws_call(gateway, "sessions.delete", {"key": session_key})
+ await websocket.send(
+ json.dumps(
+ {
+ "type": "openclaw_session_deleted",
+ "data": result,
+ "session_key": session_key,
+ }
+ )
+ )
+
+
async def handle_get_openclaw_cron(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "cron.list")
await websocket.send(json.dumps({"type": "openclaw_cron_loaded", "data": result}))
@@ -75,6 +328,106 @@ async def handle_get_openclaw_approvals(gateway, websocket, data: dict) -> None:
async def handle_get_openclaw_agents(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "agents.list")
+ sessions_result = await _ws_call(
+ gateway,
+ "sessions.list",
+ {"limit": 200, "includeLastMessage": True},
+ )
+ config_result = await _ws_call(gateway, "config.get")
+ session_model_by_agent: dict[str, str] = {}
+ default_session_model: str | None = None
+ agent_skills_by_id: dict[str, list[str] | None] = {}
+ default_agent_skills: list[str] | None = None
+
+ parsed_config = config_result.get("parsed") if isinstance(config_result, dict) else None
+ if isinstance(parsed_config, dict):
+ agents_cfg = parsed_config.get("agents")
+ if isinstance(agents_cfg, dict):
+ defaults_cfg = agents_cfg.get("defaults")
+ if isinstance(defaults_cfg, dict):
+ default_skills = defaults_cfg.get("skills")
+ if isinstance(default_skills, list):
+ default_agent_skills = [
+ str(skill).strip()
+ for skill in default_skills
+ if str(skill).strip()
+ ]
+ list_cfg = agents_cfg.get("list")
+ if isinstance(list_cfg, list):
+ for entry in list_cfg:
+ if not isinstance(entry, dict):
+ continue
+ agent_id = str(entry.get("id") or "").strip()
+ if not agent_id:
+ continue
+ skills = entry.get("skills")
+ if isinstance(skills, list):
+ agent_skills_by_id[agent_id] = [
+ str(skill).strip()
+ for skill in skills
+ if str(skill).strip()
+ ]
+ elif skills == []:
+ agent_skills_by_id[agent_id] = []
+
+ if isinstance(sessions_result, dict) and isinstance(sessions_result.get("sessions"), list):
+ defaults = sessions_result.get("defaults")
+ if isinstance(defaults, dict):
+ value = (
+ defaults.get("model")
+ or defaults.get("modelName")
+ or defaults.get("model_name")
+ )
+ if value:
+ default_session_model = str(value)
+ for session in sessions_result.get("sessions", []):
+ if not isinstance(session, dict):
+ continue
+ agent_id = str(
+ session.get("agentId")
+ or session.get("agent_id")
+ or ""
+ ).strip()
+ if not agent_id:
+ key = str(session.get("key") or session.get("sessionKey") or "").strip()
+ parts = key.split(":")
+ if len(parts) >= 3 and parts[0] == "agent":
+ agent_id = parts[1]
+ model_value = (
+ session.get("model")
+ or session.get("modelName")
+ or session.get("model_name")
+ or session.get("resolvedModel")
+ or session.get("resolved_model")
+ or session.get("defaultModel")
+ or session.get("default_model")
+ )
+ if agent_id and model_value and agent_id not in session_model_by_agent:
+ session_model_by_agent[agent_id] = str(model_value)
+
+ if isinstance(result, dict) and isinstance(result.get("agents"), list):
+ normalized_agents = []
+ for agent in result.get("agents", []):
+ if not isinstance(agent, dict):
+ normalized_agents.append(agent)
+ continue
+ normalized = dict(agent)
+ if not normalized.get("model"):
+ normalized["model"] = (
+ normalized.get("modelName")
+ or normalized.get("model_name")
+ or normalized.get("resolvedModel")
+ or normalized.get("resolved_model")
+ or normalized.get("defaultModel")
+ or normalized.get("default_model")
+ or session_model_by_agent.get(str(normalized.get("id") or "").strip())
+ or default_session_model
+ )
+ agent_id = str(normalized.get("id") or "").strip()
+ if "skills" not in normalized:
+ normalized["skills"] = agent_skills_by_id.get(agent_id, default_agent_skills)
+ normalized_agents.append(normalized)
+ result = {**result, "agents": normalized_agents}
await websocket.send(json.dumps({"type": "openclaw_agents_loaded", "data": result}))
@@ -84,7 +437,9 @@ async def handle_get_openclaw_agents_presence(gateway, websocket, data: dict) ->
async def handle_get_openclaw_skills(gateway, websocket, data: dict) -> None:
- result = await _ws_call(gateway, "skills.status")
+ agent_id = str(data.get("agent_id") or "").strip()
+ params = {"agentId": agent_id} if agent_id else {}
+ result = await _ws_call(gateway, "skills.status", params)
await websocket.send(json.dumps({"type": "openclaw_skills_loaded", "data": result}))
diff --git a/backend/services/storage.py b/backend/services/storage.py
index 960ee5b..08ea214 100644
--- a/backend/services/storage.py
+++ b/backend/services/storage.py
@@ -39,7 +39,7 @@ class StorageService:
self,
dashboard_dir: Path,
initial_cash: float = 100000.0,
- config_name: str = "mock",
+ config_name: str = "live",
):
"""
Initialize storage service
diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py
index 0abf4ed..1cff008 100644
--- a/backend/tests/test_agents.py
+++ b/backend/tests/test_agents.py
@@ -311,6 +311,17 @@ class TestRiskAgent:
class TestStorageService:
+ def test_storage_service_defaults_to_live_config(self):
+ from backend.services.storage import StorageService
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ storage = StorageService(
+ dashboard_dir=Path(tmpdir),
+ initial_cash=100000.0,
+ )
+
+ assert storage.config_name == "live"
+
def test_calculate_portfolio_value_cash_only(self):
from backend.services.storage import StorageService
diff --git a/backend/tests/test_openclaw_websocket_client.py b/backend/tests/test_openclaw_websocket_client.py
new file mode 100644
index 0000000..44be5dc
--- /dev/null
+++ b/backend/tests/test_openclaw_websocket_client.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+"""Tests for the OpenClaw WebSocket client session helpers."""
+
+import pytest
+
+from shared.client.openclaw_websocket_client import OpenClawWebSocketClient
+
+
+@pytest.mark.asyncio
+async def test_resolve_session_parses_gateway_key_response():
+ client = OpenClawWebSocketClient(gateway_token="test-token")
+
+ async def fake_send_request(method, params=None, _allow_handshake=False):
+ assert method == "sessions.resolve"
+ assert params["agentId"] == "main"
+ return {"ok": True, "key": "agent:main:main"}
+
+ client._send_request = fake_send_request # type: ignore[method-assign]
+
+ resolved = await client.resolve_session(agent_id="main")
+
+ assert resolved == "agent:main:main"
+
+
+@pytest.mark.asyncio
+async def test_send_message_uses_session_send_payload():
+ client = OpenClawWebSocketClient(gateway_token="test-token")
+
+ async def fake_send_request(method, params=None, _allow_handshake=False):
+ assert method == "sessions.send"
+ assert params == {
+ "key": "agent:main:main",
+ "message": "hello",
+ "thinking": "medium",
+ }
+ return {"ok": True, "runId": "run-1"}
+
+ client._send_request = fake_send_request # type: ignore[method-assign]
+
+ result = await client.send_message("agent:main:main", "hello", thinking="medium")
+
+ assert result["runId"] == "run-1"
+
+
+@pytest.mark.asyncio
+async def test_get_session_history_uses_sessions_preview():
+ client = OpenClawWebSocketClient(gateway_token="test-token")
+
+ async def fake_send_request(method, params=None, _allow_handshake=False):
+ assert method == "sessions.preview"
+ assert params == {"keys": ["agent:main:main"], "limit": 12}
+ return {"previews": []}
+
+ client._send_request = fake_send_request # type: ignore[method-assign]
+
+ result = await client.get_session_history("agent:main:main", limit=12)
+
+ assert result == {"previews": []}
+
+
+@pytest.mark.asyncio
+async def test_unsubscribe_uses_session_messages_unsubscribe():
+ client = OpenClawWebSocketClient(gateway_token="test-token")
+
+ async def fake_send_request(method, params=None, _allow_handshake=False):
+ assert method == "sessions.messages.unsubscribe"
+ assert params == {"key": "agent:main:main"}
+ return {"subscribed": False}
+
+ client._send_request = fake_send_request # type: ignore[method-assign]
+
+ result = await client.unsubscribe("agent:main:main")
+
+ assert result == {"subscribed": False}
diff --git a/backtest/state/server_state.json b/backtest/state/server_state.json
index ef824cb..b1f4b88 100644
--- a/backtest/state/server_state.json
+++ b/backtest/state/server_state.json
@@ -2626,6 +2626,5 @@
"trading_days_completed": 5,
"server_mode": "backtest",
"is_backtest": true,
- "is_mock_mode": false,
"last_saved": "2026-03-12T23:07:31.098122"
-}
\ No newline at end of file
+}
diff --git a/frontend/package.json b/frontend/package.json
index be9e5a7..719644e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,6 +13,9 @@
"preview:host": "vite preview --host"
},
"dependencies": {
+ "@dicebear/collection": "^9.4.2",
+ "@dicebear/core": "^9.4.2",
+ "@lobehub/icons": "^5.0.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7363493..fbe5726 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -234,6 +234,26 @@ export default function LiveTradingApp() {
workspaceFilesByAgent,
]);
+ useEffect(() => {
+ if (!isSocketReady || !clientRef.current) {
+ return;
+ }
+
+ AGENTS.forEach((agent) => {
+ if (!agent?.id) {
+ return;
+ }
+ if (!agentProfilesByAgent[agent.id]) {
+ requestAgentProfile(agent.id);
+ }
+ });
+ }, [
+ agentProfilesByAgent,
+ clientRef,
+ isSocketReady,
+ requestAgentProfile,
+ ]);
+
useEffect(() => {
const symbols = runtimeControls.displayTickers
.map((ticker) => ticker.symbol)
diff --git a/frontend/src/components/AgentCard.jsx b/frontend/src/components/AgentCard.jsx
index c10e07d..b35c32b 100644
--- a/frontend/src/components/AgentCard.jsx
+++ b/frontend/src/components/AgentCard.jsx
@@ -1,6 +1,7 @@
import React from 'react';
import { ASSETS } from '../config/constants';
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
+import LobeModelLogo from './LobeModelLogo.jsx';
/**
* Get rank medal/trophy
@@ -207,14 +208,18 @@ export default function AgentCard({ agent, onClose, isClosing }) {
justifyContent: 'center',
marginBottom: 4
}}>
- {modelInfo.logoPath ? (
-
) : (
diff --git a/frontend/src/components/AgentFeed.jsx b/frontend/src/components/AgentFeed.jsx
index 36bef9e..9dc212f 100644
--- a/frontend/src/components/AgentFeed.jsx
+++ b/frontend/src/components/AgentFeed.jsx
@@ -3,6 +3,7 @@ import { formatTime } from '../utils/formatters';
import { MESSAGE_COLORS, getAgentColors, AGENTS, ASSETS } from '../config/constants';
import { getModelIcon } from '../utils/modelIcons';
import MarkdownModal from './MarkdownModal';
+import LobeModelLogo from './LobeModelLogo.jsx';
const isAnalyst = (agentId, agentName) => {
if (agentId && agentId.includes('analyst')) return true;
@@ -167,11 +168,11 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
// Get current selection display info
const getCurrentSelectionInfo = () => {
if (selectedAgent === 'all') {
- return { label: '全部角色', modelInfo: null };
+ return { label: '全部角色', modelInfo: null, agentInfo: null };
}
const agentInfo = getAgentInfoByName(selectedAgent);
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
- return { label: selectedAgent, modelInfo };
+ return { label: selectedAgent, modelInfo, agentInfo };
};
const currentSelection = getCurrentSelectionInfo();
@@ -189,11 +190,16 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
onBlur={() => setTimeout(() => setDropdownOpen(false), 200)}
>
- {currentSelection.modelInfo?.logoPath && (
-

)}
{currentSelection.label}
@@ -223,11 +229,16 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
setDropdownOpen(false);
}}
>
- {modelInfo?.logoPath && (
-

)}
{agent}
@@ -363,16 +374,16 @@ function ConferenceMessage({ message, getAgentModelInfo }) {
return (
- {modelInfo.logoPath && (
-

)}
{message.agent}
@@ -591,16 +602,16 @@ function MessageItem({ message, itemId, isHighlighted, getAgentModelInfo }) {
>
- {modelInfo.logoPath && message.agent !== 'Memory' && (
-
)}
{title}
diff --git a/frontend/src/components/LobeModelLogo.jsx b/frontend/src/components/LobeModelLogo.jsx
new file mode 100644
index 0000000..7879319
--- /dev/null
+++ b/frontend/src/components/LobeModelLogo.jsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import ModelIcon from '@lobehub/icons/es/features/ModelIcon';
+import ProviderIcon from '@lobehub/icons/es/features/ProviderIcon';
+
+export default function LobeModelLogo({
+ model,
+ provider,
+ fallbackSrc = null,
+ alt = '',
+ size = 28,
+ shape = 'square',
+ type = 'color',
+ style = {},
+ className = '',
+}) {
+ const hasModel = typeof model === 'string' && model.trim().length > 0;
+ const hasProvider = typeof provider === 'string' && provider.trim().length > 0;
+
+ try {
+ if (hasModel) {
+ return (
+
+ );
+ }
+
+ if (hasProvider) {
+ return (
+
+ );
+ }
+ } catch {
+ // Fall through to local fallback asset.
+ }
+
+ if (fallbackSrc) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/OpenClawStatus.jsx b/frontend/src/components/OpenClawStatus.jsx
index 5d92a9c..51133df 100644
--- a/frontend/src/components/OpenClawStatus.jsx
+++ b/frontend/src/components/OpenClawStatus.jsx
@@ -1,4 +1,7 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { createAvatar } from "@dicebear/core";
+import { lorelei } from "@dicebear/collection";
+import ModelIcon from "@lobehub/icons/es/features/ModelIcon";
import { useOpenClawStore } from "../store/openclawStore";
import { useOpenClawPanel } from "../hooks/useOpenClawPanel";
@@ -23,6 +26,8 @@ const AGENT_COLORS = [
{ accent: "#84CC16" },
];
+const OPENCLAW_EXPANDED_PANEL_MAX_HEIGHT = 420;
+
function getAgentColor(agentId) {
let hash = 0;
for (let i = 0; i < (agentId || "").length; i++) {
@@ -45,21 +50,79 @@ function agentStateFromPresence(presence, agentId) {
function AvatarIcon({ agentId, size = 56, borderRadius = 14 }) {
const color = getAgentColor(agentId);
+ const avatarUri = useMemo(() => {
+ const seed = String(agentId || "unknown");
+ return createAvatar(lorelei, {
+ seed,
+ size: Math.max(64, size * 2),
+ backgroundColor: ["d1d4f9", "ffd5dc", "c0f0d1", "ffe7b8", "cde9ff"],
+ radius: 18,
+ }).toDataUri();
+ }, [agentId, size]);
+
return (
- {agentId?.slice(0, 2).toUpperCase() || "??"}
+

+
+ );
+}
+
+function parseAgentIdFromSessionKey(sessionKey) {
+ const raw = String(sessionKey || "").trim();
+ const parts = raw.split(":");
+ if (parts.length >= 3 && parts[0] === "agent") {
+ return parts[1];
+ }
+ return "";
+}
+
+function formatSessionTitle(session) {
+ const key = session?.key || session?.sessionKey || "";
+ if (session?.label) return session.label;
+ if (!key) return "未命名会话";
+ const parts = key.split(":");
+ return parts.slice(2).join(":") || key;
+}
+
+function ModelGlyph({ model, size = 22 }) {
+ if (!model) {
+ return (
+
+ );
+ }
+
+ return (
+
+
);
}
@@ -130,10 +193,13 @@ function AgentDetail({ agent, presence, skills }) {
const { workspaceFiles, workspaceFilesLoading, workspaceFilesError, workspaceFileContent } = useOpenClawStore();
const { requestWorkspaceFiles, requestWorkspaceFile } = useOpenClawPanel();
const [selectedDoc, setSelectedDoc] = useState(null);
+ const [skillsExpanded, setSkillsExpanded] = useState(false);
+ const [docsExpanded, setDocsExpanded] = useState(false);
- // Always use "main" as the workspace key since that's the only valid OpenClaw agent ID
- const workspace = agent?.id || "main";
- const rawFiles = workspaceFiles[workspace]?.files || [];
+ const workspaceAgentId =
+ typeof agent?.id === "string" && agent.id.trim() ? agent.id.trim() : "main";
+ const workspacePayload = workspaceFiles[workspaceAgentId] || null;
+ const rawFiles = workspacePayload?.files || [];
// Normalize file props: API returns uppercase (Name, Size, Path, Preview, PreviewTruncated)
const files = rawFiles.map(f => ({
name: f.Name || f.name,
@@ -141,16 +207,31 @@ function AgentDetail({ agent, presence, skills }) {
path: f.Path || f.path,
preview: f.Preview || f.preview,
previewTruncated: f.PreviewTruncated || f.previewTruncated,
+ missing: Boolean(f.missing),
}));
- const isLoadingFiles = workspaceFilesLoading && !workspaceFiles[workspace];
+ const isLoadingFiles = workspaceFilesLoading && !workspacePayload;
+ const resolvedWorkspaceError = workspacePayload?.error || workspaceFilesError || null;
// Fetch workspace files when agent changes
useEffect(() => {
- if (workspace && !workspaceFiles[workspace]) {
- requestWorkspaceFiles(workspace);
+ if (workspaceAgentId && !workspaceFiles[workspaceAgentId]) {
+ requestWorkspaceFiles(workspaceAgentId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [workspace]);
+ }, [workspaceAgentId]);
+
+ useEffect(() => {
+ if (!docsExpanded) {
+ return;
+ }
+ if (!selectedDoc && files.length > 0) {
+ const firstExisting = files.find((item) => !item.missing) || files[0];
+ setSelectedDoc(firstExisting);
+ if (firstExisting && !firstExisting.missing && !workspaceFileContent[`${workspaceAgentId}:${firstExisting.name}`]) {
+ requestWorkspaceFile(workspaceAgentId, firstExisting.name);
+ }
+ }
+ }, [docsExpanded, files, requestWorkspaceFile, selectedDoc, workspaceAgentId, workspaceFileContent]);
const agentId = agent.id || agent.name || "?";
const state = agentStateFromPresence(presence, agentId);
@@ -158,182 +239,192 @@ function AgentDetail({ agent, presence, skills }) {
const color = getAgentColor(agentId);
// Skills are global in OpenClaw — show all skills (not filtered per-agent)
+ const skillFilter = Array.isArray(agent?.skills) ? agent.skills : null;
+ const availableSkills = skills.filter((s) => {
+ const hasMissing = s.missing && (s.missing.bins?.length || s.missing.env?.length || s.missing.config?.length);
+ const allowedByAgent =
+ skillFilter === null ? true : skillFilter.includes(s.name);
+ return s.eligible !== false && s.disabled !== true && !hasMissing && allowedByAgent;
+ });
return (
- {/* Header */}
-
-
-
-
-
{agent.name || agentId}
-
{agentId}
-
-
-
-
-
-
模型
-
- {agent.model || "—"}
-
-
-
-
-
- {/* Skills + Documents: left-right layout */}
- {/* Left: Skills */}
-
- {(() => {
- const available = skills.filter(s => {
- const hasMissing = s.missing && (s.missing.bins?.length || s.missing.env?.length || s.missing.config?.length);
- return s.eligible !== false && s.disabled !== true && !hasMissing;
- });
- return available.length > 0 && (
-
-
-
-
可用技能
-
- 已就绪: {available.length}
-
-
-
-
- {available.map((skill, i) => (
-
- ))}
-
-
- );
- })()}
-
-
- {/* Right: Documents */}
-
- {workspace && (
-
-
-
-
工作区文档
-
- {files.length} 个文件
-
-
-
-
- {isLoadingFiles ? (
-
加载中…
- ) : workspaceFilesError ? (
-
加载失败
- ) : files.length === 0 ? (
-
暂无文档
- ) : (
- files.map((f) => (
-
- ))
- )}
+
+
+
+
技能
+
+ 当前 agent: {availableSkills.length}
+
+
+ {skillsExpanded ? (
+
+ {availableSkills.length === 0 ? (
+
暂无技能
+ ) : (
+ availableSkills.map((skill, i) => (
+
+ ))
+ )}
+
+ ) : (
+
默认折叠,点击展开查看技能详情
+ )}
+
+
+
+
+
+
工作区文档
+
+ {files.length} 个文件
+
+
+
+
+ {docsExpanded ? (
+
+ {isLoadingFiles ? (
+
加载中…
+ ) : resolvedWorkspaceError ? (
+
+ 加载失败: {String(resolvedWorkspaceError).slice(0, 80)}
+
+ ) : files.length === 0 ? (
+
暂无文档
+ ) : (
+ <>
+
+ {files.map((f) => {
+ const isActive = selectedDoc?.name === f.name;
+ return (
+
+ );
+ })}
+
+
+
+ {selectedDoc
+ ? (selectedDoc.missing
+ ? "(文件不存在)"
+ : (workspaceFileContent[`${workspaceAgentId}:${selectedDoc.name}`] || selectedDoc.preview || "(内容加载中...)"))
+ : "请选择一个工作区文件"}
+
+ >
+ )}
+
+ ) : (
+
默认折叠,点击展开查看工作区文档
)}
@@ -345,49 +436,172 @@ export function OpenClawStatus() {
const store = useOpenClawStore();
const {
requestStatus,
+ requestSessions,
+ requestSessionDetail,
+ requestSessionHistory,
requestAgents,
requestAgentsPresence,
requestSkills,
+ createSession,
+ subscribeSession,
+ unsubscribeSession,
+ resetSession,
+ deleteSession,
+ sendSessionMessage,
} = useOpenClawPanel();
const [selectedAgentId, setSelectedAgentId] = useState(
() => store.agents[0]?.id || store.agents[0]?.name || null
);
+ const [autoCreatedAgentId, setAutoCreatedAgentId] = useState(null);
+ const [isSessionPickerOpen, setIsSessionPickerOpen] = useState(false);
+ const chatScrollRef = useRef(null);
// Fetch data only if store is empty (on mount / page refresh)
useEffect(() => {
- if (!store.agents.length) requestAgents();
- if (!store.skills.length) requestSkills();
+ requestAgents();
+ requestSessions();
if (!store.openclawStatus) requestStatus();
requestAgentsPresence();
}, []);
+ useEffect(() => {
+ const refreshAgents = () => {
+ requestAgents();
+ requestAgentsPresence();
+ };
+
+ const intervalId = window.setInterval(() => {
+ refreshAgents();
+ }, 15000);
+
+ const handleFocus = () => {
+ refreshAgents();
+ };
+
+ window.addEventListener('focus', handleFocus);
+ return () => {
+ window.clearInterval(intervalId);
+ window.removeEventListener('focus', handleFocus);
+ };
+ }, [requestAgents, requestAgentsPresence]);
+
+ useEffect(() => {
+ const intervalId = window.setInterval(() => {
+ const agentId = selectedAgentId || null;
+ requestSkills(agentId);
+ }, 15000);
+
+ const handleFocus = () => {
+ const agentId = selectedAgentId || null;
+ requestSkills(agentId);
+ };
+
+ window.addEventListener('focus', handleFocus);
+ return () => {
+ window.clearInterval(intervalId);
+ window.removeEventListener('focus', handleFocus);
+ };
+ }, [requestSkills, selectedAgentId]);
+
const status = store.openclawStatus;
const agents = store.agents;
const presence = store.agentsPresence?.agents || {};
const skills = store.skills || [];
+ const selectedSessionKey = store.selectedSessionKey;
+ const chatMessagesBySession = store.openclawChatMessagesBySession || {};
+ const chatDraftBySession = store.openclawChatDraftBySession || {};
+ const sessionSubscriptions = store.openclawSessionSubscriptions || {};
const selectedAgent = agents.find(a => (a.id || a.name) === selectedAgentId) || agents[0] || null;
+ const filteredSessions = useMemo(() => {
+ const agentId = selectedAgent?.id || selectedAgent?.name || "";
+ if (!agentId) return store.openclawSessions || [];
+ return (store.openclawSessions || []).filter((session) => {
+ const sessionAgentId = session?.agentId || parseAgentIdFromSessionKey(session?.key || session?.sessionKey);
+ return sessionAgentId === agentId;
+ });
+ }, [selectedAgent, store.openclawSessions]);
+ const selectedSession = useMemo(
+ () => filteredSessions.find((session) => (session.key || session.sessionKey) === selectedSessionKey) || filteredSessions[0] || null,
+ [filteredSessions, selectedSessionKey],
+ );
+ const selectedMessages = selectedSessionKey ? (chatMessagesBySession[selectedSessionKey] || []) : [];
// Auto-select first agent when agents load
useEffect(() => {
if (!selectedAgentId && agents.length > 0) {
setSelectedAgentId(agents[0].id || agents[0].name);
}
+ if (selectedAgentId && agents.length > 0) {
+ const exists = agents.some((agent) => (agent.id || agent.name) === selectedAgentId);
+ if (!exists) {
+ setSelectedAgentId(agents[0].id || agents[0].name);
+ }
+ }
}, [agents, selectedAgentId]);
+ useEffect(() => {
+ if (!selectedAgentId) {
+ return;
+ }
+ requestSkills(selectedAgentId);
+ }, [requestSkills, selectedAgentId]);
+
+ useEffect(() => {
+ const agentId = selectedAgent?.id || selectedAgent?.name || null;
+ if (!agentId || filteredSessions.length > 0 || autoCreatedAgentId === agentId) {
+ return;
+ }
+ createSession({ agentId, label: "dashboard" });
+ setAutoCreatedAgentId(agentId);
+ }, [autoCreatedAgentId, createSession, filteredSessions.length, selectedAgent]);
+
+ useEffect(() => {
+ if (!selectedSessionKey && filteredSessions.length > 0) {
+ const preferredSession =
+ filteredSessions.find((session) => {
+ const key = String(session.key || session.sessionKey || '').trim().toLowerCase();
+ return key === 'agent:main:main' || key.endsWith(':main');
+ }) || filteredSessions[0];
+ store.setSelectedSessionKey(preferredSession.key || preferredSession.sessionKey);
+ }
+ }, [filteredSessions, selectedSessionKey, store]);
+
+ useEffect(() => {
+ setIsSessionPickerOpen(false);
+ }, [selectedSessionKey]);
+
+ useEffect(() => {
+ if (!selectedSessionKey) return undefined;
+ requestSessionDetail(selectedSessionKey);
+ requestSessionHistory(selectedSessionKey, 30);
+ subscribeSession(selectedSessionKey);
+ return () => {
+ unsubscribeSession(selectedSessionKey);
+ };
+ }, [requestSessionDetail, requestSessionHistory, selectedSessionKey, subscribeSession, unsubscribeSession]);
+
+ useEffect(() => {
+ if (!chatScrollRef.current) {
+ return;
+ }
+ chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
+ }, [selectedMessages, selectedSessionKey]);
+
return (
{/* Header */}
-
+
OpenClaw Agent 状态
@@ -401,9 +615,9 @@ export function OpenClawStatus() {
display: "grid",
gridTemplateColumns: agents.length > 0 ? "120px minmax(0, 1fr)" : "1fr",
gap: 16,
- alignItems: "stretch",
- minHeight: 0,
- overflow: "hidden",
+ alignItems: "start",
+ minHeight: "auto",
+ overflow: "visible",
}}>
{/* Left: agent avatar list */}
{agents.length > 0 && (
@@ -412,11 +626,11 @@ export function OpenClawStatus() {
borderRadius: 14,
background: "#FFFFFF",
boxShadow: "0 10px 24px rgba(15, 23, 42, 0.06)",
- padding: 12,
+ padding: 10,
display: "grid",
- gap: 10,
- minHeight: 0,
- overflowY: "auto",
+ gap: 12,
+ minHeight: "auto",
+ overflow: "visible",
alignContent: "start",
}}>
{agents.map((agent) => {
@@ -428,23 +642,22 @@ export function OpenClawStatus() {
return (