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 re
import shutil import shutil
import socket import socket
import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -473,6 +474,77 @@ async def list_openclaw_skills():
return {"skills": skills, "count": len(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 # 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; 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 ─────────────────────────────────────────────────────────── // ─── HTTP Helpers ───────────────────────────────────────────────────────────
async function request<T>(path: string, options?: RequestInit): Promise<T> { async function request<T>(path: string, options?: RequestInit): Promise<T> {
@ -339,6 +350,8 @@ export const api = {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ content }), 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'), openclawSkills: () => request<{ skills: OpenClawSkill[]; count: number }>('/api/openclaw/skills'),
openclawLogs: (params: { lines?: number; level?: string }) => { openclawLogs: (params: { lines?: number; level?: string }) => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();

View File

@ -6,7 +6,7 @@ import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import type { import type {
OpenClawStatus, OpenClawAgent, OpenClawSkill, OpenClawLogs, OpenClawStatus, OpenClawAgent, OpenClawSkill, OpenClawLogs,
OpenClawModels, FileTreeNode, ReadFileResult OpenClawModels, FileTreeNode, ReadFileResult, AgentMemoryResult
} from '../api/client'; } from '../api/client';
import { api } 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() { function AgentsTab() {
const [agents, setAgents] = useState<OpenClawAgent[]>([]); const [agents, setAgents] = useState<OpenClawAgent[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expandedAgent, setExpandedAgent] = useState<string | null>(null); const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'identity' | 'memory'>('identity');
const [deleting, setDeleting] = useState<string | null>(null); const [deleting, setDeleting] = useState<string | null>(null);
const [activeFile, setActiveFile] = useState<{agent: string, file: string} | null>(null); const [activeFile, setActiveFile] = useState<{agent: string, file: string} | null>(null);
@ -437,7 +531,12 @@ function AgentsTab() {
> >
<div <div
className={`p-5 cursor-pointer hover:bg-glass-hover transition-colors ${deleting === agent.name ? 'opacity-50 pointer-events-none' : ''}`} 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-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -476,13 +575,33 @@ function AgentsTab() {
{expandedAgent === agent.name && ( {expandedAgent === agent.name && (
<div className="border-t border-glass-border p-5 bg-surface-900/40 space-y-6"> <div className="border-t border-glass-border p-5 bg-surface-900/40 space-y-6">
{/* Agent Identity Files Manager */} {/* View Tabs */}
{agent.has_workspace && agent.identity_files && agent.identity_files.length > 0 && ( <div className="flex gap-2 border-b border-surface-700/50 pb-3 -mx-2 px-2">
<div> <button
<h4 className="text-xs text-gray-400 font-bold uppercase tracking-wider mb-3 flex items-center gap-2"> onClick={() => setActiveView('identity')}
<span>🧠</span> Fichiers d'identité 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'}`}
</h4> >
<div className="flex gap-2 flex-wrap mb-3"> <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 => { {agent.identity_files.map(file => {
const isActive = activeFile?.agent === agent.name && activeFile?.file === file.name; const isActive = activeFile?.agent === agent.name && activeFile?.file === file.name;
return ( return (
@ -506,10 +625,10 @@ function AgentsTab() {
{/* Identity File Editor */} {/* Identity File Editor */}
{activeFile?.agent === agent.name && ( {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 ${ <div className={`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' 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> <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"> <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"> <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 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="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 <CodeMirror
value={fileContent} value={fileContent}
height="100%" height="100%"
@ -555,7 +674,7 @@ function AgentsTab() {
)} )}
{/* Configuration (Read-only) */} {/* Configuration (Read-only) */}
{agent.config && !activeFile && ( {agent.config && !activeFile && activeView === 'identity' && (
<div> <div>
<h4 className="text-xs text-gray-400 font-bold uppercase tracking-wider mb-2 flex justify-between items-center"> <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>Configuration JSON/YAML</span>
@ -566,6 +685,8 @@ function AgentsTab() {
</pre> </pre>
</div> </div>
)} )}
</>
)}
</div> </div>
)} )}
</div> </div>
@ -1033,8 +1154,8 @@ function ConfigFileEditor({ onClose, onSave }: { onClose: () => void, onSave: ()
}; };
return ( 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="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-5 py-4 bg-surface-800 border-b border-glass-border shrink-0"> <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="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">
@ -1072,7 +1193,7 @@ function ConfigFileEditor({ onClose, onSave }: { onClose: () => void, onSave: ()
<span className="text-gray-400 font-mono">Lecture de la configuration brute...</span> <span className="text-gray-400 font-mono">Lecture de la configuration brute...</span>
</div> </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 <CodeMirror
value={content} value={content}
height="100%" height="100%"
@ -1186,7 +1307,7 @@ function FilesystemTab() {
{/* Editor Panel */} {/* Editor Panel */}
<div className={isFullscreen <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" : "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 ? ( {fileLoading ? (
@ -1204,7 +1325,7 @@ function FilesystemTab() {
) : ( ) : (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Editor Header */} {/* 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"> <div className="min-w-0 pr-4">
<h3 className="text-white font-medium flex items-center gap-2 text-base truncate"> <h3 className="text-white font-medium flex items-center gap-2 text-base truncate">
<span className="text-xl">📄</span> {selectedFile.path.split('/').pop()} <span className="text-xl">📄</span> {selectedFile.path.split('/').pop()}
@ -1256,7 +1377,7 @@ function FilesystemTab() {
</div> </div>
</div> </div>
) : editMode ? ( ) : 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 <CodeMirror
value={editContent} value={editContent}
height="100%" height="100%"