feat: implement OpenClaw integration with new API endpoints, frontend page, client, and tests.

This commit is contained in:
Bruno Charest 2026-03-14 11:38:16 -04:00
parent 8c6ef5acb8
commit 3b82d69b22
4 changed files with 274 additions and 21 deletions

View File

@ -12,6 +12,7 @@ import os
import re
import shutil
import socket
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
@ -473,6 +474,77 @@ async def list_openclaw_skills():
return {"skills": skills, "count": len(skills)}
# ═════════════════════════════════════════════════════════════════════════════════
# 4.a Agent Memory (QMD)
# ═════════════════════════════════════════════════════════════════════════════════
@router.get("/agents/{agent_name}/memory")
async def get_agent_memory(agent_name: str, limit: int = 100):
"""Attempt to read the agent's memory (QMD) SQLite database."""
ws_dir = _workspace() / agent_name
if not ws_dir.is_dir():
return {"has_db": False, "error": "Workspace not found"}
db_files = list(ws_dir.rglob("*.sqlite")) + list(ws_dir.rglob("*.db")) + list(ws_dir.rglob("*.qmd"))
if not db_files:
return {"has_db": False, "message": "No SQLite memory database found"}
db_path = db_files[0]
result = {
"has_db": True,
"db_path": str(db_path.relative_to(_home())),
"size": db_path.stat().st_size,
"timeline": [],
"stats": {"total_thoughts": 0}
}
def _read_db():
timeline = []
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Check if common thought tables exist
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = [row["name"] for row in cursor.fetchall()]
result["tables"] = tables
# Very heuristic approach to grab 'thoughts' or 'history'
target_table = None
for t in ["thoughts", "messages", "history", "runs", "memory", "qmd_memory", "events"]:
if t in tables:
target_table = t
break
if target_table:
# Get column names
cursor.execute(f"PRAGMA table_info('{target_table}')")
columns = [c[1] for c in cursor.fetchall()]
query = f"SELECT * FROM {target_table} ORDER BY rowid DESC LIMIT ?"
if "timestamp" in columns or "created_at" in columns:
order_col = "timestamp" if "timestamp" in columns else "created_at"
query = f"SELECT * FROM {target_table} ORDER BY {order_col} DESC LIMIT ?"
cursor.execute(query, (limit,))
rows = cursor.fetchall()
timeline = [dict(r) for r in rows]
# Count total
cursor.execute(f"SELECT COUNT(*) FROM {target_table}")
result["stats"]["total_thoughts"] = cursor.fetchone()[0]
conn.close()
return timeline
except Exception as e:
log.error(f"Error reading QMD db: {e}")
return []
result["timeline"] = await asyncio.to_thread(_read_db)
return result
# ═════════════════════════════════════════════════════════════════════════════════
# 5. Logs
# ═════════════════════════════════════════════════════════════════════════════════

47
backend/test_qmd.py Normal file
View File

@ -0,0 +1,47 @@
import os
from pathlib import Path
import sqlite3
def explore_qmd():
foxy_home_str = os.environ.get("FOXY_HOME", "/home/foxy/.openclaw")
# If running on windows local, this might resolve weirdly.
# We will try the raw string first.
home = Path(foxy_home_str)
# Alternatively try C:\home\foxy\.openclaw if windows
if not home.exists() and os.name == 'nt':
alt_home = Path("C:") / foxy_home_str
if alt_home.exists():
home = alt_home
workspace = home / "workspace"
print("Exploring workspace:", workspace)
print("Exists:", workspace.exists())
if not workspace.exists():
print("Cannot find workspace.")
return
sqlite_files = list(workspace.rglob("*.sqlite")) + list(workspace.rglob("*.db")) + list(workspace.rglob("*.qmd"))
print("Found sqlite files:", len(sqlite_files))
for f in sqlite_files:
print("\n--- SCHEMA FOR", f)
try:
conn = sqlite3.connect(f)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = cursor.fetchall()
for t in tables:
table_name = t[0]
print(f"Table: {table_name}")
cursor.execute(f"PRAGMA table_info('{table_name}')")
columns = cursor.fetchall()
for c in columns:
print(f" - {c[1]} ({c[2]})")
conn.close()
except Exception as e:
print("Error reading db:", e)
if __name__ == "__main__":
explore_qmd()

