feat: OpenClaw WebSocket integration with workspace file preview
- Migrate OpenClaw from HTTP (port 8004) to WebSocket (port 18789) - Add workspace file list and content preview handlers - Add OpenClawStatus component with agent/skills view - Add OpenClawView panel in trader interface - Add Zustand store for OpenClaw state management - Fix gateway logging noise (yfinance, websockets) - Fix RunWorkspaceManager.get_agent_asset_dir attribute error - Handle missing workspace files gracefully in preview Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -137,7 +137,7 @@ class RunWorkspaceManager:
|
|||||||
filename: str,
|
filename: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Load one run-scoped agent workspace file."""
|
"""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():
|
if not path.exists():
|
||||||
raise FileNotFoundError(f"File not found: {filename}")
|
raise FileNotFoundError(f"File not found: {filename}")
|
||||||
return path.read_text(encoding="utf-8")
|
return path.read_text(encoding="utf-8")
|
||||||
@@ -151,7 +151,7 @@ class RunWorkspaceManager:
|
|||||||
content: str,
|
content: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Write one run-scoped agent workspace file."""
|
"""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)
|
asset_dir.mkdir(parents=True, exist_ok=True)
|
||||||
path = asset_dir / filename
|
path = asset_dir / filename
|
||||||
path.write_text(content, encoding="utf-8")
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|||||||
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)
|
||||||
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."""
|
"""Filter out low-signal library INFO logs while keeping warnings/errors."""
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
if record.levelno >= logging.WARNING:
|
|
||||||
return True
|
|
||||||
|
|
||||||
message = record.getMessage()
|
message = record.getMessage()
|
||||||
if record.name == "httpx" and message.startswith("HTTP Request:"):
|
if record.name == "httpx" and message.startswith("HTTP Request:"):
|
||||||
return False
|
return False
|
||||||
if record.name.startswith("websockets") and "connection open" in message:
|
if record.name.startswith("websockets") and "connection open" in message:
|
||||||
return False
|
return False
|
||||||
|
if record.name.startswith("websockets") and "opening handshake failed" in message:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if record.levelno >= logging.WARNING:
|
||||||
|
return True
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from backend.runtime.manager import (
|
|||||||
set_global_runtime_manager,
|
set_global_runtime_manager,
|
||||||
clear_global_runtime_manager,
|
clear_global_runtime_manager,
|
||||||
)
|
)
|
||||||
|
from backend.gateway_server import configure_gateway_logging
|
||||||
from backend.services.gateway import Gateway
|
from backend.services.gateway import Gateway
|
||||||
from backend.services.market import MarketService
|
from backend.services.market import MarketService
|
||||||
from backend.services.storage import StorageService
|
from backend.services.storage import StorageService
|
||||||
@@ -38,6 +39,7 @@ load_dotenv()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
loguru.logger.disable("flowllm")
|
loguru.logger.disable("flowllm")
|
||||||
loguru.logger.disable("reme_ai")
|
loguru.logger.disable("reme_ai")
|
||||||
|
configure_gateway_logging(verbose=os.getenv("LOG_LEVEL", "").upper() == "DEBUG")
|
||||||
_prompt_loader = get_prompt_loader()
|
_prompt_loader = get_prompt_loader()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ from backend.tools.technical_signals import StockTechnicalAnalyzer
|
|||||||
from backend.core.scheduler import Scheduler
|
from backend.core.scheduler import Scheduler
|
||||||
from backend.services import gateway_admin_handlers
|
from backend.services import gateway_admin_handlers
|
||||||
from backend.services import gateway_cycle_support
|
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_runtime_support
|
||||||
from backend.services import gateway_stock_handlers
|
from backend.services import gateway_stock_handlers
|
||||||
from shared.client import NewsServiceClient
|
from shared.client import NewsServiceClient
|
||||||
from shared.client import TradingServiceClient
|
from shared.client import TradingServiceClient
|
||||||
|
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient, DEFAULT_GATEWAY_URL as OPENCLAW_WS_URL
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
EDITABLE_AGENT_WORKSPACE_FILES = {
|
EDITABLE_AGENT_WORKSPACE_FILES = {
|
||||||
@@ -92,6 +94,7 @@ class Gateway:
|
|||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
self._project_root = Path(__file__).resolve().parents[2]
|
self._project_root = Path(__file__).resolve().parents[2]
|
||||||
self._technical_analyzer = StockTechnicalAnalyzer()
|
self._technical_analyzer = StockTechnicalAnalyzer()
|
||||||
|
self._openclaw_ws: OpenClawWebSocketClient | None = None
|
||||||
|
|
||||||
async def start(self, host: str = "0.0.0.0", port: int = 8766):
|
async def start(self, host: str = "0.0.0.0", port: int = 8766):
|
||||||
"""Start gateway server with proper initialization order.
|
"""Start gateway server with proper initialization order.
|
||||||
@@ -185,6 +188,20 @@ class Gateway:
|
|||||||
# Give a brief moment for any existing clients to reconnect
|
# Give a brief moment for any existing clients to reconnect
|
||||||
await asyncio.sleep(0.1)
|
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
|
# PHASE 2: Start market data service
|
||||||
# Now frontend is connected, start pushing price updates
|
# Now frontend is connected, start pushing price updates
|
||||||
@@ -434,6 +451,54 @@ class Gateway:
|
|||||||
await self._handle_get_stock_technical_indicators(websocket, data)
|
await self._handle_get_stock_technical_indicators(websocket, data)
|
||||||
elif msg_type == "run_stock_enrich":
|
elif msg_type == "run_stock_enrich":
|
||||||
await self._handle_run_stock_enrich(websocket, data)
|
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)
|
||||||
|
|
||||||
except websockets.ConnectionClosed:
|
except websockets.ConnectionClosed:
|
||||||
pass
|
pass
|
||||||
@@ -669,6 +734,83 @@ class Gateway:
|
|||||||
) -> None:
|
) -> None:
|
||||||
await gateway_admin_handlers.handle_update_agent_workspace_file(self, websocket, data)
|
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
|
@staticmethod
|
||||||
def _normalize_watchlist(raw_tickers: Any) -> List[str]:
|
def _normalize_watchlist(raw_tickers: Any) -> List[str]:
|
||||||
return gateway_runtime_support.normalize_watchlist(raw_tickers)
|
return gateway_runtime_support.normalize_watchlist(raw_tickers)
|
||||||
|
|||||||
179
backend/services/gateway_openclaw_handlers.py
Normal file
179
backend/services/gateway_openclaw_handlers.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# -*- 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 _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": 1, "agentId": session_key.split(":")[1] if session_key else None})
|
||||||
|
await websocket.send(json.dumps({
|
||||||
|
"type": "openclaw_session_detail_loaded",
|
||||||
|
"data": result,
|
||||||
|
"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)
|
||||||
|
result = await _ws_call(gateway, "sessions.list", {"limit": limit})
|
||||||
|
await websocket.send(json.dumps({
|
||||||
|
"type": "openclaw_session_history_loaded",
|
||||||
|
"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")
|
||||||
|
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:
|
||||||
|
result = await _ws_call(gateway, "skills.status")
|
||||||
|
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,
|
||||||
|
)
|
||||||
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
|
||||||
532
frontend/src/components/OpenClawStatus.jsx
Normal file
532
frontend/src/components/OpenClawStatus.jsx
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useOpenClawStore } from "../store/openclawStore";
|
||||||
|
import { useOpenClawPanel } from "../hooks/useOpenClawPanel";
|
||||||
|
|
||||||
|
// Agent run states matching openclaw-control-center/src/types.ts
|
||||||
|
const AGENT_RUN_STATES = {
|
||||||
|
idle: { label: "空闲", color: "#9CA3AF" },
|
||||||
|
running: { label: "运行中", color: "#10B981" },
|
||||||
|
blocked: { label: "阻塞", color: "#F59E0B" },
|
||||||
|
waiting_approval: { label: "待审批", color: "#8B5CF6" },
|
||||||
|
error: { label: "错误", color: "#EF4444" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agent accent colors for avatar borders
|
||||||
|
const AGENT_COLORS = [
|
||||||
|
{ accent: "#3B82F6" },
|
||||||
|
{ accent: "#8B5CF6" },
|
||||||
|
{ accent: "#EC4899" },
|
||||||
|
{ accent: "#F59E0B" },
|
||||||
|
{ accent: "#10B981" },
|
||||||
|
{ accent: "#EF4444" },
|
||||||
|
{ accent: "#06B6D4" },
|
||||||
|
{ accent: "#84CC16" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getAgentColor(agentId) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < (agentId || "").length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + agentId.charCodeAt(i);
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
return AGENT_COLORS[Math.abs(hash) % AGENT_COLORS.length].accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentStateFromPresence(presence, agentId) {
|
||||||
|
const p = presence?.[agentId];
|
||||||
|
if (!p) return "idle";
|
||||||
|
if (p.status === "active") return "running";
|
||||||
|
if (p.sessions?.some(s => s.state === "blocked")) return "blocked";
|
||||||
|
if (p.sessions?.some(s => s.state === "waiting_approval")) return "waiting_approval";
|
||||||
|
if (p.sessions?.some(s => s.state === "error" || s.state === "failed")) return "error";
|
||||||
|
if (p.activeSessions > 0) return "running";
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarIcon({ agentId, size = 56, borderRadius = 14 }) {
|
||||||
|
const color = getAgentColor(agentId);
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius,
|
||||||
|
background: `${color}18`,
|
||||||
|
border: `1px solid ${color}33`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: size * 0.36,
|
||||||
|
fontWeight: 800,
|
||||||
|
color,
|
||||||
|
}}>
|
||||||
|
{agentId?.slice(0, 2).toUpperCase() || "??"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillBadge({ skill, color }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: 7,
|
||||||
|
paddingBottom: 10,
|
||||||
|
borderBottom: "1px dashed #D7DEE7",
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "flex-start" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "grid",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "#6B7280", fontWeight: 700 }}>
|
||||||
|
{expanded ? "▾" : "▸"}
|
||||||
|
</span>
|
||||||
|
{skill.emoji && <span style={{ fontSize: 12 }}>{skill.emoji}</span>}
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: "#111111" }}>
|
||||||
|
{skill.name || "未命名技能"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && skill.description && (
|
||||||
|
<div style={{
|
||||||
|
marginLeft: 20,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #E5E7EB",
|
||||||
|
background: "#FFFFFF",
|
||||||
|
padding: "10px 12px",
|
||||||
|
display: "grid",
|
||||||
|
gap: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#1F2937",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
fontFamily: '"Courier New", monospace',
|
||||||
|
}}>
|
||||||
|
{skill.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentDetail({ agent, presence, skills }) {
|
||||||
|
const { workspaceFiles, workspaceFilesLoading, workspaceFilesError, workspaceFileContent } = useOpenClawStore();
|
||||||
|
const { requestWorkspaceFiles, requestWorkspaceFile } = useOpenClawPanel();
|
||||||
|
const [selectedDoc, setSelectedDoc] = useState(null);
|
||||||
|
|
||||||
|
// Always use "main" as the workspace key since that's the only valid OpenClaw agent ID
|
||||||
|
const workspace = agent?.id || "main";
|
||||||
|
const rawFiles = workspaceFiles[workspace]?.files || [];
|
||||||
|
// Normalize file props: API returns uppercase (Name, Size, Path, Preview, PreviewTruncated)
|
||||||
|
const files = rawFiles.map(f => ({
|
||||||
|
name: f.Name || f.name,
|
||||||
|
size: f.Size || f.size,
|
||||||
|
path: f.Path || f.path,
|
||||||
|
preview: f.Preview || f.preview,
|
||||||
|
previewTruncated: f.PreviewTruncated || f.previewTruncated,
|
||||||
|
}));
|
||||||
|
const isLoadingFiles = workspaceFilesLoading && !workspaceFiles[workspace];
|
||||||
|
|
||||||
|
// Fetch workspace files when agent changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (workspace && !workspaceFiles[workspace]) {
|
||||||
|
requestWorkspaceFiles(workspace);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [workspace]);
|
||||||
|
|
||||||
|
const agentId = agent.id || agent.name || "?";
|
||||||
|
const state = agentStateFromPresence(presence, agentId);
|
||||||
|
const stateInfo = AGENT_RUN_STATES[state] || AGENT_RUN_STATES.idle;
|
||||||
|
const color = getAgentColor(agentId);
|
||||||
|
|
||||||
|
// Skills are global in OpenClaw — show all skills (not filtered per-agent)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: 16,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
alignContent: "start",
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<AvatarIcon agentId={agentId} size={58} borderRadius={12} />
|
||||||
|
<div style={{ display: "grid", gap: 4 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 800, color: "#111111" }}>{agent.name || agentId}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#6B7280" }}>{agentId}</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: "50%", background: stateInfo.color }} />
|
||||||
|
<span style={{ fontSize: 11, color: stateInfo.color, fontWeight: 700 }}>{stateInfo.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
border: `1px solid ${color}2e`,
|
||||||
|
background: `${color}0e`,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "10px 12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "grid", gap: 2 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "#4B5563", fontWeight: 700 }}>模型</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#111111", fontWeight: 800, fontFamily: '"Courier New", monospace' }}>
|
||||||
|
{agent.model || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills + Documents: left-right layout */}
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "minmax(240px, 2fr) minmax(0, 3fr)",
|
||||||
|
gap: 16,
|
||||||
|
alignItems: "start",
|
||||||
|
minHeight: 0,
|
||||||
|
}}>
|
||||||
|
{/* Left: Skills */}
|
||||||
|
<div style={{ display: "grid", gap: 10 }}>
|
||||||
|
{(() => {
|
||||||
|
const available = skills.filter(s => {
|
||||||
|
const hasMissing = s.missing && (s.missing.bins?.length || s.missing.env?.length || s.missing.config?.length);
|
||||||
|
return s.eligible !== false && s.disabled !== true && !hasMissing;
|
||||||
|
});
|
||||||
|
return available.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
border: "1px solid #E5EAF1",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "#FCFDFE",
|
||||||
|
padding: 14,
|
||||||
|
display: "grid",
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "center" }}>
|
||||||
|
<div style={{ display: "grid", gap: 2 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, color: "#111111" }}>可用技能</div>
|
||||||
|
<div style={{ fontSize: 10, color: "#6B7280", fontFamily: '"Courier New", monospace' }}>
|
||||||
|
已就绪: {available.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
border: "1px solid #E5E7EB",
|
||||||
|
background: "#F8FAFC",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
display: "grid",
|
||||||
|
gap: 8,
|
||||||
|
maxHeight: 420,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}>
|
||||||
|
{available.map((skill, i) => (
|
||||||
|
<SkillBadge key={skill.name || i} skill={skill} color={color} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Documents */}
|
||||||
|
<div style={{ display: "grid", gap: 10 }}>
|
||||||
|
{workspace && (
|
||||||
|
<div style={{
|
||||||
|
border: "1px solid #E5EAF1",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "#FCFDFE",
|
||||||
|
padding: 14,
|
||||||
|
display: "grid",
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "center" }}>
|
||||||
|
<div style={{ display: "grid", gap: 2 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, color: "#111111" }}>工作区文档</div>
|
||||||
|
<div style={{ fontSize: 10, color: "#6B7280", fontFamily: '"Courier New", monospace' }}>
|
||||||
|
{files.length} 个文件
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
border: "1px solid #E5E7EB",
|
||||||
|
background: "#F8FAFC",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
display: "grid",
|
||||||
|
gap: 8,
|
||||||
|
maxHeight: 420,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}>
|
||||||
|
{isLoadingFiles ? (
|
||||||
|
<div style={{ fontSize: 11, color: "#6B7280", fontStyle: "italic" }}>加载中…</div>
|
||||||
|
) : workspaceFilesError ? (
|
||||||
|
<div style={{ fontSize: 11, color: "#EF4444" }}>加载失败</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 11, color: "#9CA3AF" }}>暂无文档</div>
|
||||||
|
) : (
|
||||||
|
files.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const clickedFile = selectedDoc?.name === f.name ? null : f;
|
||||||
|
setSelectedDoc(clickedFile);
|
||||||
|
if (clickedFile && !workspaceFileContent[`${workspace}:${f.name}`]) {
|
||||||
|
requestWorkspaceFile(workspace, f.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
background: selectedDoc?.name === f.name ? `${color}14` : "transparent",
|
||||||
|
border: `1px solid ${selectedDoc?.name === f.name ? color + "40" : "#E5EAF1"}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "8px 10px",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "grid",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: "#111111" }}>{f.name}</div>
|
||||||
|
<div style={{ fontSize: 10, color: "#9CA3AF" }}>{f.size} B</div>
|
||||||
|
</div>
|
||||||
|
{selectedDoc?.name === f.name && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#4B5563",
|
||||||
|
fontFamily: '"Courier New", monospace',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
maxHeight: 120,
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "#FFFFFF",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "6px 8px",
|
||||||
|
marginTop: 4,
|
||||||
|
}}>
|
||||||
|
{workspaceFileContent[`${workspace}:${f.name}`] || f.preview || "(内容加载中...)"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenClawStatus() {
|
||||||
|
const store = useOpenClawStore();
|
||||||
|
const {
|
||||||
|
requestStatus,
|
||||||
|
requestAgents,
|
||||||
|
requestAgentsPresence,
|
||||||
|
requestSkills,
|
||||||
|
} = useOpenClawPanel();
|
||||||
|
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState(
|
||||||
|
() => store.agents[0]?.id || store.agents[0]?.name || null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch data only if store is empty (on mount / page refresh)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!store.agents.length) requestAgents();
|
||||||
|
if (!store.skills.length) requestSkills();
|
||||||
|
if (!store.openclawStatus) requestStatus();
|
||||||
|
requestAgentsPresence();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const status = store.openclawStatus;
|
||||||
|
const agents = store.agents;
|
||||||
|
const presence = store.agentsPresence?.agents || {};
|
||||||
|
const skills = store.skills || [];
|
||||||
|
|
||||||
|
const selectedAgent = agents.find(a => (a.id || a.name) === selectedAgentId) || agents[0] || null;
|
||||||
|
|
||||||
|
// Auto-select first agent when agents load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedAgentId && agents.length > 0) {
|
||||||
|
setSelectedAgentId(agents[0].id || agents[0].name);
|
||||||
|
}
|
||||||
|
}, [agents, selectedAgentId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
padding: "18px",
|
||||||
|
background: "linear-gradient(180deg, #ffffff 0%, #f4f7fb 100%)",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateRows: "auto 1fr",
|
||||||
|
gap: 18,
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "grid", gap: 4 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.5px", color: "#111111" }}>
|
||||||
|
OpenClaw Agent 状态
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#6B7280" }}>
|
||||||
|
监控 OpenClaw 多 Agent 运行时状态
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content: left agent list + right detail */}
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: agents.length > 0 ? "120px minmax(0, 1fr)" : "1fr",
|
||||||
|
gap: 16,
|
||||||
|
alignItems: "stretch",
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
{/* Left: agent avatar list */}
|
||||||
|
{agents.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
border: "1px solid #D9E0E7",
|
||||||
|
borderRadius: 14,
|
||||||
|
background: "#FFFFFF",
|
||||||
|
boxShadow: "0 10px 24px rgba(15, 23, 42, 0.06)",
|
||||||
|
padding: 12,
|
||||||
|
display: "grid",
|
||||||
|
gap: 10,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
alignContent: "start",
|
||||||
|
}}>
|
||||||
|
{agents.map((agent) => {
|
||||||
|
const agentId = agent.id || agent.name;
|
||||||
|
const isSelected = (agent.id || agent.name) === (selectedAgent?.id || selectedAgent?.name);
|
||||||
|
const color = getAgentColor(agentId);
|
||||||
|
const state = agentStateFromPresence(presence, agentId);
|
||||||
|
const stateInfo = AGENT_RUN_STATES[state] || AGENT_RUN_STATES.idle;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={agentId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedAgentId(agentId)}
|
||||||
|
title={agent.name || agentId}
|
||||||
|
style={{
|
||||||
|
border: isSelected ? `2px solid ${color}` : "1px solid #D9E0E7",
|
||||||
|
borderRadius: 16,
|
||||||
|
background: isSelected ? `${color}10` : "#FFFFFF",
|
||||||
|
boxShadow: isSelected ? `0 10px 20px ${color}18` : "none",
|
||||||
|
padding: 8,
|
||||||
|
display: "grid",
|
||||||
|
gap: 4,
|
||||||
|
justifyItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<AvatarIcon agentId={agentId} size={48} borderRadius={12} />
|
||||||
|
<div style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: stateInfo.color,
|
||||||
|
border: "2px solid #FFFFFF",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: isSelected ? color : "#374151",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}>
|
||||||
|
{agent.name || agentId}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right: agent detail */}
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
{/* Agent detail */}
|
||||||
|
<div style={{
|
||||||
|
border: "1px solid #D9E0E7",
|
||||||
|
borderRadius: 14,
|
||||||
|
background: "#FFFFFF",
|
||||||
|
boxShadow: "0 10px 24px rgba(15, 23, 42, 0.06)",
|
||||||
|
padding: 18,
|
||||||
|
display: "grid",
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<AvatarIcon agentId="??" size={64} borderRadius={16} />
|
||||||
|
<div style={{ fontSize: 13, color: "#9CA3AF" }}>
|
||||||
|
{store.agentsLoading ? "加载中..." : (store.agentsError ? `错误: ${String(store.agentsError).slice(0, 60)}` : "暂无 Agent")}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { requestAgents(); requestAgentsPresence(); }}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
border: "1px solid #000000",
|
||||||
|
background: "#FFFFFF",
|
||||||
|
color: "#000000",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : selectedAgent ? (
|
||||||
|
<AgentDetail agent={selectedAgent} presence={presence} skills={skills} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/components/OpenClawView.jsx
Normal file
14
frontend/src/components/OpenClawView.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { OpenClawStatus } from './OpenClawStatus';
|
||||||
|
|
||||||
|
export default function OpenClawView() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#F3F4F6',
|
||||||
|
}}>
|
||||||
|
<OpenClawStatus />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
frontend/src/hooks/useOpenClawPanel.js
Normal file
263
frontend/src/hooks/useOpenClawPanel.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
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 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(() => {
|
||||||
|
const store = getStore();
|
||||||
|
const client = store.clientRef?.current;
|
||||||
|
if (!client) return;
|
||||||
|
store.setSkillsLoading(true);
|
||||||
|
store.setSkillsError(null);
|
||||||
|
sendWithRetry({ current: client }, { type: "get_openclaw_skills" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 { AGENTS } from '../config/constants';
|
||||||
import { ReadOnlyClient } from '../services/websocket';
|
import { ReadOnlyClient } from '../services/websocket';
|
||||||
import { useRuntimeStore } from '../store/runtimeStore';
|
import { useRuntimeStore } from '../store/runtimeStore';
|
||||||
|
import { useOpenClawStore } from '../store/openclawStore';
|
||||||
import { useMarketStore } from '../store/marketStore';
|
import { useMarketStore } from '../store/marketStore';
|
||||||
import { usePortfolioStore } from '../store/portfolioStore';
|
import { usePortfolioStore } from '../store/portfolioStore';
|
||||||
import { useAgentStore } from '../store/agentStore';
|
import { useAgentStore } from '../store/agentStore';
|
||||||
@@ -797,7 +798,198 @@ export function useWebSocketConnection({
|
|||||||
|
|
||||||
fast_forward_success: (e) => {
|
fast_forward_success: (e) => {
|
||||||
console.log(`✅ ${e.message}`);
|
console.log(`✅ ${e.message}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
openclaw_status_loaded: (e) => {
|
||||||
|
useOpenClawStore.getState().setOpenclawStatus(e.data || e);
|
||||||
|
useOpenClawStore.getState().setStatusLoading(false);
|
||||||
|
},
|
||||||
|
openclaw_sessions_loaded: (e) => {
|
||||||
|
useOpenClawStore.getState().setOpenclawSessions(e.data || e);
|
||||||
|
useOpenClawStore.getState().setSessionsLoading(false);
|
||||||
|
},
|
||||||
|
openclaw_session_detail_loaded: (e) => {
|
||||||
|
useOpenClawStore.getState().setOpenclawSessionDetail(e.data || e);
|
||||||
|
useOpenClawStore.getState().setSessionDetailLoading(false);
|
||||||
|
},
|
||||||
|
openclaw_session_history_loaded: (e) => {
|
||||||
|
useOpenClawStore.getState().setOpenclawSessionHistory(e.data || 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 {
|
||||||
|
useOpenClawStore.getState().setAgents(d?.agents || []);
|
||||||
|
useOpenClawStore.getState().setAgentsError(null);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
openclaw_agents_presence_loaded: (e) => {
|
||||||
|
useOpenClawStore.getState().setAgentsPresence((e.data?.data ?? e.data) || {});
|
||||||
|
},
|
||||||
|
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 {
|
try {
|
||||||
|
|||||||
227
frontend/src/store/openclawStore.js
Normal file
227
frontend/src/store/openclawStore.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export const useOpenClawStore = create(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
// Raw data
|
||||||
|
openclawStatus: null,
|
||||||
|
openclawSessions: [],
|
||||||
|
openclawSessionDetail: null,
|
||||||
|
openclawSessionHistory: [],
|
||||||
|
openclawCronJobs: [],
|
||||||
|
openclawApprovals: [],
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
isStatusLoading: false,
|
||||||
|
isSessionsLoading: false,
|
||||||
|
isSessionDetailLoading: false,
|
||||||
|
isCronLoading: false,
|
||||||
|
isApprovalsLoading: false,
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
statusError: null,
|
||||||
|
sessionsError: null,
|
||||||
|
sessionDetailError: null,
|
||||||
|
cronError: null,
|
||||||
|
approvalsError: 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 || [], sessionsError: null }),
|
||||||
|
setOpenclawSessionDetail: (data) => set({ openclawSessionDetail: data?.session || null, sessionDetailError: null }),
|
||||||
|
setOpenclawSessionHistory: (data) => set({ openclawSessionHistory: data?.history || [], sessionDetailError: null }),
|
||||||
|
setOpenclawCronJobs: (data) => set({ openclawCronJobs: data?.cron || [], cronError: null }),
|
||||||
|
setOpenclawApprovals: (data) => set({ openclawApprovals: data?.approvals || [], approvalsError: null }),
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
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()
|
||||||
739
shared/client/openclaw_websocket_client.py
Normal file
739
shared/client/openclaw_websocket_client.py
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
# -*- 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
|
||||||
|
|
||||||
|
result = await self._send_request("sessions.resolve", params)
|
||||||
|
sessions = result.get("sessions", [])
|
||||||
|
if sessions:
|
||||||
|
return sessions[0].get("key")
|
||||||
|
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
|
||||||
|
|
||||||
|
# Use shorter timeout for send since it waits for agent response
|
||||||
|
result = await self._send_request(
|
||||||
|
"sessions.send",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 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
27
start-dev.sh
27
start-dev.sh
@@ -57,6 +57,16 @@ cleanup() {
|
|||||||
fi
|
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
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
if [ $# -gt 0 ]; then
|
if [ $# -gt 0 ]; then
|
||||||
@@ -67,11 +77,13 @@ fi
|
|||||||
export TRADING_SERVICE_URL="${TRADING_SERVICE_URL:-http://localhost:8001}"
|
export TRADING_SERVICE_URL="${TRADING_SERVICE_URL:-http://localhost:8001}"
|
||||||
export NEWS_SERVICE_URL="${NEWS_SERVICE_URL:-http://localhost:8002}"
|
export NEWS_SERVICE_URL="${NEWS_SERVICE_URL:-http://localhost:8002}"
|
||||||
export RUNTIME_SERVICE_URL="${RUNTIME_SERVICE_URL:-http://localhost:8003}"
|
export RUNTIME_SERVICE_URL="${RUNTIME_SERVICE_URL:-http://localhost:8003}"
|
||||||
|
export OPENCLAW_SERVICE_URL="${OPENCLAW_SERVICE_URL:-http://localhost:18789}"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}Starting EvoTraders split services (default mode)...${NC}"
|
echo -e "${GREEN}Starting EvoTraders split services (default mode)...${NC}"
|
||||||
echo " agent_service: http://localhost:8000"
|
echo " agent_service: http://localhost:8000"
|
||||||
echo " runtime_service: http://localhost:8003"
|
echo " runtime_service: http://localhost:8003"
|
||||||
|
echo " openclaw_gateway: ws://localhost:18789"
|
||||||
echo " trading_service: http://localhost:8001"
|
echo " trading_service: http://localhost:8001"
|
||||||
echo " news_service: http://localhost:8002"
|
echo " news_service: http://localhost:8002"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -79,13 +91,28 @@ echo "Exported backend preference URLs:"
|
|||||||
echo " TRADING_SERVICE_URL=${TRADING_SERVICE_URL}"
|
echo " TRADING_SERVICE_URL=${TRADING_SERVICE_URL}"
|
||||||
echo " NEWS_SERVICE_URL=${NEWS_SERVICE_URL}"
|
echo " NEWS_SERVICE_URL=${NEWS_SERVICE_URL}"
|
||||||
echo " RUNTIME_SERVICE_URL=${RUNTIME_SERVICE_URL}"
|
echo " RUNTIME_SERVICE_URL=${RUNTIME_SERVICE_URL}"
|
||||||
|
echo " OPENCLAW_SERVICE_URL=${OPENCLAW_SERVICE_URL}"
|
||||||
echo ""
|
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 "agent_service" "backend.apps.agent_service:app" 8000
|
||||||
start_service "runtime_service" "backend.apps.runtime_service:app" 8003
|
start_service "runtime_service" "backend.apps.runtime_service:app" 8003
|
||||||
start_service "trading_service" "backend.apps.trading_service:app" 8001
|
start_service "trading_service" "backend.apps.trading_service:app" 8001
|
||||||
start_service "news_service" "backend.apps.news_service:app" 8002
|
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 -e "${GREEN}Split services are running.${NC}"
|
||||||
echo "Use Ctrl+C to stop all services."
|
echo "Use Ctrl+C to stop all services."
|
||||||
wait
|
wait
|
||||||
|
|||||||
Reference in New Issue
Block a user