feat: update openclaw workspace integration

This commit is contained in:
2026-03-27 22:27:16 +08:00
parent 5c08c1865c
commit 4aa69650e8
26 changed files with 2103 additions and 310 deletions

1
.gitignore vendored
View File

@@ -51,7 +51,6 @@ node_modules
outputs/ outputs/
/production/ /production/
/smoke_test/ /smoke_test/
/smoke_live_mock/
# Local tooling state # Local tooling state
.omc/ .omc/

View File

@@ -1,6 +1,6 @@
{ {
"timestamp": "2026-03-26T17:14:45.135Z", "timestamp": "2026-03-27T04:53:52.906Z",
"backgroundTasks": [], "backgroundTasks": [],
"sessionStartTimestamp": "2026-03-26T17:13:16.686Z", "sessionStartTimestamp": "2026-03-27T04:53:21.944Z",
"sessionId": "83f172c1-eb0f-4418-87a5-b9d4b6ce5b61" "sessionId": "cbb9004e-771b-4e82-95d4-cea6d9753642"
} }

View File

@@ -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} {"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}

View File

@@ -1,3 +1,3 @@
{ {
"lastSentAt": "2026-03-27T03:08:22.675Z" "lastSentAt": "2026-03-27T04:55:49.635Z"
} }

View File

@@ -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"
}

View File

@@ -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 # 回测模式
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # 带记忆回测 evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # 带记忆回测
evotraders live # 实盘交易 evotraders live # 实盘交易
evotraders live --mock # 模拟/测试模式
evotraders live -t 22:30 # 定时每日交易 evotraders live -t 22:30 # 定时每日交易
evotraders frontend # 启动可视化界面 evotraders frontend # 启动可视化界面
@@ -28,7 +27,7 @@ evotraders frontend # 启动可视化界面
./start-dev.sh # 启动全部 4 个微服务 (agent, runtime, trading, news) ./start-dev.sh # 启动全部 4 个微服务 (agent, runtime, trading, news)
# Gateway WebSocket 服务器 # 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 python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
@@ -189,7 +188,6 @@ backend/
│ ├── schema.py # 数据 schema │ ├── schema.py # 数据 schema
│ ├── historical_price_manager.py # 历史价格管理 │ ├── historical_price_manager.py # 历史价格管理
│ ├── polling_price_manager.py # 轮询价格管理 │ ├── polling_price_manager.py # 轮询价格管理
│ ├── mock_price_manager.py # Mock 价格管理
│ ├── news_alignment.py # 新闻对齐 │ ├── news_alignment.py # 新闻对齐
│ ├── polygon_client.py # Polygon.io 客户端 │ ├── polygon_client.py # Polygon.io 客户端
│ └── ret_data_updater.py # 离线数据更新 │ └── ret_data_updater.py # 离线数据更新

View File

@@ -499,11 +499,29 @@ class Gateway:
await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data) await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data)
elif msg_type == "get_openclaw_workspace_file": elif msg_type == "get_openclaw_workspace_file":
await gateway_openclaw_handlers.handle_get_openclaw_workspace_file(self, websocket, data) 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: except websockets.ConnectionClosed:
pass pass
except json.JSONDecodeError: except json.JSONDecodeError:
pass 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( async def _handle_get_stock_history(
self, self,

View File

@@ -13,6 +13,63 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) 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": def _get_ws_client(gateway) -> "OpenClawWebSocketClient":
"""Get the OpenClaw WebSocket client from gateway.""" """Get the OpenClaw WebSocket client from gateway."""
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient 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: async def handle_get_openclaw_session_detail(gateway, websocket, data: dict) -> None:
session_key = data.get("session_key", "") 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({ await websocket.send(json.dumps({
"type": "openclaw_session_detail_loaded", "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, "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: async def handle_get_openclaw_session_history(gateway, websocket, data: dict) -> None:
session_key = data.get("session_key", "") session_key = data.get("session_key", "")
limit = data.get("limit", 20) 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({ await websocket.send(json.dumps({
"type": "openclaw_session_history_loaded", "type": "openclaw_session_history_loaded",
"data": result, "data": payload,
"session_key": session_key, "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: async def handle_get_openclaw_cron(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "cron.list") result = await _ws_call(gateway, "cron.list")
await websocket.send(json.dumps({"type": "openclaw_cron_loaded", "data": result})) 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: async def handle_get_openclaw_agents(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "agents.list") 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})) 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: 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})) await websocket.send(json.dumps({"type": "openclaw_skills_loaded", "data": result}))

View File

@@ -39,7 +39,7 @@ class StorageService:
self, self,
dashboard_dir: Path, dashboard_dir: Path,
initial_cash: float = 100000.0, initial_cash: float = 100000.0,
config_name: str = "mock", config_name: str = "live",
): ):
""" """
Initialize storage service Initialize storage service

View File

@@ -311,6 +311,17 @@ class TestRiskAgent:
class TestStorageService: 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): def test_calculate_portfolio_value_cash_only(self):
from backend.services.storage import StorageService from backend.services.storage import StorageService

View File

@@ -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}

View File

@@ -2626,6 +2626,5 @@
"trading_days_completed": 5, "trading_days_completed": 5,
"server_mode": "backtest", "server_mode": "backtest",
"is_backtest": true, "is_backtest": true,
"is_mock_mode": false,
"last_saved": "2026-03-12T23:07:31.098122" "last_saved": "2026-03-12T23:07:31.098122"
} }

View File

