1411 lines
66 KiB
TypeScript
1411 lines
66 KiB
TypeScript
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, ReadFileResult, AgentMemoryResult
|
||
} from '../api/client';
|
||
import { api } from '../api/client';
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
// Tab definitions
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
|
||
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: '🧩' },
|
||
{ id: 'logs', label: 'Logs', icon: '📟' },
|
||
{ id: 'models', label: 'Modèles', icon: '🧠' },
|
||
] as const;
|
||
|
||
type TabId = (typeof TABS)[number]['id'];
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
// Main Page
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
|
||
export default function OpenClawPage() {
|
||
const [activeTab, setActiveTab] = useState<TabId>('status');
|
||
const [status, setStatus] = useState<OpenClawStatus | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
api.openclawStatus()
|
||
.then(s => { setStatus(s); setLoading(false); })
|
||
.catch(() => setLoading(false));
|
||
}, []);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-center">
|
||
<div className="text-5xl mb-3 animate-spin-slow">🐙</div>
|
||
<div className="text-gray-500 text-sm">Connexion à OpenClaw…</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-600/30 to-indigo-600/30 border border-purple-500/20 flex items-center justify-center text-2xl">
|
||
🐙
|
||
</div>
|
||
<div>
|
||
<h1 className="text-xl font-bold text-white tracking-tight">OpenClaw</h1>
|
||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||
<span className={`w-2 h-2 rounded-full ${status?.gateway_online ? 'bg-green-400 shadow-[0_0_8px_rgba(74,222,128,0.5)]' : 'bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.5)]'}`}></span>
|
||
{status?.gateway_online ? 'Gateway opérationnel' : 'Gateway hors ligne'}
|
||
<span className="text-gray-700">•</span>
|
||
<span className="uppercase tracking-wider">{status?.openclaw_type}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab Bar */}
|
||
<div className="flex gap-1 p-1 rounded-2xl bg-surface-800/60 border border-glass-border overflow-x-auto">
|
||
{TABS.map(tab => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 whitespace-nowrap ${
|
||
activeTab === tab.id
|
||
? 'bg-gradient-to-r from-purple-600/20 to-indigo-600/20 text-white border border-purple-500/20 shadow-[0_0_12px_rgba(147,51,234,0.1)]'
|
||
: 'text-gray-500 hover:text-gray-300 hover:bg-glass'
|
||
}`}
|
||
>
|
||
<span className="text-base">{tab.icon}</span>
|
||
<span>{tab.label}</span>
|
||
{tab.id === 'agents' && status && (
|
||
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-300 text-[10px] font-bold">{status.agent_count}</span>
|
||
)}
|
||
{tab.id === 'skills' && status && (
|
||
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-indigo-500/20 text-indigo-300 text-[10px] font-bold">{status.skill_count}</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
<div className="animate-fade-in">
|
||
{activeTab === 'status' && <StatusTab status={status} />}
|
||
{activeTab === 'filesystem' && <FilesystemTab />}
|
||
{activeTab === 'config' && <ConfigTab />}
|
||
{activeTab === 'agents' && <AgentsTab />}
|
||
{activeTab === 'skills' && <SkillsTab />}
|
||
{activeTab === 'logs' && <LogsTab />}
|
||
{activeTab === 'models' && <ModelsTab />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
// Tab 1 — Status
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
|
||
function StatusTab({ status }: { status: OpenClawStatus | null }) {
|
||
if (!status) return <EmptyState message="Impossible de charger le status" />;
|
||
|
||
const metrics = [
|
||
{ label: 'Gateway', value: status.gateway_online ? 'En ligne' : 'Hors ligne', accent: status.gateway_online ? 'green' : 'red', icon: '🔌' },
|
||
{ label: 'Mode', value: status.openclaw_type, accent: 'purple', icon: '⚡' },
|
||
{ label: 'Bind', value: status.gateway_bind, accent: 'blue', icon: '🌐' },
|
||
{ label: 'Port', value: String(status.gateway_port), accent: 'indigo', icon: '🔗' },
|
||
{ label: 'Agents', value: String(status.agent_count), accent: 'amber', icon: '🤖' },
|
||
{ label: 'Skills', value: String(status.skill_count), accent: 'teal', icon: '🧩' },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Hero health card */}
|
||
<div className={`glass-card p-6 relative overflow-hidden ${status.gateway_online ? 'border-green-500/20' : 'border-red-500/20'}`}>
|
||
<div className="absolute -top-20 -right-20 w-60 h-60 rounded-full blur-3xl opacity-10"
|
||
style={{ background: status.gateway_online ? 'radial-gradient(circle, #22C55E, transparent)' : 'radial-gradient(circle, #EF4444, transparent)' }}
|
||
/>
|
||
<div className="flex items-center gap-6 relative z-10">
|
||
<div className={`w-20 h-20 rounded-3xl flex items-center justify-center text-4xl ${
|
||
status.gateway_online
|
||
? 'bg-gradient-to-br from-green-500/20 to-emerald-600/10 border border-green-500/30'
|
||
: 'bg-gradient-to-br from-red-500/20 to-rose-600/10 border border-red-500/30'
|
||
}`} style={status.gateway_online ? { animation: 'pulse-glow 3s ease-in-out infinite' } : undefined}>
|
||
{status.gateway_online ? '✅' : '❌'}
|
||
</div>
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-white">
|
||
{status.gateway_online ? 'Gateway Opérationnel' : 'Gateway Hors Ligne'}
|
||
</h2>
|
||
<p className="text-gray-400 text-sm mt-1">
|
||
{status.foxy_home} — Port {status.gateway_port}
|
||
</p>
|
||
<div className="flex items-center gap-3 mt-2">
|
||
<span className={`badge ${status.gateway_online ? 'badge-completed' : 'badge-failed'}`}>
|
||
{status.gateway_mode}
|
||
</span>
|
||
<span className="badge bg-purple-500/15 text-purple-300">
|
||
{status.openclaw_type}
|
||
</span>
|
||
{status.config_exists && (
|
||
<span className="badge bg-blue-500/15 text-blue-300">config ✓</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Metrics grid */}
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||
{metrics.map(m => (
|
||
<MetricCard key={m.label} {...m} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function MetricCard({ label, value, accent, icon }: { label: string; value: string; accent: string; icon: string }) {
|
||
const colorMap: Record<string, { bg: string; text: string }> = {
|
||
green: { bg: 'from-green-500/10 to-green-600/5', text: 'text-green-400' },
|
||
red: { bg: 'from-red-500/10 to-red-600/5', text: 'text-red-400' },
|
||
purple: { bg: 'from-purple-500/10 to-purple-600/5', text: 'text-purple-400' },
|
||
blue: { bg: 'from-blue-500/10 to-blue-600/5', text: 'text-blue-400' },
|
||
indigo: { bg: 'from-indigo-500/10 to-indigo-600/5', text: 'text-indigo-400' },
|
||
amber: { bg: 'from-amber-500/10 to-amber-600/5', text: 'text-amber-400' },
|
||
teal: { bg: 'from-teal-500/10 to-teal-600/5', text: 'text-teal-400' },
|
||
};
|
||
const c = colorMap[accent] || colorMap.purple;
|
||
|
||
return (
|
||
<div className={`glass-card p-4 bg-gradient-to-br ${c.bg}`}>
|
||
<div className="text-lg mb-1">{icon}</div>
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wider">{label}</div>
|
||
<div className={`text-lg font-extrabold ${c.text} mt-0.5`}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
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;
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
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={() => {
|
||
if (isDir) setOpen(!open);
|
||
else onSelect?.(node);
|
||
}}
|
||
>
|
||
{isDir ? (
|
||
<span className="text-yellow-400 w-4 text-center">{open ? '📂' : '📁'}</span>
|
||
) : (
|
||
<span className="text-gray-500 w-4 text-center">📄</span>
|
||
)}
|
||
<span className={isDir ? 'text-white font-medium' : 'text-gray-400'}>{node.name}</span>
|
||
{node.size != null && !isDir && (
|
||
<span className="text-gray-600 ml-auto text-[10px]">{formatSize(node.size)}</span>
|
||
)}
|
||
</div>
|
||
{isDir && open && node.children?.map(child => (
|
||
<TreeNode key={child.path} node={child} depth={depth + 1} onSelect={onSelect} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function formatSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
// Tab 2 — Configuration
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
|
||
function ConfigTab() {
|
||
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [editing, setEditing] = useState(false);
|
||
|
||
const fetchConfig = () => {
|
||
setLoading(true);
|
||
api.openclawConfig()
|
||
.then(d => { setConfig(d.config); setLoading(false); })
|
||
.catch(() => setLoading(false));
|
||
};
|
||
|
||
useEffect(() => { fetchConfig(); }, []);
|
||
|
||
if (loading) return <LoadingSpinner />;
|
||
if (!config) return <EmptyState message="openclaw.json introuvable ou illisible" />;
|
||
|
||
const sections = Object.entries(config);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{editing && <ConfigFileEditor onClose={() => setEditing(false)} onSave={() => { setEditing(false); fetchConfig(); }} />}
|
||
|
||
{/* Premium Header */}
|
||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between glass-card p-6 border-l-4 border-l-purple-500 relative overflow-hidden group">
|
||
<div className="absolute top-0 right-0 w-64 h-full bg-gradient-to-l from-purple-500/10 to-transparent opacity-50 group-hover:opacity-100 transition-opacity"></div>
|
||
<div className="relative z-10 mb-4 sm:mb-0">
|
||
<h2 className="text-xl font-bold text-white flex items-center gap-3">
|
||
<span>⚙️</span> Configuration Globale
|
||
</h2>
|
||
<p className="text-gray-400 text-sm mt-1.5 flex items-center gap-2">
|
||
Paramètres système chargés depuis <code className="px-1.5 py-0.5 rounded bg-surface-800 text-purple-300 font-mono text-xs">openclaw.json</code>
|
||
</p>
|
||
</div>
|
||
<div className="relative z-10 shrink-0">
|
||
<button
|
||
onClick={() => setEditing(true)}
|
||
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-sm font-semibold hover:from-purple-500 hover:to-indigo-500 transition-all shadow-[0_0_15px_rgba(147,51,234,0.3)] hover:scale-105 flex items-center gap-2"
|
||
>
|
||
<span>✏️</span> Modifier config
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Grid of config sections */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-5">
|
||
{sections.map(([key, value]) => {
|
||
const isObject = typeof value === 'object' && value !== null;
|
||
|
||
return (
|
||
<div key={key} className="glass-card flex flex-col h-full border border-surface-700/50 hover:border-purple-500/30 transition-all group overflow-hidden">
|
||
<div className="bg-surface-800/80 p-4 border-b border-surface-700/50 flex justify-between items-center group-hover:bg-surface-700/50 transition-colors">
|
||
<h3 className="font-bold text-white text-sm tracking-wide flex items-center gap-2">
|
||
<span className="text-purple-400 text-lg">❖</span> {key.toUpperCase()}
|
||
</h3>
|
||
{isObject && (
|
||
<span className="px-2 py-0.5 rounded-md bg-surface-900/50 text-gray-400 text-[10px] font-mono border border-surface-700">
|
||
{Array.isArray(value) ? `${(value as unknown[]).length} items` : `${Object.keys(value as object).length} clés`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="p-4 bg-[#1e222a] flex-1 overflow-auto max-h-96 custom-scrollbar shrink-0">
|
||
{isObject ? (
|
||
<pre className="text-[11px] font-mono text-[#abb2bf] whitespace-pre-wrap leading-relaxed">
|
||
{JSON.stringify(value, null, 2)}
|
||
</pre>
|
||
) : (
|
||
<div className="h-full flex items-center">
|
||
<code className="text-[13px] text-green-300 font-mono bg-surface-900/50 px-3 py-2 rounded-lg break-all border border-green-500/10 w-full text-center">
|
||
{renderValue(value)}
|
||
</code>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function renderValue(v: unknown): string {
|
||
if (v === null) return 'null';
|
||
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
||
if (typeof v === 'number') return String(v);
|
||
if (typeof v === 'string') return v.length > 80 ? v.slice(0, 77) + '…' : v;
|
||
return String(v);
|
||
}
|
||
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
// 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 été 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);
|
||
const [fileContent, setFileContent] = useState('');
|
||
const [fileLoading, setFileLoading] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
|
||
const loadAgents = () => {
|
||
setLoading(true);
|
||
api.openclawAgents()
|
||
.then(d => { setAgents(d.agents); setLoading(false); })
|
||
.catch(() => setLoading(false));
|
||
};
|
||
|
||
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
|
||
setIsFullscreen(false);
|
||
return;
|
||
}
|
||
setActiveFile({ agent: agentName, file: filename });
|
||
setIsFullscreen(false);
|
||
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 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 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 */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{agents.map(agent => (
|
||
<div
|
||
key={agent.name}
|
||
className={`glass-card overflow-hidden transition-all duration-300 ${
|
||
expandedAgent === agent.name ? 'ring-1 ring-purple-500/30' : ''
|
||
}`}
|
||
>
|
||
<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);
|
||
setActiveView('identity');
|
||
}}
|
||
>
|
||
<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 shrink-0">
|
||
🤖
|
||
</div>
|
||
<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] truncate max-w-[150px]">{agent.model}</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>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Expanded details */}
|
||
{expandedAgent === agent.name && (
|
||
<div className="border-t border-glass-border p-5 bg-surface-900/40 space-y-6">
|
||
|
||
{/* 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 (
|
||
<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={`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 ${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">
|
||
{isFullscreen ? '🗗 Réduire' : '⛶ Plein écran'}
|
||
</button>
|
||
<button onClick={() => { setActiveFile(null); setIsFullscreen(false); }} 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="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 pb-10">
|
||
<CodeMirror
|
||
value={fileContent}
|
||
height="100%"
|
||
theme={oneDark}
|
||
extensions={[markdown()]}
|
||
onChange={(val) => setFileContent(val)}
|
||
basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: true, tabSize: 2 }}
|
||
/>
|
||
</div>
|
||
</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>
|
||
)}
|
||
|
||
{/* Configuration (Read-only) */}
|
||
{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>
|
||
<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>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
// Tab 4 — Skills
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
|
||
function SkillsTab() {
|
||
const [skills, setSkills] = useState<OpenClawSkill[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [expandedSkill, setExpandedSkill] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
api.openclawSkills()
|
||
.then(d => { setSkills(d.skills); setLoading(false); })
|
||
.catch(() => setLoading(false));
|
||
}, []);
|
||
|
||
if (loading) return <LoadingSpinner />;
|
||
if (skills.length === 0) return <EmptyState message="Aucun skill trouvé" />;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="glass-card p-4 flex items-center gap-3">
|
||
<span className="text-lg">🧩</span>
|
||
<span className="text-white font-semibold">{skills.length} skills</span>
|
||
<span className="text-gray-500 text-sm">disponibles</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||
{skills.map(skill => (
|
||
<div
|
||
key={`${skill.source}/${skill.name}`}
|
||
className="glass-card overflow-hidden"
|
||
>
|
||
<div
|
||
className="p-5 cursor-pointer hover:bg-glass-hover transition-colors"
|
||
onClick={() => setExpandedSkill(expandedSkill === skill.name ? null : skill.name)}
|
||
>
|
||
<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-indigo-500/20 to-blue-600/10 border border-indigo-500/20 flex items-center justify-center text-xl">
|
||
🧩
|
||
</div>
|
||
<div>
|
||
<h3 className="text-white font-semibold text-sm">{skill.name}</h3>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<span className="badge bg-surface-600 text-gray-300 text-[10px]">{skill.type}</span>
|
||
<span className="text-[10px] text-gray-600 font-mono">{skill.source}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{skill.description_short && (
|
||
<p className="text-xs text-gray-400 mt-3 leading-relaxed">{skill.description_short}</p>
|
||
)}
|
||
|
||
{skill.subdirs && skill.subdirs.length > 0 && (
|
||
<div className="flex gap-1.5 mt-3 flex-wrap">
|
||
{skill.subdirs.map(d => (
|
||
<span key={d} className="px-2 py-0.5 rounded-md bg-surface-700 text-gray-400 text-[10px] font-mono">📁 {d}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3 mt-3 text-[10px] text-gray-600">
|
||
{skill.file_count != null && <span>{skill.file_count} fichiers</span>}
|
||
{skill.size != null && <span>{formatSize(skill.size)}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{expandedSkill === skill.name && skill.description && (
|
||
<div className="border-t border-glass-border p-4 bg-surface-900/40">
|
||
<h4 className="text-xs text-gray-500 uppercase tracking-wider mb-2">Description</h4>
|
||
<pre className="text-xs text-gray-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto leading-relaxed">
|
||
{skill.description}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
// Tab 5 — Logs (Terminal style)
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
|
||
function LogsTab() {
|
||
const [logs, setLogs] = useState<OpenClawLogs | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [levelFilter, setLevelFilter] = useState<string>('');
|
||
const [lineCount, setLineCount] = useState(200);
|
||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||
const logEndRef = useRef<HTMLDivElement>(null);
|
||
|
||
const fetchLogs = useCallback(async () => {
|
||
try {
|
||
const params: { lines: number; level?: string } = { lines: lineCount };
|
||
if (levelFilter) params.level = levelFilter;
|
||
const data = await api.openclawLogs(params);
|
||
setLogs(data);
|
||
} catch { /* ignore */ }
|
||
setLoading(false);
|
||
}, [lineCount, levelFilter]);
|
||
|
||
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||
|
||
useEffect(() => {
|
||
if (!autoRefresh) return;
|
||
const interval = setInterval(fetchLogs, 5000);
|
||
return () => clearInterval(interval);
|
||
}, [autoRefresh, fetchLogs]);
|
||
|
||
useEffect(() => {
|
||
if (logEndRef.current) {
|
||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
}, [logs]);
|
||
|
||
function getLineStyle(line: string): string {
|
||
const upper = line.toUpperCase();
|
||
if (upper.includes('ERROR') || upper.includes('FATAL') || upper.includes('PANIC')) return 'text-red-400';
|
||
if (upper.includes('WARN')) return 'text-yellow-400';
|
||
if (upper.includes('DEBUG') || upper.includes('TRACE')) return 'text-gray-600';
|
||
if (upper.includes('INFO')) return 'text-green-300';
|
||
return 'text-gray-400';
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Toolbar */}
|
||
<div className="glass-card p-4 flex items-center gap-3 flex-wrap">
|
||
<span className="text-lg">📟</span>
|
||
<span className="text-white font-semibold text-sm">Gateway Logs</span>
|
||
|
||
<div className="flex-1" />
|
||
|
||
{/* Level filter */}
|
||
<div className="flex gap-1">
|
||
{['', 'INFO', 'WARNING', 'ERROR'].map(lvl => (
|
||
<button
|
||
key={lvl}
|
||
onClick={() => setLevelFilter(lvl)}
|
||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||
levelFilter === lvl
|
||
? 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
|
||
: 'text-gray-500 hover:text-gray-300 bg-surface-700/50'
|
||
}`}
|
||
>
|
||
{lvl || 'Tous'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Line count */}
|
||
<select
|
||
value={lineCount}
|
||
onChange={e => setLineCount(Number(e.target.value))}
|
||
className="input !w-auto !py-1.5 text-xs"
|
||
>
|
||
<option value={50}>50 lignes</option>
|
||
<option value={200}>200 lignes</option>
|
||
<option value={500}>500 lignes</option>
|
||
<option value={1000}>1000 lignes</option>
|
||
</select>
|
||
|
||
{/* Auto-refresh toggle */}
|
||
<button
|
||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||
autoRefresh
|
||
? 'bg-green-500/20 text-green-300 border border-green-500/30'
|
||
: 'text-gray-500 bg-surface-700/50'
|
||
}`}
|
||
>
|
||
{autoRefresh ? '⏸ Auto' : '▶ Auto'}
|
||
</button>
|
||
|
||
{/* Refresh button */}
|
||
<button onClick={fetchLogs} className="btn btn-ghost text-xs !py-1.5">
|
||
🔄 Refresh
|
||
</button>
|
||
</div>
|
||
|
||
{/* Terminal */}
|
||
{loading ? <LoadingSpinner /> : (
|
||
<div className="rounded-2xl overflow-hidden border border-glass-border">
|
||
{/* Terminal header */}
|
||
<div className="flex items-center gap-3 px-4 py-2.5 bg-surface-800 border-b border-glass-border">
|
||
<div className="flex gap-1.5">
|
||
<span className="w-3 h-3 rounded-full bg-red-500/60"></span>
|
||
<span className="w-3 h-3 rounded-full bg-yellow-500/60"></span>
|
||
<span className="w-3 h-3 rounded-full bg-green-500/60"></span>
|
||
</div>
|
||
<span className="text-xs text-gray-500 font-mono flex-1">
|
||
{logs?.log_path || 'gateway.log'}
|
||
</span>
|
||
{logs && (
|
||
<span className="text-[10px] text-gray-600">
|
||
{logs.showing} / {logs.total_lines} lignes
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Terminal content */}
|
||
<div className="p-4 max-h-[600px] overflow-y-auto font-mono text-xs leading-5"
|
||
style={{ background: '#0a0e17' }}
|
||
>
|
||
{logs?.error && (
|
||
<div className="text-red-400 mb-3 p-2 rounded-lg bg-red-500/10">{logs.error}</div>
|
||
)}
|
||
{(!logs?.lines || logs.lines.length === 0) ? (
|
||
<div className="text-gray-600">Aucune ligne de log</div>
|
||
) : (
|
||
logs.lines.map((line, i) => (
|
||
<div key={i} className={`hover:bg-white/[0.02] px-1 rounded ${getLineStyle(line)}`}>
|
||
<span className="text-gray-700 select-none mr-3 inline-block w-10 text-right">{i + 1}</span>
|
||
{line}
|
||
</div>
|
||
))
|
||
)}
|
||
<div ref={logEndRef} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
// Tab 6 — Models & Providers
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
|
||
function ModelsTab() {
|
||
const [data, setData] = useState<OpenClawModels | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [editing, setEditing] = useState(false);
|
||
|
||
const fetchModels = () => {
|
||
setLoading(true);
|
||
api.openclawModels()
|
||
.then(d => { setData(d); setLoading(false); })
|
||
.catch(() => setLoading(false));
|
||
};
|
||
|
||
useEffect(() => { fetchModels(); }, []);
|
||
|
||
if (loading) return <LoadingSpinner />;
|
||
if (!data || data.error) return <EmptyState message={data?.error || 'Impossible de charger les modèles'} />;
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in">
|
||
{editing && <ConfigFileEditor onClose={() => setEditing(false)} onSave={() => { setEditing(false); fetchModels(); }} />}
|
||
|
||
{/* Premium Header */}
|
||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between glass-card p-6 border-l-4 border-l-cyan-500 relative overflow-hidden group">
|
||
<div className="absolute top-0 right-0 w-64 h-full bg-gradient-to-l from-cyan-500/10 to-transparent opacity-50 group-hover:opacity-100 transition-opacity"></div>
|
||
<div className="relative z-10 mb-4 sm:mb-0">
|
||
<h2 className="text-xl font-bold text-white flex items-center gap-3">
|
||
<span>🧠</span> Intelligence Artificielle (LLMs & MCP)
|
||
</h2>
|
||
<p className="text-gray-400 text-sm mt-1.5 flex items-center gap-2">
|
||
Fournisseurs, serveurs MCP et modèles configurés
|
||
</p>
|
||
</div>
|
||
<div className="relative z-10 shrink-0">
|
||
<button
|
||
onClick={() => setEditing(true)}
|
||
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-600 to-blue-600 text-white text-sm font-semibold hover:from-cyan-500 hover:to-blue-500 transition-all shadow-[0_0_15px_rgba(6,182,212,0.3)] hover:scale-105 flex items-center gap-2"
|
||
>
|
||
<span>✏️</span> Configurer Modèles
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Providers Grid */}
|
||
{data.providers.length > 0 && (
|
||
<div className="glass-card overflow-hidden">
|
||
<div className="p-5 bg-surface-800/50 border-b border-glass-border">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||
<span className="text-cyan-400">🔌</span> Providers / MCP Servers
|
||
</h2>
|
||
</div>
|
||
<div className="p-5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{data.providers.map((p, i) => (
|
||
<div key={i} className="p-5 rounded-xl bg-surface-900/60 border border-surface-700/50 hover:border-cyan-500/30 transition-all hover:bg-surface-800/80 group">
|
||
<div className="flex items-center gap-3 mb-3 relative">
|
||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500/20 to-blue-600/10 border border-cyan-500/20 flex items-center justify-center text-lg shadow-inner group-hover:scale-110 transition-transform">
|
||
{p.type === 'mcp' ? '🧱' : '💬'}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="text-white font-bold text-sm truncate pr-2" title={String(p.name || `Provider ${i + 1}`)}>
|
||
{String(p.name || `Provider ${i + 1}`)}
|
||
</h3>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
{Boolean(p.type) && (
|
||
<span className="px-2 py-0.5 rounded text-[9px] uppercase tracking-wider font-bold bg-surface-700 text-gray-300">
|
||
{String(p.type)}
|
||
</span>
|
||
)}
|
||
{p.enabled !== undefined && (
|
||
<span className={`px-2 py-0.5 rounded text-[9px] uppercase tracking-wider font-bold ${p.enabled ? 'bg-emerald-500/20 text-emerald-300' : 'bg-red-500/20 text-red-300'}`}>
|
||
{p.enabled ? 'actif' : 'inactif'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{Boolean(p.model) && (
|
||
<div className="text-xs text-blue-300 font-mono mt-2 bg-blue-500/10 px-3 py-1.5 rounded-lg border border-blue-500/10 inline-block truncate max-w-full">
|
||
<span className="opacity-50 mr-1">modèle:</span> {String(p.model)}
|
||
</div>
|
||
)}
|
||
{Boolean(p.endpoint) && (
|
||
<div className="text-[11px] text-gray-500 font-mono mt-2 bg-surface-800 px-3 py-1.5 rounded-lg truncate max-w-full border border-surface-700">
|
||
{String(p.endpoint)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Models Table */}
|
||
{data.models.length > 0 && (
|
||
<div className="glass-card overflow-hidden">
|
||
<div className="p-5 bg-surface-800/50 border-b border-glass-border">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||
<span className="text-purple-400">🤖</span> Modèles Configurés
|
||
</h2>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-surface-900/80">
|
||
<tr>
|
||
<th className="text-left text-[11px] text-gray-400 font-bold uppercase tracking-wider py-4 px-6 border-b border-surface-700/50 w-1/4">Identifiant Modèle</th>
|
||
<th className="text-left text-[11px] text-gray-400 font-bold uppercase tracking-wider py-4 px-6 border-b border-surface-700/50">Détails configuration (JSON)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-surface-700/50">
|
||
{data.models.map((m, i) => (
|
||
<tr key={i} className="hover:bg-surface-800/40 transition-colors">
|
||
<td className="py-4 px-6 align-top">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-lg opacity-80">🏷️</span>
|
||
<span className="text-white font-bold bg-surface-800 px-3 py-1.5 rounded-lg border border-surface-600 shadow-sm inline-block">
|
||
{String(m.name || `Model ${i + 1}`)}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<pre className="text-[11px] text-[#abb2bf] font-mono whitespace-pre-wrap leading-relaxed bg-[#1e222a] p-4 rounded-xl border border-surface-700 shadow-inner">
|
||
{JSON.stringify(m, null, 2)}
|
||
</pre>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Raw config keys */}
|
||
{data.raw_keys && data.raw_keys.length > 0 && (
|
||
<div className="glass-card p-5 flex items-center gap-4 flex-wrap">
|
||
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-2 shrink-0">
|
||
<span>🔑</span> Clés racines détectées :
|
||
</h2>
|
||
<div className="flex gap-2 flex-wrap">
|
||
{data.raw_keys.map(k => (
|
||
<span key={k} className="px-2 py-1 rounded bg-surface-800 text-gray-400 text-[10px] font-mono border border-surface-700">{k}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{data.providers.length === 0 && data.models.length === 0 && (
|
||
<EmptyState message="Aucun modèle ou provider détecté dans la configuration" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
// Shared Components
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
|
||
function LoadingSpinner() {
|
||
return (
|
||
<div className="flex items-center justify-center h-48">
|
||
<div className="text-center">
|
||
<div className="text-3xl animate-spin-slow mb-2">🐙</div>
|
||
<div className="text-gray-600 text-xs">Chargement…</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState({ message }: { message: string }) {
|
||
return (
|
||
<div className="glass-card p-12 text-center">
|
||
<div className="text-4xl mb-3 opacity-40">🐙</div>
|
||
<p className="text-gray-500 text-sm">{message}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
// Shared Config Editor Overlay
|
||
// ═════════════════════════════════════════════════════════════════════════════════
|
||
|
||
function ConfigFileEditor({ onClose, onSave }: { onClose: () => void, onSave: () => void }) {
|
||
const [content, setContent] = useState('');
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
// Lock body scroll
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
api.openclawReadFile('openclaw.json')
|
||
.then(res => {
|
||
setContent(res.pretty || res.content || '');
|
||
setLoading(false);
|
||
})
|
||
.catch(err => {
|
||
console.error(err);
|
||
setContent('Erreur de chargement du fichier openclaw.json');
|
||
setLoading(false);
|
||
});
|
||
|
||
return () => {
|
||
// Restore scroll when unmounting
|
||
document.body.style.overflow = '';
|
||
};
|
||
}, []);
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true);
|
||
try {
|
||
await api.openclawWriteFile('openclaw.json', content);
|
||
onSave();
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert('Erreur lors de la sauvegarde : ' + String(err));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<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">
|
||
✏️
|
||
</div>
|
||
<div>
|
||
<h3 className="text-white font-bold text-base flex items-center gap-2">
|
||
Édition directe
|
||
</h3>
|
||
<div className="text-xs text-gray-400 font-mono mt-0.5">openclaw.json</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={onClose}
|
||
disabled={saving}
|
||
className="px-4 py-2 rounded-lg bg-surface-700 text-gray-300 text-sm font-medium hover:bg-surface-600 transition-colors"
|
||
>
|
||
Annuler
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className={`px-6 py-2 rounded-lg bg-gradient-to-r from-emerald-600 to-teal-600 text-white text-sm font-bold transition-all shadow-[0_0_15px_rgba(16,185,129,0.4)] flex items-center gap-2 ${saving ? 'opacity-50 scale-95 cursor-wait' : 'hover:scale-105 hover:from-emerald-500 hover:to-teal-500'}`}
|
||
>
|
||
{saving ? 'Sauvegarde...' : '💾 Sauvegarder .json'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-hidden relative bg-[#282c34]">
|
||
{loading ? (
|
||
<div className="absolute inset-0 flex items-center justify-center flex-col gap-4">
|
||
<span className="text-4xl animate-spin-slow opacity-50">⚙️</span>
|
||
<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 pb-10">
|
||
<CodeMirror
|
||
value={content}
|
||
height="100%"
|
||
theme={oneDark}
|
||
extensions={[json()]}
|
||
onChange={(val) => setContent(val)}
|
||
basicSetup={{
|
||
lineNumbers: true,
|
||
foldGutter: true,
|
||
highlightActiveLine: true,
|
||
tabSize: 2,
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</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);
|
||
const [isFullscreen, setIsFullscreen] = 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={isFullscreen
|
||
? "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 ? (
|
||
<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 ${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()}
|
||
</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">
|
||
<button
|
||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||
className="px-3 py-1.5 rounded-lg bg-surface-700 text-gray-300 text-sm hover:bg-surface-600 transition-colors flex items-center justify-center font-bold"
|
||
title={isFullscreen ? "Réduire" : "Plein écran"}
|
||
>
|
||
{isFullscreen ? "🗗" : "⛶"}
|
||
</button>
|
||
{!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]:overflow-auto [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[13px] pb-10">
|
||
<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>
|
||
);
|
||
}
|