chore: mini-openclaw 初始版本
Next.js 前端 + FastAPI/LangChain 后端透明 agent平台 - 含 .gitignore, 敏感文件(sessions数据/.env/venv等)已排除 - 待后续添加 trajectory 自进化机制
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"""GET/POST /api/files + GET /api/skills — File read/write for Monaco editor."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Whitelist of editable directories (relative to backend/)
|
||||
ALLOWED_PREFIXES = ["workspace/", "memory/", "skills/", "knowledge/"]
|
||||
|
||||
# Whitelist of specific root-level files that can be accessed
|
||||
ALLOWED_ROOT_FILES = {"SKILLS_SNAPSHOT.md"}
|
||||
|
||||
|
||||
def _validate_path(rel_path: str) -> Path:
|
||||
"""Validate and resolve file path within allowed directories."""
|
||||
normalized = rel_path.replace("\\", "/").lstrip("./")
|
||||
if not (
|
||||
any(normalized.startswith(prefix) for prefix in ALLOWED_PREFIXES)
|
||||
or normalized in ALLOWED_ROOT_FILES
|
||||
):
|
||||
raise HTTPException(status_code=403, detail=f"Access denied: {rel_path}")
|
||||
full_path = (BASE_DIR / normalized).resolve()
|
||||
if not str(full_path).startswith(str(BASE_DIR)):
|
||||
raise HTTPException(status_code=403, detail="Path traversal detected")
|
||||
return full_path
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
async def read_file(path: str):
|
||||
file_path = _validate_path(path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
return {"path": path, "content": content}
|
||||
|
||||
|
||||
class FileSaveRequest(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
@router.post("/files")
|
||||
async def save_file(request: FileSaveRequest):
|
||||
file_path = _validate_path(request.path)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(request.content, encoding="utf-8")
|
||||
|
||||
# Trigger memory index rebuild when MEMORY.md is saved
|
||||
normalized = request.path.replace("\\", "/").lstrip("./")
|
||||
if normalized == "memory/MEMORY.md":
|
||||
try:
|
||||
from graph.memory_indexer import get_memory_indexer
|
||||
|
||||
indexer = get_memory_indexer(BASE_DIR)
|
||||
indexer.rebuild_index()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"path": request.path, "status": "saved"}
|
||||
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills():
|
||||
"""Scan skills/ directory and return skill list with name, path, description."""
|
||||
skills_dir = BASE_DIR / "skills"
|
||||
if not skills_dir.exists():
|
||||
return {"skills": []}
|
||||
|
||||
skills: list[dict[str, str]] = []
|
||||
for skill_dir in sorted(skills_dir.iterdir()):
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
continue
|
||||
|
||||
name = skill_dir.name
|
||||
description = ""
|
||||
rel_path = f"skills/{name}/SKILL.md"
|
||||
|
||||
# Parse YAML frontmatter
|
||||
try:
|
||||
text = skill_md.read_text(encoding="utf-8")
|
||||
if text.startswith("---"):
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
meta = yaml.safe_load(parts[1])
|
||||
if isinstance(meta, dict):
|
||||
name = meta.get("name", name)
|
||||
description = meta.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
skills.append({"name": name, "path": rel_path, "description": description})
|
||||
|
||||
return {"skills": skills}
|
||||
Reference in New Issue
Block a user