View File

@ -246,6 +246,17 @@ export interface AgentFileResult {
language: string;
}
export interface AgentMemoryResult {
has_db: boolean;
db_path?: string;
size?: number;
tables?: string[];
timeline: Record<string, any>[];
stats: { total_thoughts: number };
message?: string;
error?: string;
}
// ─── HTTP Helpers ───────────────────────────────────────────────────────────
async function request<T>(path: string, options?: RequestInit): Promise<T> {
@ -339,6 +350,8 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ content }),
}),
openclawAgentMemory: (name: string, limit: number = 100) =>
request<AgentMemoryResult>(`/api/openclaw/agents/${encodeURIComponent(name)}/memory?limit=${limit}`),
openclawSkills: () => request<{ skills: OpenClawSkill[]; count: number }>('/api/openclaw/skills'),
openclawLogs: (params: { lines?: number; level?: string }) => {
const qs = new URLSearchParams();

View File

@ -6,7 +6,7 @@ import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark';
import type {
OpenClawStatus, OpenClawAgent, OpenClawSkill, OpenClawLogs,
OpenClawModels, FileTreeNode, ReadFileResult
OpenClawModels, FileTreeNode, ReadFileResult, AgentMemoryResult
} from '../api/client';
import { api } from '../api/client';
@ -336,13 +336,107 @@ function renderValue(v: unknown): string {
// ═════════════════════════════════════════════════════════════════════════════════
// Tab 3 — Agents
// Tab 3 — Agents (with Memory Viewer)
// ═════════════════════════════════════════════════════════════════════════════════
function AgentMemoryViewer({ agentName }: { agentName: string }) {
const [memory, setMemory] = useState<AgentMemoryResult | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
api.openclawAgentMemory(agentName)
.then(res => {
if (active) {
setMemory(res);
setLoading(false);
}
})
.catch(err => {
console.error(err);
if (active) setLoading(false);
});
return () => { active = false; };
}, [agentName]);
if (loading) {
return (
<div className="flex flex-col items-center justify-center p-8 text-gray-400">
<span className="text-3xl animate-spin-slow mb-4"></span>
<p className="animate-pulse">Connexion à la mémoire QMD de l'agent...</p>
</div>
);
}
if (!memory || !memory.has_db) {
return (
<div className="bg-surface-800/50 border border-glass-border rounded-xl p-8 text-center flex flex-col items-center justify-center">
<span className="text-5xl opacity-30 grayscale mb-4">🗄</span>
<h3 className="text-white font-semibold mb-2">Mémoire Introuvable</h3>
<p className="text-gray-400 text-sm max-w-md mx-auto">
Aucune base de données SQLite QMD n'a é trouvée pour cet agent dans le workspace.
Lumina ne dispose pas (encore) d'une trame chronologique ici.
</p>
<p className="text-xs text-gray-500 font-mono mt-4">Astuce : Vérifiez la configuration du dossier workspace sous FOXY_HOME.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center px-4 py-3 bg-gradient-to-r from-blue-900/20 to-indigo-900/20 rounded-xl border border-blue-500/10">
<div>
<h4 className="text-blue-300 font-bold flex items-center gap-2">
<span>🧠</span> Labyrinth Memoire QMD
</h4>
<p className="text-xs text-blue-300/60 font-mono mt-1">{memory.db_path} ({formatSize(memory.size || 0)})</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">{memory.stats.total_thoughts}</div>
<div className="text-[10px] text-gray-400 uppercase tracking-wider">Pensées / Actions</div>
</div>
</div>
<div className="relative pl-6 before:absolute before:inset-0 before:left-[11px] before:w-0.5 before:bg-gradient-to-b before:from-blue-500/50 before:to-transparent pt-2">
{memory.timeline.length === 0 ? (
<div className="text-gray-500 text-sm py-4">La mémoire existe mais elle est actuellement vide.</div>
) : (
<div className="space-y-6">
{memory.timeline.map((item: Record<string, any>, idx: number) => (
<div key={idx} className="relative group">
{/* Node dot */}
<div className="absolute -left-[31px] top-1.5 w-4 h-4 rounded-full bg-surface-900 border-2 border-blue-500/50 group-hover:bg-blue-500 group-hover:shadow-[0_0_10px_rgba(59,130,246,0.6)] transition-all z-10"></div>
{/* Content Card */}
<div className="glass-card p-4 border border-surface-700/50 hover:border-blue-500/30 transition-colors">
<div className="flex items-start justify-between mb-2">
<span className="text-[10px] font-mono text-gray-500 bg-surface-800 px-2 py-0.5 rounded">
{item.timestamp || item.created_at || 'Inconnu'}
</span>
{(item.type || item.event_type || item.role) && (
<span className="text-[10px] uppercase font-bold text-indigo-300 bg-indigo-500/10 px-2 py-0.5 rounded">
{item.type || item.event_type || item.role}
</span>
)}
</div>
<pre className="text-sm text-gray-300 whitespace-pre-wrap font-sans leading-relaxed">
{item.content || item.message || JSON.stringify(item, null, 2)}
</pre>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
function AgentsTab() {
const [agents, setAgents] = useState<OpenClawAgent[]>([]);
const [loading, setLoading] = useState(true);
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'identity' | 'memory'>('identity');
const [deleting, setDeleting] = useState<string | null>(null);
const [activeFile, setActiveFile] = useState<{agent: string, file: string} | null>(null);
@ -437,7 +531,12 @@ function AgentsTab() {
>
<div
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); setIsFullscreen(false); }}
onClick={() => {
setExpandedAgent(expandedAgent === agent.name ? null : agent.name);
setActiveFile(null);
setIsFullscreen(false);
setActiveView('identity');
}}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
@ -476,13 +575,33 @@ function AgentsTab() {
{expandedAgent === agent.name && (
<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-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">
{/* View Tabs */}
<div className="flex gap-2 border-b border-surface-700/50 pb-3 -mx-2 px-2">
<button
onClick={() => setActiveView('identity')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${activeView === 'identity' ? 'bg-surface-800 text-white border border-surface-600 shadow-md' : 'text-gray-500 hover:text-gray-300 hover:bg-surface-800/50'}`}
>
<span>🗂</span> Paramètres & Fichiers
</button>
<button
onClick={() => setActiveView('memory')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${activeView === 'memory' ? 'bg-indigo-900/30 text-indigo-300 border border-indigo-500/20 shadow-[0_0_15px_rgba(79,70,229,0.1)]' : 'text-gray-500 hover:text-indigo-400 hover:bg-indigo-900/10'}`}
>
<span>🧠</span> Mémoires d'agent
</button>
</div>
{activeView === 'memory' ? (
<AgentMemoryViewer agentName={agent.name} />
) : (
<>
{/* Agent Identity Files Manager */}
{agent.has_workspace && agent.identity_files && agent.identity_files.length > 0 && (
<div>
<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 (
@ -506,10 +625,10 @@ function AgentsTab() {
{/* 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 flex flex-col ${
isFullscreen ? 'fixed inset-4 z-[100] shadow-2xl' : 'h-[500px] relative'
<div className={`border border-glass-border bg-[#282c34] overflow-hidden shadow-inner mt-4 animate-fade-in flex flex-col ${
isFullscreen ? 'fixed inset-0 w-screen h-screen z-[9999] m-0 p-0 rounded-none' : 'h-[500px] rounded-xl relative'
}`}>
<div className="flex items-center justify-between px-3 py-2 bg-surface-900/80 border-b border-glass-border shrink-0">
<div className={`flex items-center justify-between px-3 py-2 bg-surface-900/80 border-b border-glass-border shrink-0 ${isFullscreen ? 'h-14 px-6' : ''}`}>
<span className="text-xs font-mono text-purple-300 truncate pr-4">workspace/{agent.name}/{activeFile.file}</span>
<div className="flex gap-2 shrink-0">
<button onClick={() => setIsFullscreen(!isFullscreen)} className="px-2 py-1 rounded text-[10px] text-gray-400 hover:bg-surface-700 hover:text-white transition-colors">
@ -527,7 +646,7 @@ function AgentsTab() {
<div className="flex-1 flex items-center justify-center text-gray-500 text-sm animate-pulse">Chargement</div>
) : (
<div className="flex-1 overflow-hidden relative min-h-0 bg-[#282c34]">
<div className="absolute inset-0 custom-scrollbar [&_.cm-editor]:h-full [&_.cm-editor]:bg-transparent [&_.cm-scroller]:h-full [&_.cm-scroller]:overflow-auto [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-xs">
<div className="absolute inset-0 custom-scrollbar [&_.cm-editor]:h-full [&_.cm-editor]:bg-transparent [&_.cm-scroller]:h-full [&_.cm-scroller]:overflow-auto [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-xs pb-10">
<CodeMirror
value={fileContent}
height="100%"
@ -555,7 +674,7 @@ function AgentsTab() {
)}
{/* Configuration (Read-only) */}
{agent.config && !activeFile && (
{agent.config && !activeFile && activeView === 'identity' && (
<div>
<h4 className="text-xs text-gray-400 font-bold uppercase tracking-wider mb-2 flex justify-between items-center">
<span>Configuration JSON/YAML</span>
@ -566,6 +685,8 @@ function AgentsTab() {
</pre>
</div>
)}
</>
)}
</div>
)}
</div>
@ -1033,8 +1154,8 @@ function ConfigFileEditor({ onClose, onSave }: { onClose: () => void, onSave: ()
};
return (
<div className="fixed inset-4 z-[200] glass-card flex flex-col overflow-hidden border border-glass-border shadow-[0_40px_80px_rgb(0,0,0,0.9)] bg-surface-900/95 backdrop-blur-xl animate-fade-in">
<div className="flex items-center justify-between px-5 py-4 bg-surface-800 border-b border-glass-border shrink-0">
<div className="fixed inset-0 w-screen h-screen z-[9999] m-0 p-0 rounded-none glass-card flex flex-col overflow-hidden border border-glass-border shadow-[0_40px_80px_rgb(0,0,0,0.9)] bg-surface-900/95 backdrop-blur-xl animate-fade-in">
<div className="flex items-center justify-between px-6 py-4 bg-surface-800 border-b border-glass-border shrink-0 h-16">
<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">
@ -1072,7 +1193,7 @@ function ConfigFileEditor({ onClose, onSave }: { onClose: () => void, onSave: ()
<span className="text-gray-400 font-mono">Lecture de la configuration brute...</span>
</div>
) : (
<div className="h-full w-full absolute inset-0 [&_.cm-editor]:h-full [&_.cm-scroller]:h-full [&_.cm-scroller]:overflow-auto [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[13px] custom-scrollbar">
<div className="h-full w-full absolute inset-0 [&_.cm-editor]:h-full [&_.cm-scroller]:h-full [&_.cm-scroller]:overflow-auto [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[13px] custom-scrollbar pb-10">
<CodeMirror
value={content}
height="100%"
@ -1186,7 +1307,7 @@ function FilesystemTab() {
{/* Editor Panel */}
<div className={isFullscreen
? "fixed inset-4 z-[100] glass-card flex flex-col overflow-hidden border border-glass-border shadow-[0_30px_60px_rgb(0,0,0,0.8)] min-h-0"
? "fixed inset-0 w-screen h-screen z-[9999] m-0 p-0 rounded-none glass-card flex flex-col overflow-hidden border border-glass-border bg-surface-900 shadow-[0_30px_60px_rgb(0,0,0,0.9)] min-h-0"
: "glass-card w-2/3 flex flex-col overflow-hidden border border-glass-border shadow-[0_8px_30px_rgb(0,0,0,0.5)] min-h-0"
}>
{fileLoading ? (
@ -1204,7 +1325,7 @@ function FilesystemTab() {
) : (
<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={`p-4 bg-surface-800 border-b border-glass-border flex items-center justify-between z-10 shrink-0 ${isFullscreen ? 'h-16 px-6' : ''}`}>
<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()}
@ -1256,7 +1377,7 @@ function FilesystemTab() {
</div>
</div>
) : editMode ? (
<div className="h-full w-full absolute inset-0 [&_.cm-editor]:h-full [&_.cm-scroller]:h-full [&_.cm-scroller]:overflow-auto [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[13px]">
<div className="h-full w-full absolute inset-0 [&_.cm-editor]:h-full [&_.cm-scroller]:h-full [&_.cm-scroller]:overflow-auto [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[13px] pb-10">
<CodeMirror
value={editContent}
height="100%"