Compare commits
3 Commits
codex/chec
...
4aa69650e8
| Author | SHA1 | Date | |
|---|---|---|---|
| 4aa69650e8 | |||
| 5c08c1865c | |||
| 6ecc224427 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -51,7 +51,6 @@ node_modules
|
||||
outputs/
|
||||
/production/
|
||||
/smoke_test/
|
||||
/smoke_live_mock/
|
||||
|
||||
# Local tooling state
|
||||
.omc/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastScanned": 1774313111650,
|
||||
"lastScanned": 1774515151036,
|
||||
"projectRoot": "/Users/cillin/workspeace/evotraders",
|
||||
"techStack": {
|
||||
"languages": [
|
||||
@@ -54,7 +54,7 @@
|
||||
"path": "backend",
|
||||
"purpose": null,
|
||||
"fileCount": 4,
|
||||
"lastAccessed": 1774313111639,
|
||||
"lastAccessed": 1774515151025,
|
||||
"keyFiles": [
|
||||
"__init__.py",
|
||||
"cli.py",
|
||||
@@ -66,14 +66,14 @@
|
||||
"path": "backtest",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313111640,
|
||||
"lastAccessed": 1774515151026,
|
||||
"keyFiles": []
|
||||
},
|
||||
"data": {
|
||||
"path": "data",
|
||||
"purpose": "Data files",
|
||||
"fileCount": 3,
|
||||
"lastAccessed": 1774313111640,
|
||||
"lastAccessed": 1774515151027,
|
||||
"keyFiles": [
|
||||
"market_research.db",
|
||||
"market_research.db-shm",
|
||||
@@ -84,14 +84,14 @@
|
||||
"path": "deploy",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313111640,
|
||||
"lastAccessed": 1774515151027,
|
||||
"keyFiles": []
|
||||
},
|
||||
"docs": {
|
||||
"path": "docs",
|
||||
"purpose": "Documentation",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1774313111641,
|
||||
"lastAccessed": 1774515151027,
|
||||
"keyFiles": [
|
||||
"compat-removal-plan.md"
|
||||
]
|
||||
@@ -100,7 +100,7 @@
|
||||
"path": "evotraders.egg-info",
|
||||
"purpose": null,
|
||||
"fileCount": 6,
|
||||
"lastAccessed": 1774313111641,
|
||||
"lastAccessed": 1774515151028,
|
||||
"keyFiles": [
|
||||
"PKG-INFO",
|
||||
"SOURCES.txt",
|
||||
@@ -113,7 +113,7 @@
|
||||
"path": "frontend",
|
||||
"purpose": null,
|
||||
"fileCount": 13,
|
||||
"lastAccessed": 1774313111641,
|
||||
"lastAccessed": 1774515151028,
|
||||
"keyFiles": [
|
||||
"README.md",
|
||||
"components.json",
|
||||
@@ -126,41 +126,28 @@
|
||||
"path": "live",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313111642,
|
||||
"lastAccessed": 1774515151028,
|
||||
"keyFiles": []
|
||||
},
|
||||
"logs": {
|
||||
"path": "logs",
|
||||
"purpose": null,
|
||||
"fileCount": 6,
|
||||
"lastAccessed": 1774313111642,
|
||||
"keyFiles": [
|
||||
"2026-03-16_00-48-03.log",
|
||||
"2026-03-18_23-17-29.log",
|
||||
"2026-03-18_23-17-30.log",
|
||||
"2026-03-19_00-18-04.log",
|
||||
"2026-03-19_00-34-21.log"
|
||||
]
|
||||
},
|
||||
"reference": {
|
||||
"path": "reference",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313111643,
|
||||
"lastAccessed": 1774515151028,
|
||||
"keyFiles": []
|
||||
},
|
||||
"runs": {
|
||||
"path": "runs",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313111643,
|
||||
"lastAccessed": 1774515151029,
|
||||
"keyFiles": []
|
||||
},
|
||||
"scripts": {
|
||||
"path": "scripts",
|
||||
"purpose": "Build/utility scripts",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1774313111644,
|
||||
"lastAccessed": 1774515151030,
|
||||
"keyFiles": [
|
||||
"run_prod.sh"
|
||||
]
|
||||
@@ -169,7 +156,7 @@
|
||||
"path": "services",
|
||||
"purpose": "Business logic services",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1774313111644,
|
||||
"lastAccessed": 1774515151030,
|
||||
"keyFiles": [
|
||||
"README.md"
|
||||
]
|
||||
@@ -178,21 +165,14 @@
|
||||
"path": "shared",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313111644,
|
||||
"keyFiles": []
|
||||
},
|
||||
"workspaces": {
|
||||
"path": "workspaces",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313111645,
|
||||
"lastAccessed": 1774515151030,
|
||||
"keyFiles": []
|
||||
},
|
||||
"backend/api": {
|
||||
"path": "backend/api",
|
||||
"purpose": "API routes",
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1774313111645,
|
||||
"lastAccessed": 1774515151030,
|
||||
"keyFiles": [
|
||||
"__init__.py",
|
||||
"agents.py",
|
||||
@@ -203,7 +183,7 @@
|
||||
"path": "backend/config",
|
||||
"purpose": "Configuration files",
|
||||
"fileCount": 6,
|
||||
"lastAccessed": 1774313111646,
|
||||
"lastAccessed": 1774515151030,
|
||||
"keyFiles": [
|
||||
"__init__.py",
|
||||
"agent_profiles.yaml",
|
||||
@@ -213,8 +193,8 @@
|
||||
"backend/data": {
|
||||
"path": "backend/data",
|
||||
"purpose": "Data files",
|
||||
"fileCount": 13,
|
||||
"lastAccessed": 1774313111647,
|
||||
"fileCount": 12,
|
||||
"lastAccessed": 1774515151031,
|
||||
"keyFiles": [
|
||||
"__init__.py",
|
||||
"cache.py",
|
||||
@@ -225,7 +205,7 @@
|
||||
"path": "docs/assets",
|
||||
"purpose": "Static assets",
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1774313111647,
|
||||
"lastAccessed": 1774515151031,
|
||||
"keyFiles": [
|
||||
"dashboard.jpg",
|
||||
"evotraders_demo.gif",
|
||||
@@ -236,7 +216,7 @@
|
||||
"path": "frontend/dist",
|
||||
"purpose": "Distribution/build output",
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1774313111647,
|
||||
"lastAccessed": 1774515151031,
|
||||
"keyFiles": [
|
||||
"index.html",
|
||||
"trading_logo.png"
|
||||
@@ -246,261 +226,309 @@
|
||||
"path": "frontend/node_modules",
|
||||
"purpose": "Dependencies",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1774313111650,
|
||||
"lastAccessed": 1774515151036,
|
||||
"keyFiles": []
|
||||
}
|
||||
},
|
||||
"hotPaths": [
|
||||
{
|
||||
"path": "CLAUDE.md",
|
||||
"accessCount": 15,
|
||||
"lastAccessed": 1774342728155,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/App.jsx",
|
||||
"accessCount": 10,
|
||||
"lastAccessed": 1774339397617,
|
||||
"path": "frontend/src/hooks/useWebSocketConnection.js",
|
||||
"accessCount": 100,
|
||||
"lastAccessed": 1774550862686,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/hooks/useWebsocketSessionSync.js",
|
||||
"accessCount": 4,
|
||||
"lastAccessed": 1774313470024,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "",
|
||||
"accessCount": 4,
|
||||
"lastAccessed": 1774339108220,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/gateway.py",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1774339389171,
|
||||
"accessCount": 98,
|
||||
"lastAccessed": 1774550272354,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/gateway_openclaw_handlers.py",
|
||||
"accessCount": 91,
|
||||
"lastAccessed": 1774550256325,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/api/openclaw.py",
|
||||
"accessCount": 48,
|
||||
"lastAccessed": 1774545375555,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/hooks/useOpenClawPanel.js",
|
||||
"accessCount": 42,
|
||||
"lastAccessed": 1774550688926,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "shared/client/openclaw_client.py",
|
||||
"accessCount": 39,
|
||||
"lastAccessed": 1774545484770,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src",
|
||||
"accessCount": 35,
|
||||
"lastAccessed": 1774550715529,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "reference/openclaw/src",
|
||||
"accessCount": 33,
|
||||
"lastAccessed": 1774550840611,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "backend/services/openclaw_cli.py",
|
||||
"accessCount": 31,
|
||||
"lastAccessed": 1774545484887,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/TraderView.jsx",
|
||||
"accessCount": 23,
|
||||
"lastAccessed": 1774543366574,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "shared/models/openclaw.py",
|
||||
"accessCount": 22,
|
||||
"lastAccessed": 1774545419541,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/openclawStore.js",
|
||||
"accessCount": 20,
|
||||
"lastAccessed": 1774550319533,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/App.jsx",
|
||||
"accessCount": 18,
|
||||
"lastAccessed": 1774544542524,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/services/websocket.js",
|
||||
"accessCount": 18,
|
||||
"lastAccessed": 1774549669596,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "start-dev.sh",
|
||||
"accessCount": 15,
|
||||
"lastAccessed": 1774548224246,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/RuntimeView.jsx",
|
||||
"accessCount": 14,
|
||||
"lastAccessed": 1774518525793,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/AppShell.jsx",
|
||||
"accessCount": 13,
|
||||
"lastAccessed": 1774533781725,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/main.py",
|
||||
"accessCount": 13,
|
||||
"lastAccessed": 1774548236340,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "backend/apps/openclaw_service.py",
|
||||
"accessCount": 10,
|
||||
"lastAccessed": 1774547900186,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/OpenClawStatusPanel.jsx",
|
||||
"accessCount": 8,
|
||||
"lastAccessed": 1774533622019,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "reference/openclaw/src/commands",
|
||||
"accessCount": 7,
|
||||
"lastAccessed": 1774530402019,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/config/constants.js",
|
||||
"accessCount": 7,
|
||||
"lastAccessed": 1774544689658,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "",
|
||||
"accessCount": 6,
|
||||
"lastAccessed": 1774550700047,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "backend/services",
|
||||
"accessCount": 5,
|
||||
"lastAccessed": 1774550692490,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/uiStore.js",
|
||||
"accessCount": 4,
|
||||
"lastAccessed": 1774533747700,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/styles/GlobalStyles.jsx",
|
||||
"accessCount": 4,
|
||||
"lastAccessed": 1774533753657,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/agentStore.js",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1774342613364,
|
||||
"lastAccessed": 1774517930592,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "reference/openclaw/src/cli/skills-cli.ts",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1774527140107,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "reference/openclaw/src/commands/agents.commands.list.ts",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1774533427441,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/runtimeStore.js",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1774317990919,
|
||||
"lastAccessed": 1774517930660,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/services/websocket.js",
|
||||
"path": "frontend/src/hooks/useAgentWorkspacePanel.js",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1774318009819,
|
||||
"lastAccessed": 1774518021290,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/core/pipeline_runner.py",
|
||||
"path": "frontend/src/services/runtimeApi.js",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1774339367538,
|
||||
"lastAccessed": 1774518025465,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/runtime/manager.py",
|
||||
"path": "reference/openclaw/src/commands/agents.commands.delete.ts",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1774339367572,
|
||||
"lastAccessed": 1774530389553,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "reference/openclaw/src/commands/agents.commands.add.ts",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1774530389605,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/api/__init__.py",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1774542416191,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/vite.config.js",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1774544772960,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/index.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774515811752,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/marketStore.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313140483,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/hooks/useFeedProcessor.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313148279,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/Header.jsx",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313156696,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/TraderView.jsx",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313156753,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/uiStore.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313187460,
|
||||
"lastAccessed": 1774515838923,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/portfolioStore.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313187511,
|
||||
"lastAccessed": 1774515839687,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/store/agentStore.js",
|
||||
"path": "frontend/src/index.css",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313187573,
|
||||
"lastAccessed": 1774515988837,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/hooks/useWebSocketConnection.js",
|
||||
"path": "frontend/src/App.css",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313279414,
|
||||
"lastAccessed": 1774515998423,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/hooks/useStockDataRequests.js",
|
||||
"path": "frontend/package.json",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313319716,
|
||||
"lastAccessed": 1774516005569,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/hooks/useAgentDataRequests.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313347455,
|
||||
"lastAccessed": 1774517930219,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/AppShell.jsx",
|
||||
"path": "backend/services/gateway_admin_handlers.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774313396331,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "start-dev.sh",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774317979859,
|
||||
"lastAccessed": 1774517937966,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/apps/agent_service.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774317984348,
|
||||
"lastAccessed": 1774517946208,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "shared/client/trading_client.py",
|
||||
"path": "frontend/src/hooks",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774317984365,
|
||||
"lastAccessed": 1774517946260,
|
||||
"type": "directory"
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/hooks/useFeedProcessor.js",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774517952115,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/apps/trading_service.py",
|
||||
"path": "reference/openclaw/src/commands/models/set.ts",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774317984408,
|
||||
"lastAccessed": 1774526963526,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "pyproject.toml",
|
||||
"path": "reference/openclaw/src/commands/models/list.ts",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774317990970,
|
||||
"lastAccessed": 1774526963632,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/agents/factory.py",
|
||||
"path": "reference/openclaw/src/cli/skills-cli.format.ts",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774318009867,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/config/constants.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774318009922,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/api/__init__.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774318009973,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339107381,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/runtime/registry.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339380024,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/runtime/session.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339380084,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/runtime/context.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339380120,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/runtime/agent_runtime.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339380185,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/process/supervisor.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339389110,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/core/pipeline.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339389187,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/process/models.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339397557,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/process/registry.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774339397577,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/config/env_config.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774342678236,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "backend/config/data_config.py",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774342678253,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/env.template",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774342678290,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "env.template",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1774342678310,
|
||||
"lastAccessed": 1774526963684,
|
||||
"type": "file"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"timestamp": "2026-03-24T07:58:12.123Z",
|
||||
"timestamp": "2026-03-27T04:53:52.906Z",
|
||||
"backgroundTasks": [],
|
||||
"sessionStartTimestamp": "2026-03-24T07:58:09.417Z",
|
||||
"sessionId": "fda34772-7bd2-402e-86b2-d656296416f3"
|
||||
"sessionStartTimestamp": "2026-03-27T04:53:21.944Z",
|
||||
"sessionId": "cbb9004e-771b-4e82-95d4-cea6d9753642"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"session_id":"fda34772-7bd2-402e-86b2-d656296416f3","transcript_path":"/Users/cillin/.claude/projects/-Users-cillin-workspeace-evotraders/fda34772-7bd2-402e-86b2-d656296416f3.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":36.63980749999998,"total_duration_ms":69778027,"total_api_duration_ms":2925118,"total_lines_added":3056,"total_lines_removed":4537},"context_window":{"total_input_tokens":910503,"total_output_tokens":145207,"context_window_size":200000,"current_usage":{"input_tokens":507,"output_tokens":247,"cache_creation_input_tokens":4132,"cache_read_input_tokens":96553},"used_percentage":51,"remaining_percentage":49},"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}
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"lastSentAt": "2026-03-24T08:58:57.965Z"
|
||||
"lastSentAt": "2026-03-27T04:55:49.635Z"
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "abeaf609b74a2b7ee",
|
||||
"agent_type": "Explore",
|
||||
"started_at": "2026-03-24T08:01:40.015Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-24T08:02:31.822Z",
|
||||
"duration_ms": 51807
|
||||
},
|
||||
{
|
||||
"agent_id": "afb6750eaae72bc72",
|
||||
"agent_type": "Explore",
|
||||
"started_at": "2026-03-24T08:56:21.471Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-24T08:57:27.856Z",
|
||||
"duration_ms": 66385
|
||||
}
|
||||
],
|
||||
"total_spawned": 2,
|
||||
"total_completed": 2,
|
||||
"total_failed": 0,
|
||||
"last_updated": "2026-03-24T08:59:06.380Z"
|
||||
}
|
||||
BIN
.playwright-mcp/page-2026-03-26T12-28-14-006Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-26T12-28-14-006Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -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 # 离线数据更新
|
||||
|
||||
383
README.md
383
README.md
@@ -5,32 +5,28 @@
|
||||
<h2 align="center">EvoTraders: A Self-Evolving Multi-Agent Trading System</h2>
|
||||
|
||||
<p align="center">
|
||||
📌 <a href="http://trading.evoagents.cn">Visit us at EvoTraders website !</a>
|
||||
📌 <a href="http://trading.evoagents.cn">Visit the EvoTraders website</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
EvoTraders is an open-source financial trading agent framework that builds a trading system capable of continuous learning and evolution in real markets through multi-agent collaboration and memory systems.
|
||||
EvoTraders is an open-source financial trading agent framework that combines multi-agent collaboration, run-scoped workspaces, and memory to support both backtests and live trading workflows.
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
**Multi-Agent Collaborative Trading**
|
||||
A team of 6 members, including 4 specialized analyst roles (fundamentals, technical, sentiment, valuation) + portfolio manager + risk management, collaborating to make decisions like a real trading team.
|
||||
**Multi-agent trading team**
|
||||
Six roles collaborate like a real desk: four specialist analysts (fundamentals, technical, sentiment, valuation), one portfolio manager, and one risk manager.
|
||||
|
||||
You can customize your Agents here: [Custom Configuration](#custom-configuration)
|
||||
**Continuous learning**
|
||||
Agents can persist long-term memory with ReMe, reflect after each cycle, and evolve their decision patterns over time.
|
||||
|
||||
**Continuous Learning and Evolution**
|
||||
Based on the ReMe memory framework, agents reflect and summarize after each trade, preserving experience across rounds, and forming unique investment methodologies.
|
||||
**Backtest and live modes**
|
||||
The same runtime model supports historical simulation and live execution with real-time market data.
|
||||
|
||||
Through this design, we hope that when AI Agents form a team and enter the real-time market, they will gradually develop their own trading styles and decision preferences, rather than one-time random inference.
|
||||
|
||||
**Real-Time Market Trading**
|
||||
Supports real-time market data integration, providing backtesting mode and live trading mode, allowing AI Agents to learn and make decisions in real market fluctuations.
|
||||
|
||||
**Visualized Trading Information**
|
||||
Observe agents' analysis processes, communication records, and decision evolution in real-time, with complete tracking of return curves and analyst performance.
|
||||
**Operator-facing UI**
|
||||
The frontend exposes the trading room, runtime controls, logs, approvals, agent workspaces, and explain/news views.
|
||||
|
||||
<p>
|
||||
<img src="docs/assets/performance.jpg" width="45%">
|
||||
@@ -39,83 +35,158 @@ Observe agents' analysis processes, communication records, and decision evolutio
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture
|
||||
|
||||
The repository is currently in a transition from a modular monolith to split service surfaces. The split-service path is the default local development mode.
|
||||
|
||||
Current app surfaces:
|
||||
|
||||
- `backend.apps.agent_service` on `:8000`: control plane for workspaces, agents, skills, and guard/approval APIs
|
||||
- `backend.apps.trading_service` on `:8001`: read-only trading data APIs
|
||||
- `backend.apps.news_service` on `:8002`: read-only explain/news APIs
|
||||
- `backend.apps.runtime_service` on `:8003`: runtime lifecycle APIs
|
||||
- `backend.apps.openclaw_service` on `:8004`: read-only OpenClaw facade
|
||||
- WebSocket gateway on `:8765`: live event/feed channel for the frontend
|
||||
|
||||
The most important runtime path today is:
|
||||
|
||||
`frontend -> runtime_service/control APIs -> gateway/runtime manager -> market service + pipeline + storage`
|
||||
|
||||
Reference notes for the migration live in [services/README.md](./services/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/agentscope-ai/agentscope-samples
|
||||
cd agentscope-samples/EvoTraders
|
||||
# clone this repository, then:
|
||||
cd evotraders
|
||||
|
||||
# Install dependencies (Recommend uv!)
|
||||
# recommended
|
||||
uv pip install -e .
|
||||
# optional: pip install -e .
|
||||
|
||||
# optional
|
||||
# uv pip install -e ".[dev]"
|
||||
# pip install -e .
|
||||
```
|
||||
|
||||
# Configure environment variables
|
||||
### 2. Configure environment
|
||||
|
||||
```bash
|
||||
cp env.template .env
|
||||
# Edit .env file and add your API Keys. The following config are required:
|
||||
```
|
||||
|
||||
# finance data API: At minimum, FINANCIAL_DATASETS_API_KEY is required, corresponding to FIN_DATA_SOURCE=financial_datasets; It is recommended to add FINNHUB_API_KEY, corresponding to FIN_DATA_SOURCE=finnhub; If using live mode, FINNHUB_API_KEY must be added
|
||||
FIN_DATA_SOURCE = #finnhub or financial_datasets
|
||||
FINANCIAL_DATASETS_API_KEY= #Required
|
||||
FINNHUB_API_KEY= #Optional
|
||||
The root `env.template` is the canonical local template. A `.env.example` is also kept in the repo for reference.
|
||||
|
||||
# LLM API for Agents
|
||||
Minimum useful variables:
|
||||
|
||||
```bash
|
||||
# watchlist
|
||||
TICKERS=AAPL,MSFT,GOOGL,NVDA,TSLA,META,AMZN
|
||||
|
||||
# market data
|
||||
FIN_DATA_SOURCE=finnhub
|
||||
FINANCIAL_DATASETS_API_KEY=
|
||||
FINNHUB_API_KEY=
|
||||
POLYGON_API_KEY=
|
||||
|
||||
# agent model
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
MODEL_NAME=qwen3-max-preview
|
||||
|
||||
# LLM & embedding API for Memory
|
||||
# memory (optional unless --enable-memory is used)
|
||||
MEMORY_API_KEY=
|
||||
```
|
||||
|
||||
### Running
|
||||
Notes:
|
||||
|
||||
- `FINNHUB_API_KEY` is required for live mode.
|
||||
- `POLYGON_API_KEY` enables long-lived market-store ingestion and refresh helpers.
|
||||
- `MEMORY_API_KEY` is only required when long-term memory is enabled.
|
||||
|
||||
### 3. Start the stack
|
||||
|
||||
Recommended local development flow:
|
||||
|
||||
**Backtest Mode:**
|
||||
```bash
|
||||
evotraders backtest --start 2025-11-01 --end 2025-12-01
|
||||
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # Use Memory
|
||||
./start-dev.sh
|
||||
```
|
||||
|
||||
If you do not have market data APIs and just want to try the backtest demo, download the offline data and unzip it into `backend/data`:
|
||||
This starts:
|
||||
|
||||
- `agent_service` at `http://localhost:8000`
|
||||
- `trading_service` at `http://localhost:8001`
|
||||
- `news_service` at `http://localhost:8002`
|
||||
- `runtime_service` at `http://localhost:8003`
|
||||
- gateway WebSocket at `ws://localhost:8765`
|
||||
|
||||
Then start the frontend in another terminal:
|
||||
|
||||
```bash
|
||||
evotraders frontend
|
||||
```
|
||||
|
||||
Open `http://localhost:5173`.
|
||||
|
||||
You can also run services manually:
|
||||
|
||||
```bash
|
||||
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
|
||||
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
|
||||
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
|
||||
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
|
||||
python -m backend.main --mode live --host 0.0.0.0 --port 8765
|
||||
```
|
||||
|
||||
### 4. Run backtest or live mode from CLI
|
||||
|
||||
Backtest:
|
||||
|
||||
```bash
|
||||
evotraders backtest --start 2025-11-01 --end 2025-12-01
|
||||
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory
|
||||
evotraders backtest --config-name smoke_fullstack --start 2025-11-01 --end 2025-12-01
|
||||
```
|
||||
|
||||
Live:
|
||||
|
||||
```bash
|
||||
evotraders live
|
||||
evotraders live --enable-memory
|
||||
evotraders live --schedule-mode intraday --interval-minutes 60
|
||||
evotraders live --trigger-time 22:30
|
||||
```
|
||||
|
||||
Help:
|
||||
|
||||
```bash
|
||||
evotraders --help
|
||||
evotraders backtest --help
|
||||
evotraders live --help
|
||||
evotraders frontend --help
|
||||
```
|
||||
|
||||
### Offline backtest data
|
||||
|
||||
If you want a quick backtest demo without external market APIs, download the offline bundle and unzip it into `backend/data`:
|
||||
|
||||
```bash
|
||||
wget "https://agentscope-open.oss-cn-beijing.aliyuncs.com/ret_data.zip"
|
||||
unzip ret_data.zip -d backend/data
|
||||
```
|
||||
The zip includes basic stock price data so you can run the backtest demo out of the box.
|
||||
|
||||
**Live Trading:**
|
||||
```bash
|
||||
evotraders live # Run immediately (default)
|
||||
evotraders live --enable-memory # Use memory
|
||||
evotraders live --mock # Mock mode (testing)
|
||||
evotraders live -t 22:30 # Run daily at 22:30 local time (auto-converts to NYSE timezone)
|
||||
```
|
||||
---
|
||||
|
||||
**Get Help:**
|
||||
```bash
|
||||
evotraders --help # View global CLI help
|
||||
evotraders backtest --help # View backtest mode parameters
|
||||
evotraders live --help # View live/mock run parameters
|
||||
```
|
||||
## Runtime Data Layout
|
||||
|
||||
**Launch Visualization Interface:**
|
||||
```bash
|
||||
# Ensure npm is installed, otherwise install it:
|
||||
# npm install
|
||||
evotraders frontend # Default connects to port 8765, you can modify the address in ./frontend/env.local to change the port number
|
||||
```
|
||||
|
||||
Visit `http://localhost:5173/` to view the trading room, select a date and click Run/Replay to observe the decision-making process.
|
||||
|
||||
### Runtime Data Layout
|
||||
|
||||
- Long-lived research data is stored in `data/market_research.db`
|
||||
- Each task run writes run-scoped state under `runs/<run_id>/`
|
||||
- `runs/<run_id>/team_dashboard/*.json` is an export/compatibility layer for dashboard views, not the authoritative runtime source of truth
|
||||
- Runtime APIs prefer active runtime state, `server_state.json`, and `runtime.db`
|
||||
- Long-lived research data lives in `data/market_research.db`
|
||||
- Each run writes run-scoped state under `runs/<run_id>/`
|
||||
- `runs/<run_id>/BOOTSTRAP.md` stores run-specific bootstrap values and prompt body
|
||||
- `runs/<run_id>/state/runtime_state.json` stores runtime snapshot state
|
||||
- `runs/<run_id>/team_dashboard/*.json` is a compatibility/export layer for dashboard consumers, not the primary runtime source of truth
|
||||
|
||||
Optional retention control:
|
||||
|
||||
@@ -123,129 +194,147 @@ Optional retention control:
|
||||
RUNS_RETENTION_COUNT=20
|
||||
```
|
||||
|
||||
Only timestamped run folders like `YYYYMMDD_HHMMSS` are pruned automatically when starting a new runtime. Named runs such as `smoke_fullstack` or `test_*` are preserved.
|
||||
Only timestamped run folders like `YYYYMMDD_HHMMSS` are pruned automatically. Named runs such as `live`, `smoke_fullstack`, or `reload_demo_*` are preserved.
|
||||
|
||||
---
|
||||
|
||||
## System Architecture
|
||||
## Frontend Service Routing
|
||||
|
||||

|
||||
The frontend always uses the control plane and runtime APIs, and can optionally call split services directly for read-only data.
|
||||
|
||||
### Agent Design
|
||||
Useful frontend env vars:
|
||||
|
||||
**Analyst Team:**
|
||||
- **Fundamentals Analyst**: Financial health, profitability, growth quality
|
||||
- **Technical Analyst**: Price trends, technical indicators, momentum analysis
|
||||
- **Sentiment Analyst**: Market sentiment, news sentiment, insider trading
|
||||
- **Valuation Analyst**: DCF, residual income, EV/EBITDA
|
||||
|
||||
**Decision Layer:**
|
||||
- **Portfolio Manager**: Integrates analysis signals from analysts, executes communication strategies, combines analyst and team historical performance, recent investment memories, and long-term investment experience to make final decisions
|
||||
- **Risk Management**: Real-time price and volatility monitoring, position limits, multi-layer risk warnings
|
||||
|
||||
### Decision Process
|
||||
|
||||
```
|
||||
Real-time Market Data → Independent Analysis → Intelligent Communication (1v1/1vN/NvN) → Decision Execution → Performance Evaluation → Learning and Evolution (Memory Update)
|
||||
```bash
|
||||
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
|
||||
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
||||
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
||||
VITE_TRADING_SERVICE_URL=http://localhost:8001
|
||||
VITE_WS_URL=ws://localhost:8765
|
||||
```
|
||||
|
||||
Each trading day goes through five stages:
|
||||
|
||||
1. **Analysis Stage**: Each agent independently analyzes based on their respective tools and historical experience
|
||||
2. **Communication Stage**: Exchange views through private chats, notifications, meetings, etc.
|
||||
3. **Decision Stage**: Portfolio manager makes comprehensive judgments and provides final trades
|
||||
4. **Evaluation Stage**
|
||||
- **Performance Charts**: Track portfolio return curves vs. benchmark strategies (equal-weighted, market-cap weighted, momentum). Used to evaluate overall strategy effectiveness.
|
||||
|
||||
- **Analyst Rankings**: Click on avatars in the Trading Room to view analyst performance (win rate, bull/bear market win rate). Used to understand which analysts provide the most valuable insights.
|
||||
|
||||
- **Statistics**: Detailed position and trading history. Used for in-depth analysis of position management and execution quality.
|
||||
|
||||
5. **Review Stage**: Agents reflect on decisions and summarize experiences based on actual returns of the day, and store them in the ReMe memory framework for continuous improvement
|
||||
If these are not set, the frontend falls back to its local defaults and compatibility paths where available.
|
||||
|
||||
---
|
||||
|
||||
### Module Support
|
||||
## Decision Flow
|
||||
|
||||
- **Agent Framework**: [AgentScope](https://github.com/agentscope-ai/agentscope)
|
||||
- **Memory System**: [ReMe](https://github.com/agentscope-ai/reme)
|
||||
- **LLM Support**: OpenAI, DeepSeek, Qwen, Moonshot, Zhipu AI, etc.
|
||||
```text
|
||||
Market data -> independent analyst work -> team communication -> portfolio decision ->
|
||||
risk review -> execution/settlement -> reflection/memory update
|
||||
```
|
||||
|
||||
The runtime manager also tracks:
|
||||
|
||||
- agent registration and status
|
||||
- pending approvals
|
||||
- run events
|
||||
- current session key
|
||||
|
||||
---
|
||||
|
||||
## Custom Configuration
|
||||
|
||||
### Custom Analyst Roles
|
||||
### Add or change analyst roles
|
||||
|
||||
1. Register role information in [./backend/agents/prompts/analyst/personas.yaml](./backend/agents/prompts/analyst/personas.yaml), for example:
|
||||
1. Define the analyst persona in [backend/agents/prompts/analyst/personas.yaml](./backend/agents/prompts/analyst/personas.yaml)
|
||||
2. Register the role in [backend/config/constants.py](./backend/config/constants.py)
|
||||
3. Optionally add/update the frontend seat metadata in [frontend/src/config/constants.js](./frontend/src/config/constants.js)
|
||||
|
||||
Example persona entry:
|
||||
|
||||
```yaml
|
||||
comprehensive_analyst:
|
||||
name: "Comprehensive Analyst"
|
||||
focus:
|
||||
- ...
|
||||
preferred_tools: # Flexibly select based on situation
|
||||
- multi-factor synthesis
|
||||
preferred_tools:
|
||||
- get_stock_price
|
||||
- get_company_financials
|
||||
description: |
|
||||
As a comprehensive analyst ...
|
||||
A generalist analyst that combines multiple signals.
|
||||
```
|
||||
|
||||
2. Add role definition in [./backend/config/constants.py](./backend/config/constants.py)
|
||||
```python
|
||||
ANALYST_TYPES = {
|
||||
# Add new analyst
|
||||
"comprehensive_analyst": {
|
||||
"display_name": "Comprehensive Analyst",
|
||||
"agent_id": "comprehensive_analyst",
|
||||
"description": "Uses LLM to intelligently select analysis tools, performs comprehensive analysis",
|
||||
"order": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
### Configure per-agent models
|
||||
|
||||
3. Introduce new role in frontend configuration [./frontend/src/config/constants.js](./frontend/src/config/constants.js) (optional)
|
||||
```javascript
|
||||
export const AGENTS = [
|
||||
// Override one of the agents
|
||||
{
|
||||
id: "comprehensive_analyst",
|
||||
name: "Comprehensive Analyst",
|
||||
role: "Comprehensive Analyst",
|
||||
avatar: `${ASSET_BASE_URL}/...`,
|
||||
colors: { bg: '#F9FDFF', text: '#1565C0', accent: '#1565C0' }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Custom Models
|
||||
|
||||
Configure models used by different agents in the [.env](.env) file:
|
||||
Model overrides are configured in `.env`:
|
||||
|
||||
```bash
|
||||
AGENT_SENTIMENT_ANALYST_MODEL_NAME=qwen3-max-preview
|
||||
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=deepseek-chat
|
||||
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4-plus
|
||||
AGENT_VALUATION_ANALYST_MODEL_NAME=moonshot-v1-32k
|
||||
AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp
|
||||
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4.6
|
||||
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=qwen3-max-preview
|
||||
AGENT_VALUATION_ANALYST_MODEL_NAME=Moonshot-Kimi-K2-Instruct
|
||||
AGENT_RISK_MANAGER_MODEL_NAME=qwen3-max-preview
|
||||
AGENT_PORTFOLIO_MANAGER_MODEL_NAME=qwen3-max-preview
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
### Run-scoped bootstrap config
|
||||
|
||||
Each run can override defaults through `runs/<run_id>/BOOTSTRAP.md`. The front matter is parsed by [backend/config/bootstrap_config.py](./backend/config/bootstrap_config.py) and can define values such as:
|
||||
|
||||
```yaml
|
||||
tickers:
|
||||
- AAPL
|
||||
- MSFT
|
||||
initial_cash: 100000
|
||||
margin_requirement: 0.5
|
||||
max_comm_cycles: 2
|
||||
schedule_mode: daily
|
||||
trigger_time: "09:30"
|
||||
enable_memory: false
|
||||
```
|
||||
EvoTraders/
|
||||
|
||||
Initialize a run workspace with:
|
||||
|
||||
```bash
|
||||
evotraders init-workspace --config-name my_run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
evotraders/
|
||||
├── backend/
|
||||
│ ├── agents/ # Agent implementation
|
||||
│ ├── communication/ # Communication system
|
||||
│ ├── memory/ # Memory system (ReMe)
|
||||
│ ├── tools/ # Analysis toolset
|
||||
│ ├── servers/ # WebSocket services
|
||||
│ └── cli.py # CLI entry point
|
||||
├── frontend/ # React visualization interface
|
||||
└── logs_and_memory/ # Logs and memory data
|
||||
│ ├── agents/ # agent roles, prompts, skills, workspaces
|
||||
│ ├── api/ # FastAPI routers
|
||||
│ ├── apps/ # split service surfaces
|
||||
│ ├── core/ # pipeline, scheduler, state sync
|
||||
│ ├── runtime/ # runtime manager and agent runtime state
|
||||
│ ├── services/ # gateway, market/storage/db services
|
||||
│ └── cli.py # Typer CLI entrypoint
|
||||
├── frontend/ # React + Vite UI
|
||||
├── shared/ # shared clients and schemas for split services
|
||||
├── runs/ # run-scoped state and dashboards
|
||||
├── data/ # long-lived research artifacts
|
||||
└── services/README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests live under `backend/tests` and cover service apps, shared clients, domains, routing, enrichment, gateway support, and runtime support.
|
||||
|
||||
Typical commands:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
pytest backend/tests/test_runtime_service_app.py
|
||||
pytest backend/tests/test_trading_service_app.py
|
||||
```
|
||||
|
||||
Frontend tests:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License and Disclaimer
|
||||
|
||||
EvoTraders is a research and educational project, open-sourced under the Apache 2.0 license.
|
||||
EvoTraders is a research and educational project. Review the repository license before redistribution or commercial use.
|
||||
|
||||
**Risk Warning**: Before trading with real funds, please conduct thorough testing and risk assessment. Past performance does not guarantee future returns. Investment involves risks, and decisions should be made with caution.
|
||||
**Risk warning**: this project is not investment advice. Test thoroughly before any real-money deployment. Past performance does not guarantee future returns.
|
||||
|
||||
424
README_zh.md
424
README_zh.md
@@ -4,291 +4,337 @@
|
||||
|
||||
<h2 align="center">EvoTraders:自我进化的多智能体交易系统</h2>
|
||||
|
||||
|
||||
<p align="center">
|
||||
📌 <a href="http://trading.evoagents.cn">Visit us at EvoTraders website !</a>
|
||||
📌 <a href="http://trading.evoagents.cn">访问 EvoTraders 官网</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
EvoTraders是一个开源的金融交易智能体框架,通过多智能体协作和记忆系统,构建能够在真实市场中持续学习与进化的交易系统。
|
||||
EvoTraders 是一个开源的金融交易智能体框架,结合多智能体协作、run 级工作区和记忆机制,支持回测与实盘两类交易运行模式。
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
**多智能体协作交易**
|
||||
6名成员,包含4种专业分析师角色(基本面、技术面、情绪、估值)+ 投资组合经理 + 风险管理,像真实交易团队一样协作决策。
|
||||
**多智能体交易团队**
|
||||
系统默认包含 6 个角色:4 个分析师(基本面、技术面、情绪、估值)+ 投资经理 + 风控经理。
|
||||
|
||||
你可以在这里自定义你的Agents,支持配置不同大模型(如 Qwen、DeepSeek、GPT、Claude等)协同分析:[自定义配置](#自定义配置)
|
||||
**持续学习**
|
||||
可选接入 ReMe 长期记忆,智能体会在每轮结束后反思、复盘并沉淀经验。
|
||||
|
||||
**持续学习与进化**
|
||||
基于 ReMe 记忆框架,智能体在每次交易后反思总结,跨回合保留经验,形成独特的投资方法论。
|
||||
|
||||
通过这样的设计,我们希望当 AI Agents 组成团队进入实时市场,它们会逐渐形成自己的交易风格和决策偏好,而不是一次性的随机推理
|
||||
|
||||
|
||||
**实时市场交易**
|
||||
支持实时行情接入,提供回测模式和实盘模式,让 AI Agents 在真实市场波动中学习和决策。
|
||||
|
||||
**可视化交易信息**
|
||||
实时观察 Agents 的分析过程、沟通记录和决策演化,完整追踪收益曲线和分析师表现。
|
||||
**统一运行时**
|
||||
同一套运行时模型支持历史回测和实时行情驱动的实盘流程。
|
||||
|
||||
**可操作前端**
|
||||
前端不只是展示层,还包含交易室、运行控制、日志、审批、Agent 工作区和 explain/news 视图。
|
||||
|
||||
<p>
|
||||
<img src="docs/assets/performance.jpg" width="45%">
|
||||
<img src="./docs/assets/dashboard.jpg" width="45%">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 当前架构
|
||||
|
||||
仓库目前处于“模块化单体 -> 拆分服务”的迁移阶段,本地开发默认走 split-service 路径。
|
||||
|
||||
当前 app surface:
|
||||
|
||||
- `backend.apps.agent_service`,端口 `8000`:控制面,负责 workspaces、agents、skills、审批接口
|
||||
- `backend.apps.trading_service`,端口 `8001`:只读交易数据接口
|
||||
- `backend.apps.news_service`,端口 `8002`:只读 explain/news 接口
|
||||
- `backend.apps.runtime_service`,端口 `8003`:运行时生命周期接口
|
||||
- `backend.apps.openclaw_service`,端口 `8004`:只读 OpenClaw facade
|
||||
- WebSocket gateway,端口 `8765`:前端实时事件和 feed 通道
|
||||
|
||||
当前最关键的主链路是:
|
||||
|
||||
`frontend -> runtime_service/control APIs -> gateway/runtime manager -> market service + pipeline + storage`
|
||||
|
||||
迁移背景可参考 [services/README.md](./services/README.md)。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装
|
||||
### 1. 安装
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/agentscope-ai/agentscope-samples
|
||||
cd agentscope-samples/EvoTraders
|
||||
# 克隆仓库后进入项目目录
|
||||
cd evotraders
|
||||
|
||||
# 安装依赖(推荐使用uv)
|
||||
# 推荐
|
||||
uv pip install -e .
|
||||
# (可选)pip install -e .
|
||||
|
||||
# 配置环境变量
|
||||
# 可选
|
||||
# uv pip install -e ".[dev]"
|
||||
# pip install -e .
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
```bash
|
||||
cp env.template .env
|
||||
# 编辑 .env 文件,添加你的 API Keys,以下的配置项为必填项
|
||||
```
|
||||
|
||||
# finance data API:至少需要FINANCIAL_DATASETS_API_KEY,对应FIN_DATA_SOURCE=financial_datasets;推荐添加FINNHUB_API_KEY,对应至少需要FINANCIAL_DATASETS_API_KEY,对应FIN_DATA_SOURCE填为finnhub;如果使用live 模式必须添加FINNHUB_API_KEY
|
||||
FIN_DATA_SOURCE= #finnhub or financial_datasets
|
||||
FINANCIAL_DATASETS_API_KEY= #必需
|
||||
FINNHUB_API_KEY= #可选
|
||||
根目录 `env.template` 是当前本地开发的主模板,仓库里也保留了 `.env.example` 作为参考。
|
||||
|
||||
# LLM API for Agents
|
||||
最常用的配置项:
|
||||
|
||||
```bash
|
||||
# 自选股
|
||||
TICKERS=AAPL,MSFT,GOOGL,NVDA,TSLA,META,AMZN
|
||||
|
||||
# 行情数据
|
||||
FIN_DATA_SOURCE=finnhub
|
||||
FINANCIAL_DATASETS_API_KEY=
|
||||
FINNHUB_API_KEY=
|
||||
POLYGON_API_KEY=
|
||||
|
||||
# Agent 模型
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
MODEL_NAME=qwen3-max-preview
|
||||
|
||||
# LLM & embedding API for Memory
|
||||
# 长期记忆(只有启用 --enable-memory 才需要)
|
||||
MEMORY_API_KEY=
|
||||
```
|
||||
|
||||
### 运行
|
||||
说明:
|
||||
|
||||
- live 模式必须配置 `FINNHUB_API_KEY`
|
||||
- `POLYGON_API_KEY` 用于长期 market store 的补数和刷新
|
||||
- `MEMORY_API_KEY` 仅在启用长期记忆时需要
|
||||
|
||||
### 3. 启动服务栈
|
||||
|
||||
本地开发推荐直接使用:
|
||||
|
||||
**回测模式:**
|
||||
```bash
|
||||
evotraders backtest --start 2025-11-01 --end 2025-12-01
|
||||
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory # 使用记忆
|
||||
|
||||
./start-dev.sh
|
||||
```
|
||||
|
||||
如果没有可用的行情 API,想快速体验回测 demo,可直接下载离线数据并解压到 `backend/data`:
|
||||
该脚本会启动:
|
||||
|
||||
- `agent_service`:`http://localhost:8000`
|
||||
- `trading_service`:`http://localhost:8001`
|
||||
- `news_service`:`http://localhost:8002`
|
||||
- `runtime_service`:`http://localhost:8003`
|
||||
- gateway WebSocket:`ws://localhost:8765`
|
||||
|
||||
然后在另一个终端启动前端:
|
||||
|
||||
```bash
|
||||
evotraders frontend
|
||||
```
|
||||
|
||||
访问 `http://localhost:5173`。
|
||||
|
||||
也可以手动分别启动:
|
||||
|
||||
```bash
|
||||
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
|
||||
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
|
||||
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
|
||||
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
|
||||
python -m backend.main --mode live --host 0.0.0.0 --port 8765
|
||||
```
|
||||
|
||||
### 4. 使用 CLI 运行回测或实盘
|
||||
|
||||
回测:
|
||||
|
||||
```bash
|
||||
evotraders backtest --start 2025-11-01 --end 2025-12-01
|
||||
evotraders backtest --start 2025-11-01 --end 2025-12-01 --enable-memory
|
||||
evotraders backtest --config-name smoke_fullstack --start 2025-11-01 --end 2025-12-01
|
||||
```
|
||||
|
||||
实盘:
|
||||
|
||||
```bash
|
||||
evotraders live
|
||||
evotraders live --enable-memory
|
||||
evotraders live --schedule-mode intraday --interval-minutes 60
|
||||
evotraders live --trigger-time 22:30
|
||||
```
|
||||
|
||||
帮助:
|
||||
|
||||
```bash
|
||||
evotraders --help
|
||||
evotraders backtest --help
|
||||
evotraders live --help
|
||||
evotraders frontend --help
|
||||
```
|
||||
|
||||
### 离线回测数据
|
||||
|
||||
如果只是想快速体验回测,不依赖外部行情 API,可以下载离线数据包并解压到 `backend/data`:
|
||||
|
||||
```bash
|
||||
wget "https://agentscope-open.oss-cn-beijing.aliyuncs.com/ret_data.zip"
|
||||
unzip ret_data.zip -d backend/data
|
||||
```
|
||||
该压缩包提供基础的股票行情数据,解压后即可直接用于回测演示。
|
||||
|
||||
**实盘交易:**
|
||||
---
|
||||
|
||||
## 运行时数据布局
|
||||
|
||||
- 长期研究数据保存在 `data/market_research.db`
|
||||
- 每次 run 的状态写入 `runs/<run_id>/`
|
||||
- `runs/<run_id>/BOOTSTRAP.md` 保存该 run 的 bootstrap 值和 prompt body
|
||||
- `runs/<run_id>/state/runtime_state.json` 保存运行时快照
|
||||
- `runs/<run_id>/team_dashboard/*.json` 主要是给 dashboard 用的兼容导出层,不是唯一真相源
|
||||
|
||||
可选保留策略:
|
||||
|
||||
```bash
|
||||
evotraders live # 立即运行(默认)
|
||||
evotraders live --enable-memory # 使用记忆
|
||||
evotraders live --mock # Mock 模式(测试)
|
||||
evotraders live -t 22:30 # 每天本地时间 22:30 运行(自动转换为 NYSE 时区)
|
||||
evotraders live --schedule-mode intraday --interval-minutes 60 # 每隔 1 小时触发一次;仅交易时段执行交易,其他时段只分析
|
||||
RUNS_RETENTION_COUNT=20
|
||||
```
|
||||
|
||||
前端的“运行设置”面板也支持热更新 `schedule_mode`、`interval_minutes`、`max_comm_cycles`;其中 daily 模式时间当前按 NYSE/ET 配置。
|
||||
只有形如 `YYYYMMDD_HHMMSS` 的时间戳目录会被自动清理;`live`、`smoke_fullstack`、`reload_demo_*` 这类命名 run 会保留。
|
||||
|
||||
**获取帮助:**
|
||||
```bash
|
||||
evotraders --help # 查看整体命令行帮助
|
||||
evotraders backtest --help # 查看回测模式的参数说明
|
||||
evotraders live --help # 查看实盘/Mock 运行的参数说明
|
||||
```
|
||||
---
|
||||
|
||||
**启动可视化界面:**
|
||||
```bash
|
||||
# 确保已安装 npm, 否则请安装:
|
||||
# npm install
|
||||
evotraders frontend # 默认连接 8765 端口, 你可以修改 ./frontend/env.local 中的地址从而修改端口号
|
||||
```
|
||||
## 前端服务路由
|
||||
|
||||
访问 `http://localhost:5173/` 查看交易大厅,选择日期并点击 Run/Replay 观察决策过程。
|
||||
前端始终会使用 control plane 和 runtime API,同时可以选择直连拆分服务读取只读数据。
|
||||
|
||||
### 迁移期服务边界说明
|
||||
|
||||
当前仓库正处于从模块化单体向独立服务迁移的阶段,当前默认开发路径已经切到独立 app surface:
|
||||
|
||||
- `backend.apps.agent_service`
|
||||
- `backend.apps.runtime_service`
|
||||
- `backend.apps.trading_service`
|
||||
- `backend.apps.news_service`
|
||||
|
||||
当前本地开发默认推荐直接运行拆分后的服务:
|
||||
常用前端环境变量:
|
||||
|
||||
```bash
|
||||
./start-dev.sh split
|
||||
|
||||
# 或分别手动启动
|
||||
python -m uvicorn backend.apps.agent_service:app --port 8000 --reload
|
||||
python -m uvicorn backend.apps.runtime_service:app --port 8003 --reload
|
||||
python -m uvicorn backend.apps.trading_service:app --port 8001 --reload
|
||||
python -m uvicorn backend.apps.news_service:app --port 8002 --reload
|
||||
```
|
||||
|
||||
迁移期关键环境变量:
|
||||
|
||||
```bash
|
||||
# 后端 Gateway 优先走独立服务读取
|
||||
NEWS_SERVICE_URL=http://localhost:8002
|
||||
TRADING_SERVICE_URL=http://localhost:8001
|
||||
|
||||
# 前端浏览器直连控制面 / 运行时面
|
||||
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
|
||||
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
||||
|
||||
# 前端浏览器优先直连独立服务
|
||||
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
||||
VITE_TRADING_SERVICE_URL=http://localhost:8001
|
||||
VITE_WS_URL=ws://localhost:8765
|
||||
```
|
||||
|
||||
目前前端已支持直连 `news-service` 的 explain 只读路径包括:
|
||||
|
||||
- runtime panel / gateway port 查询已可独立指向 `runtime-service`
|
||||
- story
|
||||
- similar days
|
||||
- range explain
|
||||
- news for date
|
||||
- news categories
|
||||
|
||||
如果没有配置这些变量,系统会继续走当前保留的本地回退逻辑。
|
||||
如果不配置,前端会按本地默认值和兼容回退逻辑运行。
|
||||
|
||||
---
|
||||
|
||||
## 系统架构
|
||||
## 决策流程
|
||||
|
||||

|
||||
|
||||
### 智能体设计
|
||||
|
||||
**分析师团队:**
|
||||
- **基本面分析师**:财务健康度、盈利能力、增长质量
|
||||
- **技术分析师**:价格趋势、技术指标、动量分析
|
||||
- **情绪分析师**:市场情绪、新闻舆情、内部人交易
|
||||
- **估值分析师**:DCF、剩余收益、EV/EBITDA
|
||||
|
||||
**决策层:**
|
||||
- **投资组合经理**:整合来自分析师的分析信号,执行沟通策略,结合分析师和团队历史表现、近期投资记忆和长期投资经验,进行最终决策
|
||||
- **风险管理**:实时价格与波动率监控、头寸限制,多层风险预警
|
||||
|
||||
### 决策流程
|
||||
|
||||
```
|
||||
实时行情 → 独立分析 → 智能沟通 (1v1/1vN/NvN) → 决策执行 → 收益评估 → 学习与进化(记忆更新)
|
||||
```text
|
||||
市场数据 -> 分析师独立分析 -> 团队沟通 -> 投资决策 ->
|
||||
风控审核 -> 执行/结算 -> 复盘/记忆更新
|
||||
```
|
||||
|
||||
每个交易日经历五个阶段:
|
||||
|
||||
1. **分析阶段**:各智能体基于各自工具和历史经验独立分析
|
||||
2. **沟通阶段**:通过私聊、通知、会议等方式交换观点
|
||||
3. **决策阶段**:投资组合经理综合判断,给出最终交易
|
||||
4. **评估阶段**
|
||||
- **业绩图表**: 追踪组合收益曲线 vs. 基准策略(等权、市值加权、动量)。用于评估整体策略有效性。
|
||||
|
||||
- **分析师排名**: 在 Trading Room 点击头像查看分析师表现(胜率、牛/熊市胜率)。用于了解哪些分析师提供最有价值的洞察。
|
||||
|
||||
- **统计数据**: 详细的持仓和交易历史。用于深入分析仓位管理和执行质量。
|
||||
|
||||
4. **复盘阶段**:Agents 根据当日实际收益反思决策、总结经验,并存入 ReMe 记忆框架以持续改进
|
||||
|
||||
---
|
||||
|
||||
### 模块支持
|
||||
|
||||
- **智能体框架**:[AgentScope](https://github.com/agentscope-ai/agentscope)
|
||||
- **记忆系统**:[ReMe](https://github.com/agentscope-ai/reme)
|
||||
- **LLM 支持**:OpenAI、DeepSeek、Qwen、Moonshot、Zhipu AI 等
|
||||
运行时管理器还会跟踪:
|
||||
|
||||
- agent 注册和状态
|
||||
- 待审批项
|
||||
- run 事件
|
||||
- 当前 session key
|
||||
|
||||
---
|
||||
|
||||
## 自定义配置
|
||||
|
||||
### 自定义分析师角色
|
||||
### 新增或修改分析师角色
|
||||
|
||||
1. 在 [./backend/agents/prompts/analyst/personas.yaml](./backend/agents/prompts/analyst/personas.yaml) 中注册角色信息,例如:
|
||||
1. 在 [backend/agents/prompts/analyst/personas.yaml](./backend/agents/prompts/analyst/personas.yaml) 中定义 persona
|
||||
2. 在 [backend/config/constants.py](./backend/config/constants.py) 中注册角色
|
||||
3. 如有需要,在 [frontend/src/config/constants.js](./frontend/src/config/constants.js) 中补充前端展示元数据
|
||||
|
||||
示例:
|
||||
|
||||
```yaml
|
||||
comprehensive_analyst:
|
||||
name: "Comprehensive Analyst"
|
||||
focus:
|
||||
- ...
|
||||
preferred_tools: # Flexibly select based on situation
|
||||
- multi-factor synthesis
|
||||
preferred_tools:
|
||||
- get_stock_price
|
||||
- get_company_financials
|
||||
description: |
|
||||
As a comprehensive analyst ...
|
||||
A generalist analyst that combines multiple signals.
|
||||
```
|
||||
|
||||
2. 在 [./backend/config/constants.py](./backend/config/constants.py) 添加角色定义
|
||||
```python
|
||||
ANALYST_TYPES = {
|
||||
# 增加新的分析师
|
||||
"comprehensive_analyst": {
|
||||
"display_name": "Comprehensive Analyst",
|
||||
"agent_id": "comprehensive_analyst",
|
||||
"description": "Uses LLM to intelligently select analysis tools, performs comprehensive analysis",
|
||||
"order": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
### 配置各 Agent 使用的模型
|
||||
|
||||
3. 在前端配置 [./frontend/src/config/constants.js](./frontend/src/config/constants.js) 中引入新角色(可选)
|
||||
```javascript
|
||||
export const AGENTS = [
|
||||
// 覆盖掉其中某一个agent
|
||||
{
|
||||
id: "comprehensive_analyst",
|
||||
name: "Comprehensive Analyst",
|
||||
role: "Comprehensive Analyst",
|
||||
avatar: `${ASSET_BASE_URL}/...`,
|
||||
colors: { bg: '#F9FDFF', text: '#1565C0', accent: '#1565C0' }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 自定义模型
|
||||
|
||||
在 [.env](.env) 文件中配置不同智能体使用的模型:
|
||||
模型覆盖在 `.env` 中配置:
|
||||
|
||||
```bash
|
||||
AGENT_SENTIMENT_ANALYST_MODEL_NAME=qwen3-max-preview
|
||||
AGENT_FUNDAMENTAL_ANALYST_MODEL_NAME=deepseek-chat
|
||||
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4-plus
|
||||
AGENT_VALUATION_ANALYST_MODEL_NAME=moonshot-v1-32k
|
||||
AGENT_SENTIMENT_ANALYST_MODEL_NAME=deepseek-v3.2-exp
|
||||
AGENT_TECHNICAL_ANALYST_MODEL_NAME=glm-4.6
|
||||
AGENT_FUNDAMENTALS_ANALYST_MODEL_NAME=qwen3-max-preview
|
||||
AGENT_VALUATION_ANALYST_MODEL_NAME=Moonshot-Kimi-K2-Instruct
|
||||
AGENT_RISK_MANAGER_MODEL_NAME=qwen3-max-preview
|
||||
AGENT_PORTFOLIO_MANAGER_MODEL_NAME=qwen3-max-preview
|
||||
```
|
||||
|
||||
### 项目结构
|
||||
### run 级 BOOTSTRAP 配置
|
||||
|
||||
每个 run 都可以通过 `runs/<run_id>/BOOTSTRAP.md` 覆盖默认值。该文件由 [backend/config/bootstrap_config.py](./backend/config/bootstrap_config.py) 解析,front matter 可配置:
|
||||
|
||||
```yaml
|
||||
tickers:
|
||||
- AAPL
|
||||
- MSFT
|
||||
initial_cash: 100000
|
||||
margin_requirement: 0.5
|
||||
max_comm_cycles: 2
|
||||
schedule_mode: daily
|
||||
trigger_time: "09:30"
|
||||
enable_memory: false
|
||||
```
|
||||
EvoTraders/
|
||||
|
||||
初始化一个 run 工作区:
|
||||
|
||||
```bash
|
||||
evotraders init-workspace --config-name my_run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
evotraders/
|
||||
├── backend/
|
||||
│ ├── agents/ # 智能体实现
|
||||
│ ├── communication/ # 通信系统
|
||||
│ ├── memory/ # 记忆系统 (ReMe)
|
||||
│ ├── tools/ # 分析工具集
|
||||
│ ├── servers/ # WebSocket 服务
|
||||
│ └── cli.py # CLI 入口
|
||||
├── frontend/ # React 可视化界面
|
||||
└── logs_and_memory/ # 日志和记忆数据
|
||||
│ ├── agents/ # agent 角色、prompts、skills、workspaces
|
||||
│ ├── api/ # FastAPI 路由层
|
||||
│ ├── apps/ # 拆分服务 app surface
|
||||
│ ├── core/ # pipeline、scheduler、state sync
|
||||
│ ├── runtime/ # runtime manager 和 agent runtime state
|
||||
│ ├── services/ # gateway、market/storage/db 服务
|
||||
│ └── cli.py # Typer CLI 入口
|
||||
├── frontend/ # React + Vite 前端
|
||||
├── shared/ # 拆分服务共用 client 和 schema
|
||||
├── runs/ # run 级状态和 dashboard 导出
|
||||
├── data/ # 长期研究数据
|
||||
└── services/README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
后端测试位于 `backend/tests`,覆盖 service app、shared client、domain、路由、enrichment、gateway 支撑模块和 runtime 支撑模块。
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
pytest backend/tests/test_runtime_service_app.py
|
||||
pytest backend/tests/test_trading_service_app.py
|
||||
```
|
||||
|
||||
前端测试:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 许可与免责
|
||||
|
||||
EvoTraders 是一个研究和教育项目,采用 Apache 2.0 许可协议开源。
|
||||
EvoTraders 是研究和教育用途项目。再次分发或商用前,请先核对仓库中的实际 license 文件。
|
||||
|
||||
**风险提示**:在实际资金交易前,请务必进行充分的测试和风险评估。历史表现不代表未来收益,投资有风险,决策需谨慎。
|
||||
**风险提示**:本项目不构成投资建议。任何实盘部署前都应进行充分测试和风险评估,历史表现不代表未来收益。
|
||||
|
||||
@@ -137,7 +137,7 @@ class RunWorkspaceManager:
|
||||
filename: str,
|
||||
) -> str:
|
||||
"""Load one run-scoped agent workspace file."""
|
||||
path = self.get_agent_asset_dir(config_name, agent_id) / filename
|
||||
path = self.skills_manager.get_agent_asset_dir(config_name, agent_id) / filename
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"File not found: {filename}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
@@ -151,7 +151,7 @@ class RunWorkspaceManager:
|
||||
content: str,
|
||||
) -> None:
|
||||
"""Write one run-scoped agent workspace file."""
|
||||
asset_dir = self.get_agent_asset_dir(config_name, agent_id)
|
||||
asset_dir = self.skills_manager.get_agent_asset_dir(config_name, agent_id)
|
||||
asset_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = asset_dir / filename
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
@@ -11,11 +11,13 @@ Provides REST API endpoints for:
|
||||
from .agents import router as agents_router
|
||||
from .workspaces import router as workspaces_router
|
||||
from .guard import router as guard_router
|
||||
from .openclaw import router as openclaw_router
|
||||
from .runtime import router as runtime_router
|
||||
|
||||
__all__ = [
|
||||
"agents_router",
|
||||
"workspaces_router",
|
||||
"guard_router",
|
||||
"openclaw_router",
|
||||
"runtime_router",
|
||||
]
|
||||
|
||||
839
backend/api/openclaw.py
Normal file
839
backend/api/openclaw.py
Normal file
@@ -0,0 +1,839 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Read-only OpenClaw CLI API routes — typed with Pydantic models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService
|
||||
from shared.models.openclaw import OpenClawStatus
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/openclaw", tags=["openclaw"])
|
||||
|
||||
|
||||
def get_openclaw_cli_service() -> OpenClawCliService:
|
||||
"""Build the OpenClaw CLI service dependency."""
|
||||
return OpenClawCliService()
|
||||
|
||||
|
||||
def _raise_cli_http_error(exc: OpenClawCliError) -> None:
|
||||
detail = {
|
||||
"message": str(exc),
|
||||
"command": exc.command,
|
||||
"exit_code": exc.exit_code,
|
||||
"stdout": exc.stdout,
|
||||
"stderr": exc.stderr,
|
||||
}
|
||||
status_code = 503 if exc.exit_code is None else 502
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
status: object
|
||||
|
||||
|
||||
class SessionsResponse(BaseModel):
|
||||
sessions: list[object]
|
||||
|
||||
|
||||
class SessionDetailResponse(BaseModel):
|
||||
session: object | None
|
||||
|
||||
|
||||
class SessionHistoryResponse(BaseModel):
|
||||
session_key: str
|
||||
session_id: str | None
|
||||
events: list[object]
|
||||
history: list[object]
|
||||
raw_text: str | None
|
||||
|
||||
|
||||
class CronResponse(BaseModel):
|
||||
cron: list[object]
|
||||
jobs: list[object]
|
||||
|
||||
|
||||
class ApprovalsResponse(BaseModel):
|
||||
approvals: list[object]
|
||||
pending: list[object]
|
||||
|
||||
|
||||
class AgentsResponse(BaseModel):
|
||||
agents: list[object]
|
||||
|
||||
|
||||
class SkillsResponse(BaseModel):
|
||||
workspace_dir: str
|
||||
managed_skills_dir: str
|
||||
skills: list[object]
|
||||
|
||||
|
||||
class ModelsResponse(BaseModel):
|
||||
models: list[object]
|
||||
|
||||
|
||||
class HooksResponse(BaseModel):
|
||||
workspace_dir: str
|
||||
managed_hooks_dir: str
|
||||
hooks: list[object]
|
||||
|
||||
|
||||
class PluginsResponse(BaseModel):
|
||||
workspace_dir: str
|
||||
plugins: list[object]
|
||||
diagnostics: list[object]
|
||||
|
||||
|
||||
class SecretsAuditResponse(BaseModel):
|
||||
version: int
|
||||
status: str
|
||||
findings: list[object]
|
||||
|
||||
|
||||
class SecurityAuditResponse2(BaseModel):
|
||||
report: object | None
|
||||
secret_diagnostics: list[str]
|
||||
|
||||
|
||||
class DaemonStatusResponse(BaseModel):
|
||||
service: object | None
|
||||
port: object | None
|
||||
rpc: object | None
|
||||
health: object | None
|
||||
|
||||
|
||||
class PairingListResponse2(BaseModel):
|
||||
channel: str
|
||||
requests: list[object]
|
||||
|
||||
|
||||
class QrCodeResponse2(BaseModel):
|
||||
setup_code: str
|
||||
gateway_url: str
|
||||
auth: str
|
||||
url_source: str
|
||||
|
||||
|
||||
class UpdateStatusResponse2(BaseModel):
|
||||
update: object | None
|
||||
channel: object | None
|
||||
|
||||
|
||||
class ModelAliasesResponse(BaseModel):
|
||||
aliases: dict[str, str]
|
||||
|
||||
|
||||
class ModelFallbacksResponse(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
items: list[object]
|
||||
|
||||
|
||||
class SkillUpdateResponse(BaseModel):
|
||||
ok: bool
|
||||
slug: str
|
||||
version: str
|
||||
error: str | None
|
||||
|
||||
|
||||
class ModelsStatusResponse(BaseModel):
|
||||
configPath: str | None = None
|
||||
agentId: str | None = None
|
||||
agentDir: str | None = None
|
||||
defaultModel: str | None = None
|
||||
resolvedDefault: str | None = None
|
||||
fallbacks: list[str] = Field(default_factory=list)
|
||||
imageModel: str | None = None
|
||||
imageFallbacks: list[str] = Field(default_factory=list)
|
||||
aliases: dict[str, str] = Field(default_factory=dict)
|
||||
allowed: list[str] = Field(default_factory=list)
|
||||
auth: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChannelsStatusResponse(BaseModel):
|
||||
reachable: bool | None = None
|
||||
channelAccounts: dict[str, Any] = Field(default_factory=dict)
|
||||
channels: list[str] = Field(default_factory=list)
|
||||
issues: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChannelsListResponse(BaseModel):
|
||||
chat: dict[str, list[str]] = Field(default_factory=dict)
|
||||
auth: list[dict[str, Any]] = Field(default_factory=list)
|
||||
usage: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class HookInfoResponse(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
source: str | None = None
|
||||
pluginId: str | None = None
|
||||
filePath: str | None = None
|
||||
handlerPath: str | None = None
|
||||
hookKey: str | None = None
|
||||
emoji: str | None = None
|
||||
homepage: str | None = None
|
||||
events: list[str] = Field(default_factory=list)
|
||||
enabledByConfig: bool | None = None
|
||||
loadable: bool | None = None
|
||||
requirementsSatisfied: bool | None = None
|
||||
requirements: dict[str, Any] = Field(default_factory=dict)
|
||||
error: str | None = None
|
||||
raw: str | None = None
|
||||
|
||||
|
||||
class HooksCheckResponse(BaseModel):
|
||||
workspace_dir: str = ""
|
||||
managed_hooks_dir: str = ""
|
||||
hooks: list[dict[str, Any]] = Field(default_factory=list)
|
||||
eligible: bool | None = None
|
||||
verbose: bool | None = None
|
||||
|
||||
|
||||
class PluginInspectEntry(BaseModel):
|
||||
plugin: dict[str, Any] = Field(default_factory=dict)
|
||||
shape: str | None = None
|
||||
capabilityMode: str | None = None
|
||||
capabilityCount: int = 0
|
||||
capabilities: list[dict[str, Any]] = Field(default_factory=list)
|
||||
typedHooks: list[dict[str, Any]] = Field(default_factory=list)
|
||||
customHooks: list[dict[str, Any]] = Field(default_factory=list)
|
||||
tools: list[dict[str, Any]] = Field(default_factory=list)
|
||||
commands: list[str] = Field(default_factory=list)
|
||||
cliCommands: list[str] = Field(default_factory=list)
|
||||
services: list[str] = Field(default_factory=list)
|
||||
gatewayMethods: list[str] = Field(default_factory=list)
|
||||
mcpServers: list[dict[str, Any]] = Field(default_factory=list)
|
||||
lspServers: list[dict[str, Any]] = Field(default_factory=list)
|
||||
httpRouteCount: int = 0
|
||||
bundleCapabilities: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginsInspectResponse(BaseModel):
|
||||
inspect: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentBindingItem(BaseModel):
|
||||
agentId: str
|
||||
match: dict[str, Any]
|
||||
description: str
|
||||
|
||||
|
||||
class AgentsBindingsResponse(BaseModel):
|
||||
bindings: list[AgentBindingItem]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — use typed model methods and return Pydantic models directly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/status")
|
||||
async def api_openclaw_status(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> OpenClawStatus:
|
||||
"""Read `openclaw status --json` and return a typed model."""
|
||||
try:
|
||||
return service.status_model()
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def api_openclaw_sessions(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> SessionsResponse:
|
||||
"""Read `openclaw sessions --json` and return a typed SessionsList."""
|
||||
try:
|
||||
result = service.list_sessions_model()
|
||||
return SessionsResponse(sessions=result.sessions)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_key:path}/history")
|
||||
async def api_openclaw_session_history(
|
||||
session_key: str,
|
||||
limit: int = Query(20, ge=1, le=200),
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> SessionHistoryResponse:
|
||||
"""Read session history and return a typed SessionHistory."""
|
||||
try:
|
||||
result = service.get_session_history_model(session_key, limit=limit)
|
||||
return SessionHistoryResponse(
|
||||
session_key=result.session_key,
|
||||
session_id=result.session_id,
|
||||
events=result.events,
|
||||
history=result.events, # alias for compat
|
||||
raw_text=result.raw_text,
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_key:path}")
|
||||
async def api_openclaw_session_detail(
|
||||
session_key: str,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> SessionDetailResponse:
|
||||
"""Resolve a single session and return it as a typed model."""
|
||||
try:
|
||||
session = service.get_session_model(session_key)
|
||||
return SessionDetailResponse(session=session)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=f"session '{session_key}' not found") from exc
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/cron")
|
||||
async def api_openclaw_cron(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> CronResponse:
|
||||
"""Read `openclaw cron list --json` and return a typed CronList."""
|
||||
try:
|
||||
result = service.list_cron_jobs_model()
|
||||
return CronResponse(cron=list(result.cron), jobs=list(result.jobs))
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/approvals")
|
||||
async def api_openclaw_approvals(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> ApprovalsResponse:
|
||||
"""Read `openclaw approvals get --json` and return a typed ApprovalsList."""
|
||||
try:
|
||||
result = service.list_approvals_model()
|
||||
return ApprovalsResponse(
|
||||
approvals=list(result.approvals),
|
||||
pending=list(result.pending),
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def api_openclaw_agents(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> AgentsResponse:
|
||||
"""Read `openclaw agents list --json` and return a typed AgentsList."""
|
||||
try:
|
||||
result = service.list_agents_model()
|
||||
return AgentsResponse(agents=list(result.agents))
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/agents/presence")
|
||||
async def api_openclaw_agents_presence(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> dict[str, Any]:
|
||||
"""Read runtime session presence for all agents from session files."""
|
||||
result = service.agents_presence()
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write agents routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentAddResponse(BaseModel):
|
||||
agentId: str
|
||||
name: str
|
||||
workspace: str
|
||||
agentDir: str
|
||||
model: str | None = None
|
||||
bindings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentDeleteResponse(BaseModel):
|
||||
agentId: str
|
||||
workspace: str
|
||||
agentDir: str
|
||||
sessionsDir: str
|
||||
removedBindings: list[str] = Field(default_factory=list)
|
||||
removedAllow: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentBindResponse(BaseModel):
|
||||
agentId: str
|
||||
added: list[str] = Field(default_factory=list)
|
||||
updated: list[str] = Field(default_factory=list)
|
||||
skipped: list[str] = Field(default_factory=list)
|
||||
conflicts: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentUnbindResponse(BaseModel):
|
||||
agentId: str
|
||||
removed: list[str] = Field(default_factory=list)
|
||||
missing: list[str] = Field(default_factory=list)
|
||||
conflicts: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentIdentityResponse(BaseModel):
|
||||
agentId: str
|
||||
identity: dict[str, Any] = Field(default_factory=dict)
|
||||
workspace: str | None = None
|
||||
identityFile: str | None = None
|
||||
|
||||
|
||||
@router.post("/agents/add")
|
||||
async def api_openclaw_agents_add(
|
||||
name: str,
|
||||
*,
|
||||
workspace: str | None = None,
|
||||
model: str | None = None,
|
||||
agent_dir: str | None = None,
|
||||
bind: list[str] | None = None,
|
||||
non_interactive: bool = False,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> AgentAddResponse:
|
||||
"""Run `openclaw agents add <name>` and return JSON result."""
|
||||
try:
|
||||
result = service.agents_add(
|
||||
name,
|
||||
workspace=workspace,
|
||||
model=model,
|
||||
agent_dir=agent_dir,
|
||||
bind=bind,
|
||||
non_interactive=non_interactive,
|
||||
)
|
||||
return AgentAddResponse.model_validate(result, strict=False)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.post("/agents/delete/{id}")
|
||||
async def api_openclaw_agents_delete(
|
||||
id: str,
|
||||
force: bool = False,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> AgentDeleteResponse:
|
||||
"""Run `openclaw agents delete <id> [--force]` and return JSON result."""
|
||||
try:
|
||||
result = service.agents_delete(id, force=force)
|
||||
return AgentDeleteResponse.model_validate(result, strict=False)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.post("/agents/bind")
|
||||
async def api_openclaw_agents_bind(
|
||||
*,
|
||||
agent: str | None = None,
|
||||
bind: list[str] | None = None,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> AgentBindResponse:
|
||||
"""Run `openclaw agents bind [--agent <id>] [--bind <spec>]` and return JSON result."""
|
||||
try:
|
||||
result = service.agents_bind(agent=agent, bind=bind)
|
||||
return AgentBindResponse.model_validate(result, strict=False)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.post("/agents/unbind")
|
||||
async def api_openclaw_agents_unbind(
|
||||
*,
|
||||
agent: str | None = None,
|
||||
bind: list[str] | None = None,
|
||||
all: bool = False,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> AgentUnbindResponse:
|
||||
"""Run `openclaw agents unbind [--agent <id>] [--bind <spec>] [--all]` and return JSON result."""
|
||||
try:
|
||||
result = service.agents_unbind(agent=agent, bind=bind, all=all)
|
||||
return AgentUnbindResponse.model_validate(result, strict=False)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.post("/agents/set-identity")
|
||||
async def api_openclaw_agents_set_identity(
|
||||
*,
|
||||
agent: str | None = None,
|
||||
workspace: str | None = None,
|
||||
identity_file: str | None = None,
|
||||
name: str | None = None,
|
||||
emoji: str | None = None,
|
||||
theme: str | None = None,
|
||||
avatar: str | None = None,
|
||||
from_identity: bool = False,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> AgentIdentityResponse:
|
||||
"""Run `openclaw agents set-identity` and return JSON result."""
|
||||
try:
|
||||
result = service.agents_set_identity(
|
||||
agent=agent,
|
||||
workspace=workspace,
|
||||
identity_file=identity_file,
|
||||
name=name,
|
||||
emoji=emoji,
|
||||
theme=theme,
|
||||
avatar=avatar,
|
||||
from_identity=from_identity,
|
||||
)
|
||||
return AgentIdentityResponse.model_validate(result, strict=False)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/skills")
|
||||
async def api_openclaw_skills(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> SkillsResponse:
|
||||
"""Read `openclaw skills list --json` and return a typed SkillStatusReport."""
|
||||
try:
|
||||
result = service.list_skills_model()
|
||||
return SkillsResponse(
|
||||
workspace_dir=result.workspace_dir,
|
||||
managed_skills_dir=result.managed_skills_dir,
|
||||
skills=list(result.skills),
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def api_openclaw_models(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> ModelsResponse:
|
||||
"""Read `openclaw models list --json` and return a typed ModelsList."""
|
||||
try:
|
||||
result = service.list_models_model()
|
||||
return ModelsResponse(models=list(result.models))
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/hooks")
|
||||
async def api_openclaw_hooks(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> HooksResponse:
|
||||
try:
|
||||
result = service.list_hooks_model()
|
||||
return HooksResponse(
|
||||
workspace_dir=result.workspace_dir,
|
||||
managed_hooks_dir=result.managed_hooks_dir,
|
||||
hooks=list(result.hooks),
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/plugins")
|
||||
async def api_openclaw_plugins(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> PluginsResponse:
|
||||
try:
|
||||
result = service.list_plugins_model()
|
||||
return PluginsResponse(
|
||||
workspace_dir=result.workspace_dir,
|
||||
plugins=list(result.plugins),
|
||||
diagnostics=list(result.diagnostics),
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/secrets-audit")
|
||||
async def api_openclaw_secrets_audit(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> SecretsAuditResponse:
|
||||
try:
|
||||
result = service.secrets_audit_model()
|
||||
return SecretsAuditResponse(
|
||||
version=result.version,
|
||||
status=result.status,
|
||||
findings=list(result.findings),
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/security-audit")
|
||||
async def api_openclaw_security_audit(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> SecurityAuditResponse2:
|
||||
try:
|
||||
result = service.security_audit_model()
|
||||
return SecurityAuditResponse2(
|
||||
report=result.report.model_dump() if result.report else None,
|
||||
secret_diagnostics=list(result.secret_diagnostics),
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/daemon-status")
|
||||
async def api_openclaw_daemon_status(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> DaemonStatusResponse:
|
||||
try:
|
||||
result = service.daemon_status_model()
|
||||
return DaemonStatusResponse(
|
||||
service=result.service.model_dump() if result.service else None,
|
||||
port=result.port.model_dump() if result.port else None,
|
||||
rpc=result.rpc.model_dump() if result.rpc else None,
|
||||
health=result.health.model_dump() if result.health else None,
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/pairing")
|
||||
async def api_openclaw_pairing(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> PairingListResponse2:
|
||||
try:
|
||||
result = service.pairing_list_model()
|
||||
return PairingListResponse2(
|
||||
channel=result.channel,
|
||||
requests=list(result.requests),
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/qr")
|
||||
async def api_openclaw_qr(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> QrCodeResponse2:
|
||||
try:
|
||||
result = service.qr_code_model()
|
||||
return QrCodeResponse2(
|
||||
setup_code=result.setup_code,
|
||||
gateway_url=result.gateway_url,
|
||||
auth=result.auth,
|
||||
url_source=result.url_source,
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/update-status")
|
||||
async def api_openclaw_update_status(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> UpdateStatusResponse2:
|
||||
try:
|
||||
result = service.update_status_model()
|
||||
return UpdateStatusResponse2(
|
||||
update=result.update.model_dump() if result.update else None,
|
||||
channel=result.channel.model_dump() if result.channel else None,
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/models-aliases")
|
||||
async def api_openclaw_models_aliases(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> ModelAliasesResponse:
|
||||
try:
|
||||
result = service.list_model_aliases_model()
|
||||
return ModelAliasesResponse(aliases=result.aliases)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/models-fallbacks")
|
||||
async def api_openclaw_models_fallbacks(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> ModelFallbacksResponse:
|
||||
try:
|
||||
result = service.list_model_fallbacks_model()
|
||||
return ModelFallbacksResponse(
|
||||
key=result.key,
|
||||
label=result.label,
|
||||
items=list(result.items),
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/models-image-fallbacks")
|
||||
async def api_openclaw_models_image_fallbacks(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> ModelFallbacksResponse:
|
||||
try:
|
||||
result = service.list_model_image_fallbacks_model()
|
||||
return ModelFallbacksResponse(
|
||||
key=result.key,
|
||||
label=result.label,
|
||||
items=list(result.items),
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/skill-update")
|
||||
async def api_openclaw_skill_update(
|
||||
slug: str | None = None,
|
||||
all: bool = False,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> SkillUpdateResponse:
|
||||
try:
|
||||
result = service.skill_update_model(slug=slug, all=all)
|
||||
return SkillUpdateResponse(
|
||||
ok=result.ok,
|
||||
slug=result.slug,
|
||||
version=result.version,
|
||||
error=result.error,
|
||||
)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/models-status")
|
||||
async def api_openclaw_models_status(
|
||||
probe: bool = False,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> ModelsStatusResponse:
|
||||
"""Read `openclaw models status --json [--probe]` and return a typed dict."""
|
||||
try:
|
||||
result = service.models_status_model(probe=probe)
|
||||
return ModelsStatusResponse.model_validate(result, strict=False)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/channels-status")
|
||||
async def api_openclaw_channels_status(
|
||||
probe: bool = False,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> ChannelsStatusResponse:
|
||||
"""Read `openclaw channels status --json [--probe]` and return a typed dict."""
|
||||
try:
|
||||
result = service.channels_status_model(probe=probe)
|
||||
return ChannelsStatusResponse.model_validate(result, strict=False)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/channels-list")
|
||||
async def api_openclaw_channels_list(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> ChannelsListResponse:
|
||||
"""Read `openclaw channels list --json` and return a typed dict."""
|
||||
try:
|
||||
result = service.channels_list_model()
|
||||
return ChannelsListResponse.model_validate(result, strict=False)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/hooks/info/{name}")
|
||||
async def api_openclaw_hook_info(
|
||||
name: str,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> HookInfoResponse:
|
||||
"""Read `openclaw hooks info <name> --json` and return a typed dict."""
|
||||
try:
|
||||
result = service.hook_info_model(name)
|
||||
return HookInfoResponse.model_validate(result, strict=False)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/hooks/check")
|
||||
async def api_openclaw_hooks_check(
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> HooksCheckResponse:
|
||||
"""Read `openclaw hooks check --json` and return a typed dict."""
|
||||
try:
|
||||
result = service.hooks_check_model()
|
||||
return HooksCheckResponse.model_validate(result, strict=False)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/plugins-inspect")
|
||||
async def api_openclaw_plugins_inspect(
|
||||
plugin_id: str | None = None,
|
||||
all: bool = False,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> PluginsInspectResponse:
|
||||
"""Read `openclaw plugins inspect --json [--all]` and return a typed dict."""
|
||||
try:
|
||||
result = service.plugins_inspect_model(plugin_id=plugin_id, all=all)
|
||||
inspect = result if isinstance(result, list) else result.get("inspect", [])
|
||||
return PluginsInspectResponse(inspect=inspect)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
class AgentBindingItem(BaseModel):
|
||||
agentId: str
|
||||
match: dict[str, Any]
|
||||
description: str
|
||||
|
||||
|
||||
class AgentsBindingsResponse(BaseModel):
|
||||
bindings: list[AgentBindingItem]
|
||||
|
||||
|
||||
@router.get("/agents-bindings")
|
||||
async def api_openclaw_agents_bindings(
|
||||
agent: str | None = None,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> AgentsBindingsResponse:
|
||||
"""Read `openclaw agents bindings --json [--agent <id>]` and return bindings list."""
|
||||
try:
|
||||
result = service.agents_bindings_model(agent=agent)
|
||||
bindings = result if isinstance(result, list) else []
|
||||
return AgentsBindingsResponse(bindings=bindings)
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/gateway-status")
|
||||
async def api_openclaw_gateway_status(
|
||||
url: str | None = None,
|
||||
token: str | None = None,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> dict[str, Any]:
|
||||
"""Read `openclaw gateway status --json [--url <url>] [--token <token>]`. Returns full gateway probe result."""
|
||||
try:
|
||||
result = service.gateway_status(url=url, token=token)
|
||||
return result
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
@router.get("/memory-status")
|
||||
async def api_openclaw_memory_status(
|
||||
agent: str | None = None,
|
||||
deep: bool = False,
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Read `openclaw memory status --json [--agent <id>] [--deep]`. Returns array of per-agent memory status."""
|
||||
try:
|
||||
result = service.memory_status(agent=agent, deep=deep)
|
||||
return result if isinstance(result, list) else []
|
||||
except OpenClawCliError as exc:
|
||||
_raise_cli_http_error(exc)
|
||||
|
||||
|
||||
class WorkspaceFilesResponse(BaseModel):
|
||||
workspace: str
|
||||
files: list[dict[str, Any]]
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@router.get("/workspace-files")
|
||||
async def api_openclaw_workspace_files(
|
||||
workspace: str = Query(..., description="Path to the agent workspace directory"),
|
||||
service: OpenClawCliService = Depends(get_openclaw_cli_service),
|
||||
) -> WorkspaceFilesResponse:
|
||||
"""List .md files in an OpenClaw agent workspace with their content previews."""
|
||||
result = service.list_workspace_files(workspace)
|
||||
return WorkspaceFilesResponse.model_validate(result, strict=False)
|
||||
@@ -389,11 +389,21 @@ def _find_available_port(start_port: int = 8765, max_port: int = 9000) -> int:
|
||||
|
||||
|
||||
def _is_gateway_running() -> bool:
|
||||
"""Check if Gateway process is running."""
|
||||
"""Check if Gateway process is running.
|
||||
|
||||
Checks both the internally-managed gateway process and falls back to
|
||||
port availability (for externally-managed gateway processes).
|
||||
"""
|
||||
process = _runtime_state.gateway_process
|
||||
if process is None:
|
||||
if process is not None and process.poll() is None:
|
||||
return True
|
||||
# Fallback: check if the gateway port is in use (for externally started gateway)
|
||||
import socket
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", _runtime_state.gateway_port), timeout=1):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return process.poll() is None
|
||||
|
||||
|
||||
def _stop_gateway() -> bool:
|
||||
|
||||
@@ -5,6 +5,8 @@ from .agent_service import app as agent_app
|
||||
from .agent_service import create_app as create_agent_app
|
||||
from .news_service import app as news_app
|
||||
from .news_service import create_app as create_news_app
|
||||
from .openclaw_service import app as openclaw_app
|
||||
from .openclaw_service import create_app as create_openclaw_app
|
||||
from .runtime_service import app as runtime_app
|
||||
from .runtime_service import create_app as create_runtime_app
|
||||
from .trading_service import app as trading_app
|
||||
@@ -21,6 +23,8 @@ __all__ = [
|
||||
"create_agent_app",
|
||||
"news_app",
|
||||
"create_news_app",
|
||||
"openclaw_app",
|
||||
"create_openclaw_app",
|
||||
"runtime_app",
|
||||
"create_runtime_app",
|
||||
"trading_app",
|
||||
|
||||
49
backend/apps/openclaw_service.py
Normal file
49
backend/apps/openclaw_service.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Read-only OpenClaw CLI FastAPI surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
from backend.api import openclaw_router
|
||||
from backend.apps.cors import add_cors_middleware
|
||||
from backend.api.openclaw import get_openclaw_cli_service
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create the OpenClaw service app."""
|
||||
app = FastAPI(
|
||||
title="EvoTraders OpenClaw Service",
|
||||
description="Read-only OpenClaw CLI integration service surface",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
add_cors_middleware(app)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check(
|
||||
service=Depends(get_openclaw_cli_service),
|
||||
) -> dict[str, object]:
|
||||
return service.health()
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status(
|
||||
service=Depends(get_openclaw_cli_service),
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"status": "operational",
|
||||
"service": "openclaw-service",
|
||||
"openclaw": service.health(),
|
||||
}
|
||||
|
||||
app.include_router(openclaw_router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8004)
|
||||
@@ -72,14 +72,16 @@ class SuppressNoisyInfoFilter(logging.Filter):
|
||||
"""Filter out low-signal library INFO logs while keeping warnings/errors."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if record.levelno >= logging.WARNING:
|
||||
return True
|
||||
|
||||
message = record.getMessage()
|
||||
if record.name == "httpx" and message.startswith("HTTP Request:"):
|
||||
return False
|
||||
if record.name.startswith("websockets") and "connection open" in message:
|
||||
return False
|
||||
if record.name.startswith("websockets") and "opening handshake failed" in message:
|
||||
return False
|
||||
|
||||
if record.levelno >= logging.WARNING:
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from backend.runtime.manager import (
|
||||
set_global_runtime_manager,
|
||||
clear_global_runtime_manager,
|
||||
)
|
||||
from backend.gateway_server import configure_gateway_logging
|
||||
from backend.services.gateway import Gateway
|
||||
from backend.services.market import MarketService
|
||||
from backend.services.storage import StorageService
|
||||
@@ -38,6 +39,7 @@ load_dotenv()
|
||||
logger = logging.getLogger(__name__)
|
||||
loguru.logger.disable("flowllm")
|
||||
loguru.logger.disable("reme_ai")
|
||||
configure_gateway_logging(verbose=os.getenv("LOG_LEVEL", "").upper() == "DEBUG")
|
||||
_prompt_loader = get_prompt_loader()
|
||||
|
||||
|
||||
|
||||
@@ -26,10 +26,12 @@ from backend.tools.technical_signals import StockTechnicalAnalyzer
|
||||
from backend.core.scheduler import Scheduler
|
||||
from backend.services import gateway_admin_handlers
|
||||
from backend.services import gateway_cycle_support
|
||||
from backend.services import gateway_openclaw_handlers
|
||||
from backend.services import gateway_runtime_support
|
||||
from backend.services import gateway_stock_handlers
|
||||
from shared.client import NewsServiceClient
|
||||
from shared.client import TradingServiceClient
|
||||
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient, DEFAULT_GATEWAY_URL as OPENCLAW_WS_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
EDITABLE_AGENT_WORKSPACE_FILES = {
|
||||
@@ -92,6 +94,7 @@ class Gateway:
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._project_root = Path(__file__).resolve().parents[2]
|
||||
self._technical_analyzer = StockTechnicalAnalyzer()
|
||||
self._openclaw_ws: OpenClawWebSocketClient | None = None
|
||||
|
||||
async def start(self, host: str = "0.0.0.0", port: int = 8766):
|
||||
"""Start gateway server with proper initialization order.
|
||||
@@ -185,6 +188,20 @@ class Gateway:
|
||||
# Give a brief moment for any existing clients to reconnect
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Connect to OpenClaw Gateway (18789) via WebSocket
|
||||
logger.info("Connecting to OpenClaw Gateway...")
|
||||
try:
|
||||
self._openclaw_ws = OpenClawWebSocketClient(
|
||||
url=OPENCLAW_WS_URL,
|
||||
client_name="gateway-client",
|
||||
client_version="1.0.0",
|
||||
)
|
||||
await self._openclaw_ws.connect()
|
||||
logger.info("OpenClaw Gateway WebSocket connected")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to OpenClaw Gateway: %s", e)
|
||||
self._openclaw_ws = None
|
||||
|
||||
# ======================================================================
|
||||
# PHASE 2: Start market data service
|
||||
# Now frontend is connected, start pushing price updates
|
||||
@@ -434,11 +451,77 @@ class Gateway:
|
||||
await self._handle_get_stock_technical_indicators(websocket, data)
|
||||
elif msg_type == "run_stock_enrich":
|
||||
await self._handle_run_stock_enrich(websocket, data)
|
||||
elif msg_type == "get_openclaw_status":
|
||||
await self._handle_get_openclaw_status(websocket, data)
|
||||
elif msg_type == "get_openclaw_sessions":
|
||||
await self._handle_get_openclaw_sessions(websocket, data)
|
||||
elif msg_type == "get_openclaw_session_detail":
|
||||
await self._handle_get_openclaw_session_detail(websocket, data)
|
||||
elif msg_type == "get_openclaw_session_history":
|
||||
await self._handle_get_openclaw_session_history(websocket, data)
|
||||
elif msg_type == "get_openclaw_cron":
|
||||
await self._handle_get_openclaw_cron(websocket, data)
|
||||
elif msg_type == "get_openclaw_approvals":
|
||||
await self._handle_get_openclaw_approvals(websocket, data)
|
||||
elif msg_type == "get_openclaw_agents":
|
||||
await self._handle_get_openclaw_agents(websocket, data)
|
||||
elif msg_type == "get_openclaw_agents_presence":
|
||||
await self._handle_get_openclaw_agents_presence(websocket, data)
|
||||
elif msg_type == "get_openclaw_skills":
|
||||
await self._handle_get_openclaw_skills(websocket, data)
|
||||
elif msg_type == "get_openclaw_models":
|
||||
await self._handle_get_openclaw_models(websocket, data)
|
||||
elif msg_type == "get_openclaw_hooks":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_hooks(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_plugins":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_plugins(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_secrets_audit":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_secrets_audit(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_security_audit":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_security_audit(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_daemon_status":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_daemon_status(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_pairing":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_pairing(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_qr":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_qr(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_update_status":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_update_status(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_models_aliases":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_models_aliases(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_models_fallbacks":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_models_fallbacks(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_models_image_fallbacks":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_models_image_fallbacks(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_skill_update":
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_skill_update(self, websocket, data)
|
||||
elif msg_type == "get_openclaw_workspace_files":
|
||||
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,
|
||||
@@ -669,6 +752,83 @@ class Gateway:
|
||||
) -> None:
|
||||
await gateway_admin_handlers.handle_update_agent_workspace_file(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_status(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_status(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_sessions(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_sessions(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_session_detail(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_session_detail(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_session_history(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_session_history(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_cron(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_cron(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_approvals(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_approvals(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_agents(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_agents(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_agents_presence(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_agents_presence(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_skills(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_skills(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_models(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_models(self, websocket, data)
|
||||
|
||||
async def _handle_get_openclaw_workspace_files(
|
||||
self,
|
||||
websocket: ServerConnection,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_watchlist(raw_tickers: Any) -> List[str]:
|
||||
return gateway_runtime_support.normalize_watchlist(raw_tickers)
|
||||
|
||||
@@ -388,4 +388,15 @@ def stop_gateway(gateway: Any) -> None:
|
||||
gateway._market_status_task.cancel()
|
||||
if gateway._watchlist_ingest_task:
|
||||
gateway._watchlist_ingest_task.cancel()
|
||||
# Close OpenClaw WebSocket connection
|
||||
if gateway._openclaw_ws:
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
loop.create_task(gateway._openclaw_ws.disconnect())
|
||||
else:
|
||||
loop.run_until_complete(gateway._openclaw_ws.disconnect())
|
||||
except Exception:
|
||||
pass
|
||||
gateway._dashboard.stop()
|
||||
|
||||
534
backend/services/gateway_openclaw_handlers.py
Normal file
534
backend/services/gateway_openclaw_handlers.py
Normal file
@@ -0,0 +1,534 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""OpenClaw WebSocket handlers — gateway calls OpenClaw Gateway via WebSocket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.services.gateway import Gateway
|
||||
|
||||
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
|
||||
client = gateway._openclaw_ws
|
||||
if client is None:
|
||||
raise RuntimeError("OpenClaw Gateway not connected")
|
||||
return client
|
||||
|
||||
|
||||
async def _ws_call(gateway, method: str, params: dict | None = None) -> dict:
|
||||
"""Call OpenClaw Gateway via WebSocket and return result."""
|
||||
try:
|
||||
client = _get_ws_client(gateway)
|
||||
return await client.call_method(method, params)
|
||||
except Exception as exc:
|
||||
logger.warning("OpenClaw Gateway call failed for %s: %s", method, exc)
|
||||
return {"error": str(exc)[:200]}
|
||||
|
||||
|
||||
async def handle_get_openclaw_status(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "status")
|
||||
await websocket.send(json.dumps({"type": "openclaw_status_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_sessions(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "sessions.list", {"limit": 50, "includeLastMessage": True})
|
||||
await websocket.send(json.dumps({"type": "openclaw_sessions_loaded", "data": result}))
|
||||
|
||||
|
||||
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": 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": {"session": session, "error": None if session else f"session '{session_key}' not found"},
|
||||
"session_key": session_key,
|
||||
}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_session_history(gateway, websocket, data: dict) -> None:
|
||||
session_key = data.get("session_key", "")
|
||||
limit = data.get("limit", 20)
|
||||
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": 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}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_approvals(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "exec.approvals.get")
|
||||
await websocket.send(json.dumps({"type": "openclaw_approvals_loaded", "data": result}))
|
||||
|
||||
|
||||
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}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_agents_presence(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "node.list")
|
||||
await websocket.send(json.dumps({"type": "openclaw_agents_presence_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_skills(gateway, websocket, data: dict) -> None:
|
||||
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}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_models(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "models.list")
|
||||
await websocket.send(json.dumps({"type": "openclaw_models_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_hooks(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "tools.catalog")
|
||||
await websocket.send(json.dumps({"type": "openclaw_hooks_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_plugins(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "config.get")
|
||||
await websocket.send(json.dumps({"type": "openclaw_plugins_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_secrets_audit(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "secrets.reload")
|
||||
await websocket.send(json.dumps({"type": "openclaw_secrets_audit_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_security_audit(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "gateway.identity.get")
|
||||
await websocket.send(json.dumps({"type": "openclaw_security_audit_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_daemon_status(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "doctor.memory.status")
|
||||
await websocket.send(json.dumps({"type": "openclaw_daemon_status_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_pairing(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "device.pair.list")
|
||||
await websocket.send(json.dumps({"type": "openclaw_pairing_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_qr(gateway, websocket, data: dict) -> None:
|
||||
await websocket.send(json.dumps({"type": "openclaw_qr_loaded", "data": {"error": "QR code not available via WebSocket"}}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_update_status(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "update.run")
|
||||
await websocket.send(json.dumps({"type": "openclaw_update_status_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_models_aliases(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "models.list")
|
||||
await websocket.send(json.dumps({"type": "openclaw_models_aliases_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_models_fallbacks(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "models.list")
|
||||
await websocket.send(json.dumps({"type": "openclaw_models_fallbacks_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_models_image_fallbacks(gateway, websocket, data: dict) -> None:
|
||||
result = await _ws_call(gateway, "models.list")
|
||||
await websocket.send(json.dumps({"type": "openclaw_models_image_fallbacks_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_skill_update(gateway, websocket, data: dict) -> None:
|
||||
slug = data.get("slug")
|
||||
all_flag = data.get("all", False)
|
||||
params = {}
|
||||
if slug is not None:
|
||||
params["slug"] = slug
|
||||
if all_flag:
|
||||
params["all"] = "true"
|
||||
result = await _ws_call(gateway, "skills.update", params)
|
||||
await websocket.send(json.dumps({"type": "openclaw_skill_update_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_workspace_files(gateway, websocket, data: dict) -> None:
|
||||
raw_workspace = data.get("workspace", "")
|
||||
# Use the workspace param (which is actually the agent.id from frontend) as agent_id
|
||||
agent_id = raw_workspace or "main"
|
||||
result = await _ws_call(gateway, "agents.files.list", {"agentId": agent_id})
|
||||
if isinstance(result, dict):
|
||||
result["workspace"] = agent_id
|
||||
await websocket.send(json.dumps({"type": "openclaw_workspace_files_loaded", "data": result}))
|
||||
|
||||
|
||||
async def handle_get_openclaw_workspace_file(gateway, websocket, data: dict) -> None:
|
||||
agent_id = data.get("agent_id", "main")
|
||||
file_name = data.get("file_name", "")
|
||||
if not file_name:
|
||||
await websocket.send(json.dumps({"type": "openclaw_workspace_file_loaded", "data": {"error": "file_name is required"}}))
|
||||
return
|
||||
result = await _ws_call(gateway, "agents.files.get", {"agentId": agent_id, "name": file_name})
|
||||
await websocket.send(json.dumps({"type": "openclaw_workspace_file_loaded", "data": result}))
|
||||
754
backend/services/openclaw_cli.py
Normal file
754
backend/services/openclaw_cli.py
Normal file
@@ -0,0 +1,754 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Thin service wrapper around the OpenClaw CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from shared.models.openclaw import (
|
||||
AgentSummary,
|
||||
AgentsList,
|
||||
ApprovalRequest,
|
||||
ApprovalsList,
|
||||
CronJob,
|
||||
CronList,
|
||||
DaemonStatus,
|
||||
HookStatusEntry,
|
||||
HookStatusReport,
|
||||
ModelAliasesList,
|
||||
ModelFallbacksList,
|
||||
ModelRow,
|
||||
ModelsList,
|
||||
OpenClawStatus,
|
||||
PairingListResponse,
|
||||
PluginDiagnostic,
|
||||
PluginRecord,
|
||||
PluginsList,
|
||||
QrCodeResponse,
|
||||
SecretsAuditReport,
|
||||
SecurityAuditResponse,
|
||||
SecurityAuditReport,
|
||||
SessionEntry,
|
||||
SessionHistory,
|
||||
SessionsList,
|
||||
SkillStatusEntry,
|
||||
SkillStatusReport,
|
||||
SkillUpdateResult,
|
||||
UpdateCheckResult,
|
||||
UpdateStatusResponse,
|
||||
normalize_agents,
|
||||
normalize_approvals,
|
||||
normalize_cron_jobs,
|
||||
normalize_daemon_status,
|
||||
normalize_hooks,
|
||||
normalize_model_aliases,
|
||||
normalize_model_fallbacks,
|
||||
normalize_models,
|
||||
normalize_pairing,
|
||||
normalize_plugins,
|
||||
normalize_qr,
|
||||
normalize_security_audit,
|
||||
normalize_secrets_audit,
|
||||
normalize_session_history,
|
||||
normalize_sessions,
|
||||
normalize_skill_update,
|
||||
normalize_skills,
|
||||
normalize_status,
|
||||
normalize_update_status,
|
||||
)
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
REFERENCE_OPENCLAW_ROOT = PROJECT_ROOT / "reference" / "openclaw"
|
||||
REFERENCE_OPENCLAW_ENTRY = REFERENCE_OPENCLAW_ROOT / "openclaw.mjs"
|
||||
|
||||
|
||||
class OpenClawCliError(RuntimeError):
|
||||
"""Raised when the OpenClaw CLI invocation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
command: list[str],
|
||||
exit_code: int | None = None,
|
||||
stdout: str = "",
|
||||
stderr: str = "",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.command = command
|
||||
self.exit_code = exit_code
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenClawCliResult:
|
||||
"""Command execution result."""
|
||||
|
||||
command: list[str]
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
def resolve_openclaw_base_command() -> list[str]:
|
||||
"""Resolve the command prefix used to launch OpenClaw."""
|
||||
explicit = os.getenv("OPENCLAW_CMD", "").strip()
|
||||
if explicit:
|
||||
return shlex.split(explicit)
|
||||
|
||||
installed = shutil.which("openclaw")
|
||||
if installed:
|
||||
return [installed]
|
||||
|
||||
if REFERENCE_OPENCLAW_ENTRY.exists():
|
||||
return [sys.executable if sys.executable.endswith("node") else "node", str(REFERENCE_OPENCLAW_ENTRY)]
|
||||
|
||||
return ["openclaw"]
|
||||
|
||||
|
||||
def resolve_openclaw_cwd() -> Path:
|
||||
"""Resolve the working directory for CLI execution."""
|
||||
explicit = os.getenv("OPENCLAW_CWD", "").strip()
|
||||
if explicit:
|
||||
return Path(explicit).expanduser()
|
||||
if REFERENCE_OPENCLAW_ROOT.exists():
|
||||
return REFERENCE_OPENCLAW_ROOT
|
||||
return PROJECT_ROOT
|
||||
|
||||
|
||||
class OpenClawCliService:
|
||||
"""OpenClaw CLI integration service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_command: list[str] | None = None,
|
||||
cwd: Path | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> None:
|
||||
self.base_command = list(base_command or resolve_openclaw_base_command())
|
||||
self.cwd = cwd or resolve_openclaw_cwd()
|
||||
self.timeout_seconds = timeout_seconds or float(
|
||||
os.getenv("OPENCLAW_TIMEOUT_SECONDS", "15")
|
||||
)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
"""Return the current CLI wiring state."""
|
||||
binary = self.base_command[0] if self.base_command else "openclaw"
|
||||
resolved = shutil.which(binary) if len(self.base_command) == 1 else binary
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "openclaw-service",
|
||||
"base_command": self.base_command,
|
||||
"cwd": str(self.cwd),
|
||||
"binary_resolved": resolved is not None,
|
||||
"reference_entry_available": REFERENCE_OPENCLAW_ENTRY.exists(),
|
||||
"timeout_seconds": self.timeout_seconds,
|
||||
}
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
"""Read `openclaw status --json`."""
|
||||
return self.run_json(["status", "--json"])
|
||||
|
||||
def list_sessions(self) -> dict[str, Any]:
|
||||
"""Read `openclaw sessions --json`."""
|
||||
return self.run_json(["sessions", "--json"])
|
||||
|
||||
def get_session(self, session_key: str) -> dict[str, Any]:
|
||||
"""Resolve a single session out of the sessions list."""
|
||||
payload = self.list_sessions()
|
||||
sessions = payload.get("sessions") or []
|
||||
for item in sessions:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("key") == session_key or item.get("sessionKey") == session_key:
|
||||
return item
|
||||
raise KeyError(session_key)
|
||||
|
||||
def get_session_history(self, session_key: str, *, limit: int = 20) -> dict[str, Any]:
|
||||
"""Read session history with a JSON-first fallback to raw text."""
|
||||
args = ["sessions", "history", session_key, "--json", "--limit", str(limit)]
|
||||
try:
|
||||
return self.run_json(args)
|
||||
except OpenClawCliError as exc:
|
||||
raise exc
|
||||
except json.JSONDecodeError:
|
||||
result = self.run(args)
|
||||
return {
|
||||
"sessionKey": session_key,
|
||||
"limit": limit,
|
||||
"rawText": result.stdout,
|
||||
}
|
||||
|
||||
def list_cron_jobs(self) -> dict[str, Any]:
|
||||
"""Read `openclaw cron list --json`."""
|
||||
return self.run_json(["cron", "list", "--json"])
|
||||
|
||||
def list_approvals(self) -> dict[str, Any]:
|
||||
"""Read `openclaw approvals get --json`."""
|
||||
return self.run_json(["approvals", "get", "--json"])
|
||||
|
||||
def list_agents(self) -> dict[str, Any]:
|
||||
"""Read `openclaw agents list --json`."""
|
||||
return self.run_json(["agents", "list", "--json"])
|
||||
|
||||
def list_skills(self) -> dict[str, Any]:
|
||||
"""Read `openclaw skills list --json`."""
|
||||
return self.run_json(["skills", "list", "--json"])
|
||||
|
||||
def list_models(self) -> dict[str, Any]:
|
||||
"""Read `openclaw models list --json`."""
|
||||
return self.run_json(["models", "list", "--json"])
|
||||
|
||||
def list_hooks(self) -> dict[str, Any]:
|
||||
"""Read `openclaw hooks list --json`."""
|
||||
return self.run_json(["hooks", "list", "--json"])
|
||||
|
||||
def list_plugins(self) -> dict[str, Any]:
|
||||
"""Read `openclaw plugins list --json`."""
|
||||
return self.run_json(["plugins", "list", "--json"])
|
||||
|
||||
def secrets_audit(self) -> dict[str, Any]:
|
||||
"""Read `openclaw secrets audit --json`."""
|
||||
return self.run_json(["secrets", "audit", "--json"])
|
||||
|
||||
def security_audit(self) -> dict[str, Any]:
|
||||
"""Read `openclaw security audit --json`."""
|
||||
return self.run_json(["security", "audit", "--json"])
|
||||
|
||||
def daemon_status(self) -> dict[str, Any]:
|
||||
"""Read `openclaw daemon status --json`."""
|
||||
return self.run_json(["daemon", "status", "--json"])
|
||||
|
||||
def pairing_list(self) -> dict[str, Any]:
|
||||
"""Read `openclaw pairing list --json`."""
|
||||
return self.run_json(["pairing", "list", "--json"])
|
||||
|
||||
def qr_code(self) -> dict[str, Any]:
|
||||
"""Read `openclaw qr --json`."""
|
||||
return self.run_json(["qr", "--json"])
|
||||
|
||||
def update_status(self) -> dict[str, Any]:
|
||||
"""Read `openclaw update status --json`."""
|
||||
return self.run_json(["update", "status", "--json"])
|
||||
|
||||
def list_model_aliases(self) -> dict[str, Any]:
|
||||
"""Read `openclaw models aliases list --json`."""
|
||||
return self.run_json(["models", "aliases", "list", "--json"])
|
||||
|
||||
def list_model_fallbacks(self) -> dict[str, Any]:
|
||||
"""Read `openclaw models fallbacks list --json`."""
|
||||
return self.run_json(["models", "fallbacks", "list", "--json"])
|
||||
|
||||
def list_model_image_fallbacks(self) -> dict[str, Any]:
|
||||
"""Read `openclaw models image-fallbacks list --json`."""
|
||||
return self.run_json(["models", "image-fallbacks", "list", "--json"])
|
||||
|
||||
def skill_update(self, *, slug: str | None = None, all: bool = False) -> dict[str, Any]:
|
||||
"""Read `openclaw skills update --json`."""
|
||||
args = ["skills", "update", "--json"]
|
||||
if slug:
|
||||
args.append(slug)
|
||||
if all:
|
||||
args.append("--all")
|
||||
return self.run_json(args)
|
||||
|
||||
def models_status(self, *, probe: bool = False) -> dict[str, Any]:
|
||||
"""Read `openclaw models status --json [--probe]`."""
|
||||
args = ["models", "status", "--json"]
|
||||
if probe:
|
||||
args.append("--probe")
|
||||
return self.run_json(args)
|
||||
|
||||
def channels_status(self, *, probe: bool = False) -> dict[str, Any]:
|
||||
"""Read `openclaw channels status [--probe] --json`."""
|
||||
args = ["channels", "status", "--json"]
|
||||
if probe:
|
||||
args.append("--probe")
|
||||
return self.run_json(args)
|
||||
|
||||
def list_workspace_files(self, workspace_path: str) -> dict[str, Any]:
|
||||
"""List .md files in an OpenClaw agent workspace with their content.
|
||||
|
||||
Reads the workspace directory and returns metadata + content for each .md file.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
wp = Path(workspace_path).expanduser().resolve()
|
||||
if not wp.exists() or not wp.is_dir():
|
||||
return {"workspace": str(wp), "files": [], "error": "workspace not found"}
|
||||
|
||||
md_files = sorted(wp.glob("*.md"))
|
||||
files = []
|
||||
for md_file in md_files:
|
||||
try:
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
# Preview: first 300 chars
|
||||
preview = content[:300].strip()
|
||||
files.append({
|
||||
"name": md_file.name,
|
||||
"path": str(md_file),
|
||||
"size": len(content),
|
||||
"preview": preview,
|
||||
"previewTruncated": len(content) > 300,
|
||||
})
|
||||
except OSError as exc:
|
||||
files.append({
|
||||
"name": md_file.name,
|
||||
"path": str(md_file),
|
||||
"size": 0,
|
||||
"preview": "",
|
||||
"error": str(exc),
|
||||
})
|
||||
|
||||
return {"workspace": str(wp), "files": files}
|
||||
|
||||
def channels_list(self) -> dict[str, Any]:
|
||||
"""Read `openclaw channels list --json`."""
|
||||
return self.run_json(["channels", "list", "--json"])
|
||||
|
||||
def hook_info(self, name: str) -> dict[str, Any]:
|
||||
"""Read `openclaw hooks info <name> --json`."""
|
||||
args = ["hooks", "info", name, "--json"]
|
||||
try:
|
||||
return self.run_json(args)
|
||||
except json.JSONDecodeError:
|
||||
result = self.run(args)
|
||||
return {"raw": result.stdout}
|
||||
|
||||
def hooks_check(self) -> dict[str, Any]:
|
||||
"""Read `openclaw hooks check --json`."""
|
||||
return self.run_json(["hooks", "check", "--json"])
|
||||
|
||||
def plugins_inspect(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]:
|
||||
"""Read `openclaw plugins inspect [--json] [--all]`."""
|
||||
args = ["plugins", "inspect", "--json"]
|
||||
if all:
|
||||
args.append("--all")
|
||||
elif plugin_id:
|
||||
args.append(plugin_id)
|
||||
return self.run_json(args)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Typed variants — these use Pydantic models and are the preferred path.
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def status_model(self) -> OpenClawStatus:
|
||||
"""Read and parse `openclaw status --json` into a typed model."""
|
||||
raw = self.status()
|
||||
return normalize_status(raw)
|
||||
|
||||
def list_sessions_model(self) -> SessionsList:
|
||||
"""Read and parse `openclaw sessions --json` into a typed model."""
|
||||
raw = self.list_sessions()
|
||||
return normalize_sessions(raw)
|
||||
|
||||
def get_session_model(self, session_key: str) -> SessionEntry:
|
||||
"""Resolve a single session and return a typed model."""
|
||||
raw = self.get_session(session_key)
|
||||
return SessionEntry.model_validate(raw, strict=False)
|
||||
|
||||
def get_session_history_model(self, session_key: str, *, limit: int = 20) -> SessionHistory:
|
||||
"""Read session history and return a typed model."""
|
||||
raw = self.get_session_history(session_key, limit=limit)
|
||||
return normalize_session_history(raw, session_key=session_key)
|
||||
|
||||
def list_cron_jobs_model(self) -> CronList:
|
||||
"""Read and parse `openclaw cron list --json` into a typed model."""
|
||||
raw = self.list_cron_jobs()
|
||||
return normalize_cron_jobs(raw)
|
||||
|
||||
def list_approvals_model(self) -> ApprovalsList:
|
||||
"""Read and parse `openclaw approvals get --json` into a typed model."""
|
||||
raw = self.list_approvals()
|
||||
return normalize_approvals(raw)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Typed variants
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def list_agents_model(self) -> AgentsList:
|
||||
"""Read and parse `openclaw agents list --json` into a typed model."""
|
||||
raw = self.list_agents()
|
||||
if isinstance(raw, list):
|
||||
return AgentsList(agents=[AgentSummary.model_validate(a, strict=False) for a in raw if isinstance(a, dict)])
|
||||
return normalize_agents(raw)
|
||||
|
||||
def list_skills_model(self) -> SkillStatusReport:
|
||||
"""Read and parse `openclaw skills list --json` into a typed model."""
|
||||
raw = self.list_skills()
|
||||
return normalize_skills(raw)
|
||||
|
||||
def list_models_model(self) -> ModelsList:
|
||||
"""Read and parse `openclaw models list --json` into a typed model."""
|
||||
raw = self.list_models()
|
||||
if isinstance(raw, list):
|
||||
return ModelsList(models=[ModelRow.model_validate(m, strict=False) for m in raw if isinstance(m, dict)])
|
||||
return normalize_models(raw)
|
||||
|
||||
def list_hooks_model(self) -> HookStatusReport:
|
||||
raw = self.list_hooks()
|
||||
return normalize_hooks(raw)
|
||||
|
||||
def list_plugins_model(self) -> PluginsList:
|
||||
raw = self.list_plugins()
|
||||
return normalize_plugins(raw)
|
||||
|
||||
def secrets_audit_model(self) -> SecretsAuditReport:
|
||||
raw = self.secrets_audit()
|
||||
return normalize_secrets_audit(raw)
|
||||
|
||||
def security_audit_model(self) -> SecurityAuditResponse:
|
||||
raw = self.security_audit()
|
||||
return normalize_security_audit(raw)
|
||||
|
||||
def daemon_status_model(self) -> DaemonStatus:
|
||||
raw = self.daemon_status()
|
||||
return normalize_daemon_status(raw)
|
||||
|
||||
def pairing_list_model(self) -> PairingListResponse:
|
||||
raw = self.pairing_list()
|
||||
return normalize_pairing(raw)
|
||||
|
||||
def qr_code_model(self) -> QrCodeResponse:
|
||||
raw = self.qr_code()
|
||||
return normalize_qr(raw)
|
||||
|
||||
def update_status_model(self) -> UpdateStatusResponse:
|
||||
raw = self.update_status()
|
||||
return normalize_update_status(raw)
|
||||
|
||||
def list_model_aliases_model(self) -> ModelAliasesList:
|
||||
raw = self.list_model_aliases()
|
||||
return normalize_model_aliases(raw)
|
||||
|
||||
def list_model_fallbacks_model(self) -> ModelFallbacksList:
|
||||
raw = self.list_model_fallbacks()
|
||||
return normalize_model_fallbacks(raw)
|
||||
|
||||
def list_model_image_fallbacks_model(self) -> ModelFallbacksList:
|
||||
raw = self.list_model_image_fallbacks()
|
||||
return normalize_model_fallbacks(raw)
|
||||
|
||||
def skill_update_model(self, *, slug: str | None = None, all: bool = False) -> SkillUpdateResult:
|
||||
raw = self.skill_update(slug=slug, all=all)
|
||||
return normalize_skill_update(raw)
|
||||
|
||||
def models_status_model(self, *, probe: bool = False) -> dict[str, Any]:
|
||||
"""Read `openclaw models status --json` and return the raw dict."""
|
||||
return self.models_status(probe=probe)
|
||||
|
||||
def channels_status_model(self, *, probe: bool = False) -> dict[str, Any]:
|
||||
"""Read `openclaw channels status --json` and return the raw dict."""
|
||||
return self.channels_status(probe=probe)
|
||||
|
||||
def channels_list_model(self) -> dict[str, Any]:
|
||||
"""Read `openclaw channels list --json` and return the raw dict."""
|
||||
return self.channels_list()
|
||||
|
||||
def hook_info_model(self, name: str) -> dict[str, Any]:
|
||||
"""Read `openclaw hooks info <name> --json` and return the raw dict."""
|
||||
return self.hook_info(name)
|
||||
|
||||
def hooks_check_model(self) -> dict[str, Any]:
|
||||
"""Read `openclaw hooks check --json` and return the raw dict."""
|
||||
return self.hooks_check()
|
||||
|
||||
def plugins_inspect_model(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]:
|
||||
"""Read `openclaw plugins inspect --json [--all]` and return the raw dict."""
|
||||
return self.plugins_inspect(plugin_id=plugin_id, all=all)
|
||||
|
||||
def agents_bindings(self, *, agent: str | None = None) -> dict[str, Any]:
|
||||
"""Read `openclaw agents bindings --json [--agent <id>]`."""
|
||||
args = ["agents", "bindings", "--json"]
|
||||
if agent:
|
||||
args.extend(["--agent", agent])
|
||||
return self.run_json(args)
|
||||
|
||||
def agents_bindings_model(self, *, agent: str | None = None) -> dict[str, Any]:
|
||||
"""Read `openclaw agents bindings --json` and return the raw dict."""
|
||||
return self.agents_bindings(agent=agent)
|
||||
|
||||
def agents_presence(self) -> dict[str, Any]:
|
||||
"""Read session presence for all agents from runtime session files.
|
||||
|
||||
Reads ~/.openclaw/agents/{agentId}/sessions/sessions.json for each agent
|
||||
and counts sessions in active states within a recency window.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
openclaw_home = Path.home() / ".openclaw"
|
||||
agents_path = openclaw_home / "agents"
|
||||
|
||||
if not agents_path.exists():
|
||||
return {"status": "not_connected", "agents": {}}
|
||||
|
||||
ACTIVE_STATES = {
|
||||
"running", "active", "busy", "blocked", "waiting_approval",
|
||||
"working", "in_progress", "processing", "thinking", "executing", "streaming",
|
||||
}
|
||||
|
||||
RECENCY_WINDOW_MS = 45 * 60 * 1000 # 45 minutes
|
||||
|
||||
result: dict[str, Any] = {"status": "connected", "agents": {}}
|
||||
|
||||
try:
|
||||
for agent_dir in agents_path.iterdir():
|
||||
if not agent_dir.is_dir():
|
||||
continue
|
||||
sessions_file = agent_dir / "sessions" / "sessions.json"
|
||||
if not sessions_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
sessions_data = json.loads(sessions_file.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
|
||||
sessions = sessions_data if isinstance(sessions_data, list) else []
|
||||
now_ms = 0 # placeholder; we'll skip recency check if no ts field
|
||||
|
||||
active_count = 0
|
||||
for session in sessions:
|
||||
if not isinstance(session, dict):
|
||||
continue
|
||||
state = str(session.get("state") or session.get("status") or "").lower()
|
||||
if state in ACTIVE_STATES:
|
||||
active_count += 1
|
||||
|
||||
if active_count > 0:
|
||||
result["agents"][agent_dir.name] = {
|
||||
"activeSessions": active_count,
|
||||
"status": "active",
|
||||
}
|
||||
else:
|
||||
result["agents"][agent_dir.name] = {
|
||||
"activeSessions": 0,
|
||||
"status": "idle",
|
||||
}
|
||||
except OSError:
|
||||
result["status"] = "partial"
|
||||
|
||||
return result
|
||||
|
||||
def agents_from_config(self) -> dict[str, Any]:
|
||||
"""Read agent list directly from openclaw.json config file.
|
||||
|
||||
Falls back to scanning ~/.openclaw/agents/ directories when config is absent.
|
||||
This avoids the CLI timeout from `agents list --json`.
|
||||
"""
|
||||
import json
|
||||
|
||||
openclaw_home = Path.home() / ".openclaw"
|
||||
config_path = openclaw_home / "openclaw.json"
|
||||
|
||||
if not config_path.exists():
|
||||
return {"status": "not_connected", "agents": []}
|
||||
|
||||
try:
|
||||
raw = json.loads(config_path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {"status": "partial", "agents": []}
|
||||
|
||||
agents_list = raw.get("agents", {}).get("list", [])
|
||||
if not agents_list:
|
||||
return {"status": "partial", "agents": [], "detail": "agents.list is empty"}
|
||||
|
||||
agents = []
|
||||
for entry in agents_list:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
agent_id = entry.get("id", "").strip()
|
||||
if not agent_id:
|
||||
continue
|
||||
agents.append({
|
||||
"id": agent_id,
|
||||
"name": entry.get("name", "").strip() or agent_id,
|
||||
"model": entry.get("model") or "",
|
||||
"workspace": entry.get("workspace") or "",
|
||||
"is_default": entry.get("id") == raw.get("agents", {}).get("defaults", {}).get("id"),
|
||||
})
|
||||
|
||||
return {"status": "connected", "agents": agents}
|
||||
|
||||
def gateway_status(self, *, url: str | None = None, token: str | None = None) -> dict[str, Any]:
|
||||
"""Read `openclaw gateway status --json [--url <url>] [--token <token>]`. May fail if gateway is unreachable."""
|
||||
args = ["gateway", "status", "--json"]
|
||||
if url:
|
||||
args.extend(["--url", url])
|
||||
if token:
|
||||
args.extend(["--token", token])
|
||||
return self.run_json(args)
|
||||
|
||||
def memory_status(self, *, agent: str | None = None, deep: bool = False) -> dict[str, Any]:
|
||||
"""Read `openclaw memory status --json [--agent <id>] [--deep]`. Returns array of per-agent status."""
|
||||
args = ["memory", "status", "--json"]
|
||||
if agent:
|
||||
args.extend(["--agent", agent])
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
return self.run_json(args)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Write agents commands
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def agents_add(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
workspace: str | None = None,
|
||||
model: str | None = None,
|
||||
agent_dir: str | None = None,
|
||||
bind: list[str] | None = None,
|
||||
non_interactive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Run `openclaw agents add <name> [--workspace <dir>] [--model <id>] [--agent-dir <dir>] [--bind <spec>] [--non-interactive] --json`."""
|
||||
args = ["agents", "add", name, "--json"]
|
||||
if workspace:
|
||||
args.extend(["--workspace", workspace])
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if agent_dir:
|
||||
args.extend(["--agent-dir", agent_dir])
|
||||
if bind:
|
||||
for b in bind:
|
||||
args.extend(["--bind", b])
|
||||
if non_interactive:
|
||||
args.append("--non-interactive")
|
||||
return self.run_json(args)
|
||||
|
||||
def agents_delete(self, id: str, *, force: bool = False) -> dict[str, Any]:
|
||||
"""Run `openclaw agents delete <id> [--force] --json`."""
|
||||
args = ["agents", "delete", id, "--json"]
|
||||
if force:
|
||||
args.append("--force")
|
||||
return self.run_json(args)
|
||||
|
||||
def agents_bind(
|
||||
self,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
bind: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run `openclaw agents bind [--agent <id>] [--bind <spec>] --json`."""
|
||||
args = ["agents", "bind", "--json"]
|
||||
if agent:
|
||||
args.extend(["--agent", agent])
|
||||
if bind:
|
||||
for b in bind:
|
||||
args.extend(["--bind", b])
|
||||
return self.run_json(args)
|
||||
|
||||
def agents_unbind(
|
||||
self,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
bind: list[str] | None = None,
|
||||
all: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Run `openclaw agents unbind [--agent <id>] [--bind <spec>] [--all] --json`."""
|
||||
args = ["agents", "unbind", "--json"]
|
||||
if agent:
|
||||
args.extend(["--agent", agent])
|
||||
if bind:
|
||||
for b in bind:
|
||||
args.extend(["--bind", b])
|
||||
if all:
|
||||
args.append("--all")
|
||||
return self.run_json(args)
|
||||
|
||||
def agents_set_identity(
|
||||
self,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
workspace: str | None = None,
|
||||
identity_file: str | None = None,
|
||||
name: str | None = None,
|
||||
emoji: str | None = None,
|
||||
theme: str | None = None,
|
||||
avatar: str | None = None,
|
||||
from_identity: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Run `openclaw agents set-identity [--agent <id>] [--workspace <dir>] [--identity-file <path>] [--from-identity] [--name <n>] [--emoji <e>] [--theme <t>] [--avatar <a>] --json`."""
|
||||
args = ["agents", "set-identity", "--json"]
|
||||
if agent:
|
||||
args.extend(["--agent", agent])
|
||||
if workspace:
|
||||
args.extend(["--workspace", workspace])
|
||||
if identity_file:
|
||||
args.extend(["--identity-file", identity_file])
|
||||
if from_identity:
|
||||
args.append("--from-identity")
|
||||
if name:
|
||||
args.extend(["--name", name])
|
||||
if emoji:
|
||||
args.extend(["--emoji", emoji])
|
||||
if theme:
|
||||
args.extend(["--theme", theme])
|
||||
if avatar:
|
||||
args.extend(["--avatar", avatar])
|
||||
return self.run_json(args)
|
||||
|
||||
def run_json(self, args: list[str]) -> dict[str, Any]:
|
||||
"""Run the CLI and decode JSON stdout, falling back to stderr."""
|
||||
result = self.run(args)
|
||||
text = result.stdout.strip() or result.stderr.strip()
|
||||
if not text:
|
||||
return {}
|
||||
return json.loads(text)
|
||||
|
||||
def run(self, args: list[str]) -> OpenClawCliResult:
|
||||
"""Run the CLI and return stdout/stderr."""
|
||||
command = [*self.base_command, *args]
|
||||
env = os.environ.copy()
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=self.cwd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise OpenClawCliError(
|
||||
"OpenClaw CLI executable was not found.",
|
||||
command=command,
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise OpenClawCliError(
|
||||
f"OpenClaw CLI timed out after {self.timeout_seconds:.1f}s.",
|
||||
command=command,
|
||||
stdout=exc.stdout or "",
|
||||
stderr=exc.stderr or "",
|
||||
) from exc
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise OpenClawCliError(
|
||||
"OpenClaw CLI command failed.",
|
||||
command=command,
|
||||
exit_code=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
)
|
||||
|
||||
return OpenClawCliResult(
|
||||
command=command,
|
||||
exit_code=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
60
backend/tests/test_openclaw_cli_service.py
Normal file
60
backend/tests/test_openclaw_cli_service.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the OpenClaw CLI service wrapper."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService
|
||||
|
||||
|
||||
class _Completed:
|
||||
def __init__(self, *, returncode=0, stdout="", stderr=""):
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def test_openclaw_cli_service_runs_json_command(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
|
||||
def _fake_run(command, **kwargs):
|
||||
captured["command"] = command
|
||||
captured["cwd"] = kwargs["cwd"]
|
||||
return _Completed(stdout='{"sessions":[{"key":"main/session-1"}]}')
|
||||
|
||||
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
|
||||
|
||||
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
|
||||
payload = service.list_sessions()
|
||||
|
||||
assert payload["sessions"][0]["key"] == "main/session-1"
|
||||
assert captured["command"] == ["openclaw", "sessions", "--json"]
|
||||
assert captured["cwd"] == tmp_path
|
||||
|
||||
|
||||
def test_openclaw_cli_service_raises_on_failure(monkeypatch, tmp_path):
|
||||
def _fake_run(command, **kwargs):
|
||||
return _Completed(returncode=7, stdout="", stderr="boom")
|
||||
|
||||
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
|
||||
|
||||
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
|
||||
|
||||
with pytest.raises(OpenClawCliError) as exc_info:
|
||||
service.list_cron_jobs()
|
||||
|
||||
assert exc_info.value.exit_code == 7
|
||||
assert exc_info.value.stderr == "boom"
|
||||
|
||||
|
||||
def test_openclaw_cli_service_can_extract_single_session(monkeypatch, tmp_path):
|
||||
def _fake_run(command, **kwargs):
|
||||
return _Completed(stdout='{"sessions":[{"key":"main/session-1","agentId":"main"}]}')
|
||||
|
||||
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
|
||||
|
||||
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
|
||||
session = service.get_session("main/session-1")
|
||||
|
||||
assert session["agentId"] == "main"
|
||||
110
backend/tests/test_openclaw_service_app.py
Normal file
110
backend/tests/test_openclaw_service_app.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the extracted OpenClaw service app surface."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.apps.openclaw_service import create_app
|
||||
from backend.api import openclaw as openclaw_module
|
||||
|
||||
|
||||
class _FakeOpenClawCliService:
|
||||
def health(self):
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "openclaw-service",
|
||||
"base_command": ["openclaw"],
|
||||
"cwd": "/tmp/openclaw",
|
||||
"binary_resolved": True,
|
||||
"reference_entry_available": True,
|
||||
"timeout_seconds": 15.0,
|
||||
}
|
||||
|
||||
def status(self):
|
||||
return {"runtimeVersion": "2026.3.24"}
|
||||
|
||||
def list_sessions(self):
|
||||
return {
|
||||
"sessions": [
|
||||
{"key": "main/session-1", "agentId": "main"},
|
||||
{"key": "analyst/session-2", "agentId": "analyst"},
|
||||
]
|
||||
}
|
||||
|
||||
def get_session(self, session_key: str):
|
||||
for session in self.list_sessions()["sessions"]:
|
||||
if session["key"] == session_key:
|
||||
return session
|
||||
raise KeyError(session_key)
|
||||
|
||||
def get_session_history(self, session_key: str, *, limit: int = 20):
|
||||
return {
|
||||
"sessionKey": session_key,
|
||||
"limit": limit,
|
||||
"items": [{"role": "assistant", "text": "hello"}],
|
||||
}
|
||||
|
||||
def list_cron_jobs(self):
|
||||
return {"jobs": [{"id": "job-1", "name": "Daily sync"}]}
|
||||
|
||||
def list_approvals(self):
|
||||
return {"approvals": [{"id": "ap-1", "status": "pending"}]}
|
||||
|
||||
|
||||
def test_openclaw_service_routes_are_exposed():
|
||||
app = create_app()
|
||||
paths = {route.path for route in app.routes}
|
||||
|
||||
assert "/health" in paths
|
||||
assert "/api/status" in paths
|
||||
assert "/api/openclaw/status" in paths
|
||||
assert "/api/openclaw/sessions" in paths
|
||||
assert "/api/openclaw/sessions/{session_key:path}" in paths
|
||||
assert "/api/openclaw/sessions/{session_key:path}/history" in paths
|
||||
assert "/api/openclaw/cron" in paths
|
||||
assert "/api/openclaw/approvals" in paths
|
||||
|
||||
|
||||
def test_openclaw_service_read_routes():
|
||||
app = create_app()
|
||||
app.dependency_overrides[openclaw_module.get_openclaw_cli_service] = (
|
||||
lambda: _FakeOpenClawCliService()
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
health = client.get("/health")
|
||||
status = client.get("/api/status")
|
||||
openclaw_status = client.get("/api/openclaw/status")
|
||||
sessions = client.get("/api/openclaw/sessions")
|
||||
session = client.get("/api/openclaw/sessions/main/session-1")
|
||||
history = client.get("/api/openclaw/sessions/main/session-1/history", params={"limit": 5})
|
||||
cron = client.get("/api/openclaw/cron")
|
||||
approvals = client.get("/api/openclaw/approvals")
|
||||
|
||||
assert health.status_code == 200
|
||||
assert health.json()["service"] == "openclaw-service"
|
||||
assert status.status_code == 200
|
||||
assert status.json()["status"] == "operational"
|
||||
assert openclaw_status.status_code == 200
|
||||
assert openclaw_status.json()["runtimeVersion"] == "2026.3.24"
|
||||
assert sessions.status_code == 200
|
||||
assert len(sessions.json()["sessions"]) == 2
|
||||
assert session.status_code == 200
|
||||
assert session.json()["session"]["agentId"] == "main"
|
||||
assert history.status_code == 200
|
||||
assert history.json()["limit"] == 5
|
||||
assert cron.status_code == 200
|
||||
assert cron.json()["jobs"][0]["id"] == "job-1"
|
||||
assert approvals.status_code == 200
|
||||
assert approvals.json()["approvals"][0]["id"] == "ap-1"
|
||||
|
||||
|
||||
def test_openclaw_service_session_404():
|
||||
app = create_app()
|
||||
app.dependency_overrides[openclaw_module.get_openclaw_cli_service] = (
|
||||
lambda: _FakeOpenClawCliService()
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/openclaw/sessions/missing")
|
||||
|
||||
assert response.status_code == 404
|
||||
74
backend/tests/test_openclaw_websocket_client.py
Normal file
74
backend/tests/test_openclaw_websocket_client.py
Normal 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}
|
||||
@@ -4,6 +4,7 @@
|
||||
import pytest
|
||||
|
||||
from shared.client.control_client import ControlPlaneClient
|
||||
from shared.client.openclaw_client import OpenClawServiceClient
|
||||
from shared.client.runtime_client import RuntimeServiceClient
|
||||
|
||||
|
||||
@@ -105,3 +106,25 @@ async def test_runtime_service_client_hits_current_runtime_routes():
|
||||
("get", "/config", None),
|
||||
("put", "/config", {"schedule_mode": "intraday"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openclaw_service_client_hits_current_openclaw_routes():
|
||||
client = OpenClawServiceClient()
|
||||
client._client = _DummyAsyncClient()
|
||||
|
||||
await client.fetch_status()
|
||||
await client.list_sessions()
|
||||
await client.get_session("main/session-1")
|
||||
await client.get_session_history("main/session-1", limit=5)
|
||||
await client.list_cron_jobs()
|
||||
await client.list_approvals()
|
||||
|
||||
assert client._client.calls == [
|
||||
("get", "/status", None),
|
||||
("get", "/sessions", None),
|
||||
("get", "/sessions/main/session-1", None),
|
||||
("get", "/sessions/main/session-1/history", {"limit": 5}),
|
||||
("get", "/cron", None),
|
||||
("get", "/approvals", None),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
121
deploy/README.md
Normal file
121
deploy/README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Deployment Notes
|
||||
|
||||
This directory contains the current production-oriented deployment artifacts for
|
||||
the EvoTraders frontend site and the live gateway process.
|
||||
|
||||
## Contents
|
||||
|
||||
- [deploy/systemd/evotraders.service](./systemd/evotraders.service)
|
||||
- systemd unit for the long-running EvoTraders gateway process
|
||||
- [scripts/run_prod.sh](../scripts/run_prod.sh)
|
||||
- production launch script used by the systemd unit
|
||||
- [deploy/nginx/evotraders.cillinn.com.conf](./nginx/evotraders.cillinn.com.conf)
|
||||
- HTTPS nginx config with WebSocket proxying
|
||||
- [deploy/nginx/evotraders.cillinn.com.http.conf](./nginx/evotraders.cillinn.com.http.conf)
|
||||
- plain HTTP/static-site variant
|
||||
|
||||
## Current Production Shape
|
||||
|
||||
The checked-in production path is intentionally minimal:
|
||||
|
||||
- nginx serves the built frontend from `/var/www/evotraders/current`
|
||||
- nginx proxies `/ws` to `127.0.0.1:8765`
|
||||
- systemd runs `scripts/run_prod.sh`
|
||||
- `scripts/run_prod.sh` starts `python3 -m backend.main` in live mode on `127.0.0.1:8765`
|
||||
|
||||
This means the checked-in production example is centered on the gateway and
|
||||
frontend, not on exposing the split FastAPI services directly.
|
||||
|
||||
## Important Paths And Ports
|
||||
|
||||
- frontend root: `/var/www/evotraders/current`
|
||||
- gateway bind: `127.0.0.1:8765`
|
||||
- public WebSocket path: `/ws`
|
||||
- working directory expected by systemd: `/root/code/evotraders`
|
||||
|
||||
## systemd
|
||||
|
||||
The current systemd unit:
|
||||
|
||||
- uses `WorkingDirectory=/root/code/evotraders`
|
||||
- executes [scripts/run_prod.sh](../scripts/run_prod.sh)
|
||||
- restarts automatically on failure
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/systemd/evotraders.service /etc/systemd/system/evotraders.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable evotraders
|
||||
sudo systemctl start evotraders
|
||||
```
|
||||
|
||||
Check status and logs:
|
||||
|
||||
```bash
|
||||
sudo systemctl status evotraders
|
||||
journalctl -u evotraders -f
|
||||
```
|
||||
|
||||
## nginx
|
||||
|
||||
The HTTPS nginx config does two things:
|
||||
|
||||
- redirects `http://evotraders.cillinn.com` to HTTPS
|
||||
- proxies `/ws` to the local gateway process with WebSocket upgrade headers
|
||||
|
||||
Typical install flow:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx/evotraders.cillinn.com.conf /etc/nginx/sites-available/evotraders.cillinn.com.conf
|
||||
sudo ln -s /etc/nginx/sites-available/evotraders.cillinn.com.conf /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
The checked-in TLS config expects Let's Encrypt assets at:
|
||||
|
||||
- `/etc/letsencrypt/live/evotraders.cillinn.com/fullchain.pem`
|
||||
- `/etc/letsencrypt/live/evotraders.cillinn.com/privkey.pem`
|
||||
|
||||
## Environment Expectations
|
||||
|
||||
Before using the production scripts, ensure the runtime environment has:
|
||||
|
||||
- a usable Python environment
|
||||
- repo dependencies installed
|
||||
- required market/model API keys
|
||||
- any desired `TICKERS` override
|
||||
|
||||
The production script currently sets:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/root/code/evotraders/.pydeps:.
|
||||
TICKERS=${TICKERS:-AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN}
|
||||
```
|
||||
|
||||
It then launches:
|
||||
|
||||
```bash
|
||||
python3 -m backend.main \
|
||||
--mode live \
|
||||
--config-name production \
|
||||
--host 127.0.0.1 \
|
||||
--port 8765 \
|
||||
--trigger-time now \
|
||||
--poll-interval 15
|
||||
```
|
||||
|
||||
## What This Deployment Does Not Yet Cover
|
||||
|
||||
The checked-in deployment artifacts do not currently document or automate:
|
||||
|
||||
- split FastAPI service deployment on `8000` to `8004`
|
||||
- OpenClaw gateway deployment on `18789`
|
||||
- database backup/retention workflows
|
||||
- frontend build/publish steps
|
||||
- secret management
|
||||
|
||||
If you move production fully to split-service mode, update this directory so it
|
||||
documents the new service topology explicitly instead of relying on the gateway-
|
||||
only path.
|
||||
@@ -1,28 +1,116 @@
|
||||
# Compatibility Removal Plan
|
||||
# Compatibility And Migration Status
|
||||
|
||||
This document tracks the remaining migration-only surfaces that still exist
|
||||
after the move to split-first development.
|
||||
This document tracks the remaining migration-related boundaries after the
|
||||
repository switched to split-first development.
|
||||
|
||||
## Migration-only Surfaces
|
||||
## Current Status
|
||||
|
||||
None currently remain as dedicated compatibility wrappers.
|
||||
The repo no longer depends on a combined FastAPI compatibility wrapper for
|
||||
normal local development. The default path is now:
|
||||
|
||||
## Completed Removals
|
||||
`agent_service + trading_service + news_service + runtime_service + gateway`
|
||||
|
||||
That means compatibility is no longer a separate startup mode. What remains is
|
||||
mostly protocol-level and routing-level compatibility while the codebase
|
||||
continues to move responsibilities into clearer service surfaces.
|
||||
|
||||
## What Was Removed
|
||||
|
||||
### `backend.app`
|
||||
|
||||
- Removed after compatibility startup switched to
|
||||
`backend.apps.combined_service:app` directly.
|
||||
- Removed after startup paths switched away from the legacy app wrapper.
|
||||
|
||||
### `backend.apps.combined_service`
|
||||
|
||||
- Removed after split-service startup became the only supported local dev mode.
|
||||
|
||||
### `shared.client.AgentServiceClient`
|
||||
|
||||
- Removed after split-aware clients became the default import surface.
|
||||
- Replacement:
|
||||
- Replaced by:
|
||||
- `ControlPlaneClient`
|
||||
- `RuntimeServiceClient`
|
||||
- `TradingServiceClient`
|
||||
- `NewsServiceClient`
|
||||
|
||||
### `backend.apps.combined_service`
|
||||
## What Still Exists For Compatibility
|
||||
|
||||
- Removed after split-service mode became the only supported dev startup path.
|
||||
These are not legacy wrappers in the old sense, but they still preserve
|
||||
backward-compatible behavior while migration settles.
|
||||
|
||||
### Gateway-mediated flows
|
||||
|
||||
- The WebSocket gateway still carries a mix of:
|
||||
- live runtime feed transport
|
||||
- orchestration
|
||||
- selected read flows that have not been moved to direct browser service calls
|
||||
- This is intentional for now because the frontend still depends on the gateway
|
||||
for event streaming and some compatibility reads.
|
||||
|
||||
### In-process fallbacks
|
||||
|
||||
- Some read paths still support local-module fallback when split-service URLs
|
||||
are not configured.
|
||||
- Relevant variables include:
|
||||
- `TRADING_SERVICE_URL`
|
||||
- `NEWS_SERVICE_URL`
|
||||
- This keeps the app resilient during migration, but it also means behavior can
|
||||
differ depending on env configuration.
|
||||
|
||||
### Dual OpenClaw integration surfaces
|
||||
|
||||
- OpenClaw currently appears through two different shapes:
|
||||
- WebSocket gateway integration on `:18789`
|
||||
- optional REST surface at `backend.apps.openclaw_service` on `:8004`
|
||||
- These are both valid, but they are not the same surface and should not be
|
||||
documented as interchangeable.
|
||||
|
||||
## Remaining Migration Risks
|
||||
|
||||
### Split service deployment is not yet the checked-in production default
|
||||
|
||||
- The repo documents split-service local development clearly.
|
||||
- The checked-in production example still centers on `backend.main` and nginx
|
||||
WebSocket proxying.
|
||||
- This is a topology mismatch to keep in mind when changing deploy docs or prod
|
||||
automation.
|
||||
|
||||
### Environment-dependent routing
|
||||
|
||||
- The frontend and gateway can switch behavior based on configured service URLs.
|
||||
- This is helpful operationally, but it makes debugging more configuration-
|
||||
sensitive than a fully fixed service topology.
|
||||
|
||||
### Runtime/control-plane separation is logical, not fully operationally isolated
|
||||
|
||||
- `runtime_service` owns lifecycle APIs.
|
||||
- `agent_service` owns control-plane APIs.
|
||||
- The gateway still hosts the live runtime orchestration path, so the split is
|
||||
clean at the API level but not yet a completely independent service mesh.
|
||||
|
||||
## Exit Criteria For Declaring Migration Complete
|
||||
|
||||
Migration can be considered effectively complete when all of the following are
|
||||
true:
|
||||
|
||||
1. Production deployment docs and scripts explicitly run the same split-service
|
||||
topology used in development, or intentionally document a different stable
|
||||
production topology.
|
||||
2. Critical read paths no longer require ambiguous fallback behavior to local
|
||||
module implementations.
|
||||
3. OpenClaw integration is documented as a stable contract with clear guidance
|
||||
on when to use the WebSocket gateway versus the REST surface.
|
||||
4. The frontend-service routing model is stable enough that direct-service and
|
||||
gateway-mediated paths are deliberate design choices rather than migration
|
||||
leftovers.
|
||||
|
||||
## Practical Read Of The Current State
|
||||
|
||||
The migration away from combined-service startup is done.
|
||||
|
||||
What remains is not “legacy startup debt”, but:
|
||||
|
||||
- topology clarification
|
||||
- deployment consistency
|
||||
- reduction of env-dependent fallback behavior
|
||||
- sharper documentation around gateway and OpenClaw boundaries
|
||||
|
||||
@@ -20,6 +20,9 @@ MARKET_DB_PATH= #optional path for long-lived market_research.db | 长期市场
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
MODEL_NAME=qwen3-max-preview
|
||||
OPENCLAW_CMD=
|
||||
OPENCLAW_CWD=
|
||||
OPENCLAW_TIMEOUT_SECONDS=15
|
||||
EXPLAIN_ENRICH_USE_LLM=false
|
||||
EXPLAIN_ENRICH_MODEL_PROVIDER=
|
||||
EXPLAIN_ENRICH_MODEL_NAME=
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
## QuickStart
|
||||
## Frontend Quick Start
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Optional Direct Service Calls
|
||||
Default dev URL: `http://localhost:5173`
|
||||
|
||||
The frontend still works with the compatibility backend entrypoint by default.
|
||||
In the current test-stage setup, split services are the recommended default.
|
||||
Point the frontend directly at those standalone services:
|
||||
The frontend expects the EvoTraders gateway WebSocket on `ws://localhost:8765` unless overridden.
|
||||
|
||||
## Recommended Local Backend Stack
|
||||
|
||||
Start the split backend services from the project root:
|
||||
|
||||
```bash
|
||||
./start-dev.sh
|
||||
```
|
||||
|
||||
That gives you:
|
||||
|
||||
- control plane at `http://localhost:8000/api`
|
||||
- trading service at `http://localhost:8001`
|
||||
- news service at `http://localhost:8002`
|
||||
- runtime service at `http://localhost:8003/api/runtime`
|
||||
- gateway WebSocket at `ws://localhost:8765`
|
||||
|
||||
## Frontend Environment Variables
|
||||
|
||||
You can point the frontend directly at those services with:
|
||||
|
||||
```bash
|
||||
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
|
||||
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
||||
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
||||
VITE_TRADING_SERVICE_URL=http://localhost:8001
|
||||
VITE_WS_URL=ws://localhost:8765
|
||||
```
|
||||
|
||||
Current direct-call coverage:
|
||||
There is also a starter template at [frontend/env.template](./env.template).
|
||||
|
||||
- runtime panel + gateway port discovery
|
||||
## Direct-Service Coverage
|
||||
|
||||
Current direct-call coverage includes:
|
||||
|
||||
- runtime panel data loading
|
||||
- gateway port/runtime discovery
|
||||
- `story`
|
||||
- `similar days`
|
||||
- `range explain`
|
||||
- `news for date`
|
||||
- `news categories`
|
||||
- selected trading reads such as price history and insider trades
|
||||
|
||||
If these variables are not set, the frontend falls back to the existing
|
||||
WebSocket-driven compatibility flow.
|
||||
If these variables are not set, the frontend falls back to local defaults and compatibility paths where they still exist.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAgentStore } from './store/agentStore';
|
||||
import { useMarketStore } from './store/marketStore';
|
||||
import { usePortfolioStore } from './store/portfolioStore';
|
||||
import { useRuntimeStore } from './store/runtimeStore';
|
||||
import { useOpenClawStore } from './store/openclawStore';
|
||||
import { useUIStore } from './store/uiStore';
|
||||
|
||||
const EDITABLE_AGENT_WORKSPACE_FILES = [
|
||||
@@ -141,6 +142,11 @@ export default function LiveTradingApp() {
|
||||
addSystemMessage,
|
||||
});
|
||||
|
||||
// Make clientRef available to OpenClaw panel via store
|
||||
useEffect(() => {
|
||||
useOpenClawStore.getState().setClientRef(clientRef);
|
||||
}, [clientRef]);
|
||||
|
||||
const runtimeControls = useRuntimeControls({
|
||||
clientRef,
|
||||
currentTickers: tickers,
|
||||
@@ -228,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)
|
||||
|
||||
@@ -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 ? (
|
||||
<img
|
||||
src={modelInfo.logoPath}
|
||||
{agent.modelName || modelInfo.logoPath ? (
|
||||
<LobeModelLogo
|
||||
model={agent.modelName}
|
||||
provider={agent.modelProvider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
size={36}
|
||||
type="color"
|
||||
shape="square"
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<div className="custom-select-value">
|
||||
{currentSelection.modelInfo?.logoPath && (
|
||||
<img
|
||||
src={currentSelection.modelInfo.logoPath}
|
||||
alt={currentSelection.modelInfo.provider}
|
||||
{(currentSelection.agentInfo?.modelName || currentSelection.modelInfo?.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={currentSelection.agentInfo?.modelName}
|
||||
provider={currentSelection.agentInfo?.modelProvider}
|
||||
fallbackSrc={currentSelection.modelInfo?.logoPath}
|
||||
alt={currentSelection.modelInfo?.provider}
|
||||
size={18}
|
||||
className="select-model-icon"
|
||||
shape="square"
|
||||
type="color"
|
||||
/>
|
||||
)}
|
||||
<span>{currentSelection.label}</span>
|
||||
@@ -223,11 +229,16 @@ const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref)
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{modelInfo?.logoPath && (
|
||||
<img
|
||||
src={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
{(agentInfo?.modelName || modelInfo?.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={agentInfo?.modelName}
|
||||
provider={agentInfo?.modelProvider}
|
||||
fallbackSrc={modelInfo?.logoPath}
|
||||
alt={modelInfo?.provider}
|
||||
size={18}
|
||||
className="select-model-icon"
|
||||
shape="square"
|
||||
type="color"
|
||||
/>
|
||||
)}
|
||||
<span>{agent}</span>
|
||||
@@ -363,16 +374,16 @@ function ConferenceMessage({ message, getAgentModelInfo }) {
|
||||
return (
|
||||
<div className="conf-message-item">
|
||||
<div className="conf-agent-name" style={{ color: agentColors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
||||
{modelInfo.logoPath && (
|
||||
<img
|
||||
src={modelInfo.logoPath}
|
||||
{(agentModelData.modelName || modelInfo.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={agentModelData.modelName}
|
||||
provider={agentModelData.modelProvider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
size={20}
|
||||
shape="circle"
|
||||
type="color"
|
||||
style={{ borderRadius: '50%' }}
|
||||
/>
|
||||
)}
|
||||
{message.agent}
|
||||
@@ -591,16 +602,16 @@ function MessageItem({ message, itemId, isHighlighted, getAgentModelInfo }) {
|
||||
>
|
||||
<div className="feed-item-header">
|
||||
<span className="feed-item-title" style={{ color: colors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
||||
{modelInfo.logoPath && message.agent !== 'Memory' && (
|
||||
<img
|
||||
src={modelInfo.logoPath}
|
||||
{message.agent !== 'Memory' && (agentModelData.modelName || modelInfo.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={agentModelData.modelName}
|
||||
provider={agentModelData.modelProvider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
size={20}
|
||||
shape="circle"
|
||||
type="color"
|
||||
style={{ borderRadius: '50%' }}
|
||||
/>
|
||||
)}
|
||||
{title}
|
||||
|
||||
@@ -14,6 +14,7 @@ const AgentFeed = lazy(() => import('./AgentFeed'));
|
||||
const StatisticsView = lazy(() => import('./StatisticsView'));
|
||||
const StockExplainView = lazy(() => import('./StockExplainView.jsx'));
|
||||
const TraderView = lazy(() => import('./TraderView.jsx'));
|
||||
const OpenClawView = lazy(() => import('./OpenClawView.jsx'));
|
||||
|
||||
function ViewLoadingFallback({ label = '加载中...' }) {
|
||||
return (
|
||||
@@ -171,7 +172,8 @@ export default function AppShell({
|
||||
const base = `view-slider-five ${currentView === 'traders' ? 'show-traders' :
|
||||
currentView === 'room' ? 'show-room' :
|
||||
currentView === 'explain' ? 'show-explain' :
|
||||
currentView === 'statistics' ? 'show-statistics' : 'show-chart'}`;
|
||||
currentView === 'chart' ? 'show-chart' :
|
||||
currentView === 'statistics' ? 'show-statistics' : 'show-openclaw'}`;
|
||||
return base;
|
||||
}, [currentView]);
|
||||
|
||||
@@ -382,6 +384,12 @@ export default function AppShell({
|
||||
>
|
||||
统计
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'openclaw' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('openclaw')}
|
||||
>
|
||||
OpenClaw
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={viewClassName}>
|
||||
@@ -485,6 +493,13 @@ export default function AppShell({
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* OpenClaw View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载 OpenClaw 视图..." />}>
|
||||
<OpenClawView />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
78
frontend/src/components/LobeModelLogo.jsx
Normal file
78
frontend/src/components/LobeModelLogo.jsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1085
frontend/src/components/OpenClawStatus.jsx
Normal file
1085
frontend/src/components/OpenClawStatus.jsx
Normal file
File diff suppressed because it is too large
Load Diff
5
frontend/src/components/OpenClawView.jsx
Normal file
5
frontend/src/components/OpenClawView.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { OpenClawStatus } from './OpenClawStatus';
|
||||
|
||||
export default function OpenClawView() {
|
||||
return <OpenClawStatus />;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { ASSETS, SCENE_NATIVE, AGENT_SEATS, AGENTS } from '../config/constants';
|
||||
import AgentCard from './AgentCard';
|
||||
import { getModelIcon } from '../utils/modelIcons';
|
||||
import LobeModelLogo from './LobeModelLogo.jsx';
|
||||
|
||||
/**
|
||||
* Custom hook to load an image
|
||||
@@ -518,21 +519,23 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile
|
||||
{medal}
|
||||
</span>
|
||||
)}
|
||||
{modelInfo.logoPath && (
|
||||
<img
|
||||
src={modelInfo.logoPath}
|
||||
{(agentData?.modelName || modelInfo.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={agentData?.modelName}
|
||||
provider={agentData?.modelProvider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
size={25}
|
||||
shape="circle"
|
||||
type="color"
|
||||
className="agent-model-badge"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -12,
|
||||
width: 25,
|
||||
height: 25,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid #ffffff',
|
||||
background: '#ffffff',
|
||||
objectFit: 'contain',
|
||||
padding: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'none'
|
||||
@@ -642,10 +645,15 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfile
|
||||
|
||||
{/* Agent header with model icon */}
|
||||
<div className="room-bubble-header">
|
||||
{modelInfo.logoPath && (
|
||||
<img
|
||||
src={modelInfo.logoPath}
|
||||
{(agentData?.modelName || modelInfo.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={agentData?.modelName}
|
||||
provider={agentData?.modelProvider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
size={18}
|
||||
shape="circle"
|
||||
type="color"
|
||||
className="bubble-model-icon"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import JSZip from 'jszip';
|
||||
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
|
||||
import LobeModelLogo from './LobeModelLogo.jsx';
|
||||
|
||||
export default function TraderView({
|
||||
agents,
|
||||
@@ -127,7 +128,7 @@ export default function TraderView({
|
||||
padding: '18px',
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #f4f7fb 100%)',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
||||
gridTemplateRows: 'auto auto 1fr',
|
||||
gap: 18
|
||||
}}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
@@ -138,13 +139,16 @@ export default function TraderView({
|
||||
聚焦查看每个 Agent 的模型、工具组、技能编排和工作区记忆,不展示交易表现数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '120px minmax(0, 1fr)',
|
||||
gap: 16,
|
||||
alignItems: 'stretch',
|
||||
minHeight: 0
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Left: agent avatar list */}
|
||||
<div style={{
|
||||
border: '1px solid #D9E0E7',
|
||||
borderRadius: 14,
|
||||
@@ -202,6 +206,7 @@ export default function TraderView({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: agent detail content */}
|
||||
<div style={{
|
||||
border: '1px solid #D9E0E7',
|
||||
borderRadius: 14,
|
||||
@@ -245,13 +250,16 @@ export default function TraderView({
|
||||
alignItems: 'center',
|
||||
gap: 10
|
||||
}}>
|
||||
{modelInfo.logoPath && (
|
||||
<img
|
||||
src={modelInfo.logoPath}
|
||||
<LobeModelLogo
|
||||
model={profile.model_name}
|
||||
provider={profile.model_provider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
style={{ width: 26, height: 26, borderRadius: 999 }}
|
||||
size={26}
|
||||
shape="circle"
|
||||
type="color"
|
||||
style={{ borderRadius: 999 }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'grid', gap: 2 }}>
|
||||
<div style={{ fontSize: 11, color: '#4B5563', fontWeight: 700 }}>模型</div>
|
||||
<div style={{ fontSize: 12, color: '#111111', fontWeight: 800 }}>
|
||||
@@ -265,7 +273,8 @@ export default function TraderView({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(300px, 420px) minmax(0, 1fr)',
|
||||
gap: 16,
|
||||
alignItems: 'start'
|
||||
alignItems: 'start',
|
||||
minHeight: 0
|
||||
}}>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{
|
||||
|
||||
356
frontend/src/hooks/useOpenClawPanel.js
Normal file
356
frontend/src/hooks/useOpenClawPanel.js
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useCallback } from "react";
|
||||
import { useOpenClawStore } from "../store/openclawStore";
|
||||
|
||||
const RETRY_DELAY_MS = 250;
|
||||
|
||||
function sendWithRetry(clientRef, payload, retries = 3) {
|
||||
const attemptSend = (remaining) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) return false;
|
||||
const sent = client.send(typeof payload === "string" ? payload : JSON.stringify(payload));
|
||||
if (sent || remaining <= 0) return sent;
|
||||
window.setTimeout(() => attemptSend(remaining - 1), RETRY_DELAY_MS);
|
||||
return false;
|
||||
};
|
||||
return attemptSend(retries);
|
||||
}
|
||||
|
||||
export function useOpenClawPanel() {
|
||||
// Access store state directly — do NOT destructure store as a useCallback dep
|
||||
// or every store update will recreate all callbacks and trigger infinite loops.
|
||||
const getStore = () => useOpenClawStore.getState();
|
||||
|
||||
const requestStatus = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setStatusLoading(true);
|
||||
store.setStatusError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_status" });
|
||||
}, []);
|
||||
|
||||
const requestSessions = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSessionsLoading(true);
|
||||
store.setSessionsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_sessions" });
|
||||
}, []);
|
||||
|
||||
const requestSessionDetail = useCallback((sessionKey) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSelectedSessionKey(sessionKey);
|
||||
store.setSessionDetailLoading(true);
|
||||
store.setSessionDetailError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_session_detail", session_key: sessionKey });
|
||||
}, []);
|
||||
|
||||
const requestSessionHistory = useCallback((sessionKey, limit = 20) => {
|
||||
const client = getStore().clientRef?.current;
|
||||
if (!client) return;
|
||||
sendWithRetry({ current: client }, {
|
||||
type: "get_openclaw_session_history",
|
||||
session_key: sessionKey,
|
||||
limit,
|
||||
});
|
||||
}, []);
|
||||
|
||||
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 store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setCronLoading(true);
|
||||
store.setCronError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_cron" });
|
||||
}, []);
|
||||
|
||||
const requestApprovals = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setApprovalsLoading(true);
|
||||
store.setApprovalsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_approvals" });
|
||||
}, []);
|
||||
|
||||
const requestAgents = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setAgentsLoading(true);
|
||||
store.setAgentsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_agents" });
|
||||
}, []);
|
||||
|
||||
const requestAgentsPresence = useCallback(() => {
|
||||
const client = getStore().clientRef?.current;
|
||||
if (!client) return;
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_agents_presence" });
|
||||
}, []);
|
||||
|
||||
const requestSkills = useCallback((agentId = null) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSkillsLoading(true);
|
||||
store.setSkillsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_skills", agent_id: agentId });
|
||||
}, []);
|
||||
|
||||
const requestModels = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setModelsLoading(true);
|
||||
store.setModelsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_models" });
|
||||
}, []);
|
||||
|
||||
const requestHooks = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setHooksLoading(true);
|
||||
store.setHooksError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_hooks" });
|
||||
}, []);
|
||||
|
||||
const requestPlugins = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setPluginsLoading(true);
|
||||
store.setPluginsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_plugins" });
|
||||
}, []);
|
||||
|
||||
const requestSecretsAudit = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSecretsAuditLoading(true);
|
||||
store.setSecretsAuditError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_secrets_audit" });
|
||||
}, []);
|
||||
|
||||
const requestSecurityAudit = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSecurityAuditLoading(true);
|
||||
store.setSecurityAuditError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_security_audit" });
|
||||
}, []);
|
||||
|
||||
const requestDaemonStatus = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setDaemonStatusLoading(true);
|
||||
store.setDaemonStatusError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_daemon_status" });
|
||||
}, []);
|
||||
|
||||
const requestPairing = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setPairingLoading(true);
|
||||
store.setPairingError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_pairing" });
|
||||
}, []);
|
||||
|
||||
const requestQrCode = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setQrCodeLoading(true);
|
||||
store.setQrCodeError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_qr" });
|
||||
}, []);
|
||||
|
||||
const requestUpdateStatus = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setUpdateStatusLoading(true);
|
||||
store.setUpdateStatusError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_update_status" });
|
||||
}, []);
|
||||
|
||||
const requestModelsAliases = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setModelsAliasesLoading(true);
|
||||
store.setModelsAliasesError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_models_aliases" });
|
||||
}, []);
|
||||
|
||||
const requestModelsFallbacks = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setModelsFallbacksLoading(true);
|
||||
store.setModelsFallbacksError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_models_fallbacks" });
|
||||
}, []);
|
||||
|
||||
const requestModelsImageFallbacks = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setModelsImageFallbacksLoading(true);
|
||||
store.setModelsImageFallbacksError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_models_image_fallbacks" });
|
||||
}, []);
|
||||
|
||||
const requestSkillUpdate = useCallback((slug = null, all = false) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSkillUpdateLoading(true);
|
||||
store.setSkillUpdateError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_skill_update", slug, all });
|
||||
}, []);
|
||||
|
||||
const requestWorkspaceFiles = useCallback((workspace) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client || !workspace) return;
|
||||
store.setWorkspaceFilesLoading(true);
|
||||
store.setWorkspaceFilesError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_workspace_files", workspace });
|
||||
}, []);
|
||||
|
||||
const requestWorkspaceFile = useCallback((agent_id, file_name) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client || !agent_id || !file_name) return;
|
||||
console.log("[DEBUG] requestWorkspaceFile:", { type: "get_openclaw_workspace_file", agent_id, file_name });
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_workspace_file", agent_id, file_name });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
requestStatus,
|
||||
requestSessions,
|
||||
requestSessionDetail,
|
||||
requestSessionHistory,
|
||||
resolveSession,
|
||||
createSession,
|
||||
subscribeSession,
|
||||
unsubscribeSession,
|
||||
resetSession,
|
||||
deleteSession,
|
||||
sendSessionMessage,
|
||||
requestCron,
|
||||
requestApprovals,
|
||||
requestAgents,
|
||||
requestAgentsPresence,
|
||||
requestSkills,
|
||||
requestModels,
|
||||
requestHooks,
|
||||
requestPlugins,
|
||||
requestSecretsAudit,
|
||||
requestSecurityAudit,
|
||||
requestDaemonStatus,
|
||||
requestPairing,
|
||||
requestQrCode,
|
||||
requestUpdateStatus,
|
||||
requestModelsAliases,
|
||||
requestModelsFallbacks,
|
||||
requestModelsImageFallbacks,
|
||||
requestSkillUpdate,
|
||||
requestWorkspaceFiles,
|
||||
requestWorkspaceFile,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback } from 'react';
|
||||
import { AGENTS } from '../config/constants';
|
||||
import { ReadOnlyClient } from '../services/websocket';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import { useOpenClawStore } from '../store/openclawStore';
|
||||
import { useMarketStore } from '../store/marketStore';
|
||||
import { usePortfolioStore } from '../store/portfolioStore';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
@@ -65,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.
|
||||
* Manages clientRef, connection, and ALL event handlers.
|
||||
@@ -797,7 +1098,337 @@ export function useWebSocketConnection({
|
||||
|
||||
fast_forward_success: (e) => {
|
||||
console.log(`✅ ${e.message}`);
|
||||
},
|
||||
|
||||
openclaw_status_loaded: (e) => {
|
||||
useOpenClawStore.getState().setOpenclawStatus(e.data || e);
|
||||
useOpenClawStore.getState().setStatusLoading(false);
|
||||
},
|
||||
openclaw_sessions_loaded: (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);
|
||||
},
|
||||
openclaw_session_detail_loaded: (e) => {
|
||||
useOpenClawStore.getState().setOpenclawSessionDetail(e.data || e);
|
||||
useOpenClawStore.getState().setSessionDetailLoading(false);
|
||||
},
|
||||
openclaw_session_history_loaded: (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) => {
|
||||
useOpenClawStore.getState().setOpenclawCronJobs(e.data || e);
|
||||
useOpenClawStore.getState().setCronLoading(false);
|
||||
},
|
||||
openclaw_approvals_loaded: (e) => {
|
||||
useOpenClawStore.getState().setOpenclawApprovals(e.data || e);
|
||||
useOpenClawStore.getState().setApprovalsLoading(false);
|
||||
},
|
||||
openclaw_agents_loaded: (e) => {
|
||||
useOpenClawStore.getState().setAgentsLoading(false);
|
||||
const d = e.data?.data ?? e.data;
|
||||
if (d?.error) {
|
||||
useOpenClawStore.getState().setAgentsError(d.error);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
},
|
||||
openclaw_agents_presence_loaded: (e) => {
|
||||
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) => {
|
||||
useOpenClawStore.getState().setSkillsLoading(false);
|
||||
const d = e.data?.data ?? e.data;
|
||||
if (d?.error) {
|
||||
useOpenClawStore.getState().setSkillsError(d.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setSkills(d?.skills || []);
|
||||
useOpenClawStore.getState().setSkillsError(null);
|
||||
}
|
||||
},
|
||||
openclaw_models_loaded: (e) => {
|
||||
useOpenClawStore.getState().setModelsLoading(false);
|
||||
const d = e.data?.data ?? e.data;
|
||||
if (d?.error) {
|
||||
useOpenClawStore.getState().setModelsError(d.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setModels(d?.models || []);
|
||||
useOpenClawStore.getState().setModelsError(null);
|
||||
}
|
||||
},
|
||||
openclaw_workspace_files_loaded: (e) => {
|
||||
useOpenClawStore.getState().setWorkspaceFilesLoading(false);
|
||||
const d = e.data?.data ?? e.data;
|
||||
const workspace = d?.workspace || "";
|
||||
if (d?.error) {
|
||||
useOpenClawStore.getState().setWorkspaceFilesError(d.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setWorkspaceFiles(workspace, d);
|
||||
useOpenClawStore.getState().setWorkspaceFilesError(null);
|
||||
}
|
||||
},
|
||||
openclaw_workspace_file_loaded: (e) => {
|
||||
const d = e.data?.data ?? e.data;
|
||||
console.log("[DEBUG] workspace_file_loaded:", { d });
|
||||
if (d?.error) return;
|
||||
const agentId = d?.agentId || "main";
|
||||
const fileName = d?.file?.Name || d?.file?.name || "";
|
||||
const key = `${agentId}:${fileName}`;
|
||||
if (d?.file?.missing) {
|
||||
useOpenClawStore.getState().setWorkspaceFileContent(key, "(文件不存在)");
|
||||
} else if (d?.file?.content) {
|
||||
useOpenClawStore.getState().setWorkspaceFileContent(key, d.file.content);
|
||||
}
|
||||
},
|
||||
openclaw_hooks_loaded: (e) => {
|
||||
useOpenClawStore.getState().setHooksLoading(false);
|
||||
const d = e.data?.data ?? e.data;
|
||||
if (d?.error) {
|
||||
useOpenClawStore.getState().setHooksError(d.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setHooks(d?.hooks || []);
|
||||
useOpenClawStore.getState().setHooksError(null);
|
||||
}
|
||||
},
|
||||
openclaw_plugins_loaded: (e) => {
|
||||
useOpenClawStore.getState().setPluginsLoading(false);
|
||||
const d = e.data?.data ?? e.data;
|
||||
if (d?.error) {
|
||||
useOpenClawStore.getState().setPluginsError(d.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setPlugins(d?.plugins || []);
|
||||
useOpenClawStore.getState().setPluginsError(null);
|
||||
}
|
||||
},
|
||||
openclaw_secrets_audit_loaded: (e) => {
|
||||
useOpenClawStore.getState().setSecretsAuditLoading(false);
|
||||
if (e.data?.data?.error) {
|
||||
useOpenClawStore.getState().setSecretsAuditError(e.data.data.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setSecretsAudit(e.data?.data || null);
|
||||
useOpenClawStore.getState().setSecretsAuditError(null);
|
||||
}
|
||||
},
|
||||
openclaw_security_audit_loaded: (e) => {
|
||||
useOpenClawStore.getState().setSecurityAuditLoading(false);
|
||||
if (e.data?.data?.error) {
|
||||
useOpenClawStore.getState().setSecurityAuditError(e.data.data.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setSecurityAudit(e.data?.data || null);
|
||||
useOpenClawStore.getState().setSecurityAuditError(null);
|
||||
}
|
||||
},
|
||||
openclaw_daemon_status_loaded: (e) => {
|
||||
useOpenClawStore.getState().setDaemonStatusLoading(false);
|
||||
if (e.data?.data?.error) {
|
||||
useOpenClawStore.getState().setDaemonStatusError(e.data.data.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setDaemonStatus(e.data?.data || null);
|
||||
useOpenClawStore.getState().setDaemonStatusError(null);
|
||||
}
|
||||
},
|
||||
openclaw_pairing_loaded: (e) => {
|
||||
useOpenClawStore.getState().setPairingLoading(false);
|
||||
if (e.data?.data?.error) {
|
||||
useOpenClawStore.getState().setPairingError(e.data.data.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setPairing(e.data?.data || null);
|
||||
useOpenClawStore.getState().setPairingError(null);
|
||||
}
|
||||
},
|
||||
openclaw_qr_loaded: (e) => {
|
||||
useOpenClawStore.getState().setQrCodeLoading(false);
|
||||
if (e.data?.data?.error) {
|
||||
useOpenClawStore.getState().setQrCodeError(e.data.data.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setQrCode(e.data?.data || null);
|
||||
useOpenClawStore.getState().setQrCodeError(null);
|
||||
}
|
||||
},
|
||||
openclaw_update_status_loaded: (e) => {
|
||||
useOpenClawStore.getState().setUpdateStatusLoading(false);
|
||||
if (e.data?.data?.error) {
|
||||
useOpenClawStore.getState().setUpdateStatusError(e.data.data.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setUpdateStatus(e.data?.data || null);
|
||||
useOpenClawStore.getState().setUpdateStatusError(null);
|
||||
}
|
||||
},
|
||||
openclaw_models_aliases_loaded: (e) => {
|
||||
useOpenClawStore.getState().setModelsAliasesLoading(false);
|
||||
if (e.data?.data?.error) {
|
||||
useOpenClawStore.getState().setModelsAliasesError(e.data.data.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setModelsAliases(e.data?.data || null);
|
||||
useOpenClawStore.getState().setModelsAliasesError(null);
|
||||
}
|
||||
},
|
||||
openclaw_models_fallbacks_loaded: (e) => {
|
||||
useOpenClawStore.getState().setModelsFallbacksLoading(false);
|
||||
if (e.data?.data?.error) {
|
||||
useOpenClawStore.getState().setModelsFallbacksError(e.data.data.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setModelsFallbacks(e.data?.data?.items || []);
|
||||
useOpenClawStore.getState().setModelsFallbacksError(null);
|
||||
}
|
||||
},
|
||||
openclaw_models_image_fallbacks_loaded: (e) => {
|
||||
useOpenClawStore.getState().setModelsImageFallbacksLoading(false);
|
||||
if (e.data?.data?.error) {
|
||||
useOpenClawStore.getState().setModelsImageFallbacksError(e.data.data.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setModelsImageFallbacks(e.data?.data?.items || []);
|
||||
useOpenClawStore.getState().setModelsImageFallbacksError(null);
|
||||
}
|
||||
},
|
||||
openclaw_skill_update_loaded: (e) => {
|
||||
useOpenClawStore.getState().setSkillUpdateLoading(false);
|
||||
if (e.data?.data?.error) {
|
||||
useOpenClawStore.getState().setSkillUpdateError(e.data.data.error);
|
||||
} else {
|
||||
useOpenClawStore.getState().setSkillUpdate(e.data?.data || null);
|
||||
useOpenClawStore.getState().setSkillUpdateError(null);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
354
frontend/src/store/openclawStore.js
Normal file
354
frontend/src/store/openclawStore.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export const useOpenClawStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Raw data
|
||||
openclawStatus: null,
|
||||
openclawSessions: [],
|
||||
openclawSessionsDefaults: null,
|
||||
openclawSessionDetail: null,
|
||||
openclawSessionHistory: [],
|
||||
openclawCronJobs: [],
|
||||
openclawApprovals: [],
|
||||
openclawResolvedSessionKey: null,
|
||||
openclawChatMessagesBySession: {},
|
||||
openclawChatDraftBySession: {},
|
||||
openclawChatSendingBySession: {},
|
||||
openclawSessionSubscriptions: {},
|
||||
|
||||
// Loading states
|
||||
isStatusLoading: false,
|
||||
isSessionsLoading: false,
|
||||
isSessionDetailLoading: false,
|
||||
isCronLoading: false,
|
||||
isApprovalsLoading: false,
|
||||
isChatSending: false,
|
||||
|
||||
// Error states
|
||||
statusError: null,
|
||||
sessionsError: null,
|
||||
sessionDetailError: null,
|
||||
cronError: null,
|
||||
approvalsError: null,
|
||||
chatError: null,
|
||||
|
||||
// Agents state
|
||||
agents: [],
|
||||
agentsLoading: false,
|
||||
agentsError: null,
|
||||
agentsPresence: {},
|
||||
|
||||
// Skills state
|
||||
skills: [],
|
||||
skillsLoading: false,
|
||||
skillsError: null,
|
||||
|
||||
// Models state
|
||||
models: [],
|
||||
modelsLoading: false,
|
||||
modelsError: null,
|
||||
|
||||
// Hooks state
|
||||
hooks: [],
|
||||
hooksLoading: false,
|
||||
hooksError: null,
|
||||
|
||||
// Plugins state
|
||||
plugins: [],
|
||||
pluginsLoading: false,
|
||||
pluginsError: null,
|
||||
|
||||
// Secrets audit state
|
||||
secretsAudit: null,
|
||||
secretsAuditLoading: false,
|
||||
secretsAuditError: null,
|
||||
|
||||
// Security audit state
|
||||
securityAudit: null,
|
||||
securityAuditLoading: false,
|
||||
securityAuditError: null,
|
||||
|
||||
// Daemon status state
|
||||
daemonStatus: null,
|
||||
daemonStatusLoading: false,
|
||||
daemonStatusError: null,
|
||||
|
||||
// Pairing state
|
||||
pairing: null,
|
||||
pairingLoading: false,
|
||||
pairingError: null,
|
||||
|
||||
// QR code state
|
||||
qrCode: null,
|
||||
qrCodeLoading: false,
|
||||
qrCodeError: null,
|
||||
|
||||
// Update status state
|
||||
updateStatus: null,
|
||||
updateStatusLoading: false,
|
||||
updateStatusError: null,
|
||||
|
||||
// Models aliases state
|
||||
modelsAliases: null,
|
||||
modelsAliasesLoading: false,
|
||||
modelsAliasesError: null,
|
||||
|
||||
// Models fallbacks state
|
||||
modelsFallbacks: [],
|
||||
modelsFallbacksLoading: false,
|
||||
modelsFallbacksError: null,
|
||||
|
||||
// Models image fallbacks state
|
||||
modelsImageFallbacks: [],
|
||||
modelsImageFallbacksLoading: false,
|
||||
modelsImageFallbacksError: null,
|
||||
|
||||
// Skill update state
|
||||
skillUpdate: null,
|
||||
skillUpdateLoading: false,
|
||||
skillUpdateError: null,
|
||||
|
||||
// Workspace files state (per agent, keyed by workspace path)
|
||||
workspaceFiles: {},
|
||||
workspaceFilesLoading: false,
|
||||
workspaceFilesError: null,
|
||||
|
||||
// Workspace file content (keyed by "agentId:filename")
|
||||
workspaceFileContent: {},
|
||||
|
||||
// Selected session key for detail/history drill-down
|
||||
selectedSessionKey: null,
|
||||
|
||||
// WebSocket client ref (set by App.jsx on connection)
|
||||
clientRef: null,
|
||||
setClientRef: (ref) => set({ clientRef: ref }),
|
||||
|
||||
// Setters
|
||||
setOpenclawStatus: (data) => set({ openclawStatus: data, statusError: null }),
|
||||
setOpenclawSessions: (data) => set({
|
||||
openclawSessions: data?.sessions || [],
|
||||
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 }),
|
||||
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 }),
|
||||
|
||||
setStatusLoading: (v) => set({ isStatusLoading: v }),
|
||||
setSessionsLoading: (v) => set({ isSessionsLoading: v }),
|
||||
setSessionDetailLoading: (v) => set({ isSessionDetailLoading: v }),
|
||||
setCronLoading: (v) => set({ isCronLoading: v }),
|
||||
setApprovalsLoading: (v) => set({ isApprovalsLoading: v }),
|
||||
|
||||
setStatusError: (e) => set({ statusError: e }),
|
||||
setSessionsError: (e) => set({ sessionsError: e }),
|
||||
setSessionDetailError: (e) => set({ sessionDetailError: e }),
|
||||
setCronError: (e) => set({ cronError: e }),
|
||||
setApprovalsError: (e) => set({ approvalsError: e }),
|
||||
setChatError: (e) => set({ chatError: e }),
|
||||
|
||||
setAgents: (agents) => set({ agents }),
|
||||
setAgentsLoading: (loading) => set({ agentsLoading: loading }),
|
||||
setAgentsError: (error) => set({ agentsError: error }),
|
||||
setAgentsPresence: (presence) => set({ agentsPresence: presence }),
|
||||
setSkills: (skills) => set({ skills }),
|
||||
setSkillsLoading: (loading) => set({ skillsLoading: loading }),
|
||||
setSkillsError: (error) => set({ skillsError: error }),
|
||||
setModels: (models) => set({ models }),
|
||||
setModelsLoading: (loading) => set({ modelsLoading: loading }),
|
||||
setModelsError: (error) => set({ modelsError: error }),
|
||||
|
||||
setHooks: (hooks) => set({ hooks }),
|
||||
setHooksLoading: (loading) => set({ hooksLoading: loading }),
|
||||
setHooksError: (error) => set({ hooksError: error }),
|
||||
setPlugins: (plugins) => set({ plugins }),
|
||||
setPluginsLoading: (loading) => set({ pluginsLoading: loading }),
|
||||
setPluginsError: (error) => set({ pluginsError: error }),
|
||||
setSecretsAudit: (data) => set({ secretsAudit: data }),
|
||||
setSecretsAuditLoading: (loading) => set({ secretsAuditLoading: loading }),
|
||||
setSecretsAuditError: (error) => set({ secretsAuditError: error }),
|
||||
setSecurityAudit: (data) => set({ securityAudit: data }),
|
||||
setSecurityAuditLoading: (loading) => set({ securityAuditLoading: loading }),
|
||||
setSecurityAuditError: (error) => set({ securityAuditError: error }),
|
||||
setDaemonStatus: (data) => set({ daemonStatus: data }),
|
||||
setDaemonStatusLoading: (loading) => set({ daemonStatusLoading: loading }),
|
||||
setDaemonStatusError: (error) => set({ daemonStatusError: error }),
|
||||
setPairing: (data) => set({ pairing: data }),
|
||||
setPairingLoading: (loading) => set({ pairingLoading: loading }),
|
||||
setPairingError: (error) => set({ pairingError: error }),
|
||||
setQrCode: (data) => set({ qrCode: data }),
|
||||
setQrCodeLoading: (loading) => set({ qrCodeLoading: loading }),
|
||||
setQrCodeError: (error) => set({ qrCodeError: error }),
|
||||
setUpdateStatus: (data) => set({ updateStatus: data }),
|
||||
setUpdateStatusLoading: (loading) => set({ updateStatusLoading: loading }),
|
||||
setUpdateStatusError: (error) => set({ updateStatusError: error }),
|
||||
setModelsAliases: (data) => set({ modelsAliases: data }),
|
||||
setModelsAliasesLoading: (loading) => set({ modelsAliasesLoading: loading }),
|
||||
setModelsAliasesError: (error) => set({ modelsAliasesError: error }),
|
||||
setModelsFallbacks: (data) => set({ modelsFallbacks: data }),
|
||||
setModelsFallbacksLoading: (loading) => set({ modelsFallbacksLoading: loading }),
|
||||
setModelsFallbacksError: (error) => set({ modelsFallbacksError: error }),
|
||||
setModelsImageFallbacks: (data) => set({ modelsImageFallbacks: data }),
|
||||
setModelsImageFallbacksLoading: (loading) => set({ modelsImageFallbacksLoading: loading }),
|
||||
setModelsImageFallbacksError: (error) => set({ modelsImageFallbacksError: error }),
|
||||
setSkillUpdate: (data) => set({ skillUpdate: data }),
|
||||
setSkillUpdateLoading: (loading) => set({ skillUpdateLoading: loading }),
|
||||
setSkillUpdateError: (error) => set({ skillUpdateError: error }),
|
||||
|
||||
setWorkspaceFiles: (workspace, data) => set((state) => ({
|
||||
workspaceFiles: { ...state.workspaceFiles, [workspace]: data },
|
||||
})),
|
||||
setWorkspaceFilesLoading: (loading) => set({ workspaceFilesLoading: loading }),
|
||||
setWorkspaceFilesError: (error) => set({ workspaceFilesError: error }),
|
||||
setWorkspaceFileContent: (key, content) => set((state) => ({
|
||||
workspaceFileContent: { ...state.workspaceFileContent, [key]: content },
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "openclaw-store",
|
||||
// Skip persisting ephemeral UI state
|
||||
partialize: (state) => ({
|
||||
// Persist only data, not loading/error/UI states
|
||||
openclawStatus: state.openclawStatus,
|
||||
openclawSessions: state.openclawSessions,
|
||||
openclawCronJobs: state.openclawCronJobs,
|
||||
openclawApprovals: state.openclawApprovals,
|
||||
agents: state.agents,
|
||||
agentsPresence: state.agentsPresence,
|
||||
skills: state.skills,
|
||||
models: state.models,
|
||||
hooks: state.hooks,
|
||||
plugins: state.plugins,
|
||||
secretsAudit: state.secretsAudit,
|
||||
securityAudit: state.securityAudit,
|
||||
daemonStatus: state.daemonStatus,
|
||||
pairing: state.pairing,
|
||||
qrCode: state.qrCode,
|
||||
updateStatus: state.updateStatus,
|
||||
modelsAliases: state.modelsAliases,
|
||||
modelsFallbacks: state.modelsFallbacks,
|
||||
modelsImageFallbacks: state.modelsImageFallbacks,
|
||||
skillUpdate: state.skillUpdate,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -9,7 +9,7 @@ const resolveValue = (updater, currentValue) => (
|
||||
*/
|
||||
export const useUIStore = create((set) => ({
|
||||
// Current view
|
||||
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
|
||||
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'openclaw' | 'runtime'
|
||||
setCurrentView: (currentView) => set((state) => ({ currentView: resolveValue(currentView, state.currentView) })),
|
||||
|
||||
// Chart tab
|
||||
|
||||
@@ -1098,6 +1098,10 @@ export default function GlobalStyles() {
|
||||
transform: translateX(-80%);
|
||||
}
|
||||
|
||||
.view-slider-five.show-openclaw {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.view-panel {
|
||||
flex: 0 0 33.333%;
|
||||
width: 33.333%;
|
||||
|
||||
BIN
frontend/trader-full.png
Normal file
BIN
frontend/trader-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
BIN
frontend/trader-view.png
Normal file
BIN
frontend/trader-view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -282,7 +282,6 @@
|
||||
"trading_days_completed": 0,
|
||||
"server_mode": "live",
|
||||
"is_backtest": false,
|
||||
"is_mock_mode": false,
|
||||
"data_sources": {
|
||||
"preferred": [
|
||||
"yfinance",
|
||||
|
||||
Submodule reference/CoPaw deleted from 934cfce0a7
Submodule reference/Hyper-Alpha-Arena deleted from f137cff476
Submodule reference/PokieTicker deleted from 4fed7755e5
Submodule reference/openclaw updated: 7b151afeeb...f92c92515b
1
reference/openclaw-control-center
Submodule
1
reference/openclaw-control-center
Submodule
Submodule reference/openclaw-control-center added at 473f42bb41
@@ -1,73 +1,160 @@
|
||||
# EvoTraders Services Architecture
|
||||
# EvoTraders Service Surfaces
|
||||
|
||||
This repo is currently in a **migration state** between a modular monolith and
|
||||
fully split services. Service boundaries now exist as dedicated FastAPI app
|
||||
surfaces, and local development now runs those split services directly.
|
||||
This repository is in a split-first state: local development now assumes
|
||||
separate app surfaces and a dedicated WebSocket gateway instead of a single
|
||||
combined backend entrypoint.
|
||||
|
||||
## Current App Surfaces
|
||||
## Service Map
|
||||
|
||||
| App surface | Default port | Responsibility |
|
||||
| Surface | Default port | Role |
|
||||
| --- | --- | --- |
|
||||
| `backend.apps.agent_service` | 8000 | Control-plane only: workspaces, agents, guard. |
|
||||
| `backend.apps.runtime_service` | 8003 | Runtime lifecycle only: `/api/runtime/*`. |
|
||||
| `backend.apps.trading_service` | 8001 | Read-only trading data: prices, financials, insider trades, market status, market cap. |
|
||||
| `backend.apps.news_service` | 8002 | Read-only explain/news data: enriched news, categories, story, similar days, range explain. |
|
||||
| `backend.apps.agent_service` | `8000` | Control plane for workspaces, agents, skills, guard/approvals |
|
||||
| `backend.apps.trading_service` | `8001` | Read-only trading data APIs such as prices, financials, insider trades |
|
||||
| `backend.apps.news_service` | `8002` | Read-only explain/news APIs such as story, similar days, range explain |
|
||||
| `backend.apps.runtime_service` | `8003` | Runtime lifecycle APIs under `/api/runtime/*` |
|
||||
| `backend.apps.openclaw_service` | `8004` | Read-only OpenClaw REST facade |
|
||||
| Gateway (`backend.main`) | `8765` | WebSocket feed, runtime event stream, legacy/compat orchestration path |
|
||||
| OpenClaw Gateway | `18789` | External OpenClaw WebSocket endpoint consumed by EvoTraders gateway |
|
||||
|
||||
## Local Development Modes
|
||||
## What Runs By Default In Dev
|
||||
|
||||
### 1. Split-service mode
|
||||
|
||||
This is now the default development mode.
|
||||
The supported local dev path is:
|
||||
|
||||
```bash
|
||||
./start-dev.sh
|
||||
|
||||
# explicit
|
||||
./start-dev.sh split
|
||||
```
|
||||
|
||||
Run dedicated service surfaces explicitly:
|
||||
That script starts:
|
||||
|
||||
- `agent_service` on `8000`
|
||||
- `trading_service` on `8001`
|
||||
- `news_service` on `8002`
|
||||
- `runtime_service` on `8003`
|
||||
- EvoTraders gateway on `8765`
|
||||
|
||||
It does **not** start `openclaw_service` on `8004`.
|
||||
|
||||
Instead, the gateway expects an OpenClaw WebSocket server to already be
|
||||
available at `ws://localhost:18789` unless you override the OpenClaw gateway
|
||||
configuration outside the script.
|
||||
|
||||
## Manual Startup
|
||||
|
||||
Run split service surfaces explicitly:
|
||||
|
||||
```bash
|
||||
python -m uvicorn backend.apps.agent_service:app --port 8000 --reload
|
||||
python -m uvicorn backend.apps.runtime_service:app --port 8003 --reload
|
||||
python -m uvicorn backend.apps.trading_service:app --port 8001 --reload
|
||||
python -m uvicorn backend.apps.news_service:app --port 8002 --reload
|
||||
python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --reload
|
||||
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
|
||||
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
|
||||
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
|
||||
python -m backend.main --mode live --host 0.0.0.0 --port 8765
|
||||
```
|
||||
|
||||
## Migration Variables
|
||||
Optional OpenClaw REST surface:
|
||||
|
||||
These env vars control whether the app still uses local-module fallbacks or
|
||||
prefers service boundaries:
|
||||
```bash
|
||||
python -m uvicorn backend.apps.openclaw_service:app --host 0.0.0.0 --port 8004 --reload
|
||||
```
|
||||
|
||||
| Variable | Used by | Purpose |
|
||||
## Runtime Responsibilities
|
||||
|
||||
The runtime path is intentionally split:
|
||||
|
||||
- `runtime_service` handles start, stop, restart, current runtime info, logs, and runtime state APIs
|
||||
- `agent_service` handles control-plane reads and writes for agents, workspaces, files, and approvals
|
||||
- `backend.main` / gateway hosts the live WebSocket channel and coordinates market service, scheduler, and pipeline execution
|
||||
|
||||
The practical request path looks like:
|
||||
|
||||
`frontend -> runtime_service/control APIs -> gateway/runtime manager -> market service + pipeline + storage`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend routing preferences
|
||||
|
||||
These variables let the gateway or tools prefer split services over in-process fallbacks:
|
||||
|
||||
| Variable | Used by | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `NEWS_SERVICE_URL` | backend Gateway | Prefer `news-service` for explain/news read paths |
|
||||
| `TRADING_SERVICE_URL` | backend Gateway | Prefer `trading-service` for trading read paths |
|
||||
| `RUNTIME_SERVICE_URL` | reserved | Future runtime/control-plane split follow-up |
|
||||
| `VITE_NEWS_SERVICE_URL` | frontend | Direct browser calls to `news-service` for selected explain paths |
|
||||
| `VITE_TRADING_SERVICE_URL` | frontend | Reserved for future direct trading reads |
|
||||
| `TRADING_SERVICE_URL` | gateway, data tools | Prefer `trading_service` for trading reads |
|
||||
| `NEWS_SERVICE_URL` | gateway, data tools | Prefer `news_service` for explain/news reads |
|
||||
| `RUNTIME_SERVICE_URL` | dev scripts / future follow-up | Reserved for runtime-service-aware flows |
|
||||
| `OPENCLAW_SERVICE_URL` | dev scripts / future follow-up | Points at the OpenClaw gateway origin in current dev setup |
|
||||
|
||||
If these are empty, the repo keeps using local module fallbacks where they still exist.
|
||||
Current `start-dev.sh` defaults:
|
||||
|
||||
## Current Internal Direction
|
||||
```bash
|
||||
TRADING_SERVICE_URL=http://localhost:8001
|
||||
NEWS_SERVICE_URL=http://localhost:8002
|
||||
RUNTIME_SERVICE_URL=http://localhost:8003
|
||||
OPENCLAW_SERVICE_URL=http://localhost:18789
|
||||
```
|
||||
|
||||
The repository is now organized around split service surfaces:
|
||||
Note that `OPENCLAW_SERVICE_URL` currently points at the OpenClaw gateway origin used by the live WebSocket bridge, not the optional REST app on `:8004`.
|
||||
|
||||
### Frontend service targets
|
||||
|
||||
The frontend can directly call split services with:
|
||||
|
||||
```bash
|
||||
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
|
||||
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
||||
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
||||
VITE_TRADING_SERVICE_URL=http://localhost:8001
|
||||
VITE_WS_URL=ws://localhost:8765
|
||||
```
|
||||
|
||||
## Current Frontend Direct-Call Coverage
|
||||
|
||||
Direct browser calls currently cover:
|
||||
|
||||
- runtime panel loading and runtime discovery
|
||||
- story
|
||||
- similar days
|
||||
- range explain
|
||||
- news for date
|
||||
- news categories
|
||||
- selected trading reads such as stock history and insider trades
|
||||
|
||||
Other flows still depend on the gateway WebSocket and control plane APIs.
|
||||
|
||||
## OpenClaw Integration Notes
|
||||
|
||||
There are two separate OpenClaw integration surfaces in this repo:
|
||||
|
||||
- OpenClaw WebSocket gateway on `:18789`
|
||||
- used directly by `backend/services/gateway.py`
|
||||
- this is what `start-dev.sh` assumes exists
|
||||
- `backend.apps.openclaw_service` on `:8004`
|
||||
- optional REST facade over OpenClaw CLI-backed reads
|
||||
- useful for typed client access and service-level testing
|
||||
|
||||
Do not treat those as interchangeable in docs or deployment config.
|
||||
|
||||
## Internal Module Direction
|
||||
|
||||
The codebase is now organized around these boundaries:
|
||||
|
||||
```text
|
||||
frontend
|
||||
├─ runtime/control/news/trading split endpoints
|
||||
└─ selective per-request fallbacks where still retained
|
||||
├─ runtime/control/news/trading API clients
|
||||
└─ WebSocket runtime feed
|
||||
|
||||
backend.apps.agent_service
|
||||
└─ control-plane routes
|
||||
|
||||
backend.apps.runtime_service
|
||||
└─ runtime lifecycle + gateway discovery
|
||||
└─ runtime lifecycle routes
|
||||
|
||||
backend.apps.trading_service
|
||||
└─ read-only trading contract
|
||||
|
||||
backend.apps.news_service
|
||||
└─ read-only explain/news contract
|
||||
|
||||
backend.apps.openclaw_service
|
||||
└─ optional OpenClaw REST facade
|
||||
|
||||
backend.main / backend.services.gateway
|
||||
└─ live orchestration, feed transport, scheduler, runtime coordination
|
||||
```
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
"""Shared client package."""
|
||||
|
||||
from shared.client.control_client import ControlPlaneClient
|
||||
from shared.client.trading_client import TradingServiceClient
|
||||
from shared.client.news_client import NewsServiceClient
|
||||
from shared.client.openclaw_client import OpenClawServiceClient
|
||||
from shared.client.runtime_client import RuntimeServiceClient
|
||||
from shared.client.trading_client import TradingServiceClient
|
||||
|
||||
__all__ = [
|
||||
"ControlPlaneClient",
|
||||
"RuntimeServiceClient",
|
||||
"TradingServiceClient",
|
||||
"NewsServiceClient",
|
||||
"OpenClawServiceClient",
|
||||
]
|
||||
|
||||
415
shared/client/openclaw_client.py
Normal file
415
shared/client/openclaw_client.py
Normal file
@@ -0,0 +1,415 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""OpenClaw service client — typed async wrapper for openclaw-service REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from shared.models.openclaw import (
|
||||
AgentsList,
|
||||
ApprovalRequest,
|
||||
ApprovalsList,
|
||||
CronJob,
|
||||
CronList,
|
||||
DaemonStatus,
|
||||
HookStatusReport,
|
||||
ModelAliasesList,
|
||||
ModelFallbacksList,
|
||||
ModelsList,
|
||||
OpenClawStatus,
|
||||
PairingListResponse,
|
||||
QrCodeResponse,
|
||||
SecretsAuditReport,
|
||||
SecurityAuditResponse,
|
||||
SessionEntry,
|
||||
SessionHistory,
|
||||
SessionsList,
|
||||
SkillStatusReport,
|
||||
SkillUpdateResult,
|
||||
UpdateStatusResponse,
|
||||
normalize_agents,
|
||||
normalize_approvals,
|
||||
normalize_cron_jobs,
|
||||
normalize_daemon_status,
|
||||
normalize_hooks,
|
||||
normalize_model_aliases,
|
||||
normalize_model_fallbacks,
|
||||
normalize_models,
|
||||
normalize_pairing,
|
||||
normalize_qr,
|
||||
normalize_security_audit,
|
||||
normalize_secrets_audit,
|
||||
normalize_session_history,
|
||||
normalize_sessions,
|
||||
normalize_skill_update,
|
||||
normalize_skills,
|
||||
normalize_status,
|
||||
normalize_update_status,
|
||||
normalize_plugins,
|
||||
PluginsList,
|
||||
)
|
||||
|
||||
|
||||
class OpenClawServiceClient:
|
||||
"""Async client for the openclaw-service API surface.
|
||||
|
||||
All methods return typed Pydantic models. The raw JSON dict is
|
||||
accessible via the `.model_dump()` method on each result.
|
||||
|
||||
Example::
|
||||
|
||||
async with OpenClawServiceClient() as client:
|
||||
status = await client.fetch_status()
|
||||
print(status.runtime_version) # typed
|
||||
print(status.model_dump()["runtimeVersion"]) # raw dict
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = "http://localhost:8004/api/openclaw"):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def __aenter__(self) -> "OpenClawServiceClient":
|
||||
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
|
||||
async def fetch_status(self) -> OpenClawStatus:
|
||||
"""GET /status — returns parsed OpenClawStatus model."""
|
||||
response = await self._client.get("/status")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_status(raw)
|
||||
|
||||
async def list_sessions(self) -> SessionsList:
|
||||
"""GET /sessions — returns parsed SessionsList model."""
|
||||
response = await self._client.get("/sessions")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_sessions(raw)
|
||||
|
||||
async def get_session(self, session_key: str) -> SessionEntry:
|
||||
"""GET /sessions/{session_key} — returns parsed SessionEntry model."""
|
||||
response = await self._client.get(f"/sessions/{session_key}")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
session = raw.get("session") or {}
|
||||
return SessionEntry.model_validate(session, strict=False)
|
||||
|
||||
async def get_session_history(self, session_key: str, *, limit: int = 20) -> SessionHistory:
|
||||
"""GET /sessions/{session_key}/history — returns parsed SessionHistory model."""
|
||||
response = await self._client.get(
|
||||
f"/sessions/{session_key}/history",
|
||||
params={"limit": limit},
|
||||
)
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_session_history(raw, session_key=session_key)
|
||||
|
||||
async def list_cron_jobs(self) -> CronList:
|
||||
"""GET /cron — returns parsed CronList model."""
|
||||
response = await self._client.get("/cron")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_cron_jobs(raw)
|
||||
|
||||
async def list_approvals(self) -> ApprovalsList:
|
||||
"""GET /approvals — returns parsed ApprovalsList model."""
|
||||
response = await self._client.get("/approvals")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_approvals(raw)
|
||||
|
||||
async def list_agents(self) -> AgentsList:
|
||||
"""GET /agents — returns parsed AgentsList model."""
|
||||
response = await self._client.get("/agents")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_agents(raw)
|
||||
|
||||
async def list_skills(self) -> SkillStatusReport:
|
||||
"""GET /skills — returns parsed SkillStatusReport model."""
|
||||
response = await self._client.get("/skills")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_skills(raw)
|
||||
|
||||
async def list_models(self) -> ModelsList:
|
||||
"""GET /models — returns parsed ModelsList model."""
|
||||
response = await self._client.get("/models")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_models(raw)
|
||||
|
||||
async def list_hooks(self) -> HookStatusReport:
|
||||
response = await self._client.get("/hooks")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_hooks(raw)
|
||||
|
||||
async def list_plugins(self) -> PluginsList:
|
||||
response = await self._client.get("/plugins")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_plugins(raw)
|
||||
|
||||
async def secrets_audit(self) -> SecretsAuditReport:
|
||||
response = await self._client.get("/secrets-audit")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_secrets_audit(raw)
|
||||
|
||||
async def security_audit(self) -> SecurityAuditResponse:
|
||||
response = await self._client.get("/security-audit")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_security_audit(raw)
|
||||
|
||||
async def daemon_status(self) -> DaemonStatus:
|
||||
response = await self._client.get("/daemon-status")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_daemon_status(raw)
|
||||
|
||||
async def pairing_list(self) -> PairingListResponse:
|
||||
response = await self._client.get("/pairing")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_pairing(raw)
|
||||
|
||||
async def qr_code(self) -> QrCodeResponse:
|
||||
response = await self._client.get("/qr")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_qr(raw)
|
||||
|
||||
async def update_status(self) -> UpdateStatusResponse:
|
||||
response = await self._client.get("/update-status")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_update_status(raw)
|
||||
|
||||
async def list_model_aliases(self) -> ModelAliasesList:
|
||||
response = await self._client.get("/models-aliases")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_model_aliases(raw)
|
||||
|
||||
async def list_model_fallbacks(self) -> ModelFallbacksList:
|
||||
response = await self._client.get("/models-fallbacks")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_model_fallbacks(raw)
|
||||
|
||||
async def list_model_image_fallbacks(self) -> ModelFallbacksList:
|
||||
response = await self._client.get("/models-image-fallbacks")
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_model_fallbacks(raw)
|
||||
|
||||
async def skill_update(self, *, slug: str | None = None, all: bool = False) -> SkillUpdateResult:
|
||||
params = {}
|
||||
if slug is not None:
|
||||
params["slug"] = slug
|
||||
if all:
|
||||
params["all"] = "true"
|
||||
response = await self._client.get("/skill-update", params=params)
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
return normalize_skill_update(raw)
|
||||
|
||||
async def models_status(self, *, probe: bool = False) -> dict[str, Any]:
|
||||
"""GET /models-status — returns parsed models status dict."""
|
||||
params = {"probe": "true"} if probe else {}
|
||||
response = await self._client.get("/models-status", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def channels_status(self, *, probe: bool = False) -> dict[str, Any]:
|
||||
"""GET /channels-status — returns parsed channels status dict."""
|
||||
params = {"probe": "true"} if probe else {}
|
||||
response = await self._client.get("/channels-status", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def channels_list(self) -> dict[str, Any]:
|
||||
"""GET /channels-list — returns parsed channels list dict."""
|
||||
response = await self._client.get("/channels-list")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def hook_info(self, name: str) -> dict[str, Any]:
|
||||
"""GET /hooks/info/{name} — returns parsed hook info dict."""
|
||||
response = await self._client.get(f"/hooks/info/{name}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def hooks_check(self) -> dict[str, Any]:
|
||||
"""GET /hooks/check — returns parsed hooks check dict."""
|
||||
response = await self._client.get("/hooks/check")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def plugins_inspect(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]:
|
||||
"""GET /plugins-inspect — returns parsed plugins inspect dict."""
|
||||
params: dict[str, Any] = {}
|
||||
if all:
|
||||
params["all"] = "true"
|
||||
elif plugin_id:
|
||||
params["plugin_id"] = plugin_id
|
||||
response = await self._client.get("/plugins-inspect", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def agents_bindings(self, *, agent: str | None = None) -> dict[str, Any]:
|
||||
"""GET /agents-bindings — returns parsed agents bindings list dict."""
|
||||
params: dict[str, Any] = {}
|
||||
if agent:
|
||||
params["agent"] = agent
|
||||
response = await self._client.get("/agents-bindings", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def agents_presence(self) -> dict[str, Any]:
|
||||
"""GET /agents/presence — returns runtime session presence for all agents."""
|
||||
response = await self._client.get("/agents/presence")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def workspace_files(self, workspace_path: str) -> dict[str, Any]:
|
||||
"""GET /workspace-files?workspace=<path> — list .md files in a workspace."""
|
||||
response = await self._client.get("/workspace-files", params={"workspace": workspace_path})
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Write agents operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def agents_add(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
workspace: str | None = None,
|
||||
model: str | None = None,
|
||||
agent_dir: str | None = None,
|
||||
bind: list[str] | None = None,
|
||||
non_interactive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""POST /agents/add — create a new agent."""
|
||||
params: dict[str, Any] = {"name": name}
|
||||
if workspace:
|
||||
params["workspace"] = workspace
|
||||
if model:
|
||||
params["model"] = model
|
||||
if agent_dir:
|
||||
params["agent_dir"] = agent_dir
|
||||
if bind:
|
||||
params["bind"] = bind
|
||||
if non_interactive:
|
||||
params["non_interactive"] = "true"
|
||||
response = await self._client.post("/agents/add", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def agents_delete(self, id: str, *, force: bool = False) -> dict[str, Any]:
|
||||
"""POST /agents/delete/{id} — delete an agent."""
|
||||
params: dict[str, Any] = {}
|
||||
if force:
|
||||
params["force"] = "true"
|
||||
response = await self._client.post(f"/agents/delete/{id}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def agents_bind(
|
||||
self,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
bind: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST /agents/bind — add routing bindings to an agent."""
|
||||
params: dict[str, Any] = {}
|
||||
if agent:
|
||||
params["agent"] = agent
|
||||
if bind:
|
||||
params["bind"] = bind
|
||||
response = await self._client.post("/agents/bind", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def agents_unbind(
|
||||
self,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
bind: list[str] | None = None,
|
||||
all: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""POST /agents/unbind — remove routing bindings from an agent."""
|
||||
params: dict[str, Any] = {}
|
||||
if agent:
|
||||
params["agent"] = agent
|
||||
if bind:
|
||||
params["bind"] = bind
|
||||
if all:
|
||||
params["all"] = "true"
|
||||
response = await self._client.post("/agents/unbind", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def agents_set_identity(
|
||||
self,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
workspace: str | None = None,
|
||||
identity_file: str | None = None,
|
||||
name: str | None = None,
|
||||
emoji: str | None = None,
|
||||
theme: str | None = None,
|
||||
avatar: str | None = None,
|
||||
from_identity: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""POST /agents/set-identity — update agent identity."""
|
||||
params: dict[str, Any] = {}
|
||||
if agent:
|
||||
params["agent"] = agent
|
||||
if workspace:
|
||||
params["workspace"] = workspace
|
||||
if identity_file:
|
||||
params["identity_file"] = identity_file
|
||||
if name:
|
||||
params["name"] = name
|
||||
if emoji:
|
||||
params["emoji"] = emoji
|
||||
if theme:
|
||||
params["theme"] = theme
|
||||
if avatar:
|
||||
params["avatar"] = avatar
|
||||
if from_identity:
|
||||
params["from_identity"] = "true"
|
||||
response = await self._client.post("/agents/set-identity", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def gateway_status(self, *, url: str | None = None, token: str | None = None) -> dict[str, Any]:
|
||||
"""GET /gateway-status — returns parsed gateway status dict."""
|
||||
params: dict[str, Any] = {}
|
||||
if url:
|
||||
params["url"] = url
|
||||
if token:
|
||||
params["token"] = token
|
||||
response = await self._client.get("/gateway-status", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def memory_status(self, *, agent: str | None = None, deep: bool = False) -> list[dict[str, Any]]:
|
||||
"""GET /memory-status — returns list of per-agent memory status dicts."""
|
||||
params: dict[str, Any] = {}
|
||||
if agent:
|
||||
params["agent"] = agent
|
||||
if deep:
|
||||
params["deep"] = "true"
|
||||
response = await self._client.get("/memory-status", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
764
shared/client/openclaw_websocket_client.py
Normal file
764
shared/client/openclaw_websocket_client.py
Normal file
@@ -0,0 +1,764 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""OpenClaw Gateway WebSocket client for bidirectional agent communication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default Gateway port
|
||||
DEFAULT_GATEWAY_PORT = 18789
|
||||
DEFAULT_GATEWAY_URL = f"ws://127.0.0.1:{DEFAULT_GATEWAY_PORT}"
|
||||
|
||||
# Protocol version (from protocol/schema/protocol-schemas.ts)
|
||||
PROTOCOL_VERSION = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceIdentity:
|
||||
"""Device identity for Gateway authentication."""
|
||||
device_id: str
|
||||
public_key_pem: bytes
|
||||
private_key_pem: bytes
|
||||
|
||||
@classmethod
|
||||
def load_or_create(cls, identity_dir: Path | None = None) -> "DeviceIdentity":
|
||||
"""Load existing device identity from OpenClaw's identity directory, or create a new one."""
|
||||
if identity_dir is None:
|
||||
identity_dir = Path.home() / ".openclaw" / "identity"
|
||||
|
||||
device_json = identity_dir / "device.json"
|
||||
|
||||
# Check if identity exists in OpenClaw's format
|
||||
if device_json.exists():
|
||||
import json
|
||||
data = json.loads(device_json.read_text())
|
||||
return cls(
|
||||
device_id=data["deviceId"],
|
||||
public_key_pem=data["publicKeyPem"].encode(),
|
||||
private_key_pem=data["privateKeyPem"].encode(),
|
||||
)
|
||||
|
||||
# Fall back to old devices directory format
|
||||
device_dir = Path.home() / ".openclaw" / "devices"
|
||||
id_file = device_dir / "device_id"
|
||||
pubkey_file = device_dir / "device_pubkey.pem"
|
||||
privkey_file = device_dir / "device_privkey.pem"
|
||||
|
||||
if id_file.exists() and pubkey_file.exists() and privkey_file.exists():
|
||||
device_id = id_file.read_text().strip()
|
||||
public_key_pem = pubkey_file.read_bytes()
|
||||
private_key_pem = privkey_file.read_bytes()
|
||||
return cls(
|
||||
device_id=device_id,
|
||||
public_key_pem=public_key_pem,
|
||||
private_key_pem=private_key_pem,
|
||||
)
|
||||
|
||||
# Generate new identity (Ed25519, matching OpenClaw's approach)
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
public_key = private_key.public_key()
|
||||
|
||||
# Derive device ID from public key (SHA256 hash)
|
||||
import hashlib
|
||||
public_key_raw = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
device_id = hashlib.sha256(public_key_raw).hexdigest()
|
||||
public_key_pem = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
private_key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
# Save to disk
|
||||
device_dir.mkdir(parents=True, exist_ok=True)
|
||||
id_file.write_text(device_id)
|
||||
pubkey_file.write_bytes(public_key_pem)
|
||||
privkey_file.write_bytes(private_key_pem)
|
||||
|
||||
logger.info(f"Created new device identity: {device_id}")
|
||||
return cls(
|
||||
device_id=device_id,
|
||||
public_key_pem=public_key_pem,
|
||||
private_key_pem=private_key_pem,
|
||||
)
|
||||
|
||||
def sign(self, payload: str) -> tuple[int, int]:
|
||||
"""Sign a payload (for ECDSA keys)."""
|
||||
private_key = serialization.load_pem_private_key(
|
||||
self.private_key_pem, password=None, backend=default_backend()
|
||||
)
|
||||
signature = private_key.sign(payload.encode(), ec.ECDSA(hashes.SHA256()))
|
||||
r, s = decode_dss_signature(signature)
|
||||
return r, s
|
||||
|
||||
def sign_base64url(self, payload: str) -> str:
|
||||
"""Sign payload and return base64url encoded signature (matches TypeScript crypto.sign)."""
|
||||
import base64
|
||||
private_key = serialization.load_pem_private_key(
|
||||
self.private_key_pem, password=None, backend=default_backend()
|
||||
)
|
||||
# Ed25519 signing (used by OpenClaw)
|
||||
sig = private_key.sign(payload.encode())
|
||||
return base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayHello:
|
||||
"""Gateway hello response after connection."""
|
||||
protocol: int
|
||||
server_version: str
|
||||
conn_id: str
|
||||
methods: list[str]
|
||||
events: list[str]
|
||||
device_token: str | None = None
|
||||
role: str | None = None
|
||||
scopes: list[str] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageEvent:
|
||||
"""Incoming message event from agent."""
|
||||
event: str
|
||||
payload: dict[str, Any]
|
||||
seq: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendResult:
|
||||
"""Result of sending a message."""
|
||||
message_id: str
|
||||
session_key: str
|
||||
ok: bool
|
||||
|
||||
|
||||
class OpenClawWebSocketClient:
|
||||
"""WebSocket client for OpenClaw Gateway.
|
||||
|
||||
Supports:
|
||||
- Device authentication
|
||||
- Send messages to agents via sessions.send
|
||||
- Receive real-time responses via event subscription
|
||||
- Session management
|
||||
|
||||
Example usage:
|
||||
async with OpenClawWebSocketClient() as client:
|
||||
await client.connect()
|
||||
result = await client.send_message(session_key, "Hello agent!")
|
||||
async for event in client.subscribe(session_key):
|
||||
print(event)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = DEFAULT_GATEWAY_URL,
|
||||
gateway_token: str | None = None,
|
||||
device_identity: DeviceIdentity | None = None,
|
||||
client_name: str = "cli", # Must be a valid GatewayClientId (cli, gateway-client, etc)
|
||||
client_version: str = "1.0.0",
|
||||
timeout_ms: int = 30000,
|
||||
):
|
||||
self.url = url
|
||||
self.gateway_token = gateway_token or self._load_gateway_token()
|
||||
self.device_identity = device_identity
|
||||
self.client_name = client_name
|
||||
self.client_version = client_version
|
||||
self.timeout_ms = timeout_ms
|
||||
|
||||
self._ws: websockets.WebSocketClientProtocol | None = None
|
||||
self._hello: GatewayHello | None = None
|
||||
self._pending: dict[str, asyncio.Future] = {}
|
||||
self._event_handlers: list[Callable[[MessageEvent], None]] = []
|
||||
self._recv_task: asyncio.Task | None = None
|
||||
self._nonce: str | None = None
|
||||
self._connected = False
|
||||
|
||||
@staticmethod
|
||||
def _load_gateway_token() -> str | None:
|
||||
"""Load gateway token from ~/.openclaw/openclaw.json."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
token_file = Path.home() / ".openclaw" / "openclaw.json"
|
||||
if token_file.exists():
|
||||
data = json.loads(token_file.read_text())
|
||||
return data.get("gateway", {}).get("auth", {}).get("token")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._ws is not None
|
||||
|
||||
async def __aenter__(self) -> "OpenClawWebSocketClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
await self.disconnect()
|
||||
|
||||
async def connect(self) -> GatewayHello:
|
||||
"""Connect to the Gateway and complete authentication handshake."""
|
||||
if self._connected:
|
||||
return self._hello
|
||||
|
||||
# Load or create device identity
|
||||
if self.device_identity is None:
|
||||
self.device_identity = DeviceIdentity.load_or_create()
|
||||
|
||||
logger.info(f"Connecting to OpenClaw Gateway at {self.url}")
|
||||
|
||||
self._ws = await websockets.connect(
|
||||
self.url,
|
||||
max_size=25 * 1024 * 1024, # 25MB max payload
|
||||
)
|
||||
|
||||
# Start receive loop
|
||||
self._recv_task = asyncio.create_task(self._recv_loop())
|
||||
|
||||
# Wait for connect.challenge
|
||||
challenge = await self._wait_for_event("connect.challenge")
|
||||
self._nonce = challenge.payload.get("nonce")
|
||||
|
||||
# Build connect params
|
||||
connect_params = self._build_connect_params()
|
||||
|
||||
# Debug: log connect params
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger.debug(f"Connect params: {connect_params}")
|
||||
|
||||
# Send connect request and wait for hello-ok
|
||||
hello_event = await self._send_request("connect", connect_params, _allow_handshake=True)
|
||||
self._hello = GatewayHello(
|
||||
protocol=hello_event["protocol"],
|
||||
server_version=hello_event["server"]["version"],
|
||||
conn_id=hello_event["server"]["connId"],
|
||||
methods=hello_event["features"]["methods"],
|
||||
events=hello_event["features"]["events"],
|
||||
device_token=hello_event.get("auth", {}).get("deviceToken"),
|
||||
role=hello_event.get("auth", {}).get("role"),
|
||||
scopes=hello_event.get("auth", {}).get("scopes"),
|
||||
)
|
||||
|
||||
self._connected = True
|
||||
logger.info(f"Connected to OpenClaw Gateway v{self._hello.server_version}")
|
||||
logger.info(f"Supported methods: {self._hello.methods}")
|
||||
|
||||
return self._hello
|
||||
|
||||
def _build_connect_params(self) -> dict[str, Any]:
|
||||
"""Build connect parameters with device authentication.
|
||||
|
||||
Implements V3 device auth payload format:
|
||||
v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
|
||||
"""
|
||||
import base64
|
||||
|
||||
# Load public key - use Raw format for Ed25519 (32 bytes)
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
private_key = serialization.load_pem_private_key(
|
||||
self.device_identity.private_key_pem, password=None, backend=default_backend()
|
||||
)
|
||||
if isinstance(private_key, ed25519.Ed25519PrivateKey):
|
||||
public_key = private_key.public_key()
|
||||
public_key_raw = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
else:
|
||||
# ECDSA: use SPKI format
|
||||
public_key = serialization.load_pem_public_key(
|
||||
self.device_identity.public_key_pem, backend=default_backend()
|
||||
)
|
||||
public_key_raw = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
public_key_b64 = base64.urlsafe_b64encode(public_key_raw).rstrip(b"=").decode()
|
||||
|
||||
# Build auth payload for signing (V3 format)
|
||||
signed_at_ms = int(time.time() * 1000)
|
||||
scopes = "operator.admin,operator.approvals,operator.pairing,operator.read,operator.write"
|
||||
token = self.gateway_token or ""
|
||||
platform = "darwin"
|
||||
device_family = ""
|
||||
|
||||
# V3 payload: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
|
||||
auth_payload = "|".join([
|
||||
"v3",
|
||||
self.device_identity.device_id,
|
||||
self.client_name, # clientId
|
||||
"backend", # clientMode
|
||||
"operator", # role
|
||||
scopes,
|
||||
str(signed_at_ms),
|
||||
token,
|
||||
self._nonce or "",
|
||||
platform,
|
||||
device_family,
|
||||
])
|
||||
|
||||
signature_b64 = self.device_identity.sign_base64url(auth_payload)
|
||||
|
||||
params = {
|
||||
"minProtocol": PROTOCOL_VERSION,
|
||||
"maxProtocol": PROTOCOL_VERSION,
|
||||
"client": {
|
||||
"id": self.client_name,
|
||||
"version": self.client_version,
|
||||
"platform": platform,
|
||||
"mode": "backend",
|
||||
},
|
||||
"device": {
|
||||
"id": self.device_identity.device_id,
|
||||
"publicKey": public_key_b64,
|
||||
"signature": signature_b64,
|
||||
"signedAt": signed_at_ms,
|
||||
"nonce": self._nonce,
|
||||
},
|
||||
"auth": {
|
||||
"token": token or None,
|
||||
},
|
||||
"role": "operator",
|
||||
"scopes": scopes.split(","),
|
||||
}
|
||||
|
||||
# Debug output
|
||||
print(f"DEBUG: nonce={self._nonce}", file=sys.stderr)
|
||||
print(f"DEBUG: auth_payload={auth_payload}", file=sys.stderr)
|
||||
print(f"DEBUG: connect params = {json.dumps(params, indent=2)}", file=sys.stderr)
|
||||
|
||||
return params
|
||||
|
||||
async def _recv_loop(self) -> None:
|
||||
"""Receive and dispatch incoming messages."""
|
||||
try:
|
||||
async for raw in self._ws:
|
||||
if raw is None:
|
||||
break
|
||||
await self._handle_frame(json.loads(raw))
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Receive loop error: {e}")
|
||||
finally:
|
||||
# Clean up pending futures
|
||||
for future in self._pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(Exception("Connection closed"))
|
||||
self._pending.clear()
|
||||
self._connected = False
|
||||
|
||||
async def _handle_frame(self, frame: dict[str, Any]) -> None:
|
||||
"""Handle incoming frame."""
|
||||
frame_type = frame.get("type")
|
||||
|
||||
if frame_type == "event":
|
||||
event_name = frame.get("event", "")
|
||||
payload = frame.get("payload", {})
|
||||
seq = frame.get("seq")
|
||||
|
||||
event = MessageEvent(event=event_name, payload=payload, seq=seq)
|
||||
|
||||
# Handle connect challenge
|
||||
if event_name == "connect.challenge":
|
||||
nonce = payload.get("nonce")
|
||||
if nonce:
|
||||
self._nonce = nonce
|
||||
challenge_event = MessageEvent(event=event_name, payload={"nonce": nonce}, seq=seq)
|
||||
for handler in self._event_handlers:
|
||||
try:
|
||||
handler(challenge_event)
|
||||
except Exception as e:
|
||||
logger.error(f"Event handler error: {e}")
|
||||
|
||||
# Notify event handlers
|
||||
for handler in self._event_handlers:
|
||||
try:
|
||||
handler(event)
|
||||
except Exception as e:
|
||||
logger.error(f"Event handler error: {e}")
|
||||
|
||||
elif frame_type == "res":
|
||||
req_id = frame.get("id")
|
||||
if req_id in self._pending:
|
||||
future = self._pending.pop(req_id)
|
||||
if frame.get("ok"):
|
||||
future.set_result(frame.get("payload", {}))
|
||||
else:
|
||||
error = frame.get("error", {})
|
||||
future.set_exception(Exception(f"{error.get('code', 'ERROR')}: {error.get('message', 'Unknown error')}"))
|
||||
|
||||
async def _wait_for_event(self, event_name: str, timeout_ms: int | None = None) -> MessageEvent:
|
||||
"""Wait for a specific event."""
|
||||
future: asyncio.Future = asyncio.Future()
|
||||
timeout = timeout_ms or self.timeout_ms
|
||||
|
||||
def handler(event: MessageEvent) -> None:
|
||||
if event.event == event_name:
|
||||
if not future.done():
|
||||
future.set_result(event)
|
||||
|
||||
self._event_handlers.append(handler)
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout / 1000)
|
||||
finally:
|
||||
self._event_handlers.remove(handler)
|
||||
|
||||
async def _send_request(self, method: str, params: dict[str, Any] | None = None, _allow_handshake: bool = False) -> dict[str, Any]:
|
||||
"""Send a request and wait for response.
|
||||
|
||||
Args:
|
||||
method: The RPC method name
|
||||
params: Optional parameters for the method
|
||||
_allow_handshake: If True, allow sending even during handshake (for connect method)
|
||||
"""
|
||||
if not self._ws:
|
||||
raise Exception("Not connected to Gateway")
|
||||
if not self._connected and not _allow_handshake:
|
||||
raise Exception("Not connected to Gateway")
|
||||
|
||||
req_id = str(uuid.uuid4())
|
||||
frame = {"type": "req", "id": req_id, "method": method}
|
||||
if params:
|
||||
frame["params"] = params
|
||||
|
||||
future: asyncio.Future = asyncio.Future()
|
||||
self._pending[req_id] = future
|
||||
|
||||
await self._ws.send(json.dumps(frame))
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(future, self.timeout_ms / 1000)
|
||||
except asyncio.TimeoutError:
|
||||
self._pending.pop(req_id, None)
|
||||
raise TimeoutError(f"Request {method} timed out after {self.timeout_ms}ms")
|
||||
|
||||
async def call_method(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Call any RPC method on the Gateway.
|
||||
|
||||
Args:
|
||||
method: The RPC method name (e.g., "sessions.list", "agents.list")
|
||||
params: Optional parameters for the method
|
||||
|
||||
Returns:
|
||||
The response payload from the Gateway
|
||||
"""
|
||||
return await self._send_request(method, params)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the Gateway."""
|
||||
self._connected = False
|
||||
|
||||
if self._recv_task:
|
||||
self._recv_task.cancel()
|
||||
try:
|
||||
await self._recv_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self._ws:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
|
||||
self._hello = None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Session operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def list_sessions(
|
||||
self,
|
||||
limit: int = 50,
|
||||
agent_id: str | None = None,
|
||||
include_last_message: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List active sessions."""
|
||||
params: dict[str, Any] = {"limit": limit, "includeLastMessage": include_last_message}
|
||||
if agent_id:
|
||||
params["agentId"] = agent_id
|
||||
|
||||
result = await self._send_request("sessions.list", params)
|
||||
return result.get("sessions", [])
|
||||
|
||||
async def resolve_session(
|
||||
self,
|
||||
agent_id: str | None = None,
|
||||
label: str | None = None,
|
||||
channel: str | None = None,
|
||||
include_global: bool = True,
|
||||
) -> str | None:
|
||||
"""Resolve a session key by agent and optional channel."""
|
||||
params: dict[str, Any] = {"includeGlobal": include_global}
|
||||
if agent_id:
|
||||
params["agentId"] = agent_id
|
||||
if label:
|
||||
params["label"] = label
|
||||
if channel:
|
||||
params["channel"] = channel
|
||||
|
||||
result = await self._send_request("sessions.resolve", params)
|
||||
key = result.get("key")
|
||||
if isinstance(key, str) and key.strip():
|
||||
return key.strip()
|
||||
return None
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
session_key: str,
|
||||
message: str,
|
||||
thinking: str | None = None,
|
||||
timeout_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a message to an agent session.
|
||||
|
||||
Args:
|
||||
session_key: The session key (format: agentId:channelId:accountId:conversationId)
|
||||
message: The message text to send
|
||||
thinking: Optional thinking/reasoning to include
|
||||
timeout_ms: Timeout for the request
|
||||
|
||||
Returns:
|
||||
The response payload containing message ID and result
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
"key": session_key,
|
||||
"message": message,
|
||||
}
|
||||
if thinking:
|
||||
params["thinking"] = thinking
|
||||
|
||||
previous_timeout_ms = self.timeout_ms
|
||||
if timeout_ms is not None:
|
||||
self.timeout_ms = timeout_ms
|
||||
try:
|
||||
return await self._send_request("sessions.send", params)
|
||||
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:
|
||||
"""Subscribe to messages from a session.
|
||||
|
||||
Usage:
|
||||
async for event in client.subscribe(session_key):
|
||||
print(f"Event: {event.event}", event.payload)
|
||||
|
||||
Args:
|
||||
session_key: The session key to subscribe to
|
||||
|
||||
Returns:
|
||||
AsyncIterator of MessageEvents
|
||||
"""
|
||||
# First subscribe to the session
|
||||
await self._send_request("sessions.messages.subscribe", {"key": session_key})
|
||||
|
||||
return AsyncMessageIterator(self, session_key)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Agent operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def list_agents(self) -> list[dict[str, Any]]:
|
||||
"""List configured agents."""
|
||||
result = await self._send_request("agents.list", {})
|
||||
return result.get("agents", [])
|
||||
|
||||
async def get_agent(self, agent_id: str) -> dict[str, Any] | None:
|
||||
"""Get agent details."""
|
||||
agents = await self.list_agents()
|
||||
for agent in agents:
|
||||
if agent.get("id") == agent_id:
|
||||
return agent
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Channel operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def channels_status(self, probe: bool = False) -> dict[str, Any]:
|
||||
"""Get channel status."""
|
||||
params = {"probe": probe} if probe else {}
|
||||
return await self._send_request("channels.status", params)
|
||||
|
||||
async def channels_list(self) -> list[dict[str, Any]]:
|
||||
"""List configured channels."""
|
||||
result = await self._send_request("channels.list", {})
|
||||
return result.get("channels", [])
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Convenience methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def send_to_agent(
|
||||
self,
|
||||
agent_id: str,
|
||||
message: str,
|
||||
channel: str | None = None,
|
||||
label: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convenience method to send a message to an agent.
|
||||
|
||||
Resolves the session automatically.
|
||||
|
||||
Args:
|
||||
agent_id: The agent ID
|
||||
message: Message to send
|
||||
channel: Optional channel to route through
|
||||
label: Optional session label
|
||||
|
||||
Returns:
|
||||
The agent's response
|
||||
"""
|
||||
session_key = await self.resolve_session(agent_id=agent_id, label=label, channel=channel)
|
||||
if not session_key:
|
||||
raise ValueError(f"No session found for agent {agent_id}")
|
||||
|
||||
return await self.send_message(session_key, message)
|
||||
|
||||
def add_event_handler(self, handler: Callable[[MessageEvent], None]) -> None:
|
||||
"""Add an event handler for incoming events."""
|
||||
self._event_handlers.append(handler)
|
||||
|
||||
def remove_event_handler(self, handler: Callable[[MessageEvent], None]) -> None:
|
||||
"""Remove an event handler."""
|
||||
self._event_handlers.remove(handler)
|
||||
|
||||
|
||||
class AsyncMessageIterator:
|
||||
"""Async iterator for session messages."""
|
||||
|
||||
def __init__(self, client: OpenClawWebSocketClient, session_key: str):
|
||||
self._client = client
|
||||
self._session_key = session_key
|
||||
self._queue: asyncio.Queue[MessageEvent] = asyncio.Queue()
|
||||
self._handler_added = False
|
||||
|
||||
def _on_event(self, event: MessageEvent) -> None:
|
||||
"""Handle incoming event and check if it's for our session."""
|
||||
# Filter to session-specific events
|
||||
payload = event.payload or {}
|
||||
event_session_key = payload.get("sessionKey") or payload.get("key")
|
||||
if event_session_key == self._session_key or event.event.startswith("sessions."):
|
||||
self._queue.put_nowait(event)
|
||||
|
||||
async def __aiter__(self) -> "AsyncMessageIterator":
|
||||
if not self._handler_added:
|
||||
self._client.add_event_handler(self._on_event)
|
||||
self._handler_added = True
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> MessageEvent:
|
||||
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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
async def send_to_agent(
|
||||
message: str,
|
||||
agent_id: str,
|
||||
gateway_url: str = DEFAULT_GATEWAY_URL,
|
||||
gateway_token: str | None = None,
|
||||
channel: str | None = None,
|
||||
label: str | None = None,
|
||||
timeout_ms: int = 60000,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a message to an agent and wait for response.
|
||||
|
||||
This is a convenience function for one-shot message sending.
|
||||
|
||||
Args:
|
||||
message: The message to send
|
||||
agent_id: The agent ID to target
|
||||
gateway_url: Gateway WebSocket URL
|
||||
gateway_token: Optional gateway auth token
|
||||
channel: Optional channel to route through
|
||||
label: Optional session label
|
||||
timeout_ms: Timeout in milliseconds
|
||||
|
||||
Returns:
|
||||
The agent's response
|
||||
|
||||
Example:
|
||||
response = await send_to_agent("Hello!", agent_id="my-agent")
|
||||
"""
|
||||
async with OpenClawWebSocketClient(
|
||||
url=gateway_url,
|
||||
gateway_token=gateway_token,
|
||||
) as client:
|
||||
await client.connect()
|
||||
return await client.send_to_agent(
|
||||
agent_id=agent_id,
|
||||
message=message,
|
||||
channel=channel,
|
||||
label=label,
|
||||
)
|
||||
|
||||
|
||||
async def list_active_sessions(
|
||||
gateway_url: str = DEFAULT_GATEWAY_URL,
|
||||
gateway_token: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List active sessions.
|
||||
|
||||
Args:
|
||||
gateway_url: Gateway WebSocket URL
|
||||
gateway_token: Optional gateway auth token
|
||||
agent_id: Optional agent ID to filter by
|
||||
|
||||
Returns:
|
||||
List of active sessions
|
||||
"""
|
||||
async with OpenClawWebSocketClient(
|
||||
url=gateway_url,
|
||||
gateway_token=gateway_token,
|
||||
) as client:
|
||||
await client.connect()
|
||||
return await client.list_sessions(agent_id=agent_id)
|
||||
36
shared/models/__init__.py
Normal file
36
shared/models/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Shared data models."""
|
||||
|
||||
from shared.models.openclaw import (
|
||||
OpenClawStatus,
|
||||
SessionEntry,
|
||||
SessionHistory,
|
||||
SessionHistoryEvent,
|
||||
SessionsList,
|
||||
CronJob,
|
||||
CronList,
|
||||
ApprovalRequest,
|
||||
ApprovalsList,
|
||||
normalize_status,
|
||||
normalize_sessions,
|
||||
normalize_session_history,
|
||||
normalize_cron_jobs,
|
||||
normalize_approvals,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OpenClawStatus",
|
||||
"SessionEntry",
|
||||
"SessionHistory",
|
||||
"SessionHistoryEvent",
|
||||
"SessionsList",
|
||||
"CronJob",
|
||||
"CronList",
|
||||
"ApprovalRequest",
|
||||
"ApprovalsList",
|
||||
"normalize_status",
|
||||
"normalize_sessions",
|
||||
"normalize_session_history",
|
||||
"normalize_cron_jobs",
|
||||
"normalize_approvals",
|
||||
]
|
||||
1103
shared/models/openclaw.py
Normal file
1103
shared/models/openclaw.py
Normal file
File diff suppressed because it is too large
Load Diff
153
start-dev.sh
153
start-dev.sh
@@ -1,8 +1,8 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# EvoTraders Development Startup Script
|
||||
# Split-service mode only
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
echo "=========================================="
|
||||
echo "EvoTraders Development Environment"
|
||||
@@ -14,23 +14,83 @@ GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check virtual environment
|
||||
if [ -z "$VIRTUAL_ENV" ]; then
|
||||
echo -e "${YELLOW}Warning: Virtual environment not activated${NC}"
|
||||
echo "Activating .venv..."
|
||||
source .venv/bin/activate
|
||||
fi
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
# Load environment variables
|
||||
PIDS=()
|
||||
|
||||
require_command() {
|
||||
local command_name="$1"
|
||||
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||
echo -e "${RED}Missing required command: ${command_name}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_python_module() {
|
||||
local module_name="$1"
|
||||
if ! python -c "import ${module_name}" >/dev/null 2>&1; then
|
||||
echo -e "${RED}Missing required Python module: ${module_name}${NC}"
|
||||
echo "Install dependencies with one of:"
|
||||
echo " pip install -r requirements.txt"
|
||||
echo " pip install -r requirements-dev.txt"
|
||||
echo " uv pip install -e '.[dev]'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
load_env_file() {
|
||||
if [ -f .env ]; then
|
||||
echo -e "${GREEN}Loading environment from .env${NC}"
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo -e "${YELLOW}Warning: .env file not found${NC}"
|
||||
echo -e "${YELLOW}Warning: .env file not found. Copy env.template to .env first if you need live credentials.${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
cd /Users/cillin/workspeace/evotraders
|
||||
PIDS=()
|
||||
check_env_var() {
|
||||
local var_name="$1"
|
||||
local severity="${2:-warn}"
|
||||
local value="${!var_name:-}"
|
||||
if [ -z "${value}" ]; then
|
||||
if [ "${severity}" = "error" ]; then
|
||||
echo -e "${RED}Missing required environment variable: ${var_name}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${YELLOW}Warning: ${var_name} is not set${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_openclaw_gateway() {
|
||||
local target_host="127.0.0.1"
|
||||
local target_port="18789"
|
||||
if python - <<PY >/dev/null 2>&1
|
||||
import socket
|
||||
sock = socket.socket()
|
||||
sock.settimeout(1.0)
|
||||
sock.connect(("${target_host}", ${target_port}))
|
||||
sock.close()
|
||||
PY
|
||||
then
|
||||
echo -e "${GREEN}OpenClaw gateway reachable at ws://${target_host}:${target_port}${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Warning: OpenClaw gateway is not reachable at ws://${target_host}:${target_port}${NC}"
|
||||
echo " OpenClaw panel features may be unavailable until it is started."
|
||||
fi
|
||||
}
|
||||
|
||||
print_prereq_help() {
|
||||
echo "Environment checks:"
|
||||
echo " - repo root: ${SCRIPT_DIR}"
|
||||
echo " - python: $(command -v python)"
|
||||
if [ -n "${VIRTUAL_ENV:-}" ]; then
|
||||
echo " - virtualenv: ${VIRTUAL_ENV}"
|
||||
else
|
||||
echo " - virtualenv: not activated"
|
||||
fi
|
||||
}
|
||||
|
||||
start_service() {
|
||||
local name="$1"
|
||||
@@ -57,6 +117,16 @@ cleanup() {
|
||||
fi
|
||||
}
|
||||
|
||||
kill_port() {
|
||||
local port="$1"
|
||||
local pids=$(lsof -ti :${port} 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then
|
||||
echo -e "${YELLOW}Port ${port} is in use, killing PID(s): ${pids}${NC}"
|
||||
echo "$pids" | xargs kill -9 2>/dev/null || true
|
||||
sleep 0.5
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
@@ -64,14 +134,56 @@ if [ $# -gt 0 ]; then
|
||||
echo "Split-service mode is now the only supported development mode."
|
||||
fi
|
||||
|
||||
require_command python
|
||||
require_command lsof
|
||||
|
||||
if [ -z "${VIRTUAL_ENV:-}" ]; then
|
||||
if [ -f ".venv/bin/activate" ]; then
|
||||
echo -e "${YELLOW}Virtual environment not activated; auto-activating .venv${NC}"
|
||||
# shellcheck disable=SC1091
|
||||
source .venv/bin/activate
|
||||
else
|
||||
echo -e "${YELLOW}Warning: no active virtual environment and .venv not found${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
load_env_file
|
||||
|
||||
print_prereq_help
|
||||
|
||||
python - <<'PY'
|
||||
import sys
|
||||
if sys.version_info < (3, 9):
|
||||
raise SystemExit("Python 3.9+ is required")
|
||||
print(f"Python version OK: {sys.version.split()[0]}")
|
||||
PY
|
||||
|
||||
check_python_module fastapi
|
||||
check_python_module uvicorn
|
||||
check_python_module websockets
|
||||
check_python_module yaml
|
||||
check_python_module dotenv
|
||||
|
||||
check_env_var OPENAI_API_KEY
|
||||
check_env_var FINNHUB_API_KEY
|
||||
check_env_var FIN_DATA_SOURCE
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}Warning: npm is not installed. Frontend startup via 'evotraders frontend' will not work.${NC}"
|
||||
fi
|
||||
|
||||
export TRADING_SERVICE_URL="${TRADING_SERVICE_URL:-http://localhost:8001}"
|
||||
export NEWS_SERVICE_URL="${NEWS_SERVICE_URL:-http://localhost:8002}"
|
||||
export RUNTIME_SERVICE_URL="${RUNTIME_SERVICE_URL:-http://localhost:8003}"
|
||||
export OPENCLAW_SERVICE_URL="${OPENCLAW_SERVICE_URL:-http://localhost:18789}"
|
||||
|
||||
check_openclaw_gateway
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Starting EvoTraders split services (default mode)...${NC}"
|
||||
echo " agent_service: http://localhost:8000"
|
||||
echo " runtime_service: http://localhost:8003"
|
||||
echo " openclaw_gateway: ws://localhost:18789"
|
||||
echo " trading_service: http://localhost:8001"
|
||||
echo " news_service: http://localhost:8002"
|
||||
echo ""
|
||||
@@ -79,13 +191,28 @@ echo "Exported backend preference URLs:"
|
||||
echo " TRADING_SERVICE_URL=${TRADING_SERVICE_URL}"
|
||||
echo " NEWS_SERVICE_URL=${NEWS_SERVICE_URL}"
|
||||
echo " RUNTIME_SERVICE_URL=${RUNTIME_SERVICE_URL}"
|
||||
echo " OPENCLAW_SERVICE_URL=${OPENCLAW_SERVICE_URL}"
|
||||
echo ""
|
||||
|
||||
echo -e "${GREEN}Checking ports...${NC}"
|
||||
kill_port 8000
|
||||
kill_port 8001
|
||||
kill_port 8002
|
||||
kill_port 8003
|
||||
kill_port 8765
|
||||
|
||||
start_service "agent_service" "backend.apps.agent_service:app" 8000
|
||||
start_service "runtime_service" "backend.apps.runtime_service:app" 8003
|
||||
start_service "trading_service" "backend.apps.trading_service:app" 8001
|
||||
start_service "news_service" "backend.apps.news_service:app" 8002
|
||||
|
||||
echo -e "${GREEN}Starting Gateway (WebSocket, port 8765)...${NC}"
|
||||
SERVICE_NAME="gateway" python -m backend.main \
|
||||
--mode live \
|
||||
--host 0.0.0.0 \
|
||||
--port 8765 &
|
||||
PIDS+=($!)
|
||||
|
||||
echo -e "${GREEN}Split services are running.${NC}"
|
||||
echo "Use Ctrl+C to stop all services."
|
||||
wait
|
||||
|
||||
68
test_openclaw_ws.py
Normal file
68
test_openclaw_ws.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Quick test script for OpenClaw WebSocket client."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
from shared.client.openclaw_websocket_client import (
|
||||
OpenClawWebSocketClient,
|
||||
DEFAULT_GATEWAY_URL,
|
||||
)
|
||||
|
||||
|
||||
async def test_connection():
|
||||
"""Test basic connection to OpenClaw Gateway."""
|
||||
print(f"Connecting to {DEFAULT_GATEWAY_URL}...")
|
||||
|
||||
client = OpenClawWebSocketClient(
|
||||
url=DEFAULT_GATEWAY_URL,
|
||||
client_name="cli",
|
||||
client_version="1.0.0",
|
||||
gateway_token="d4b2d831b8a177b5cac07e781f438af3840bd0dfaca630ee",
|
||||
)
|
||||
|
||||
try:
|
||||
hello = await client.connect()
|
||||
print(f"✓ Connected!")
|
||||
print(f" Protocol version: {hello.protocol}")
|
||||
print(f" Server version: {hello.server_version}")
|
||||
print(f" Connection ID: {hello.conn_id}")
|
||||
|
||||
# List sessions
|
||||
print("\nListing sessions...")
|
||||
sessions = await client.list_sessions(limit=5)
|
||||
print(f" Found {len(sessions)} sessions")
|
||||
for session in sessions[:3]:
|
||||
print(f" - {session.get('key', 'unknown')}")
|
||||
|
||||
# List agents
|
||||
print("\nListing agents...")
|
||||
agents = await client.list_agents()
|
||||
print(f" Found {len(agents)} agents")
|
||||
for agent in agents[:3]:
|
||||
print(f" - {agent.get('id', 'unknown')}: {agent.get('name', 'unknown')}")
|
||||
|
||||
# Send a message to agent
|
||||
if sessions:
|
||||
session_key = sessions[0].get('key')
|
||||
if session_key:
|
||||
print(f"\nSending message to session: {session_key}")
|
||||
result = await client.send_message(session_key, "Hello! This is a test message from Python.")
|
||||
print(f" Message sent: {result}")
|
||||
|
||||
await client.disconnect()
|
||||
print("\n✓ All tests passed!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = asyncio.run(test_connection())
|
||||
sys.exit(0 if success else 1)
|
||||
Reference in New Issue
Block a user