feat: brain-engine + brain-ui + docs — template full stack standalone

- brain-engine: server, embed, search, RAG, MCP, start.sh (standalone)
- brain-ui: source React complète, build.sh, DocsView avec tier colors
- docs: 14 pages guides humains (getting-started, architecture, sessions, workflows, agents, vues tier)
- brain-compose.yml v0.9.0: tier featured ajouté, sessions/agents par tier, coach_level, API key schema
- DISTRIBUTION_CHECKLIST v1.2: brain-engine + brain-ui + docs dans la checklist
This commit is contained in:
2026-03-20 20:25:40 +01:00
parent c249d417f5
commit 8244a07881
93 changed files with 12088 additions and 34 deletions

View File

@@ -0,0 +1,104 @@
import { useState, useEffect, useCallback } from 'react'
import type { CosmosPoint, VisualizeResponse, ZoneKey } from '../types'
const CACHE_TTL_MS = 30 * 60 * 1000
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
interface CosmosCache {
timestamp: number
points: CosmosPoint[]
generated_at: string
umap_params: VisualizeResponse['umap_params']
}
const MOCK_ZONES: ZoneKey[] = ['public', 'kernel', 'instance', 'satellite']
function generateMockPoints(): CosmosPoint[] {
return Array.from({ length: 50 }, (_, i) => {
const zone = MOCK_ZONES[i % 4]
return {
id: `mock-${i}`,
path: `${zone}/document-${i}.md`,
zone,
label: `Document ${i}`,
excerpt: `Extrait du document ${i} — contenu de démonstration pour la visualisation Cosmos Sprint 4.`,
x: (Math.random() - 0.5) * 4,
y: (Math.random() - 0.5) * 4,
z: (Math.random() - 0.5) * 4,
}
})
}
export function useCosmosData() {
const [points, setPoints] = useState<CosmosPoint[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [generatedAt, setGeneratedAt] = useState<string | null>(null)
const [cached, setCached] = useState(false)
const cacheKey = `cosmos_cache_${Math.floor(Date.now() / CACHE_TTL_MS)}`
const load = useCallback(async (force = false) => {
setLoading(true)
setError(null)
if (USE_MOCK || !API_BASE) {
await new Promise((r) => setTimeout(r, 400))
setPoints(generateMockPoints())
setGeneratedAt(new Date().toISOString())
setCached(false)
setLoading(false)
return
}
if (!force) {
const raw = localStorage.getItem(cacheKey)
if (raw) {
try {
const parsed: CosmosCache = JSON.parse(raw)
if (Date.now() - parsed.timestamp < CACHE_TTL_MS) {
setPoints(parsed.points)
setGeneratedAt(parsed.generated_at)
setCached(true)
setLoading(false)
return
}
} catch {
localStorage.removeItem(cacheKey)
}
}
}
try {
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}
const url = force ? `${API_BASE}/visualize?force=true` : `${API_BASE}/visualize`
const res = await fetch(url, { credentials: 'include', headers })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: VisualizeResponse = await res.json()
setPoints(data.points)
setGeneratedAt(data.generated_at)
setCached(data.cached)
const cachePayload: CosmosCache = {
timestamp: Date.now(),
points: data.points,
generated_at: data.generated_at,
umap_params: data.umap_params,
}
localStorage.setItem(cacheKey, JSON.stringify(cachePayload))
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue')
} finally {
setLoading(false)
}
}, [cacheKey])
useEffect(() => {
load()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { points, loading, error, generatedAt, cached, reload: () => load(true) }
}

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react'
import type { InfraService, InfraResponse } from '../types'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const MOCK_SERVICES: InfraService[] = [
{ id: 'pm2-brain-engine', name: 'brain-engine', type: 'pm2', status: 'online', port: 7700, uptime: 3600000, restarts: 0, memory: 52428800, cpu: 0 },
{ id: 'pm2-tetardpg', name: 'tetardpg', type: 'pm2', status: 'online', port: 4000, uptime: 7200000, restarts: 2, memory: 97517568, cpu: 0 },
{ id: 'pm2-super-oauth', name: 'super-oauth', type: 'pm2', status: 'online', port: 3001, uptime: 18000000, restarts: 0, memory: 94371840, cpu: 0 },
{ id: 'pm2-originsdigital', name: 'originsdigital', type: 'pm2', status: 'online', port: 3002, uptime: 7200000, restarts: 58, memory: 83886080, cpu: 0 },
{ id: 'apache', name: 'Apache2', type: 'system', status: 'online', port: 443 },
{ id: 'brain-engine-info', name: 'brain-engine', type: 'info', status: 'online', port: 7700 },
{ id: 'gitea', name: 'Gitea', type: 'info', status: 'online', port: 3000 },
]
function formatUptime(ms: number | null | undefined): string {
if (!ms) return '—'
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
if (s < 86400) return `${Math.floor(s / 3600)}h`
return `${Math.floor(s / 86400)}j`
}
function formatMemory(bytes: number | null | undefined): string {
if (!bytes) return '—'
return `${Math.round(bytes / 1024 / 1024)}mb`
}
export function useInfra() {
const [services, setServices] = useState<InfraService[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = async () => {
setLoading(true)
setError(null)
if (USE_MOCK || !API_BASE) {
await new Promise(r => setTimeout(r, 300))
setServices(MOCK_SERVICES)
setLoading(false)
return
}
try {
const r = await fetch(`${API_BASE}/infra`, { credentials: 'include' })
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data: InfraResponse = await r.json()
setServices(data.services)
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur')
setServices(MOCK_SERVICES)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
return { services, loading, error, reload: load, formatUptime, formatMemory }
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react'
import { useBrainStore, LogLine } from '../store/brain.store'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const MOCK_LINES: LogLine[] = [
{ ts: new Date().toISOString(), level: 'info', msg: '[mock] workflow started' },
{ ts: new Date().toISOString(), level: 'debug', msg: '[mock] step INIT — done' },
{ ts: new Date().toISOString(), level: 'warn', msg: '[mock] gate pending — awaiting approval' },
]
export function useLogs(project: string, active: boolean) {
const logs = useBrainStore((s) => s.logs[project] ?? [])
const appendLogs = useBrainStore((s) => s.appendLogs)
const lastTsRef = useRef<string>('')
useEffect(() => {
if (!active) return
if (USE_MOCK || !API_BASE) {
appendLogs(project, MOCK_LINES)
return
}
const poll = async () => {
try {
const since = lastTsRef.current ? `?since=${encodeURIComponent(lastTsRef.current)}` : ''
const r = await fetch(`${API_BASE}/logs/${encodeURIComponent(project)}${since}`, {
credentials: 'include',
})
if (!r.ok) return
const data = await r.json()
const lines: LogLine[] = data.lines ?? []
if (lines.length > 0) {
lastTsRef.current = lines[lines.length - 1].ts
appendLogs(project, lines)
}
} catch {
// réseau — on ignore
}
}
poll()
const interval = setInterval(poll, 2000)
return () => clearInterval(interval)
}, [project, active]) // eslint-disable-line react-hooks/exhaustive-deps
return logs
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react'
import type { TeamPreset } from '../types'
const MOCK_TEAMS: TeamPreset[] = [
{
id: 'team-frontend',
label: 'Team Frontend',
icon: '⚛️',
agents: ['brain-ui-scribe', 'frontend-stack', 'optimizer-frontend'],
capabilities: ['react', 'typescript', 'tailwind', 'vite'],
gate_required: false,
default_timeout_min: 30,
},
{
id: 'team-backend',
label: 'Team Backend',
icon: '⚙️',
agents: ['debug', 'optimizer-backend', 'optimizer-db', 'pm2', 'migration'],
capabilities: ['nestjs', 'typescript', 'mysql', 'typeorm'],
gate_required: false,
default_timeout_min: 45,
},
{
id: 'team-infra',
label: 'Team Infra',
icon: '🖥️',
agents: ['vps', 'ci-cd', 'monitoring', 'secrets-guardian'],
capabilities: ['apache', 'vps', 'ssl', 'ci-cd'],
gate_required: true,
default_timeout_min: 20,
},
{
id: 'team-content',
label: 'Team Content',
icon: '🎬',
agents: ['content-strategist', 'scriptwriter', 'seo-youtube'],
capabilities: ['youtube', 'seo', 'scriptwriting'],
gate_required: false,
default_timeout_min: 60,
},
{
id: 'team-security',
label: 'Team Sécurité',
icon: '🔒',
agents: ['security', 'secrets-guardian', 'code-review'],
capabilities: ['jwt', 'oauth', 'owasp', 'secrets-rotation'],
gate_required: true,
default_timeout_min: 30,
},
{
id: 'team-fullstack',
label: 'Team Fullstack',
icon: '🔀',
agents: ['frontend-stack', 'optimizer-backend', 'optimizer-db', 'debug'],
capabilities: ['react', 'nestjs', 'mysql', 'typescript'],
gate_required: false,
default_timeout_min: 60,
},
{
id: 'team-game',
label: 'Team Game',
icon: '🎮',
agents: ['game-designer', 'optimizer-backend', 'optimizer-db'],
capabilities: ['game-design', 'nestjs', 'mysql'],
gate_required: false,
default_timeout_min: 45,
},
]
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
export function useTeams() {
const [teams, setTeams] = useState<TeamPreset[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (USE_MOCK || !API_BASE) {
setTeams(MOCK_TEAMS)
setIsLoading(false)
return
}
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
fetch(`${API_BASE}/teams`, {
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((data) => setTeams(data))
.catch(() => setTeams(MOCK_TEAMS))
.finally(() => setIsLoading(false))
}, [])
return { teams, isLoading }
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from 'react'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
export interface TierInfo {
tier: 'owner' | 'pro' | 'free'
features: string[]
kernel_access: boolean
}
const MOCK_TIER: TierInfo = {
tier: 'owner',
features: ['cosmos', 'workspace', 'workflows', 'builder', 'secrets', 'infra', 'editor'],
kernel_access: true,
}
export function useTier() {
const [tierInfo, setTierInfo] = useState<TierInfo>(MOCK_TIER)
const [loading, setLoading] = useState(!USE_MOCK)
useEffect(() => {
if (USE_MOCK || !API_BASE) {
setTierInfo(MOCK_TIER)
setLoading(false)
return
}
fetch(`${API_BASE}/tier`, { credentials: 'include' })
.then((r) => r.json())
.then((data: TierInfo) => setTierInfo(data))
.catch(() => setTierInfo(MOCK_TIER))
.finally(() => setLoading(false))
}, [])
const hasFeature = (feature: string) => tierInfo.features.includes(feature)
return { tierInfo, loading, hasFeature }
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useRef } from 'react'
import { useBrainStore } from '../store/brain.store'
import type { Toast } from '../components/ToastProvider'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
function buildWsUrl(): string {
// Si API_BASE est un chemin relatif (ex: '/api'), construire l'URL dynamiquement
if (!API_BASE || API_BASE.startsWith('/')) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const base = API_BASE || '/api'
return `${proto}://${location.host}${base}/ws`
}
// Si API_BASE est une URL absolue (ex: 'http://localhost:3333/api')
return API_BASE.replace(/^http/, 'ws') + '/ws'
}
const RECONNECT_DELAY_MS = 3000
type AddToast = (message: string, level: Toast['level'], context?: string) => void
export function useWebSocket(addToast?: AddToast) {
const statusRef = useRef<'connecting' | 'connected' | 'disconnected'>('disconnected')
useEffect(() => {
if (USE_MOCK || !API_BASE) {
useBrainStore.getState().setWsStatus('connected')
return
}
const wsUrl = buildWsUrl()
let ws: WebSocket | null = null
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
let destroyed = false
const setStatus = (s: 'connecting' | 'connected' | 'disconnected') => {
statusRef.current = s
const storeStatus =
s === 'connected' ? 'connected' :
s === 'connecting' ? 'disconnected' :
'disconnected'
useBrainStore.getState().setWsStatus(storeStatus)
}
const connect = () => {
if (destroyed) return
setStatus('connecting')
ws = new WebSocket(wsUrl)
ws.onopen = () => {
setStatus('connected')
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string)
const store = useBrainStore.getState()
switch (msg.type) {
case 'workflow:update':
if (Array.isArray(msg.data?.workflows)) {
store.setWorkflows(msg.data.workflows)
} else if (msg.payload) {
store.updateWorkflow(msg.payload)
}
break
case 'log:line': {
const project = msg.data?.project ?? msg.project ?? 'unknown'
const line = msg.data?.line ?? msg.line ?? ''
if (line) {
store.appendLogs(project, [{
ts: new Date().toISOString(),
level: detectLevel(line),
msg: line,
}])
}
break
}
case 'ambient:event': {
const context = msg.data?.context ?? msg.context ?? ''
const message = msg.data?.message ?? msg.message ?? ''
store.appendLogs('ambient', [{
ts: new Date().toISOString(),
level: 'info',
msg: `[${context}] ${message}`,
}])
addToast?.(
message,
(msg.data?.level ?? msg.level) === 'warn' ? 'warn' : 'info',
context || undefined,
)
break
}
case 'brain:updated': {
const path = msg.data?.path ?? msg.path ?? ''
console.log('brain:updated', path)
addToast?.(`brain mis à jour : ${path}`, 'success')
break
}
// Compatibilité avec l'ancien format gate:pending de useWorkflows
case 'gate:pending': {
const { workflowId, stepId } = msg.payload ?? {}
if (workflowId && stepId) {
store.appendLogs(workflowId, [{
ts: new Date().toISOString(),
level: 'warn',
msg: `Gate en attente — step ${stepId}`,
}])
}
const step = msg.payload?.stepId ?? msg.data?.step ?? ''
const workflow = msg.payload?.workflowId ?? msg.data?.workflow ?? ''
addToast?.(`Gate en attente : ${step}${workflow}`, 'warn')
break
}
default:
break
}
} catch {
// message malformé — ignorer
}
}
ws.onclose = () => {
if (!destroyed) {
setStatus('disconnected')
reconnectTimeout = setTimeout(connect, RECONNECT_DELAY_MS)
}
}
ws.onerror = () => {
useBrainStore.getState().setWsStatus('error')
ws?.close()
}
}
connect()
return () => {
destroyed = true
if (reconnectTimeout) clearTimeout(reconnectTimeout)
ws?.close()
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { status: statusRef.current }
}
// Détecte le niveau de log d'une ligne texte brute
function detectLevel(line: string): 'info' | 'warn' | 'error' | 'debug' {
const upper = line.toUpperCase()
if (upper.includes('ERROR') || upper.includes('ERR ') || upper.includes('FATAL')) return 'error'
if (upper.includes('WARN')) return 'warn'
if (upper.includes('DEBUG')) return 'debug'
return 'info'
}

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { MOCK_WORKFLOWS } from '../components/WorkflowBoard'
import { useBrainStore } from '../store/brain.store'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
export function useWorkflows() {
const workflows = useBrainStore((s) => s.workflows)
const wsStatus = useBrainStore((s) => s.wsStatus)
const setWorkflows = useBrainStore((s) => s.setWorkflows)
const setWsStatus = useBrainStore((s) => s.setWsStatus)
useEffect(() => {
if (USE_MOCK || !API_BASE) {
setWorkflows(MOCK_WORKFLOWS)
setWsStatus('connected')
return
}
// Fetch initial
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}
fetch(`${API_BASE}/workflows`, { credentials: 'include', headers })
.then((r) => r.json())
.then((data) => setWorkflows(data))
.catch(() => setWorkflows(MOCK_WORKFLOWS))
return () => {}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { workflows, wsStatus }
}

View File

@@ -0,0 +1,33 @@
import { useMemo } from 'react'
import { useBrainStore } from '../store/brain.store'
import type { WorkspaceWorkflow } from '../types'
const WORKFLOW_COLORS = ['#6366f1', '#f59e0b', '#22c55e', '#ef4444', '#8b5cf6', '#06b6d4']
function computeLayout(workflows: ReturnType<typeof useBrainStore.getState>['workflows']): WorkspaceWorkflow[] {
return workflows.map((wf, wfIdx) => {
const baseX = (wfIdx - workflows.length / 2) * 4
const color = WORKFLOW_COLORS[wfIdx % WORKFLOW_COLORS.length]
const steps = (wf.steps ?? []).map((step, stepIdx) => {
const z = step.status === 'done' ? -stepIdx * 0.5 : stepIdx === 0 ? 1 : 0
return {
id: step.id,
label: step.label,
status: step.status as WorkspaceWorkflow['steps'][number]['status'],
isGate: step.isGate ?? false,
x: baseX + Math.sin(stepIdx * 0.8) * 0.5,
y: (workflows.length / 2 - stepIdx) * 1.5,
z,
}
})
return { id: wf.id, name: wf.name, steps, teamId: undefined, color }
})
}
export function useWorkspaceData() {
const workflows = useBrainStore((s) => s.workflows)
const workspaceWorkflows = useMemo(() => computeLayout(workflows), [workflows])
return { workflows: workspaceWorkflows }
}