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 ? ( - {modelInfo.provider} ) : ( 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.modelInfo.provider} )} {currentSelection.label} @@ -223,11 +229,16 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) setDropdownOpen(false); }} > - {modelInfo?.logoPath && ( - {modelInfo.provider} )} {agent} @@ -363,16 +374,16 @@ function ConferenceMessage({ message, getAgentModelInfo }) { return (
- {modelInfo.logoPath && ( - {modelInfo.provider} )} {message.agent} @@ -591,16 +602,16 @@ function MessageItem({ message, itemId, isHighlighted, getAgentModelInfo }) { >
- {modelInfo.logoPath && message.agent !== 'Memory' && ( - {modelInfo.provider} )} {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 ( + {alt} + ); + } + + 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() || "??"} + {agentId +
+ ); +} + +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}
-
-
- {stateInfo.label} -
-
-
-
-
-
模型
-
- {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 ( +
+ {selectedSessionKey || "暂无会话"} +
+
+
+ {selectedSessionKey && ( +
+ + +
+ )} +
+ {isSessionPickerOpen && ( +
+
+
+ 当前 Agent 共 {filteredSessions.length} 个会话 +
+
+ + +
+
+ {filteredSessions.length === 0 ? ( +
当前 Agent 暂无可见会话
+ ) : filteredSessions.map((session) => { + const key = session.key || session.sessionKey; + const isActive = key === selectedSessionKey; + return ( + + ); + })} +
+ )} +
+ +
+ {!selectedSession ? ( +
没有可用会话
+ ) : selectedMessages.length === 0 ? ( +
暂无消息,发送一条试试
+ ) : selectedMessages.map((message) => ( +
+
+
+ {message.role || "event"} +
+
+ {message.timestamp || ""} +
+
+
+ {message.text} +
+
+ ))} +
+ +
+