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:
104
brain-ui/src/hooks/useCosmosData.ts
Normal file
104
brain-ui/src/hooks/useCosmosData.ts
Normal 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) }
|
||||
}
|
||||
63
brain-ui/src/hooks/useInfra.ts
Normal file
63
brain-ui/src/hooks/useInfra.ts
Normal 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 }
|
||||
}
|
||||
50
brain-ui/src/hooks/useLogs.ts
Normal file
50
brain-ui/src/hooks/useLogs.ts
Normal 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
|
||||
}
|
||||
96
brain-ui/src/hooks/useTeams.ts
Normal file
96
brain-ui/src/hooks/useTeams.ts
Normal 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 }
|
||||
}
|
||||
39
brain-ui/src/hooks/useTier.ts
Normal file
39
brain-ui/src/hooks/useTier.ts
Normal 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 }
|
||||
}
|
||||
161
brain-ui/src/hooks/useWebSocket.ts
Normal file
161
brain-ui/src/hooks/useWebSocket.ts
Normal 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'
|
||||
}
|
||||
34
brain-ui/src/hooks/useWorkflows.ts
Normal file
34
brain-ui/src/hooks/useWorkflows.ts
Normal 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 }
|
||||
}
|
||||
33
brain-ui/src/hooks/useWorkspaceData.ts
Normal file
33
brain-ui/src/hooks/useWorkspaceData.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user