feat: Add OpenClaw module with dedicated frontend page, backend API, and documentation.

This commit is contained in:
Bruno Charest 2026-03-13 23:27:08 -04:00
parent 69642a17ea
commit 2a22990461
6 changed files with 1142 additions and 99 deletions

View File

@ -10,12 +10,13 @@ import json
import logging
import os
import re
import shutil
import socket
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, Query
from fastapi import APIRouter, Body, HTTPException, Query
from app.config import settings
@ -27,12 +28,53 @@ _SECRET_PATTERNS = re.compile(
r"(token|key|secret|password|apiKey|api_key)", re.IGNORECASE
)
# Agent identity files expected in workspace/<agent_name>/
AGENT_IDENTITY_FILES = [
"AGENTS.md", "BOOTSTRAP.md", "HEARTBEAT.md", "IDENTITY.md",
"identity.md", "SOUL.md", "TOOLS.md", "USER.md",
]
# File extensions we consider text-editable
_TEXT_EXTENSIONS = {
".json", ".yaml", ".yml", ".md", ".txt", ".sh", ".py", ".js", ".ts",
".tsx", ".jsx", ".css", ".html", ".xml", ".toml", ".ini", ".cfg",
".conf", ".log", ".env", ".gitignore", ".dockerfile",
}
_LANG_MAP = {
".json": "json",
".yaml": "yaml", ".yml": "yaml",
".md": "markdown",
".py": "python",
".js": "javascript", ".jsx": "javascript",
".ts": "typescript", ".tsx": "typescript",
".sh": "shell",
".css": "css",
".html": "html",
".xml": "xml",
".toml": "toml",
}
def _home() -> Path:
"""Resolve the OpenClaw home directory."""
return Path(settings.FOXY_HOME)
def _workspace() -> Path:
"""Resolve the OpenClaw workspace directory."""
return Path(settings.FOXY_WORKSPACE)
def _safe_path(relative: str) -> Path:
"""Resolve a relative path under FOXY_HOME, preventing path traversal."""
home = _home()
resolved = (home / relative).resolve()
if not str(resolved).startswith(str(home.resolve())):
raise HTTPException(status_code=403, detail="Path traversal denied")
return resolved
def _mask_secrets(obj: Any, depth: int = 0) -> Any:
"""Recursively mask values whose keys look like secrets."""
if depth > 20:
@ -67,7 +109,7 @@ def _read_json(path: Path) -> Optional[dict]:
return None
def _dir_tree(root: Path, max_depth: int = 3, _depth: int = 0) -> list[dict]:
def _dir_tree(root: Path, max_depth: int = 5, _depth: int = 0) -> list[dict]:
"""Build a lightweight directory tree."""
entries: list[dict] = []
if not root.is_dir() or _depth > max_depth:
@ -179,7 +221,11 @@ async def list_openclaw_agents():
if entry.is_dir():
# Directory-based agent — look for config files
agent["type"] = "directory"
config_files = list(entry.glob("*.json")) + list(entry.glob("*.yaml")) + list(entry.glob("*.yml"))
config_files = (
list(entry.glob("*.json"))
+ list(entry.glob("*.yaml"))
+ list(entry.glob("*.yml"))
)
agent["config_files"] = [f.name for f in config_files]
# Try to read agent config
@ -188,9 +234,14 @@ async def list_openclaw_agents():
if cfg:
agent["config"] = _mask_secrets(cfg)
agent["model"] = cfg.get("model", cfg.get("modelId", None))
agent["system_prompt"] = cfg.get("systemPrompt", cfg.get("system_prompt", cfg.get("instructions", None)))
agent["system_prompt"] = cfg.get(
"systemPrompt",
cfg.get("system_prompt", cfg.get("instructions", None)),
)
if agent["system_prompt"] and len(agent["system_prompt"]) > 300:
agent["system_prompt_preview"] = agent["system_prompt"][:300] + ""
agent["system_prompt_preview"] = (
agent["system_prompt"][:300] + ""
)
break
# Count files
@ -208,11 +259,152 @@ async def list_openclaw_agents():
agent["config"] = _mask_secrets(cfg)
agent["model"] = cfg.get("model", cfg.get("modelId", None))
# Check workspace for identity files
ws_dir = _workspace() / entry.name
if ws_dir.is_dir():
agent["has_workspace"] = True
identity_files = []
for fname in AGENT_IDENTITY_FILES:
fpath = ws_dir / fname
if fpath.is_file():
try:
identity_files.append(
{"name": fname, "size": fpath.stat().st_size}
)
except OSError:
identity_files.append({"name": fname, "size": 0})
agent["identity_files"] = identity_files
else:
agent["has_workspace"] = False
agent["identity_files"] = []
agents.append(agent)
return {"agents": agents, "count": len(agents)}
@router.delete("/agents/{agent_name}")
async def delete_agent(agent_name: str):
"""Delete an agent directory from agents/."""
agent_dir = _home() / "agents" / agent_name
if not agent_dir.exists():
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
try:
if agent_dir.is_dir():
shutil.rmtree(str(agent_dir))
else:
agent_dir.unlink()
log.info(f"Agent deleted: {agent_name}")
return {"message": f"Agent '{agent_name}' deleted"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/agents/{agent_name}/files")
async def list_agent_identity_files(agent_name: str):
"""List identity files for an agent from workspace/<agent_name>/."""
ws_dir = _workspace() / agent_name
if not ws_dir.is_dir():
return {"files": [], "workspace": str(ws_dir), "exists": False}
files = []
for fname in AGENT_IDENTITY_FILES:
fpath = ws_dir / fname
if fpath.is_file():
try:
stat = fpath.stat()
files.append({
"name": fname,
"size": stat.st_size,
"modified": datetime.fromtimestamp(
stat.st_mtime, tz=timezone.utc
).isoformat(),
})
except OSError:
files.append({"name": fname, "size": 0, "modified": None})
# Also list other non-identity files in the workspace
other_files = []
try:
for child in sorted(ws_dir.iterdir()):
if child.is_file() and child.name not in AGENT_IDENTITY_FILES:
try:
other_files.append({
"name": child.name,
"size": child.stat().st_size,
})
except OSError:
other_files.append({"name": child.name, "size": 0})
except PermissionError:
pass
return {
"files": files,
"other_files": other_files,
"workspace": str(ws_dir),
"exists": True,
}
@router.get("/agents/{agent_name}/file")
async def read_agent_file(
agent_name: str,
filename: str = Query(..., description="Filename to read"),
):
"""Read an agent identity file content."""
ws_dir = _workspace() / agent_name
file_path = ws_dir / filename
# Security: ensure the file is within the workspace directory
if not str(file_path.resolve()).startswith(str(ws_dir.resolve())):
raise HTTPException(status_code=403, detail="Path traversal denied")
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File '{filename}' not found")
try:
content = await asyncio.to_thread(
file_path.read_text, encoding="utf-8", errors="replace"
)
return {
"content": content,
"filename": filename,
"size": file_path.stat().st_size,
"language": _LANG_MAP.get(file_path.suffix.lower(), "text"),
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/agents/{agent_name}/file")
async def write_agent_file(
agent_name: str,
filename: str = Query(..., description="Filename to write"),
content: str = Body(..., embed=True),
):
"""Write/update an agent identity file."""
ws_dir = _workspace() / agent_name
file_path = ws_dir / filename
# Security: ensure the file is within the workspace directory
if not str(file_path.resolve()).startswith(str(ws_dir.resolve())):
raise HTTPException(status_code=403, detail="Path traversal denied")
try:
ws_dir.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(
file_path.write_text, content, encoding="utf-8"
)
log.info(f"Agent file written: {agent_name}/{filename}")
return {
"message": f"File '{filename}' saved",
"size": file_path.stat().st_size,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ═════════════════════════════════════════════════════════════════════════════════
# 4. Skills
# ═════════════════════════════════════════════════════════════════════════════════
@ -221,7 +413,6 @@ async def list_openclaw_agents():
@router.get("/skills")
async def list_openclaw_skills():
"""List available skills from the skills/ directory."""
# Skills can be in multiple locations
possible_dirs = [
_home() / "skills",
_home() / "workspace" / "skills",
@ -239,32 +430,34 @@ async def list_openclaw_skills():
if entry.is_dir():
skill["type"] = "directory"
# Look for SKILL.md or README.md
for doc_name in ("SKILL.md", "README.md", "skill.md", "readme.md"):
doc = entry / doc_name
if doc.is_file():
try:
content = doc.read_text(encoding="utf-8", errors="replace")
# Extract description from YAML frontmatter or first lines
skill["description"] = content[:500]
# Try extracting YAML description
if content.startswith("---"):
end = content.find("---", 3)
if end > 0:
frontmatter = content[3:end].strip()
for line in frontmatter.split("\n"):
if line.startswith("description:"):
skill["description_short"] = line.split(":", 1)[1].strip().strip("'\"")
skill["description_short"] = (
line.split(":", 1)[1]
.strip()
.strip("'\"")
)
break
except Exception:
pass
break
# Count files
try:
all_files = list(entry.rglob("*"))
skill["file_count"] = len([f for f in all_files if f.is_file()])
skill["subdirs"] = [d.name for d in entry.iterdir() if d.is_dir()]
skill["subdirs"] = [
d.name for d in entry.iterdir() if d.is_dir()
]
except Exception:
skill["file_count"] = 0
@ -288,28 +481,30 @@ async def list_openclaw_skills():
@router.get("/logs")
async def get_gateway_logs(
lines: int = Query(200, ge=10, le=2000),
level: Optional[str] = Query(None, description="Filter by log level: INFO, WARNING, ERROR"),
level: Optional[str] = Query(
None, description="Filter by log level: INFO, WARNING, ERROR"
),
):
"""Read the last N lines of the gateway log."""
log_file = _home() / "logs" / "gateway.log"
if not log_file.is_file():
# Try alternate locations
alt = _home() / "gateway.log"
if alt.is_file():
log_file = alt
else:
return {"lines": [], "error": "Gateway log file not found", "log_path": str(log_file)}
return {
"lines": [],
"error": "Gateway log file not found",
"log_path": str(log_file),
}
try:
content = await asyncio.to_thread(
log_file.read_text, encoding="utf-8", errors="replace"
)
all_lines = content.strip().split("\n")
# Take last N lines
result_lines = all_lines[-lines:]
# Filter by level if requested
if level:
level_upper = level.upper()
result_lines = [l for l in result_lines if level_upper in l.upper()]
@ -337,22 +532,30 @@ async def list_models():
if data is None:
return {"models": [], "providers": [], "error": "openclaw.json not found"}
# Extract providers
providers = []
providers_cfg = data.get("mcpServers", data.get("providers", data.get("llm", {})))
providers_cfg = data.get(
"mcpServers", data.get("providers", data.get("llm", {}))
)
if isinstance(providers_cfg, dict):
for name, cfg in providers_cfg.items():
provider: dict[str, Any] = {
"name": name,
"type": cfg.get("type", cfg.get("provider", "unknown")) if isinstance(cfg, dict) else "unknown",
"type": (
cfg.get("type", cfg.get("provider", "unknown"))
if isinstance(cfg, dict)
else "unknown"
),
}
if isinstance(cfg, dict):
provider["enabled"] = cfg.get("enabled", True)
provider["model"] = cfg.get("model", cfg.get("modelId", None))
provider["endpoint"] = cfg.get("endpoint", cfg.get("baseUrl", cfg.get("url", None)))
provider["model"] = cfg.get(
"model", cfg.get("modelId", None)
)
provider["endpoint"] = cfg.get(
"endpoint", cfg.get("baseUrl", cfg.get("url", None))
)
providers.append(provider)
# Extract models list if present
models = []
models_cfg = data.get("models", [])
if isinstance(models_cfg, list):
@ -389,3 +592,81 @@ async def get_filesystem():
tree = await asyncio.to_thread(_dir_tree, home)
return {"root": str(home), "tree": tree}
# ═════════════════════════════════════════════════════════════════════════════════
# 8. File Read / Write (for Arborescence editor)
# ═════════════════════════════════════════════════════════════════════════════════
@router.get("/file")
async def read_file(
path: str = Query(..., description="Relative path under FOXY_HOME"),
):
"""Read a file from the OpenClaw home directory."""
file_path = _safe_path(path)
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
suffix = file_path.suffix.lower()
language = _LANG_MAP.get(suffix, "text")
is_text = suffix in _TEXT_EXTENSIONS or suffix == ""
if not is_text:
# For binary files, just return metadata
return {
"content": None,
"binary": True,
"path": path,
"size": file_path.stat().st_size,
"language": language,
}
try:
content = await asyncio.to_thread(
file_path.read_text, encoding="utf-8", errors="replace"
)
# Pretty-print JSON if applicable
pretty = None
if suffix == ".json":
try:
parsed = json.loads(content)
pretty = json.dumps(parsed, indent=2, ensure_ascii=False)
except json.JSONDecodeError:
pass
return {
"content": content,
"pretty": pretty,
"binary": False,
"path": path,
"size": file_path.stat().st_size,
"language": language,
"modified": datetime.fromtimestamp(
file_path.stat().st_mtime, tz=timezone.utc
).isoformat(),
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/file")
async def write_file(
path: str = Query(..., description="Relative path under FOXY_HOME"),
content: str = Body(..., embed=True),
):
"""Write/update a file in the OpenClaw home directory."""
file_path = _safe_path(path)
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(file_path.write_text, content, encoding="utf-8")
log.info(f"File written: {path}")
return {
"message": f"File '{path}' saved",
"size": file_path.stat().st_size,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,33 @@
# 🚀 Propositions d'Innovations pour OpenClaw Dashboard
Voici 6 propositions de fonctionnalités innovantes pour amener la section OpenClaw du dashboard Foxy Dev Team au niveau supérieur en termes de professionnalisme et de puissance d'intégration multi-agents.
## 1. Visualisateur de Workflows Multi-Agents (Map)
**Concept** : Un canevas interactif (drag & drop) permettant de visualiser comment les agents interagissent entre eux.
- **Utilité** : D'un simple coup d'œil, voir quel agent appelle quel skill, ou quel agent délègue une tâche à un autre.
- **Technologie** : Intégration de `React Flow` pour dessiner des graphes relationnels générés automatiquement depuis la configuration des agents.
## 2. Terminal Logs "Real-Time" (WebSockets)
**Concept** : Remplacer le rafraîchissement manuel (polling API) par un flux continu en temps réel de logs.
- **Utilité** : Le terminal ressemble à un vrai terminal Linux, réactif à la milliseconde sans surcharger le serveur avec des requêtes HTTP.
- **Technologie** : Implémenter des WebSockets dans l'API FastAPI (`app.routers.openclaw`) et utiliser un vrai composant terminal web comme `xterm.js`.
## 3. Playground "Bac à Sable" d'Agent
**Concept** : Un espace intégré pour discuter directement avec un agent OpenClaw en isolement.
- **Utilité** : Permet de tester un `system_prompt` ou un [skill](file:///c:/dev/git/openclaw/FoxyDevTeam/foxy-dev-team/backend/app/routers/openclaw.py#413-474) que l'on vient de modifier via l'éditeur CodeMirror, sans avoir à lancer l'intégralité du workflow global. Voir en temps réel les `Tool Calls` JSON passer.
- **Technologie** : Une interface de chat (similaire à ChatGPT) branchée directement sur l'agent ciblé via l'API OpenClaw.
## 4. "Marketplace" de Skills / Registry
**Concept** : Un onglet permettant de découvrir, installer et mettre à jour des Skills OpenClaw depuis des dépôts distants (GitHub, registre interne).
- **Utilité** : Démocratiser l'usage et la réutilisabilité des Skills. Au lieu de copier-coller du code Python, un développeur clique sur "Installer" et le Skill est injecté dans `FOXY_HOME/skills/`.
- **Technologie** : API backend capable de `git clone` ou télécharger des sources, couplé à une belle UI de "Store".
## 5. Débogage "Time-Travel" (Voyage dans le temps)
**Concept** : Garder un historique de l'état mental (mémoire) des agents à chaque étape d'une mission.
- **Utilité** : Lorsqu'un agent fait une erreur ou une hallucination, le développeur peut "rembobiner" l'état de l'agent pour voir exactement ce qu'il a perçu à l'étape -3 et pourquoi il a pris sa décision.
- **Technologie** : Affichage côté frontend des checkpoints d'exécution (`HEARTBEAT.md`, traces JSON).
## 6. Analyseur IA des Erreurs Gateway
**Concept** : Utiliser un LLM (via un des providers configurés d'OpenClaw) pour lire les logs Gateway quand une erreur survient, et proposer une solution (Auto-healing).
- **Utilité** : Au lieu de lire une stack-trace ardue dans l'onglet Logs, l'UI affiche un composant magique "Explication IA : L'erreur vient du port 18789 qui est déjà utilisé. Cliquez ici pour libérer le port."
- **Technologie** : Prompt engineering injectant les 50 dernières lignes du log d'erreur à un modèle LLM interne au dashboard.

View File

@ -8,6 +8,13 @@
"name": "foxy-dev-team-dashboard",
"version": "2.0.0",
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@uiw/react-codemirror": "^4.25.8",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
@ -256,6 +263,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -304,6 +320,184 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz",
"integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz",
"integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.40.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz",
"integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -796,6 +990,101 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz",
"integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1497,6 +1786,59 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.8",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.8.tgz",
"integrity": "sha512-9Rr+liiBmK4xzZHszL+twNRJApthqmITBwDP3emNTtTrkBFN4gHlqfp+nodKmoVt1+bUH1qQCtyqt+7dbDTHiw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/autocomplete": ">=6.0.0",
"@codemirror/commands": ">=6.0.0",
"@codemirror/language": ">=6.0.0",
"@codemirror/lint": ">=6.0.0",
"@codemirror/search": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/@uiw/react-codemirror": {
"version": "4.25.8",
"resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.8.tgz",
"integrity": "sha512-A0aLOuJZm2yJ+U9GlMFwxwFciztjd5LhcAG4SMqFxdD58wH+sCQXuY4UU5J2hqgS390qAlShtUgREvJPUonbuQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@codemirror/commands": "^6.1.0",
"@codemirror/state": "^6.1.1",
"@codemirror/theme-one-dark": "^6.0.0",
"@uiw/codemirror-extensions-basic-setup": "4.25.8",
"codemirror": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.11.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/theme-one-dark": ">=6.0.0",
"@codemirror/view": ">=6.0.0",
"codemirror": ">=6.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -1586,6 +1928,21 @@
],
"license": "CC-BY-4.0"
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1606,6 +1963,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -2316,6 +2679,12 @@
"node": ">=0.10.0"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
@ -2474,6 +2843,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -9,17 +9,24 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@uiw/react-codemirror": "^4.25.8",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"@tailwindcss/vite": "^4.0.0",
"tailwindcss": "^4.0.0"
"vite": "^6.0.0"
}
}

View File

@ -173,6 +173,8 @@ export interface OpenClawAgent {
system_prompt?: string;
system_prompt_preview?: string;
file_count?: number;
has_workspace?: boolean;
identity_files?: { name: string; size: number }[];
}
export interface OpenClawSkill {
@ -209,6 +211,41 @@ export interface FileTreeNode {
children?: FileTreeNode[];
}
export interface ReadFileResult {
content: string | null;
pretty?: string | null;
binary: boolean;
path: string;
size: number;
language: string;
modified?: string;
}
export interface WriteFileResult {
message: string;
size: number;
}
export interface IdentityFile {
name: string;
size: number;
modified?: string | null;
}
export interface AgentFilesResult {
files: IdentityFile[];
other_files: IdentityFile[];
workspace: string;
exists: boolean;
}
export interface AgentFileResult {
content: string;
filename: string;
size: number;
language: string;
}
// ─── HTTP Helpers ───────────────────────────────────────────────────────────
async function request<T>(path: string, options?: RequestInit): Promise<T> {
@ -291,15 +328,31 @@ export const api = {
// OpenClaw
openclawStatus: () => request<OpenClawStatus>('/api/openclaw/status'),
openclawConfig: () => request<{ config: Record<string, unknown> | null; error?: string }>('/api/openclaw/config'),
openclawConfig: () => request<{ config: Record<string, unknown> }>('/api/openclaw/config'),
openclawAgents: () => request<{ agents: OpenClawAgent[]; count: number }>('/api/openclaw/agents'),
openclawDeleteAgent: (name: string) => request<{ message: string }>(`/api/openclaw/agents/${encodeURIComponent(name)}`, { method: 'DELETE' }),
openclawAgentFiles: (name: string) => request<AgentFilesResult>(`/api/openclaw/agents/${encodeURIComponent(name)}/files`),
openclawReadAgentFile: (name: string, filename: string) =>
request<AgentFileResult>(`/api/openclaw/agents/${encodeURIComponent(name)}/file?filename=${encodeURIComponent(filename)}`),
openclawWriteAgentFile: (name: string, filename: string, content: string) =>
request<WriteFileResult>(`/api/openclaw/agents/${encodeURIComponent(name)}/file?filename=${encodeURIComponent(filename)}`, {
method: 'PUT',
body: JSON.stringify({ content }),
}),
openclawSkills: () => request<{ skills: OpenClawSkill[]; count: number }>('/api/openclaw/skills'),
openclawLogs: (params?: { lines?: number; level?: string }) => {
const qs = params ? '?' + new URLSearchParams(
Object.fromEntries(Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]))
).toString() : '';
return request<OpenClawLogs>(`/api/openclaw/logs${qs}`);
openclawLogs: (params: { lines?: number; level?: string }) => {
const qs = new URLSearchParams();
if (params.lines) qs.set('lines', params.lines.toString());
if (params.level) qs.set('level', params.level);
return request<OpenClawLogs>(`/api/openclaw/logs?${qs.toString()}`);
},
openclawModels: () => request<OpenClawModels>('/api/openclaw/models'),
openclawFilesystem: () => request<{ root: string; tree: FileTreeNode[] }>('/api/openclaw/filesystem'),
openclawReadFile: (path: string) =>
request<ReadFileResult>(`/api/openclaw/file?path=${encodeURIComponent(path)}`),
openclawWriteFile: (path: string, content: string) =>
request<WriteFileResult>(`/api/openclaw/file?path=${encodeURIComponent(path)}`, {
method: 'PUT',
body: JSON.stringify({ content }),
}),
};

View File

@ -1,7 +1,12 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark';
import type {
OpenClawStatus, OpenClawAgent, OpenClawSkill, OpenClawLogs,
OpenClawModels, FileTreeNode,
OpenClawModels, FileTreeNode, ReadFileResult
} from '../api/client';
import { api } from '../api/client';
@ -11,6 +16,7 @@ import { api } from '../api/client';
const TABS = [
{ id: 'status', label: 'Status', icon: '🔋' },
{ id: 'filesystem', label: 'Arborescence', icon: '📂' },
{ id: 'config', label: 'Configuration', icon: '⚙️' },
{ id: 'agents', label: 'Agents', icon: '🤖' },
{ id: 'skills', label: 'Skills', icon: '🧩' },
@ -93,6 +99,7 @@ export default function OpenClawPage() {
{/* Tab Content */}
<div className="animate-fade-in">
{activeTab === 'status' && <StatusTab status={status} />}
{activeTab === 'filesystem' && <FilesystemTab />}
{activeTab === 'config' && <ConfigTab />}
{activeTab === 'agents' && <AgentsTab />}
{activeTab === 'skills' && <SkillsTab />}
@ -109,15 +116,6 @@ export default function OpenClawPage() {
// ═════════════════════════════════════════════════════════════════════════════════
function StatusTab({ status }: { status: OpenClawStatus | null }) {
const [tree, setTree] = useState<FileTreeNode[]>([]);
const [treeLoading, setTreeLoading] = useState(true);
useEffect(() => {
api.openclawFilesystem()
.then(d => { setTree(d.tree); setTreeLoading(false); })
.catch(() => setTreeLoading(false));
}, []);
if (!status) return <EmptyState message="Impossible de charger le status" />;
const metrics = [
@ -172,24 +170,6 @@ function StatusTab({ status }: { status: OpenClawStatus | null }) {
<MetricCard key={m.label} {...m} />
))}
</div>
{/* Filesystem tree */}
<div className="glass-card p-6">
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span>📂</span> Arborescence FOXY_HOME
</h2>
{treeLoading ? (
<div className="text-gray-500 text-sm animate-pulse">Chargement de l'arborescence</div>
) : tree.length === 0 ? (
<p className="text-gray-500 text-sm">Aucun fichier trouvé</p>
) : (
<div className="font-mono text-xs max-h-80 overflow-y-auto space-y-0.5">
{tree.map(node => (
<TreeNode key={node.path} node={node} depth={0} />
))}
</div>
)}
</div>
</div>
);
}
@ -217,7 +197,7 @@ function MetricCard({ label, value, accent, icon }: { label: string; value: stri
}
function TreeNode({ node, depth }: { node: FileTreeNode; depth: number }) {
function TreeNode({ node, depth, onSelect }: { node: FileTreeNode; depth: number, onSelect?: (n: FileTreeNode) => void }) {
const [open, setOpen] = useState(depth < 1);
const isDir = node.type === 'directory';
const indent = depth * 20;
@ -225,11 +205,14 @@ function TreeNode({ node, depth }: { node: FileTreeNode; depth: number }) {
return (
<div>
<div
className={`flex items-center gap-2 py-1 px-2 rounded-lg transition-colors ${
isDir ? 'hover:bg-surface-700/50 cursor-pointer' : 'text-gray-400'
className={`flex items-center gap-2 py-1 px-2 rounded-lg transition-colors cursor-pointer ${
isDir ? 'hover:bg-surface-700/50' : 'hover:bg-surface-700/50 text-gray-400'
}`}
style={{ paddingLeft: `${indent + 8}px` }}
onClick={() => isDir && setOpen(!open)}
onClick={() => {
if (isDir) setOpen(!open);
else onSelect?.(node);
}}
>
{isDir ? (
<span className="text-yellow-400 w-4 text-center">{open ? '📂' : '📁'}</span>
@ -242,7 +225,7 @@ function TreeNode({ node, depth }: { node: FileTreeNode; depth: number }) {
)}
</div>
{isDir && open && node.children?.map(child => (
<TreeNode key={child.path} node={child} depth={depth + 1} />
<TreeNode key={child.path} node={child} depth={depth + 1} onSelect={onSelect} />
))}
</div>
);
@ -356,22 +339,83 @@ function AgentsTab() {
const [loading, setLoading] = useState(true);
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
useEffect(() => {
const [deleting, setDeleting] = useState<string | null>(null);
const [activeFile, setActiveFile] = useState<{agent: string, file: string} | null>(null);
const [fileContent, setFileContent] = useState('');
const [fileLoading, setFileLoading] = useState(false);
const [saving, setSaving] = useState(false);
const loadAgents = () => {
setLoading(true);
api.openclawAgents()
.then(d => { setAgents(d.agents); setLoading(false); })
.catch(() => setLoading(false));
}, []);
};
if (loading) return <LoadingSpinner />;
useEffect(() => { loadAgents(); }, []);
const handleDelete = async (e: React.MouseEvent, name: string) => {
e.stopPropagation();
if (!confirm(`Voulez-vous vraiment supprimer l'agent "${name}" ?\n\n⚠ Cette action supprimera définitivement son dossier de configuration.`)) return;
setDeleting(name);
try {
await api.openclawDeleteAgent(name);
if (expandedAgent === name) setExpandedAgent(null);
loadAgents();
} catch(err) {
console.error(err);
alert('Erreur lors de la suppression: ' + String(err));
} finally {
setDeleting(null);
}
};
const openFile = async (agentName: string, filename: string) => {
if (activeFile?.agent === agentName && activeFile?.file === filename) {
setActiveFile(null); // toggle close
return;
}
setActiveFile({ agent: agentName, file: filename });
setFileLoading(true);
try {
const data = await api.openclawReadAgentFile(agentName, filename);
setFileContent(data.content || '');
} catch(err) {
console.error(err);
setFileContent('Erreur de chargement du fichier.');
} finally {
setFileLoading(false);
}
};
const saveFile = async () => {
if (!activeFile) return;
setSaving(true);
try {
await api.openclawWriteAgentFile(activeFile.agent, activeFile.file, fileContent);
loadAgents(); // refresh file sizes
setActiveFile(null); // close the editor after saving
} catch(err) {
console.error(err);
alert('Erreur lors de la sauvegarde: ' + String(err));
} finally {
setSaving(false);
}
};
if (loading && agents.length === 0) return <LoadingSpinner />;
if (agents.length === 0) return <EmptyState message="Aucun agent trouvé dans le répertoire agents/" />;
return (
<div className="space-y-4">
{/* Agent count header */}
<div className="glass-card p-4 flex items-center gap-3">
<div className="glass-card p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-lg">🤖</span>
<span className="text-white font-semibold">{agents.length} agents</span>
<span className="text-gray-500 text-sm">détectés dans le répertoire OpenClaw</span>
<span className="text-gray-500 text-sm hidden sm:inline">détectés dans le répertoire OpenClaw</span>
</div>
<button onClick={loadAgents} className="text-gray-400 hover:text-white transition-colors" title="Actualiser">🔄</button>
</div>
{/* Agent grid */}
@ -384,55 +428,122 @@ function AgentsTab() {
}`}
>
<div
className="p-5 cursor-pointer hover:bg-glass-hover transition-colors"
onClick={() => setExpandedAgent(expandedAgent === agent.name ? null : agent.name)}
className={`p-5 cursor-pointer hover:bg-glass-hover transition-colors ${deleting === agent.name ? 'opacity-50 pointer-events-none' : ''}`}
onClick={() => { setExpandedAgent(expandedAgent === agent.name ? null : agent.name); setActiveFile(null); }}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-600/10 border border-amber-500/20 flex items-center justify-center text-xl">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-600/10 border border-amber-500/20 flex items-center justify-center text-xl shrink-0">
🤖
</div>
<div>
<h3 className="text-white font-semibold text-sm">{agent.name}</h3>
<div className="flex items-center gap-2 mt-1">
<div className="min-w-0">
<h3 className="text-white font-semibold text-sm truncate pr-2">{agent.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="badge bg-surface-600 text-gray-300 text-[10px]">{agent.type}</span>
{agent.model && (
<span className="badge bg-purple-500/15 text-purple-300 text-[10px]">{agent.model}</span>
<span className="badge bg-purple-500/15 text-purple-300 text-[10px] truncate max-w-[150px]">{agent.model}</span>
)}
{agent.file_count != null && (
<span className="text-[10px] text-gray-600">{agent.file_count} fichiers</span>
{agent.has_workspace && (
<span className="badge bg-green-500/15 text-green-300 text-[10px]" title="A un dossier workspace défini">ws </span>
)}
</div>
</div>
</div>
<span className="text-gray-600 text-sm">{expandedAgent === agent.name ? '▲' : '▼'}</span>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={(e) => handleDelete(e, agent.name)}
className="p-1.5 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-400/10 transition-colors"
title="Supprimer l'agent"
>
🗑
</button>
<span className="text-gray-600 text-sm ml-2 w-4 text-center">
{expandedAgent === agent.name ? '▲' : '▼'}
</span>
</div>
{/* Config files */}
{agent.config_files && agent.config_files.length > 0 && (
<div className="flex gap-2 mt-3 flex-wrap">
{agent.config_files.map(f => (
<span key={f} className="px-2 py-0.5 rounded-md bg-surface-700 text-gray-400 text-[10px] font-mono">{f}</span>
))}
</div>
)}
</div>
{/* Expanded details */}
{expandedAgent === agent.name && (
<div className="border-t border-glass-border p-5 bg-surface-900/40 space-y-4">
{agent.system_prompt && (
<div className="border-t border-glass-border p-5 bg-surface-900/40 space-y-6">
{/* Agent Identity Files Manager */}
{agent.has_workspace && agent.identity_files && agent.identity_files.length > 0 && (
<div>
<h4 className="text-xs text-gray-500 uppercase tracking-wider mb-2">System Prompt</h4>
<div className="p-3 rounded-xl bg-surface-800 text-xs text-gray-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto leading-relaxed">
<h4 className="text-xs text-gray-400 font-bold uppercase tracking-wider mb-3 flex items-center gap-2">
<span>🧠</span> Fichiers d'identité
</h4>
<div className="flex gap-2 flex-wrap mb-3">
{agent.identity_files.map(file => {
const isActive = activeFile?.agent === agent.name && activeFile?.file === file.name;
return (
<button
key={file.name}
onClick={() => openFile(agent.name, file.name)}
className={`px-3 py-1.5 rounded-lg text-xs font-mono transition-all ${
isActive
? 'bg-gradient-to-r from-purple-600 to-indigo-600 text-white shadow-[0_0_10px_rgba(147,51,234,0.3)]'
: file.size > 0
? 'bg-surface-800 text-gray-300 border border-surface-600 hover:border-purple-500/50 hover:bg-surface-700'
: 'bg-surface-800 text-gray-600 border border-surface-700/50 opacity-60 hover:opacity-100 hover:text-gray-400'
}`}
>
{file.name}
{file.size > 0 && <span className="ml-1.5 opacity-60 text-[9px]">{formatSize(file.size)}</span>}
</button>
);
})}
</div>
{/* Identity File Editor */}
{activeFile?.agent === agent.name && (
<div className="rounded-xl border border-glass-border bg-[#282c34] overflow-hidden shadow-inner mt-4 animate-fade-in relative">
<div className="flex items-center justify-between px-3 py-2 bg-surface-900/80 border-b border-glass-border">
<span className="text-xs font-mono text-purple-300">workspace/{agent.name}/{activeFile.file}</span>
<div className="flex gap-2">
<button onClick={() => setActiveFile(null)} className="px-2 py-1 rounded text-[10px] text-gray-400 hover:bg-surface-700 hover:text-white transition-colors">Fermer</button>
<button onClick={saveFile} disabled={saving} className="px-3 py-1 rounded bg-purple-600/80 hover:bg-purple-500 text-white text-[10px] font-medium transition-colors">
{saving ? '...' : 'Sauvegarder'}
</button>
</div>
</div>
{fileLoading ? (
<div className="p-8 text-center text-gray-500 text-sm animate-pulse">Chargement</div>
) : (
<div className="max-h-80 overflow-y-auto custom-scrollbar [&_.cm-editor]:bg-transparent [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-xs">
<CodeMirror
value={fileContent}
theme={oneDark}
extensions={[markdown()]}
onChange={(val) => setFileContent(val)}
basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: true, tabSize: 2 }}
/>
</div>
)}
</div>
)}
</div>
)}
{/* System Prompt (Read-only overlay for old config style) */}
{agent.system_prompt && !activeFile && (
<div>
<h4 className="text-xs text-gray-400 font-bold uppercase tracking-wider mb-2">Instructions principales</h4>
<div className="px-4 py-3 rounded-xl bg-surface-800/80 text-xs text-gray-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto leading-relaxed custom-scrollbar border border-surface-700/50">
{agent.system_prompt}
</div>
</div>
)}
{agent.config && (
{/* Configuration (Read-only) */}
{agent.config && !activeFile && (
<div>
<h4 className="text-xs text-gray-500 uppercase tracking-wider mb-2">Configuration complète</h4>
<pre className="p-3 rounded-xl bg-surface-800 text-xs text-gray-300 font-mono whitespace-pre-wrap max-h-64 overflow-y-auto leading-relaxed">
<h4 className="text-xs text-gray-400 font-bold uppercase tracking-wider mb-2 flex justify-between items-center">
<span>Configuration JSON/YAML</span>
<span className="font-mono lowercase text-[10px] text-gray-500">{agent.config_files?.join(', ')}</span>
</h4>
<pre className="px-4 py-3 rounded-xl bg-surface-800/80 text-[11px] text-gray-400 font-mono whitespace-pre-wrap max-h-64 overflow-y-auto leading-relaxed custom-scrollbar border border-surface-700/50">
{JSON.stringify(agent.config, null, 2)}
</pre>
</div>
@ -815,3 +926,186 @@ function EmptyState({ message }: { message: string }) {
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════════
// Tab 7 — Filesystem (Arborescence)
// ═════════════════════════════════════════════════════════════════════════════════
function FilesystemTab() {
const [tree, setTree] = useState<FileTreeNode[]>([]);
const [treeLoading, setTreeLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState<ReadFileResult | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [editContent, setEditContent] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
loadTree();
}, []);
const loadTree = () => {
setTreeLoading(true);
api.openclawFilesystem()
.then(d => { setTree(d.tree); setTreeLoading(false); })
.catch(() => setTreeLoading(false));
};
const handleSelectFile = async (node: FileTreeNode) => {
if (node.type !== 'file') return;
setFileLoading(true);
setEditMode(false);
try {
const data = await api.openclawReadFile(node.path);
setSelectedFile(data);
setEditContent(data.pretty || data.content || '');
} catch (err) {
console.error(err);
} finally {
setFileLoading(false);
}
};
const handleSave = async () => {
if (!selectedFile) return;
setSaving(true);
try {
await api.openclawWriteFile(selectedFile.path, editContent);
const data = await api.openclawReadFile(selectedFile.path);
setSelectedFile(data);
setEditMode(false);
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
};
const getExtensions = () => {
if (!selectedFile) return [];
const lang = selectedFile.language;
if (lang === 'json') return [json()];
if (lang === 'markdown') return [markdown()];
if (lang === 'yaml') return [yaml()];
return [];
};
return (
<div className="flex gap-6 h-[calc(100vh-250px)] min-h-[600px]">
{/* Sidebar Tree */}
<div className="glass-card w-1/3 p-4 flex flex-col border border-glass-border">
<div className="flex items-center justify-between mb-4 px-2">
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<span>📂</span> Arborescence
</h2>
<button onClick={loadTree} className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-surface-700 transition-colors" title="Actualiser">🔄</button>
</div>
<div className="flex-1 overflow-y-auto font-mono text-[13px] pr-2 custom-scrollbar">
{treeLoading ? (
<div className="text-gray-500 animate-pulse px-2">Chargement de l'arborescence</div>
) : tree.length === 0 ? (
<p className="text-gray-500 px-2">Aucun fichier trouvé</p>
) : (
<div className="space-y-0.5">
{tree.map(node => (
<TreeNode key={node.path} node={node} depth={0} onSelect={handleSelectFile} />
))}
</div>
)}
</div>
</div>
{/* Editor Panel */}
<div className="glass-card w-2/3 flex flex-col overflow-hidden border border-glass-border shadow-[0_8px_30px_rgb(0,0,0,0.5)]">
{fileLoading ? (
<div className="flex-1 flex items-center justify-center bg-surface-900/40">
<div className="text-gray-400 flex flex-col items-center gap-3">
<span className="text-3xl animate-spin-slow"></span>
<span className="text-sm tracking-wide">Ouverture du fichier</span>
</div>
</div>
) : !selectedFile ? (
<div className="flex-1 flex items-center justify-center text-gray-500 flex-col gap-4 bg-surface-900/40">
<span className="text-5xl opacity-30 grayscale">📄</span>
<p className="tracking-wide">Sélectionnez un fichier pour le visualiser</p>
</div>
) : (
<div className="flex flex-col h-full">
{/* Editor Header */}
<div className="p-4 bg-surface-800 border-b border-glass-border flex items-center justify-between z-10 shrink-0">
<div className="min-w-0 pr-4">
<h3 className="text-white font-medium flex items-center gap-2 text-base truncate">
<span className="text-xl">📄</span> {selectedFile.path.split('/').pop()}
</h3>
<div className="flex items-center gap-3 mt-1 text-[11px] text-gray-400 font-mono">
<span className="truncate max-w-[200px] md:max-w-xs" title={selectedFile.path}>{selectedFile.path}</span>
<span className="opacity-50"></span>
<span>{formatSize(selectedFile.size)}</span>
<span className="opacity-50"></span>
<span className="uppercase text-purple-300 bg-purple-500/10 px-1.5 py-0.5 rounded">{selectedFile.language}</span>
</div>
</div>
<div className="flex gap-2 shrink-0">
{!selectedFile.binary && (
<>
{editMode ? (
<>
<button onClick={() => setEditMode(false)} disabled={saving} className="px-3.5 py-1.5 rounded-lg bg-surface-700 text-gray-300 text-sm hover:bg-surface-600 transition-colors">
Annuler
</button>
<button onClick={handleSave} disabled={saving} className={`px-4 py-1.5 rounded-lg bg-gradient-to-r from-emerald-600 to-teal-600 text-white text-sm font-medium hover:from-emerald-500 hover:to-teal-500 transition-all shadow-[0_0_12px_rgba(16,185,129,0.3)] flex items-center gap-2 ${saving ? 'opacity-50 cursor-not-allowed scale-95' : 'hover:scale-105'}`}>
{saving ? 'Sauvegarde…' : '💾 Sauvegarder'}
</button>
</>
) : (
<button onClick={() => { setEditMode(true); setEditContent(selectedFile.pretty || selectedFile.content || ''); }} className="px-4 py-1.5 rounded-lg bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-sm font-medium hover:from-purple-500 hover:to-indigo-500 transition-all shadow-[0_0_12px_rgba(147,51,234,0.3)] hover:scale-105 flex items-center gap-2">
<span></span> Modifier
</button>
)}
</>
)}
</div>
</div>
{/* Editor Body */}
<div className="flex-1 overflow-hidden relative bg-[#282c34]">
{selectedFile.binary ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-500 bg-surface-900/60">
<div className="text-center">
<div className="text-5xl mb-3 opacity-30">📦</div>
<p className="tracking-wide">Fichier binaire. Aperçu non disponible.</p>
</div>
</div>
) : editMode ? (
<div className="h-full w-full absolute inset-0 [&_.cm-editor]:h-full [&_.cm-scroller]:h-full [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[13px]">
<CodeMirror
value={editContent}
height="100%"
theme={oneDark}
extensions={getExtensions()}
onChange={(val) => setEditContent(val)}
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
tabSize: 2,
}}
/>
</div>
) : (
<div className="h-full overflow-y-auto w-full custom-scrollbar absolute inset-0">
<div className="p-4 min-h-full">
<pre className="text-[13px] text-[#abb2bf] font-mono whitespace-pre-wrap leading-relaxed">
{selectedFile.pretty || selectedFile.content}
</pre>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}