feat: AgentCatalog dynamique + route /ui/docs directe + URL sync

This commit is contained in:
2026-03-20 22:06:08 +01:00
parent 5762275aef
commit b51ea60579
3 changed files with 263 additions and 3 deletions

View File

@@ -45,7 +45,15 @@ const NAV_ITEMS: NavItem[] = [
function AppInner() {
const { addToast } = useToast()
const [activeView, setActiveView] = useState<ActiveView>('workflows')
// Detect URL path for direct routing (/ui/docs → docs view)
const initialView = (): ActiveView => {
const path = window.location.pathname
if (path.includes('/docs')) return 'docs'
if (path.includes('/cosmos')) return 'cosmos'
if (path.includes('/workspace')) return 'workspace'
return 'workflows'
}
const [activeView, setActiveView] = useState<ActiveView>(initialView)
const [pendingGate, setPendingGate] = useState<PendingGate | null>(null)
const [gateDrawer, setGateDrawer] = useState<{ open: boolean; workflowId: string | null; stepId: string | null }>({
open: false,
@@ -55,6 +63,14 @@ function AppInner() {
const [logsProject, setLogsProject] = useState<string | null>(null)
const [paletteOpen, setPaletteOpen] = useState(false)
// Sync URL with active view
const handleViewChange = (view: ActiveView) => {
setActiveView(view)
const base = import.meta.env.BASE_URL || '/ui/'
const slug = view === 'workflows' ? '' : view
window.history.replaceState(null, '', `${base}${slug}`)
}
const { workflows, wsStatus } = useWorkflows()
useWebSocket(addToast)
const storeWorkflows = useBrainStore((s) => s.workflows)
@@ -132,7 +148,7 @@ function AppInner() {
<div className="mx-3 my-1" style={{ borderTop: '1px solid #2a2a2a' }} />
)}
<button
onClick={() => setActiveView(item.id)}
onClick={() => handleViewChange(item.id)}
className="flex items-center gap-3 px-3 py-2 rounded text-sm font-medium text-left transition-colors w-full"
style={
isActive
@@ -285,7 +301,7 @@ function AppInner() {
{paletteOpen && (
<CommandPalette
onClose={() => setPaletteOpen(false)}
onNavigate={(view) => { setActiveView(view as ActiveView); setPaletteOpen(false) }}
onNavigate={(view) => { handleViewChange(view as ActiveView); setPaletteOpen(false) }}
/>
)}
</div>

View File

@@ -0,0 +1,240 @@
import { useState, useEffect } from 'react'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
interface Agent {
id: string
label: string
tier: string
export: boolean
status: string
triggers: string[]
scope: string
description: string
}
const TIER_COLORS: Record<string, { emoji: string; color: string; bg: string }> = {
free: { emoji: '🟢', color: '#4ade80', bg: 'rgba(34,197,94,0.1)' },
featured: { emoji: '🔵', color: '#60a5fa', bg: 'rgba(59,130,246,0.1)' },
pro: { emoji: '🟠', color: '#fbbf24', bg: 'rgba(245,158,11,0.1)' },
full: { emoji: '🟣', color: '#c084fc', bg: 'rgba(168,85,247,0.1)' },
owner: { emoji: '🟣', color: '#c084fc', bg: 'rgba(168,85,247,0.1)' },
}
// Groupes métier pour organiser les agents
const AGENT_GROUPS: Record<string, { label: string; agents: string[] }> = {
'code': {
label: 'Code & Qualite',
agents: ['code-review', 'security', 'testing', 'refacto', 'optimizer-backend', 'optimizer-db', 'optimizer-frontend', 'frontend-stack'],
},
'infra': {
label: 'Infra & Deploy',
agents: ['vps', 'ci-cd', 'monitoring', 'pm2', 'mail', 'migration'],
},
'brain': {
label: 'Brain & Systeme',
agents: ['scribe', 'todo-scribe', 'metabolism-scribe', 'wiki-scribe', 'coach', 'coach-boot', 'coach-scribe', 'capital-scribe', 'toolkit-scribe', 'helloWorld', 'session-orchestrator', 'secrets-guardian', 'brain-guardian', 'key-guardian', 'pre-flight', 'feature-gate', 'brain-hypervisor', 'kernel-orchestrator'],
},
'explore': {
label: 'Exploration',
agents: ['debug', 'brainstorm', 'mentor', 'orchestrator', 'interprete', 'aside', 'recruiter', 'agent-review', 'time-anchor', 'pattern-scribe'],
},
}
function getAgentGroup(agentId: string): string {
for (const [group, data] of Object.entries(AGENT_GROUPS)) {
if (data.agents.includes(agentId)) return group
}
return 'other'
}
export function AgentCatalog() {
const [agents, setAgents] = useState<Agent[]>([])
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<string>('all')
useEffect(() => {
fetch(`${API_BASE}/agents`)
.then(r => r.json())
.then(data => setAgents(Array.isArray(data) ? data : []))
.catch(e => setError(e.message))
}, [])
if (error) return <div style={{ color: '#ef4444' }}>Erreur: {error}</div>
if (!agents.length) return <div style={{ color: '#4b5563' }}>Chargement...</div>
// Grouper les agents
const grouped: Record<string, Agent[]> = {}
for (const agent of agents) {
const group = getAgentGroup(agent.id)
if (!grouped[group]) grouped[group] = []
grouped[group].push(agent)
}
// Stats
const tierCounts = agents.reduce<Record<string, number>>((acc, a) => {
acc[a.tier] = (acc[a.tier] || 0) + 1
return acc
}, {})
const filteredAgents = filter === 'all' ? agents : agents.filter(a => {
if (filter === 'code' || filter === 'infra' || filter === 'brain' || filter === 'explore') {
return getAgentGroup(a.id) === filter
}
return a.tier === filter
})
const filteredGrouped: Record<string, Agent[]> = {}
for (const agent of filteredAgents) {
const group = getAgentGroup(agent.id)
if (!filteredGrouped[group]) filteredGrouped[group] = []
filteredGrouped[group].push(agent)
}
return (
<div>
<h1>Catalogue des agents</h1>
<p style={{ color: '#9ca3af' }}>
{agents.length} agents disponibles donnees live depuis brain-engine
</p>
{/* Stats bar */}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', margin: '1rem 0' }}>
<StatBadge
label="Tous"
count={agents.length}
active={filter === 'all'}
color="#818cf8"
onClick={() => setFilter('all')}
/>
{Object.entries(TIER_COLORS).filter(([t]) => tierCounts[t]).map(([tier, colors]) => (
<StatBadge
key={tier}
label={`${colors.emoji} ${tier}`}
count={tierCounts[tier] || 0}
active={filter === tier}
color={colors.color}
onClick={() => setFilter(filter === tier ? 'all' : tier)}
/>
))}
</div>
{/* Group filters */}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', margin: '0.5rem 0 1.5rem' }}>
{Object.entries(AGENT_GROUPS).map(([key, data]) => (
<button
key={key}
onClick={() => setFilter(filter === key ? 'all' : key)}
style={{
background: filter === key ? 'rgba(99,102,241,0.15)' : 'transparent',
color: filter === key ? '#818cf8' : '#6b7280',
border: '1px solid #2a2a2a',
borderRadius: '4px',
padding: '0.25rem 0.75rem',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
{data.label}
</button>
))}
</div>
{/* Agent cards by group */}
{Object.entries(AGENT_GROUPS)
.filter(([key]) => filteredGrouped[key]?.length)
.map(([key, data]) => (
<div key={key} style={{ marginBottom: '2rem' }}>
<h2>{data.label}</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{filteredGrouped[key].map(agent => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
</div>
))
}
{/* Uncategorized */}
{filteredGrouped['other']?.length > 0 && (
<div style={{ marginBottom: '2rem' }}>
<h2>Autres</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{filteredGrouped['other'].map(agent => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
</div>
)}
</div>
)
}
function AgentCard({ agent }: { agent: Agent }) {
const tier = TIER_COLORS[agent.tier] || TIER_COLORS['free']
return (
<div
style={{
background: '#141414',
border: '1px solid #2a2a2a',
borderLeft: `3px solid ${tier.color}`,
borderRadius: '4px',
padding: '0.75rem 1rem',
display: 'flex',
alignItems: 'flex-start',
gap: '0.75rem',
}}
>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<code style={{ color: '#a78bfa', fontSize: '0.875rem' }}>{agent.id}</code>
<span
style={{
fontSize: '0.65rem',
padding: '0.1em 0.4em',
borderRadius: '3px',
background: tier.bg,
color: tier.color,
fontWeight: 600,
}}
>
{tier.emoji} {agent.tier}
</span>
</div>
{agent.description && (
<p style={{ color: '#9ca3af', fontSize: '0.8rem', margin: '0.25rem 0 0' }}>
{agent.description}
</p>
)}
</div>
{agent.triggers.length > 0 && (
<div style={{ fontSize: '0.7rem', color: '#4b5563', whiteSpace: 'nowrap' }}>
{agent.triggers.slice(0, 3).join(', ')}
</div>
)}
</div>
)
}
function StatBadge({ label, count, active, color, onClick }: {
label: string; count: number; active: boolean; color: string; onClick: () => void
}) {
return (
<button
onClick={onClick}
style={{
background: active ? `${color}22` : 'transparent',
border: `1px solid ${active ? color : '#2a2a2a'}`,
borderRadius: '6px',
padding: '0.4rem 0.75rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
}}
>
<span style={{ color, fontWeight: 700, fontSize: '1rem' }}>{count}</span>
<span style={{ color: active ? '#d1d5db' : '#6b7280', fontSize: '0.8rem' }}>{label}</span>
</button>
)
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, ReactNode } from 'react'
import ReactMarkdown, { Components } from 'react-markdown'
import { TierComparatif, TierSingle } from './TierDashboard'
import { AgentCatalog } from './AgentDashboard'
interface DocFile {
name: string
@@ -210,6 +211,9 @@ export default function DocsView() {
const tierName = activeDoc.replace('vue-', '')
return <article className="docs-markdown"><TierSingle tierName={tierName} /></article>
}
if (liveMode && activeDoc === 'agents') {
return <article className="docs-markdown"><AgentCatalog /></article>
}
// Mode standard — markdown
return (