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:
2026-03-27 11:08:15 +08:00
parent 9bcc4221a4
commit 6ecc224427
20 changed files with 5691 additions and 6 deletions

View 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>
);
}

View 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>
);
}

View 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,
};
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback } from 'react';
import { AGENTS } from '../config/constants';
import { ReadOnlyClient } from '../services/websocket';
import { useRuntimeStore } from '../store/runtimeStore';
import { useOpenClawStore } from '../store/openclawStore';
import { useMarketStore } from '../store/marketStore';
import { usePortfolioStore } from '../store/portfolioStore';
import { useAgentStore } from '../store/agentStore';
@@ -797,7 +798,198 @@ export function useWebSocketConnection({
fast_forward_success: (e) => {
console.log(`${e.message}`);
}
},
openclaw_status_loaded: (e) => {
useOpenClawStore.getState().setOpenclawStatus(e.data || e);
useOpenClawStore.getState().setStatusLoading(false);
},
openclaw_sessions_loaded: (e) => {
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 {

View 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,
}),
}
)
);