@@ -13,6 +13,9 @@
"preview:host": "vite preview --host" "preview:host": "vite preview --host"
}, },
"dependencies": { "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-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",

View File

@@ -234,6 +234,26 @@ export default function LiveTradingApp() {
workspaceFilesByAgent, 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(() => { useEffect(() => {
const symbols = runtimeControls.displayTickers const symbols = runtimeControls.displayTickers
.map((ticker) => ticker.symbol) .map((ticker) => ticker.symbol)

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { ASSETS } from '../config/constants'; import { ASSETS } from '../config/constants';
import { getModelIcon, getShortModelName } from '../utils/modelIcons'; import { getModelIcon, getShortModelName } from '../utils/modelIcons';
import LobeModelLogo from './LobeModelLogo.jsx';
/** /**
* Get rank medal/trophy * Get rank medal/trophy
@@ -207,14 +208,18 @@ export default function AgentCard({ agent, onClose, isClosing }) {
justifyContent: 'center', justifyContent: 'center',
marginBottom: 4 marginBottom: 4
}}> }}>
{modelInfo.logoPath ? ( {agent.modelName || modelInfo.logoPath ? (
<img <LobeModelLogo
src={modelInfo.logoPath} model={agent.modelName}
provider={agent.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider} alt={modelInfo.provider}
size={36}
type="color"
shape="square"
style={{ style={{
maxHeight: '100%', maxHeight: '100%',
maxWidth: '100%', maxWidth: '100%',
objectFit: 'contain'
}} }}
/> />
) : ( ) : (

View File

@@ -3,6 +3,7 @@ import { formatTime } from '../utils/formatters';
import { MESSAGE_COLORS, getAgentColors, AGENTS, ASSETS } from '../config/constants'; import { MESSAGE_COLORS, getAgentColors, AGENTS, ASSETS } from '../config/constants';
import { getModelIcon } from '../utils/modelIcons'; import { getModelIcon } from '../utils/modelIcons';
import MarkdownModal from './MarkdownModal'; import MarkdownModal from './MarkdownModal';
import LobeModelLogo from './LobeModelLogo.jsx';
const isAnalyst = (agentId, agentName) => { const isAnalyst = (agentId, agentName) => {
if (agentId && agentId.includes('analyst')) return true; if (agentId && agentId.includes('analyst')) return true;
@@ -167,11 +168,11 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
// Get current selection display info // Get current selection display info
const getCurrentSelectionInfo = () => { const getCurrentSelectionInfo = () => {
if (selectedAgent === 'all') { if (selectedAgent === 'all') {
return { label: '全部角色', modelInfo: null }; return { label: '全部角色', modelInfo: null, agentInfo: null };
} }
const agentInfo = getAgentInfoByName(selectedAgent); const agentInfo = getAgentInfoByName(selectedAgent);
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null; const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
return { label: selectedAgent, modelInfo }; return { label: selectedAgent, modelInfo, agentInfo };
}; };
const currentSelection = getCurrentSelectionInfo(); const currentSelection = getCurrentSelectionInfo();
@@ -189,11 +190,16 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
onBlur={() => setTimeout(() => setDropdownOpen(false), 200)} onBlur={() => setTimeout(() => setDropdownOpen(false), 200)}
> >
<div className="custom-select-value"> <div className="custom-select-value">
{currentSelection.modelInfo?.logoPath && ( {(currentSelection.agentInfo?.modelName || currentSelection.modelInfo?.logoPath) && (
<img <LobeModelLogo
src={currentSelection.modelInfo.logoPath} model={currentSelection.agentInfo?.modelName}
alt={currentSelection.modelInfo.provider} provider={currentSelection.agentInfo?.modelProvider}
fallbackSrc={currentSelection.modelInfo?.logoPath}
alt={currentSelection.modelInfo?.provider}
size={18}
className="select-model-icon" className="select-model-icon"
shape="square"
type="color"
/> />
)} )}
<span>{currentSelection.label}</span> <span>{currentSelection.label}</span>
@@ -223,11 +229,16 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
setDropdownOpen(false); setDropdownOpen(false);
}} }}
> >
{modelInfo?.logoPath && ( {(agentInfo?.modelName || modelInfo?.logoPath) && (
<img <LobeModelLogo
src={modelInfo.logoPath} model={agentInfo?.modelName}
alt={modelInfo.provider} provider={agentInfo?.modelProvider}
fallbackSrc={modelInfo?.logoPath}
alt={modelInfo?.provider}
size={18}
className="select-model-icon" className="select-model-icon"
shape="square"
type="color"
/> />
)} )}
<span>{agent}</span> <span>{agent}</span>
@@ -363,16 +374,16 @@ function ConferenceMessage({ message, getAgentModelInfo }) {
return ( return (
<div className="conf-message-item"> <div className="conf-message-item">
<div className="conf-agent-name" style={{ color: agentColors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}> <div className="conf-agent-name" style={{ color: agentColors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
{modelInfo.logoPath && ( {(agentModelData.modelName || modelInfo.logoPath) && (
<img <LobeModelLogo
src={modelInfo.logoPath} model={agentModelData.modelName}
provider={agentModelData.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider} alt={modelInfo.provider}
style={{ size={20}
width: '20px', shape="circle"
height: '20px', type="color"
borderRadius: '50%', style={{ borderRadius: '50%' }}
objectFit: 'contain'
}}
/> />
)} )}
{message.agent} {message.agent}
@@ -591,16 +602,16 @@ function MessageItem({ message, itemId, isHighlighted, getAgentModelInfo }) {
> >
<div className="feed-item-header"> <div className="feed-item-header">
<span className="feed-item-title" style={{ color: colors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}> <span className="feed-item-title" style={{ color: colors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
{modelInfo.logoPath && message.agent !== 'Memory' && ( {message.agent !== 'Memory' && (agentModelData.modelName || modelInfo.logoPath) && (
<img <LobeModelLogo
src={modelInfo.logoPath} model={agentModelData.modelName}
provider={agentModelData.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider} alt={modelInfo.provider}
style={{ size={20}
width: '20px', shape="circle"
height: '20px', type="color"
borderRadius: '50%', style={{ borderRadius: '50%' }}
objectFit: 'contain'
}}
/> />
)} )}
{title} {title}

View File

@@ -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 (
<ModelIcon
model={model}
size={size}
shape={shape}
type={type}
className={className}
style={style}
/>
);
}
if (hasProvider) {
return (
<ProviderIcon
provider={provider.toLowerCase()}
size={size}
shape={shape}
type={type}
className={className}
style={style}
/>
);
}
} catch {
// Fall through to local fallback asset.
}
if (fallbackSrc) {
return (
<img
src={fallbackSrc}
alt={alt}
className={className}
style={{
width: size,
height: size,
objectFit: 'contain',
...style,
}}
/>
);
}
return (
<div
className={className}
style={{
width: size,
height: size,
borderRadius: shape === 'circle' ? '50%' : 8,
background: '#F3F4F6',
border: '1px solid #D1D5DB',
...style,
}}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,5 @@
import { OpenClawStatus } from './OpenClawStatus'; import { OpenClawStatus } from './OpenClawStatus';
export default function OpenClawView() { export default function OpenClawView() {
return ( return <OpenClawStatus />;
<div style={{
height: '100%',
overflow: 'auto',
padding: '16px',
background: '#F3F4F6',
}}>
<OpenClawStatus />
</div>
);
} }

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { ASSETS, SCENE_NATIVE, AGENT_SEATS, AGENTS } from '../config/constants'; import { ASSETS, SCENE_NATIVE, AGENT_SEATS, AGENTS } from '../config/constants';
import AgentCard from './AgentCard'; import AgentCard from './AgentCard';
import { getModelIcon } from '../utils/modelIcons'; import { getModelIcon } from '../utils/modelIcons';
import LobeModelLogo from './LobeModelLogo.jsx';
/** /**
* Custom hook to load an image * Custom hook to load an image
@@ -518,21 +519,23 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile
{medal} {medal}
</span> </span>
)} )}
{modelInfo.logoPath && ( {(agentData?.modelName || modelInfo.logoPath) && (
<img <LobeModelLogo
src={modelInfo.logoPath} model={agentData?.modelName}
provider={agentData?.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider} alt={modelInfo.provider}
size={25}
shape="circle"
type="color"
className="agent-model-badge" className="agent-model-badge"
style={{ style={{
position: 'absolute', position: 'absolute',
top: -12, top: -12,
right: -12, right: -12,
width: 25,
height: 25,
borderRadius: '50%', borderRadius: '50%',
border: '2px solid #ffffff', border: '2px solid #ffffff',
background: '#ffffff', background: '#ffffff',
objectFit: 'contain',
padding: 2, padding: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)', boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'none' pointerEvents: 'none'
@@ -642,10 +645,15 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile
{/* Agent header with model icon */} {/* Agent header with model icon */}
<div className="room-bubble-header"> <div className="room-bubble-header">
{modelInfo.logoPath && ( {(agentData?.modelName || modelInfo.logoPath) && (
<img <LobeModelLogo
src={modelInfo.logoPath} model={agentData?.modelName}
provider={agentData?.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider} alt={modelInfo.provider}
size={18}
shape="circle"
type="color"
className="bubble-model-icon" className="bubble-model-icon"
/> />
)} )}

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { getModelIcon, getShortModelName } from '../utils/modelIcons'; import { getModelIcon, getShortModelName } from '../utils/modelIcons';
import LobeModelLogo from './LobeModelLogo.jsx';
export default function TraderView({ export default function TraderView({
agents, agents,
@@ -249,13 +250,16 @@ export default function TraderView({
alignItems: 'center', alignItems: 'center',
gap: 10 gap: 10
}}> }}>
{modelInfo.logoPath && ( <LobeModelLogo
<img model={profile.model_name}
src={modelInfo.logoPath} provider={profile.model_provider}
alt={modelInfo.provider} fallbackSrc={modelInfo.logoPath}
style={{ width: 26, height: 26, borderRadius: 999 }} alt={modelInfo.provider}
/> size={26}
)} shape="circle"
type="color"
style={{ borderRadius: 999 }}
/>
<div style={{ display: 'grid', gap: 2 }}> <div style={{ display: 'grid', gap: 2 }}>
<div style={{ fontSize: 11, color: '#4B5563', fontWeight: 700 }}>模型</div> <div style={{ fontSize: 11, color: '#4B5563', fontWeight: 700 }}>模型</div>
<div style={{ fontSize: 12, color: '#111111', fontWeight: 800 }}> <div style={{ fontSize: 12, color: '#111111', fontWeight: 800 }}>

View File

@@ -58,6 +58,92 @@ export function useOpenClawPanel() {
}); });
}, []); }, []);
const resolveSession = useCallback(({ agentId, label = null, channel = null, includeGlobal = true }) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_resolve_session",
agent_id: agentId,
label,
channel,
include_global: includeGlobal,
});
}, []);
const createSession = useCallback(({ agentId, label = null, model = null, initialMessage = null }) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !agentId) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_create_session",
agent_id: agentId,
label,
model,
initial_message: initialMessage,
});
}, []);
const subscribeSession = useCallback((sessionKey) => {
const client = getStore().clientRef?.current;
if (!client || !sessionKey) return;
sendWithRetry({ current: client }, {
type: "openclaw_subscribe_session",
session_key: sessionKey,
});
}, []);
const unsubscribeSession = useCallback((sessionKey) => {
const client = getStore().clientRef?.current;
if (!client || !sessionKey) return;
sendWithRetry({ current: client }, {
type: "openclaw_unsubscribe_session",
session_key: sessionKey,
});
}, []);
const resetSession = useCallback((sessionKey) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !sessionKey) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_reset_session",
session_key: sessionKey,
});
}, []);
const deleteSession = useCallback((sessionKey) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !sessionKey) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_delete_session",
session_key: sessionKey,
});
}, []);
const sendSessionMessage = useCallback((sessionKey, message, thinking = null) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !sessionKey || !message?.trim()) return;
sendWithRetry({ current: client }, {
type: "openclaw_subscribe_session",
session_key: sessionKey,
});
store.setOpenclawChatSendingForSession?.(sessionKey, true);
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_send_message",
session_key: sessionKey,
message: message.trim(),
thinking,
});
}, []);
const requestCron = useCallback(() => { const requestCron = useCallback(() => {
const store = getStore(); const store = getStore();
const client = store.clientRef?.current; const client = store.clientRef?.current;
@@ -91,13 +177,13 @@ export function useOpenClawPanel() {
sendWithRetry({ current: client }, { type: "get_openclaw_agents_presence" }); sendWithRetry({ current: client }, { type: "get_openclaw_agents_presence" });
}, []); }, []);
const requestSkills = useCallback(() => { const requestSkills = useCallback((agentId = null) => {
const store = getStore(); const store = getStore();
const client = store.clientRef?.current; const client = store.clientRef?.current;
if (!client) return; if (!client) return;
store.setSkillsLoading(true); store.setSkillsLoading(true);
store.setSkillsError(null); store.setSkillsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_skills" }); sendWithRetry({ current: client }, { type: "get_openclaw_skills", agent_id: agentId });
}, []); }, []);
const requestModels = useCallback(() => { const requestModels = useCallback(() => {
@@ -239,6 +325,13 @@ export function useOpenClawPanel() {
requestSessions, requestSessions,
requestSessionDetail, requestSessionDetail,
requestSessionHistory, requestSessionHistory,
resolveSession,
createSession,
subscribeSession,
unsubscribeSession,
resetSession,
deleteSession,
sendSessionMessage,
requestCron, requestCron,
requestApprovals, requestApprovals,
requestAgents, requestAgents,

View File

@@ -66,6 +66,306 @@ function buildTickersFromSymbols(symbols, previousTickers = []) {
}); });
} }
function normalizeOpenClawHistoryItems(history) {
if (!Array.isArray(history)) {
return [];
}
return history
.map((item, index) => {
const role = item?.role || item?.senderRole || item?.kind || item?.type || 'event';
const isFinal = hasOpenClawFinalTag(item);
const text = extractOpenClawText(item);
if (!shouldKeepOpenClawMessage(item)) {
return null;
}
const timestamp = item?.timestamp || item?.ts || item?.createdAt || item?.time || null;
const nestedMeta = item?.message?.__openclaw || item?.__openclaw || null;
const seq = item?.messageSeq ?? item?.seq ?? nestedMeta?.seq ?? null;
const messageId = item?.messageId ?? item?.id ?? nestedMeta?.id ?? null;
return {
id: messageId || (seq !== null ? `seq:${seq}` : `${timestamp || 'history'}:${index}`),
role,
text: String(text || ''),
timestamp,
seq,
messageId,
isFinal,
raw: item,
};
})
.filter(Boolean);
}
function unwrapOpenClawFinal(value) {
if (typeof value !== 'string') {
return null;
}
const match = value.match(/<final>([\s\S]*?)<\/final>/i);
if (!match) {
return null;
}
return match[1].trim();
}
function stripOpenClawFinalTags(value) {
if (typeof value !== 'string') {
return value ? String(value) : '';
}
return value.replace(/<\/?final>/gi, '').trim();
}
function shouldHideOpenClawMessage({ role, text }) {
const normalizedRole = String(role || '').toLowerCase();
const normalizedText = String(text || '').trim();
if (normalizedRole === 'system') {
return true;
}
if (normalizedRole === 'user') {
if (normalizedText.startsWith('Sender (untrusted metadata):')) {
return true;
}
if (normalizedText.startsWith('[Fri ') || normalizedText.startsWith('[Sat ') || normalizedText.startsWith('[Sun ')
|| normalizedText.startsWith('[Mon ') || normalizedText.startsWith('[Tue ') || normalizedText.startsWith('[Wed ')
|| normalizedText.startsWith('[Thu ')) {
return true;
}
}
return false;
}
function shouldKeepOpenClawMessage(item) {
const role = item?.role || item?.senderRole || item?.kind || item?.type || 'event';
const text = extractOpenClawText(item);
const isFinal = hasOpenClawFinalTag(item);
if (shouldHideOpenClawMessage({ role, text })) {
return false;
}
const normalizedRole = String(role || '').toLowerCase();
if (normalizedRole === 'assistant') {
return isFinal;
}
if (!normalizedRole || normalizedRole === 'event') {
return isFinal;
}
return true;
}
function hasOpenClawFinalTag(item) {
if (typeof item === 'string') {
return /<final>[\s\S]*?<\/final>/i.test(item);
}
if (!item || typeof item !== 'object') {
return false;
}
const candidates = [];
if (typeof item.text === 'string') candidates.push(item.text);
if (typeof item.message === 'string') candidates.push(item.message);
if (typeof item.content === 'string') candidates.push(item.content);
const nestedMessage = item.message && typeof item.message === 'object' ? item.message : null;
if (nestedMessage) {
if (typeof nestedMessage.content === 'string') candidates.push(nestedMessage.content);
if (Array.isArray(nestedMessage.content)) {
nestedMessage.content.forEach((entry) => {
if (typeof entry === 'string') candidates.push(entry);
if (entry?.type === 'text' && typeof entry?.text === 'string') candidates.push(entry.text);
});
}
}
if (Array.isArray(item.content)) {
item.content.forEach((entry) => {
if (typeof entry === 'string') candidates.push(entry);
if (entry?.type === 'text' && typeof entry?.text === 'string') candidates.push(entry.text);
});
}
return candidates.some((value) => /<final>[\s\S]*?<\/final>/i.test(value));
}
function extractOpenClawText(item) {
if (typeof item === 'string') {
return unwrapOpenClawFinal(item) || stripOpenClawFinalTags(item);
}
if (!item || typeof item !== 'object') {
return item ? String(item) : '';
}
if (typeof item.text === 'string' && item.text.trim()) {
return unwrapOpenClawFinal(item.text) || stripOpenClawFinalTags(item.text);
}
if (typeof item.message === 'string' && item.message.trim()) {
return unwrapOpenClawFinal(item.message) || stripOpenClawFinalTags(item.message);
}
if (typeof item.content === 'string' && item.content.trim()) {
return unwrapOpenClawFinal(item.content) || stripOpenClawFinalTags(item.content);
}
const nestedMessage = item.message && typeof item.message === 'object' ? item.message : null;
if (nestedMessage) {
if (typeof nestedMessage.content === 'string' && nestedMessage.content.trim()) {
return unwrapOpenClawFinal(nestedMessage.content) || stripOpenClawFinalTags(nestedMessage.content);
}
if (Array.isArray(nestedMessage.content)) {
const textBlock = nestedMessage.content.find((entry) => entry?.type === 'text' && typeof entry?.text === 'string');
if (textBlock?.text) {
return unwrapOpenClawFinal(textBlock.text) || stripOpenClawFinalTags(textBlock.text);
}
}
}
if (Array.isArray(item.content)) {
const textParts = item.content
.map((entry) => {
if (typeof entry === 'string') return entry;
if (entry?.type === 'text' && typeof entry?.text === 'string') return entry.text;
return '';
})
.filter(Boolean);
if (textParts.length > 0) {
const merged = textParts.join('\n');
return unwrapOpenClawFinal(merged) || stripOpenClawFinalTags(merged);
}
}
if (typeof item.summary === 'string' && item.summary.trim()) {
return item.summary;
}
if (typeof item.value === 'string' && item.value.trim()) {
return item.value;
}
return JSON.stringify(item);
}
function normalizeOpenClawLiveEvent(evt) {
const payload = evt?.payload || {};
const nestedMessage = payload?.message && typeof payload.message === 'object' ? payload.message : null;
const nestedMeta = nestedMessage?.__openclaw || payload?.__openclaw || null;
const isFinal = hasOpenClawFinalTag(payload);
const text = extractOpenClawText(payload) || evt?.event || '';
const role =
payload.role
|| nestedMessage?.role
|| payload.senderRole
|| payload.kind
|| evt?.event
|| 'event';
const seq = payload.messageSeq ?? payload.seq ?? nestedMeta?.seq ?? null;
const messageId = payload.messageId ?? payload.id ?? nestedMeta?.id ?? null;
return {
id: messageId || (seq !== null ? `seq:${seq}` : `${evt?.event || 'event'}:${Date.now()}`),
role,
text: String(text),
timestamp: payload.timestamp || payload.ts || new Date().toISOString(),
seq,
messageId,
isFinal,
raw: payload,
};
}
function shouldAppendOpenClawLiveEvent(evt) {
const name = String(evt?.event || '');
const payload = evt?.payload || {};
if (name === 'session.message') {
return shouldKeepOpenClawMessage(payload);
}
return Boolean(payload.text || payload.message || payload.content);
}
function requestOpenClawSessionHistory(clientRef, sessionKey, limit = 30) {
const client = clientRef?.current;
if (!client || !sessionKey) {
return false;
}
return client.send(JSON.stringify({
type: 'get_openclaw_session_history',
session_key: sessionKey,
limit,
}));
}
function normalizeOpenClawAgents(agents, presence, sessionsPayload = null) {
const normalizedAgents = Array.isArray(agents) ? agents : [];
const presenceAgents = presence?.agents || presence || {};
const sessionDefaults = sessionsPayload?.defaults || {};
const sessions = Array.isArray(sessionsPayload?.sessions) ? sessionsPayload.sessions : [];
const sessionModelByAgent = new Map();
sessions.forEach((session) => {
if (!session || typeof session !== 'object') return;
let agentId = String(session.agentId || session.agent_id || '').trim();
if (!agentId) {
const key = String(session.key || session.sessionKey || '').trim();
const parts = key.split(':');
if (parts.length >= 3 && parts[0] === 'agent') {
agentId = parts[1];
}
}
const modelValue =
session.model ||
session.modelName ||
session.model_name ||
session.resolvedModel ||
session.resolved_model ||
null;
if (agentId && modelValue && !sessionModelByAgent.has(agentId)) {
sessionModelByAgent.set(agentId, modelValue);
}
});
return normalizedAgents.map((agent) => {
if (!agent || typeof agent !== 'object') {
return agent;
}
const agentId = String(agent.id || agent.agentId || '').trim();
const presenceEntry = agentId ? presenceAgents?.[agentId] : null;
const presenceSessions = Array.isArray(presenceEntry?.sessions) ? presenceEntry.sessions : [];
const firstPresenceSession = presenceSessions.find((session) => {
const value = session?.model || session?.modelName || session?.model_name || session?.resolvedModel;
return typeof value === 'string' && value.trim();
});
const model =
agent.model ||
agent.modelName ||
agent.model_name ||
agent.resolvedModel ||
agent.resolved_model ||
agent.defaultModel ||
agent.default_model ||
sessionModelByAgent.get(agentId) ||
sessionDefaults.model ||
sessionDefaults.modelName ||
sessionDefaults.model_name ||
firstPresenceSession?.model ||
firstPresenceSession?.modelName ||
firstPresenceSession?.model_name ||
firstPresenceSession?.resolvedModel ||
null;
return {
...agent,
model: typeof model === 'string' && model.trim() ? model.trim() : null,
};
});
}
/** /**
* Custom hook for WebSocket connection lifecycle and event handling. * Custom hook for WebSocket connection lifecycle and event handling.
* Manages clientRef, connection, and ALL event handlers. * Manages clientRef, connection, and ALL event handlers.
@@ -805,7 +1105,15 @@ export function useWebSocketConnection({
useOpenClawStore.getState().setStatusLoading(false); useOpenClawStore.getState().setStatusLoading(false);
}, },
openclaw_sessions_loaded: (e) => { openclaw_sessions_loaded: (e) => {
useOpenClawStore.getState().setOpenclawSessions(e.data || e); const payload = e.data || e;
useOpenClawStore.getState().setOpenclawSessions(payload);
const currentAgents = useOpenClawStore.getState().agents || [];
const presence = useOpenClawStore.getState().agentsPresence;
if (currentAgents.length > 0) {
useOpenClawStore.getState().setAgents(
normalizeOpenClawAgents(currentAgents, presence, payload),
);
}
useOpenClawStore.getState().setSessionsLoading(false); useOpenClawStore.getState().setSessionsLoading(false);
}, },
openclaw_session_detail_loaded: (e) => { openclaw_session_detail_loaded: (e) => {
@@ -813,7 +1121,120 @@ export function useWebSocketConnection({
useOpenClawStore.getState().setSessionDetailLoading(false); useOpenClawStore.getState().setSessionDetailLoading(false);
}, },
openclaw_session_history_loaded: (e) => { openclaw_session_history_loaded: (e) => {
useOpenClawStore.getState().setOpenclawSessionHistory(e.data || e); const data = e.data || e;
const sessionKey = e.session_key || data?.session_key || useOpenClawStore.getState().selectedSessionKey;
useOpenClawStore.getState().setOpenclawSessionHistory(data);
if (sessionKey) {
useOpenClawStore.getState().replaceOpenclawChatHistory(
sessionKey,
normalizeOpenClawHistoryItems(data?.history || []),
);
}
},
openclaw_session_resolved: (e) => {
const d = e.data || {};
useOpenClawStore.getState().setOpenclawResolvedSessionKey(d.key || null);
if (d?.error) {
useOpenClawStore.getState().setChatError(d.error);
} else {
useOpenClawStore.getState().setChatError(null);
}
},
openclaw_session_created: (e) => {
const d = e.data || {};
if (d?.error) {
useOpenClawStore.getState().setChatError(d.error);
return;
}
if (d?.entry || d?.key) {
const createdKey = d?.key || d?.entry?.key || d?.entry?.sessionKey || '';
useOpenClawStore.getState().appendOpenclawSession(
d.entry || {
key: createdKey,
sessionKey: createdKey,
agentId: String(createdKey).split(':')[1] || '',
}
);
}
if (d?.key) {
useOpenClawStore.getState().setSelectedSessionKey(d.key);
useOpenClawStore.getState().setOpenclawResolvedSessionKey(d.key);
useOpenClawStore.getState().setChatError(null);
}
},
openclaw_session_subscribed: (e) => {
const sessionKey = e.session_key || e.data?.key || null;
if (sessionKey) {
useOpenClawStore.getState().setOpenclawSessionSubscribed(sessionKey, true);
}
if (e.data?.error) {
useOpenClawStore.getState().setChatError(e.data.error);
}
},
openclaw_session_unsubscribed: (e) => {
const sessionKey = e.session_key || e.data?.key || null;
if (sessionKey) {
useOpenClawStore.getState().setOpenclawSessionSubscribed(sessionKey, false);
}
},
openclaw_session_reset: (e) => {
const sessionKey = e.session_key || e.data?.key || null;
if (e.data?.error) {
useOpenClawStore.getState().setChatError(e.data.error);
return;
}
if (sessionKey) {
useOpenClawStore.getState().replaceOpenclawChatHistory(sessionKey, []);
useOpenClawStore.getState().setChatError(null);
}
},
openclaw_session_deleted: (e) => {
const sessionKey = e.session_key || e.data?.key || null;
if (e.data?.error) {
useOpenClawStore.getState().setChatError(e.data.error);
return;
}
if (sessionKey) {
useOpenClawStore.getState().removeOpenclawSession(sessionKey);
useOpenClawStore.getState().setChatError(null);
}
},
openclaw_message_sent: (e) => {
const sessionKey = e.session_key || e.data?.key || useOpenClawStore.getState().selectedSessionKey;
if (sessionKey) {
useOpenClawStore.getState().setOpenclawChatSendingForSession(sessionKey, false);
}
if (e.data?.error) {
useOpenClawStore.getState().setChatError(e.data.error);
} else {
useOpenClawStore.getState().setChatError(null);
if (sessionKey && (e.data?.status || e.data?.runId || e.data?.messageSeq !== undefined)) {
const statusBits = [
e.data?.status ? `status=${e.data.status}` : null,
e.data?.runId ? `runId=${e.data.runId}` : null,
e.data?.messageSeq !== undefined ? `seq=${e.data.messageSeq}` : null,
].filter(Boolean);
useOpenClawStore.getState().appendOpenclawChatMessage(sessionKey, {
id: `send-meta:${e.data?.runId || Date.now()}`,
role: 'system',
text: `消息已提交到 OpenClaw${statusBits.length ? ` (${statusBits.join(', ')})` : ''}`,
timestamp: new Date().toISOString(),
});
}
if (sessionKey) {
window.setTimeout(() => requestOpenClawSessionHistory(clientRef, sessionKey, 30), 600);
}
}
},
openclaw_session_event: (e) => {
const sessionKey = e.session_key || e.payload?.sessionKey || e.payload?.key;
if (!sessionKey || !shouldAppendOpenClawLiveEvent(e)) {
return;
}
useOpenClawStore.getState().appendOpenclawChatMessage(
sessionKey,
normalizeOpenClawLiveEvent(e),
);
}, },
openclaw_cron_loaded: (e) => { openclaw_cron_loaded: (e) => {
useOpenClawStore.getState().setOpenclawCronJobs(e.data || e); useOpenClawStore.getState().setOpenclawCronJobs(e.data || e);
@@ -829,12 +1250,30 @@ export function useWebSocketConnection({
if (d?.error) { if (d?.error) {
useOpenClawStore.getState().setAgentsError(d.error); useOpenClawStore.getState().setAgentsError(d.error);
} else { } else {
useOpenClawStore.getState().setAgents(d?.agents || []); const presence = useOpenClawStore.getState().agentsPresence;
const sessionsPayload = {
sessions: useOpenClawStore.getState().openclawSessions || [],
defaults: useOpenClawStore.getState().openclawSessionsDefaults || null,
};
useOpenClawStore.getState().setAgents(
normalizeOpenClawAgents(d?.agents || [], presence, sessionsPayload),
);
useOpenClawStore.getState().setAgentsError(null); useOpenClawStore.getState().setAgentsError(null);
} }
}, },
openclaw_agents_presence_loaded: (e) => { openclaw_agents_presence_loaded: (e) => {
useOpenClawStore.getState().setAgentsPresence((e.data?.data ?? e.data) || {}); const presencePayload = (e.data?.data ?? e.data) || {};
useOpenClawStore.getState().setAgentsPresence(presencePayload);
const currentAgents = useOpenClawStore.getState().agents || [];
if (currentAgents.length > 0) {
const sessionsPayload = {
sessions: useOpenClawStore.getState().openclawSessions || [],
defaults: useOpenClawStore.getState().openclawSessionsDefaults || null,
};
useOpenClawStore.getState().setAgents(
normalizeOpenClawAgents(currentAgents, presencePayload, sessionsPayload),
);
}
}, },
openclaw_skills_loaded: (e) => { openclaw_skills_loaded: (e) => {
useOpenClawStore.getState().setSkillsLoading(false); useOpenClawStore.getState().setSkillsLoading(false);

View File

@@ -7,10 +7,16 @@ export const useOpenClawStore = create(
// Raw data // Raw data
openclawStatus: null, openclawStatus: null,
openclawSessions: [], openclawSessions: [],
openclawSessionsDefaults: null,
openclawSessionDetail: null, openclawSessionDetail: null,
openclawSessionHistory: [], openclawSessionHistory: [],
openclawCronJobs: [], openclawCronJobs: [],
openclawApprovals: [], openclawApprovals: [],
openclawResolvedSessionKey: null,
openclawChatMessagesBySession: {},
openclawChatDraftBySession: {},
openclawChatSendingBySession: {},
openclawSessionSubscriptions: {},
// Loading states // Loading states
isStatusLoading: false, isStatusLoading: false,
@@ -18,6 +24,7 @@ export const useOpenClawStore = create(
isSessionDetailLoading: false, isSessionDetailLoading: false,
isCronLoading: false, isCronLoading: false,
isApprovalsLoading: false, isApprovalsLoading: false,
isChatSending: false,
// Error states // Error states
statusError: null, statusError: null,
@@ -25,6 +32,7 @@ export const useOpenClawStore = create(
sessionDetailError: null, sessionDetailError: null,
cronError: null, cronError: null,
approvalsError: null, approvalsError: null,
chatError: null,
// Agents state // Agents state
agents: [], agents: [],
@@ -119,11 +127,129 @@ export const useOpenClawStore = create(
// Setters // Setters
setOpenclawStatus: (data) => set({ openclawStatus: data, statusError: null }), setOpenclawStatus: (data) => set({ openclawStatus: data, statusError: null }),
setOpenclawSessions: (data) => set({ openclawSessions: data?.sessions || [], sessionsError: null }), setOpenclawSessions: (data) => set({
setOpenclawSessionDetail: (data) => set({ openclawSessionDetail: data?.session || null, sessionDetailError: null }), openclawSessions: data?.sessions || [],
setOpenclawSessionHistory: (data) => set({ openclawSessionHistory: data?.history || [], sessionDetailError: null }), openclawSessionsDefaults: data?.defaults || null,
sessionsError: null,
}),
appendOpenclawSession: (session) => set((state) => {
const key = session?.key || session?.sessionKey;
if (!key) {
return {};
}
const existing = state.openclawSessions || [];
const deduped = existing.filter((item) => (item?.key || item?.sessionKey) !== key);
return { openclawSessions: [session, ...deduped] };
}),
removeOpenclawSession: (sessionKey) => set((state) => ({
openclawSessions: (state.openclawSessions || []).filter(
(item) => (item?.key || item?.sessionKey) !== sessionKey
),
selectedSessionKey:
state.selectedSessionKey === sessionKey ? null : state.selectedSessionKey,
})),
setOpenclawSessionDetail: (data) => set({ openclawSessionDetail: data?.session || null, sessionDetailError: data?.error || null }),
setOpenclawSessionHistory: (data) => set({ openclawSessionHistory: data?.history || [], sessionDetailError: data?.error || null }),
setOpenclawCronJobs: (data) => set({ openclawCronJobs: data?.cron || [], cronError: null }), setOpenclawCronJobs: (data) => set({ openclawCronJobs: data?.cron || [], cronError: null }),
setOpenclawApprovals: (data) => set({ openclawApprovals: data?.approvals || [], approvalsError: null }), setOpenclawApprovals: (data) => set({ openclawApprovals: data?.approvals || [], approvalsError: null }),
setOpenclawResolvedSessionKey: (key) => set({ openclawResolvedSessionKey: key || null }),
setOpenclawChatDraft: (sessionKey, value) => set((state) => ({
openclawChatDraftBySession: { ...state.openclawChatDraftBySession, [sessionKey]: value },
})),
appendOpenclawChatMessage: (sessionKey, message) => set((state) => {
const current = state.openclawChatMessagesBySession[sessionKey] || [];
const sameMessageIndex = current.findIndex((item) => {
const sameId = Boolean(message?.id && item?.id && message.id === item.id);
const sameMessageId = Boolean(
message?.messageId &&
item?.messageId &&
message.messageId === item.messageId
);
const sameSeq = Boolean(
message?.seq !== undefined &&
message?.seq !== null &&
item?.seq !== undefined &&
item?.seq !== null &&
message.seq === item.seq &&
message?.role === item?.role
);
const incomingText = String(message?.text || '').trim();
const existingText = String(item?.text || '').trim();
const incomingTs = Date.parse(message?.timestamp || '');
const existingTs = Date.parse(item?.timestamp || '');
const nearInTime =
Number.isFinite(incomingTs) &&
Number.isFinite(existingTs) &&
Math.abs(incomingTs - existingTs) < 1500;
const sameAssistantText =
message?.role === 'assistant' &&
item?.role === 'assistant' &&
incomingText &&
existingText &&
(
incomingText === existingText ||
incomingText.startsWith(existingText) ||
existingText.startsWith(incomingText)
) &&
nearInTime;
return sameId || sameMessageId || sameSeq || sameAssistantText;
});
if (sameMessageIndex >= 0) {
const next = [...current];
next[sameMessageIndex] = { ...next[sameMessageIndex], ...message };
return {
openclawChatMessagesBySession: {
...state.openclawChatMessagesBySession,
[sessionKey]: next,
},
};
}
return {
openclawChatMessagesBySession: {
...state.openclawChatMessagesBySession,
[sessionKey]: [...current, message],
},
};
}),
replaceOpenclawChatHistory: (sessionKey, messages) => set((state) => {
const incoming = Array.isArray(messages) ? messages : [];
const existing = state.openclawChatMessagesBySession[sessionKey] || [];
const merged = [];
const seen = new Set();
const signatureFor = (message) => {
if (!message) return "";
if (message.id) return `id:${message.id}`;
if (message.messageId) return `mid:${message.messageId}`;
if (message.seq !== undefined && message.seq !== null) return `seq:${message.seq}`;
return `txt:${message.role || ""}:${String(message.text || "").trim()}`;
};
for (const message of [...incoming, ...existing]) {
const signature = signatureFor(message);
if (!signature || seen.has(signature)) {
continue;
}
seen.add(signature);
merged.push(message);
}
return {
openclawChatMessagesBySession: {
...state.openclawChatMessagesBySession,
[sessionKey]: merged,
},
};
}),
setOpenclawChatSendingForSession: (sessionKey, value) => set((state) => ({
openclawChatSendingBySession: { ...state.openclawChatSendingBySession, [sessionKey]: Boolean(value) },
isChatSending: Boolean(value),
})),
setOpenclawSessionSubscribed: (sessionKey, value) => set((state) => ({
openclawSessionSubscriptions: { ...state.openclawSessionSubscriptions, [sessionKey]: Boolean(value) },
})),
setSelectedSessionKey: (key) => set({ selectedSessionKey: key }), setSelectedSessionKey: (key) => set({ selectedSessionKey: key }),
@@ -138,6 +264,7 @@ export const useOpenClawStore = create(
setSessionDetailError: (e) => set({ sessionDetailError: e }), setSessionDetailError: (e) => set({ sessionDetailError: e }),
setCronError: (e) => set({ cronError: e }), setCronError: (e) => set({ cronError: e }),
setApprovalsError: (e) => set({ approvalsError: e }), setApprovalsError: (e) => set({ approvalsError: e }),
setChatError: (e) => set({ chatError: e }),
setAgents: (agents) => set({ agents }), setAgents: (agents) => set({ agents }),
setAgentsLoading: (loading) => set({ agentsLoading: loading }), setAgentsLoading: (loading) => set({ agentsLoading: loading }),

View File

@@ -282,7 +282,6 @@
"trading_days_completed": 0, "trading_days_completed": 0,
"server_mode": "live", "server_mode": "live",
"is_backtest": false, "is_backtest": false,
"is_mock_mode": false,
"data_sources": { "data_sources": {
"preferred": [ "preferred": [
"yfinance", "yfinance",
@@ -300,4 +299,4 @@
} }
}, },
"last_saved": "2026-03-16T00:25:09.700631" "last_saved": "2026-03-16T00:25:09.700631"
} }

View File

@@ -517,11 +517,13 @@ class OpenClawWebSocketClient:
params["agentId"] = agent_id params["agentId"] = agent_id
if label: if label:
params["label"] = label params["label"] = label
if channel:
params["channel"] = channel
result = await self._send_request("sessions.resolve", params) result = await self._send_request("sessions.resolve", params)
sessions = result.get("sessions", []) key = result.get("key")
if sessions: if isinstance(key, str) and key.strip():
return sessions[0].get("key") return key.strip()
return None return None
async def send_message( async def send_message(
@@ -549,12 +551,30 @@ class OpenClawWebSocketClient:
if thinking: if thinking:
params["thinking"] = thinking params["thinking"] = thinking
# Use shorter timeout for send since it waits for agent response previous_timeout_ms = self.timeout_ms
result = await self._send_request( if timeout_ms is not None:
"sessions.send", self.timeout_ms = timeout_ms
params, try:
) return await self._send_request("sessions.send", params)
return result finally:
self.timeout_ms = previous_timeout_ms
async def unsubscribe(self, session_key: str) -> dict[str, Any]:
"""Unsubscribe from messages for a session."""
return await self._send_request("sessions.messages.unsubscribe", {"key": session_key})
async def get_session_history(
self,
session_key: str,
limit: int = 20,
) -> dict[str, Any]:
"""Best-effort session history read.
OpenClaw's public Gateway surface is subscription-first for live message flow.
History is not consistently exposed over the same WS methods across builds, so
callers should still keep a CLI or REST fallback available.
"""
return await self._send_request("sessions.preview", {"keys": [session_key], "limit": limit})
async def subscribe(self, session_key: str) -> AsyncMessageIterator: async def subscribe(self, session_key: str) -> AsyncMessageIterator:
"""Subscribe to messages from a session. """Subscribe to messages from a session.
@@ -670,6 +690,11 @@ class AsyncMessageIterator:
async def __anext__(self) -> MessageEvent: async def __anext__(self) -> MessageEvent:
return await self._queue.get() return await self._queue.get()
async def aclose(self) -> None:
if self._handler_added:
self._client.remove_event_handler(self._on_event)
self._handler_added = False
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Synchronous convenience functions # Synchronous convenience functions