Compare commits
3 Commits
b551b21408
...
7b61f18e00
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b61f18e00 | |||
| f97e970650 | |||
| de4cd85798 |
@@ -125,7 +125,7 @@ TIER_RANK = {'free': 0, 'featured': 1, 'pro': 2, 'owner': 3, 'full': 3} # chaî
|
|||||||
|
|
||||||
# ── Tier cache ──────────────────────────────────────────────────────────────────
|
# ── Tier cache ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
KEYS_API = os.getenv('BRAIN_KEYS_API', '') # URL key server — vide = tier free par défaut
|
KEYS_API = os.getenv('BRAIN_KEYS_API', '')
|
||||||
TIER_TTL = 3600 # 1h TTL normal
|
TIER_TTL = 3600 # 1h TTL normal
|
||||||
TIER_GRACE = 7 * 86400 # 7 jours grace offline
|
TIER_GRACE = 7 * 86400 # 7 jours grace offline
|
||||||
|
|
||||||
@@ -211,15 +211,7 @@ _ws_clients: list[WebSocket] = []
|
|||||||
# Racine du brain (un niveau au-dessus de brain-engine/)
|
# Racine du brain (un niveau au-dessus de brain-engine/)
|
||||||
BRAIN_ROOT = Path(__file__).parent.parent
|
BRAIN_ROOT = Path(__file__).parent.parent
|
||||||
|
|
||||||
app = FastAPI(title='Brain-as-a-Service', version='BE-4', docs_url='/docs')
|
app = FastAPI(title='Brain-as-a-Service', version='BE-4', docs_url='/api-docs')
|
||||||
|
|
||||||
# ── Montage brain-ui static (si build disponible) ────────────────────────────
|
|
||||||
|
|
||||||
_UI_DIST = BRAIN_ROOT / 'brain-ui' / 'dist'
|
|
||||||
if _UI_DIST.is_dir():
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
app.mount('/ui', StaticFiles(directory=str(_UI_DIST), html=True), name='brain-ui')
|
|
||||||
log.info('brain-ui monté sur /ui depuis %s', _UI_DIST)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Level 2 — localhost frictionless ───────────────────────────────────────────
|
# ── Level 2 — localhost frictionless ───────────────────────────────────────────
|
||||||
@@ -275,6 +267,163 @@ def health():
|
|||||||
return JSONResponse(status_code=503, content={'status': 'error', 'detail': str(e), 'uptime': uptime})
|
return JSONResponse(status_code=503, content={'status': 'error', 'detail': str(e), 'uptime': uptime})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Brain-compose live — données tiers depuis brain-compose.yml ─────────────────
|
||||||
|
|
||||||
|
@app.get('/brain-compose/tiers')
|
||||||
|
def brain_compose_tiers():
|
||||||
|
"""Retourne les feature_sets structurés depuis brain-compose.yml — source de vérité."""
|
||||||
|
compose_path = BRAIN_ROOT / 'brain-compose.yml'
|
||||||
|
if not compose_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail='brain-compose.yml introuvable')
|
||||||
|
|
||||||
|
# Parse custom — brain-compose.yml utilise `extends:` inline qui n'est pas du YAML standard
|
||||||
|
raw = compose_path.read_text(encoding='utf-8')
|
||||||
|
# Retirer les lignes `extends: X` qui cassent le parser YAML
|
||||||
|
cleaned = re.sub(r'^\s+extends:\s+\w+\s*$', '', raw, flags=re.MULTILINE)
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(cleaned) if _YAML_AVAILABLE else {}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f'Erreur parsing brain-compose.yml: {e}')
|
||||||
|
|
||||||
|
feature_sets = data.get('feature_sets', {})
|
||||||
|
version = data.get('version', 'unknown')
|
||||||
|
|
||||||
|
# Résoudre l'héritage (extends) et compter les agents cumulés
|
||||||
|
resolved = {}
|
||||||
|
tier_chain = ['free', 'featured', 'pro', 'full']
|
||||||
|
|
||||||
|
cumulative_agents: list[str] = []
|
||||||
|
cumulative_sessions: list[str] = []
|
||||||
|
|
||||||
|
for tier_name in tier_chain:
|
||||||
|
tier_data = feature_sets.get(tier_name, {})
|
||||||
|
if not tier_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Agents de ce tier
|
||||||
|
tier_agents = tier_data.get('agents', [])
|
||||||
|
if tier_agents == '*':
|
||||||
|
# full = tous les agents
|
||||||
|
agents_dir = BRAIN_ROOT / 'agents'
|
||||||
|
if agents_dir.is_dir():
|
||||||
|
all_agents = sorted([
|
||||||
|
f.stem for f in agents_dir.glob('*.md')
|
||||||
|
if f.stem not in ('AGENTS', 'CATALOG', '_template', '_template-orchestrator')
|
||||||
|
])
|
||||||
|
cumulative_agents = all_agents
|
||||||
|
tier_agents_list = cumulative_agents[:]
|
||||||
|
else:
|
||||||
|
for a in tier_agents:
|
||||||
|
if a not in cumulative_agents:
|
||||||
|
cumulative_agents.append(a)
|
||||||
|
tier_agents_list = cumulative_agents[:]
|
||||||
|
|
||||||
|
# Sessions de ce tier
|
||||||
|
tier_sessions = tier_data.get('sessions', [])
|
||||||
|
if tier_sessions == '*':
|
||||||
|
cumulative_sessions = [
|
||||||
|
'navigate', 'work', 'debug', 'brainstorm', 'brain', 'handoff',
|
||||||
|
'coach', 'capital', 'audit', 'deploy', 'infra', 'urgence',
|
||||||
|
'kernel', 'edit-brain', 'pilote'
|
||||||
|
]
|
||||||
|
elif isinstance(tier_sessions, list):
|
||||||
|
for s in tier_sessions:
|
||||||
|
if s != 'extends' and not isinstance(s, dict) and s not in cumulative_sessions:
|
||||||
|
cumulative_sessions.append(s)
|
||||||
|
|
||||||
|
resolved[tier_name] = {
|
||||||
|
'description': tier_data.get('description', ''),
|
||||||
|
'coach_level': tier_data.get('coach_level', ''),
|
||||||
|
'distillation': tier_data.get('distillation', False),
|
||||||
|
'agents_new': [a for a in (tier_agents if isinstance(tier_agents, list) else []) if a != '*'],
|
||||||
|
'agents_total': tier_agents_list,
|
||||||
|
'agents_count': len(tier_agents_list),
|
||||||
|
'sessions_new': [s for s in (tier_sessions if isinstance(tier_sessions, list) else []) if s not in ('extends',) and not isinstance(s, dict)],
|
||||||
|
'sessions_total': cumulative_sessions[:],
|
||||||
|
'sessions_count': len(cumulative_sessions),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'version': version,
|
||||||
|
'tiers': resolved,
|
||||||
|
'tier_chain': tier_chain,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Docs live — sert docs/*.md depuis le filesystem ────────────────────────────
|
||||||
|
|
||||||
|
@app.get('/docs')
|
||||||
|
def docs_list():
|
||||||
|
"""Liste les fichiers docs/*.md avec métadonnées (frontmatter group/label)."""
|
||||||
|
docs_dir = BRAIN_ROOT / 'docs'
|
||||||
|
if not docs_dir.is_dir():
|
||||||
|
return {'docs': []}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for f in sorted(docs_dir.glob('*.md')):
|
||||||
|
if f.name == 'README.md':
|
||||||
|
continue
|
||||||
|
# Extraire le group depuis le contenu (heuristique basée sur le nom)
|
||||||
|
name = f.stem
|
||||||
|
group = _guess_doc_group(name)
|
||||||
|
label = _guess_doc_label(name)
|
||||||
|
results.append({
|
||||||
|
'name': name,
|
||||||
|
'label': label,
|
||||||
|
'group': group,
|
||||||
|
'path': f'/docs/{f.name}',
|
||||||
|
'size': f.stat().st_size,
|
||||||
|
'modified': datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
return {'docs': results}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/docs/{filename}')
|
||||||
|
def docs_read(filename: str):
|
||||||
|
"""Retourne le contenu brut d'un fichier docs/*.md."""
|
||||||
|
# Sécurité : pas de path traversal
|
||||||
|
if '/' in filename or '..' in filename:
|
||||||
|
raise HTTPException(status_code=400, detail='Nom de fichier invalide')
|
||||||
|
target = BRAIN_ROOT / 'docs' / filename
|
||||||
|
if not target.exists() or not target.suffix == '.md':
|
||||||
|
raise HTTPException(status_code=404, detail=f'{filename} introuvable')
|
||||||
|
content = target.read_text(encoding='utf-8')
|
||||||
|
# Strip frontmatter
|
||||||
|
content = re.sub(r'^---[\s\S]*?---\n*', '', content)
|
||||||
|
return JSONResponse(content={'name': target.stem, 'content': content})
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_doc_group(name: str) -> str:
|
||||||
|
"""Heuristique pour grouper les docs par famille."""
|
||||||
|
if name.startswith('agents'):
|
||||||
|
return 'Agents'
|
||||||
|
if name.startswith('vue-'):
|
||||||
|
return 'Vues'
|
||||||
|
return 'Guides'
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_doc_label(name: str) -> str:
|
||||||
|
"""Heuristique pour le label sidebar."""
|
||||||
|
labels = {
|
||||||
|
'getting-started': 'Demarrer',
|
||||||
|
'architecture': 'Architecture',
|
||||||
|
'sessions': 'Sessions',
|
||||||
|
'workflows': 'Workflows',
|
||||||
|
'satellites': 'Satellites',
|
||||||
|
'brain-engine-guide': 'Brain-engine',
|
||||||
|
'agents': "Vue d'ensemble",
|
||||||
|
'agents-code': 'Code & Qualite',
|
||||||
|
'agents-infra': 'Infra & Deploy',
|
||||||
|
'agents-brain': 'Brain & Systeme',
|
||||||
|
'vue-tiers': 'Comparatif',
|
||||||
|
'vue-free': '🟢 free',
|
||||||
|
'vue-featured': '🔵 featured',
|
||||||
|
'vue-pro': '🟠 pro',
|
||||||
|
'vue-full': '🟣 full',
|
||||||
|
}
|
||||||
|
return labels.get(name, name.replace('-', ' ').title())
|
||||||
|
|
||||||
|
|
||||||
@app.get('/search')
|
@app.get('/search')
|
||||||
def search(
|
def search(
|
||||||
q: str = Query(..., description='Requête en langage naturel'),
|
q: str = Query(..., description='Requête en langage naturel'),
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import { useState, useEffect, ReactNode } from 'react'
|
import { useState, useEffect, ReactNode } from 'react'
|
||||||
import ReactMarkdown, { Components } from 'react-markdown'
|
import ReactMarkdown, { Components } from 'react-markdown'
|
||||||
|
import { TierComparatif, TierSingle } from './TierDashboard'
|
||||||
|
|
||||||
interface DocFile {
|
interface DocFile {
|
||||||
name: string
|
name: string
|
||||||
label: string
|
label: string
|
||||||
path: string
|
path: string
|
||||||
group?: string
|
group: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOCS: DocFile[] = [
|
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
|
||||||
|
|
||||||
|
// Fallback statique — utilisé si brain-engine n'est pas dispo
|
||||||
|
const STATIC_DOCS: DocFile[] = [
|
||||||
{ name: 'getting-started', label: 'Demarrer', path: import.meta.env.BASE_URL + 'docs/getting-started.md', group: 'Guides' },
|
{ name: 'getting-started', label: 'Demarrer', path: import.meta.env.BASE_URL + 'docs/getting-started.md', group: 'Guides' },
|
||||||
{ name: 'architecture', label: 'Architecture', path: import.meta.env.BASE_URL + 'docs/architecture.md', group: 'Guides' },
|
{ name: 'architecture', label: 'Architecture', path: import.meta.env.BASE_URL + 'docs/architecture.md', group: 'Guides' },
|
||||||
{ name: 'sessions', label: 'Sessions', path: import.meta.env.BASE_URL + 'docs/sessions.md', group: 'Guides' },
|
{ name: 'sessions', label: 'Sessions', path: import.meta.env.BASE_URL + 'docs/sessions.md', group: 'Guides' },
|
||||||
|
{ name: 'workflows', label: 'Workflows', path: import.meta.env.BASE_URL + 'docs/workflows.md', group: 'Guides' },
|
||||||
{ name: 'satellites', label: 'Satellites', path: import.meta.env.BASE_URL + 'docs/satellites.md', group: 'Guides' },
|
{ name: 'satellites', label: 'Satellites', path: import.meta.env.BASE_URL + 'docs/satellites.md', group: 'Guides' },
|
||||||
{ name: 'brain-engine-guide', label: 'Brain-engine', path: import.meta.env.BASE_URL + 'docs/brain-engine-guide.md', group: 'Guides' },
|
{ name: 'brain-engine-guide', label: 'Brain-engine', path: import.meta.env.BASE_URL + 'docs/brain-engine-guide.md', group: 'Guides' },
|
||||||
{ name: 'workflows', label: 'Workflows', path: import.meta.env.BASE_URL + 'docs/workflows.md', group: 'Guides' },
|
{ name: 'agents', label: "Vue d'ensemble", path: import.meta.env.BASE_URL + 'docs/agents.md', group: 'Agents' },
|
||||||
{ name: 'agents', label: 'Vue d\'ensemble', path: import.meta.env.BASE_URL + 'docs/agents.md', group: 'Agents' },
|
|
||||||
{ name: 'agents-code', label: 'Code & Qualite', path: import.meta.env.BASE_URL + 'docs/agents-code.md', group: 'Agents' },
|
{ name: 'agents-code', label: 'Code & Qualite', path: import.meta.env.BASE_URL + 'docs/agents-code.md', group: 'Agents' },
|
||||||
{ name: 'agents-infra', label: 'Infra & Deploy', path: import.meta.env.BASE_URL + 'docs/agents-infra.md', group: 'Agents' },
|
{ name: 'agents-infra', label: 'Infra & Deploy', path: import.meta.env.BASE_URL + 'docs/agents-infra.md', group: 'Agents' },
|
||||||
{ name: 'agents-brain', label: 'Brain & Systeme', path: import.meta.env.BASE_URL + 'docs/agents-brain.md', group: 'Agents' },
|
{ name: 'agents-brain', label: 'Brain & Systeme', path: import.meta.env.BASE_URL + 'docs/agents-brain.md', group: 'Agents' },
|
||||||
@@ -26,6 +30,9 @@ const DOCS: DocFile[] = [
|
|||||||
{ name: 'vue-full', label: '🟣 full', path: import.meta.env.BASE_URL + 'docs/vue-full.md', group: 'Vues' },
|
{ name: 'vue-full', label: '🟣 full', path: import.meta.env.BASE_URL + 'docs/vue-full.md', group: 'Vues' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Order for consistent sidebar display
|
||||||
|
const GROUP_ORDER = ['Guides', 'Agents', 'Vues']
|
||||||
|
|
||||||
// Detect tier markers in blockquote content and apply CSS class
|
// Detect tier markers in blockquote content and apply CSS class
|
||||||
const TIER_MARKERS: Record<string, string> = {
|
const TIER_MARKERS: Record<string, string> = {
|
||||||
'\u{1F7E2}': 'tier-free', // 🟢
|
'\u{1F7E2}': 'tier-free', // 🟢
|
||||||
@@ -58,42 +65,91 @@ const mdComponents: Components = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DocsView() {
|
export default function DocsView() {
|
||||||
|
const [docs, setDocs] = useState<DocFile[]>(STATIC_DOCS)
|
||||||
const [activeDoc, setActiveDoc] = useState<string>('getting-started')
|
const [activeDoc, setActiveDoc] = useState<string>('getting-started')
|
||||||
const [content, setContent] = useState<string>('')
|
const [content, setContent] = useState<string>('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [liveMode, setLiveMode] = useState(false)
|
||||||
|
|
||||||
|
// Fetch docs list from brain-engine API — fallback to static
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const doc = DOCS.find((d) => d.name === activeDoc)
|
fetch(`${API_BASE}/docs`)
|
||||||
if (!doc) return
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error('API indisponible')
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data.docs && data.docs.length > 0) {
|
||||||
|
setDocs(data.docs)
|
||||||
|
setLiveMode(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Silencieux — on reste sur les docs statiques
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Fetch active doc content
|
||||||
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
fetch(doc.path)
|
if (liveMode) {
|
||||||
.then((res) => {
|
// Mode live — fetch depuis l'API brain-engine
|
||||||
if (!res.ok) throw new Error(`${res.status}`)
|
fetch(`${API_BASE}/docs/${activeDoc}.md`)
|
||||||
return res.text()
|
.then((res) => {
|
||||||
})
|
if (!res.ok) throw new Error(`${res.status}`)
|
||||||
.then((text) => {
|
return res.json()
|
||||||
const stripped = text.replace(/^---[\s\S]*?---\n*/, '')
|
})
|
||||||
setContent(stripped)
|
.then((data) => {
|
||||||
setLoading(false)
|
setContent(data.content)
|
||||||
})
|
setLoading(false)
|
||||||
.catch((err) => {
|
})
|
||||||
setError(`Impossible de charger ${doc.path}: ${err.message}`)
|
.catch((err) => {
|
||||||
setLoading(false)
|
setError(`Impossible de charger ${activeDoc}: ${err.message}`)
|
||||||
})
|
setLoading(false)
|
||||||
}, [activeDoc])
|
})
|
||||||
|
} else {
|
||||||
|
// Mode statique — fetch depuis les fichiers dist/
|
||||||
|
const doc = docs.find((d) => d.name === activeDoc)
|
||||||
|
if (!doc) { setLoading(false); return }
|
||||||
|
|
||||||
// Group docs by group
|
fetch(doc.path)
|
||||||
const groups = DOCS.reduce<Record<string, DocFile[]>>((acc, doc) => {
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`)
|
||||||
|
return res.text()
|
||||||
|
})
|
||||||
|
.then((text) => {
|
||||||
|
const stripped = text.replace(/^---[\s\S]*?---\n*/, '')
|
||||||
|
setContent(stripped)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(`Impossible de charger ${doc.path}: ${err.message}`)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [activeDoc, liveMode, docs])
|
||||||
|
|
||||||
|
// Group docs
|
||||||
|
const groups = docs.reduce<Record<string, DocFile[]>>((acc, doc) => {
|
||||||
const g = doc.group || 'Autres'
|
const g = doc.group || 'Autres'
|
||||||
if (!acc[g]) acc[g] = []
|
if (!acc[g]) acc[g] = []
|
||||||
acc[g].push(doc)
|
acc[g].push(doc)
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
|
// Sort groups by defined order
|
||||||
|
const sortedGroups = GROUP_ORDER
|
||||||
|
.filter((g) => groups[g])
|
||||||
|
.map((g) => [g, groups[g]] as [string, DocFile[]])
|
||||||
|
|
||||||
|
// Add any groups not in ORDER
|
||||||
|
Object.entries(groups).forEach(([g, d]) => {
|
||||||
|
if (!GROUP_ORDER.includes(g)) sortedGroups.push([g, d])
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-hidden">
|
<div className="flex h-full overflow-hidden">
|
||||||
{/* Sidebar docs */}
|
{/* Sidebar docs */}
|
||||||
@@ -101,13 +157,22 @@ export default function DocsView() {
|
|||||||
className="flex flex-col flex-shrink-0 border-r overflow-y-auto"
|
className="flex flex-col flex-shrink-0 border-r overflow-y-auto"
|
||||||
style={{ width: 200, borderColor: '#2a2a2a', background: '#141414' }}
|
style={{ width: 200, borderColor: '#2a2a2a', background: '#141414' }}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-3 border-b" style={{ borderColor: '#2a2a2a' }}>
|
<div className="px-3 py-3 border-b flex items-center justify-between" style={{ borderColor: '#2a2a2a' }}>
|
||||||
<span className="text-xs font-mono" style={{ color: '#6b7280' }}>
|
<span className="text-xs font-mono" style={{ color: '#6b7280' }}>
|
||||||
Documentation
|
Documentation
|
||||||
</span>
|
</span>
|
||||||
|
{liveMode && (
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono"
|
||||||
|
style={{ color: '#22c55e' }}
|
||||||
|
title="Docs servies en live depuis brain-engine — pas de rebuild necessaire"
|
||||||
|
>
|
||||||
|
live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-col gap-0.5 p-2">
|
<nav className="flex flex-col gap-0.5 p-2">
|
||||||
{Object.entries(groups).map(([group, docs]) => (
|
{sortedGroups.map(([group, groupDocs]) => (
|
||||||
<div key={group}>
|
<div key={group}>
|
||||||
<div
|
<div
|
||||||
className="text-xs font-mono px-3 py-1.5 mt-2"
|
className="text-xs font-mono px-3 py-1.5 mt-2"
|
||||||
@@ -115,7 +180,7 @@ export default function DocsView() {
|
|||||||
>
|
>
|
||||||
{group.toUpperCase()}
|
{group.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
{docs.map((doc) => (
|
{groupDocs.map((doc) => (
|
||||||
<button
|
<button
|
||||||
key={doc.name}
|
key={doc.name}
|
||||||
onClick={() => setActiveDoc(doc.name)}
|
onClick={() => setActiveDoc(doc.name)}
|
||||||
@@ -136,21 +201,37 @@ export default function DocsView() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto" style={{ padding: '2rem 3rem' }}>
|
<div className="flex-1 overflow-y-auto" style={{ padding: '2rem 3rem' }}>
|
||||||
{loading && (
|
{(() => {
|
||||||
<div style={{ color: '#4b5563' }} className="text-sm font-mono">
|
// Mode live + page tier → composant React dynamique
|
||||||
Chargement...
|
if (liveMode && activeDoc === 'vue-tiers') {
|
||||||
</div>
|
return <article className="docs-markdown"><TierComparatif /></article>
|
||||||
)}
|
}
|
||||||
{error && (
|
if (liveMode && activeDoc.startsWith('vue-')) {
|
||||||
<div style={{ color: '#ef4444' }} className="text-sm font-mono">
|
const tierName = activeDoc.replace('vue-', '')
|
||||||
{error}
|
return <article className="docs-markdown"><TierSingle tierName={tierName} /></article>
|
||||||
</div>
|
}
|
||||||
)}
|
|
||||||
{!loading && !error && (
|
// Mode standard — markdown
|
||||||
<article className="docs-markdown">
|
return (
|
||||||
<ReactMarkdown components={mdComponents}>{content}</ReactMarkdown>
|
<>
|
||||||
</article>
|
{loading && (
|
||||||
)}
|
<div style={{ color: '#4b5563' }} className="text-sm font-mono">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: '#ef4444' }} className="text-sm font-mono">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && (
|
||||||
|
<article className="docs-markdown">
|
||||||
|
<ReactMarkdown components={mdComponents}>{content}</ReactMarkdown>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
254
brain-ui/src/components/TierDashboard.tsx
Normal file
254
brain-ui/src/components/TierDashboard.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
|
||||||
|
|
||||||
|
interface TierData {
|
||||||
|
description: string
|
||||||
|
coach_level: string
|
||||||
|
distillation: boolean
|
||||||
|
agents_new: string[]
|
||||||
|
agents_total: string[]
|
||||||
|
agents_count: number
|
||||||
|
sessions_new: string[]
|
||||||
|
sessions_total: string[]
|
||||||
|
sessions_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TiersResponse {
|
||||||
|
version: string
|
||||||
|
tiers: Record<string, TierData>
|
||||||
|
tier_chain: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_COLORS: Record<string, { emoji: string; border: string; bg: string; text: string }> = {
|
||||||
|
free: { emoji: '🟢', border: '#22c55e', bg: 'rgba(34,197,94,0.06)', text: '#4ade80' },
|
||||||
|
featured: { emoji: '🔵', border: '#3b82f6', bg: 'rgba(59,130,246,0.06)', text: '#60a5fa' },
|
||||||
|
pro: { emoji: '🟠', border: '#f59e0b', bg: 'rgba(245,158,11,0.06)', text: '#fbbf24' },
|
||||||
|
full: { emoji: '🟣', border: '#a855f7', bg: 'rgba(168,85,247,0.06)', text: '#c084fc' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_TITLES: Record<string, string> = {
|
||||||
|
free: 'Tu forkes, ca marche',
|
||||||
|
featured: 'Le brain te connait',
|
||||||
|
pro: "L'atelier complet",
|
||||||
|
full: 'Ton brain, tes regles',
|
||||||
|
}
|
||||||
|
|
||||||
|
const COACH_LABELS: Record<string, string> = {
|
||||||
|
boot: 'Observation — intervient sur risque critique uniquement',
|
||||||
|
full: 'Mentorat complet — bilans, objectifs, progression',
|
||||||
|
L2: 'Mentorat long terme — anticipe, challenge, milestones',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparatif — vue multi-tiers
|
||||||
|
export function TierComparatif() {
|
||||||
|
const [data, setData] = useState<TiersResponse | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/brain-compose/tiers`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setData)
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (error) return <div style={{ color: '#ef4444' }}>Erreur: {error}</div>
|
||||||
|
if (!data) return <div style={{ color: '#4b5563' }}>Chargement...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Comparatif tiers</h1>
|
||||||
|
<p style={{ color: '#9ca3af' }}>
|
||||||
|
Donnees live depuis <code>brain-compose.yml</code> v{data.version}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{data.tier_chain.map(tierName => {
|
||||||
|
const tier = data.tiers[tierName]
|
||||||
|
if (!tier) return null
|
||||||
|
const colors = TIER_COLORS[tierName]
|
||||||
|
return (
|
||||||
|
<blockquote
|
||||||
|
key={tierName}
|
||||||
|
style={{
|
||||||
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
|
background: colors.bg,
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
margin: '1rem 0',
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<strong style={{ color: colors.text }}>
|
||||||
|
{colors.emoji} {tierName} — {TIER_TITLES[tierName]}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong style={{ color: '#f3f4f6' }}>{tier.agents_count} agents. {tier.sessions_count} sessions.</strong>
|
||||||
|
{tier.distillation && <span style={{ color: '#60a5fa' }}> Distillation RAG active.</span>}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#9ca3af' }}>{tier.description}</p>
|
||||||
|
</blockquote>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<h2>Detail par tier</h2>
|
||||||
|
|
||||||
|
{data.tier_chain.map(tierName => {
|
||||||
|
const tier = data.tiers[tierName]
|
||||||
|
if (!tier) return null
|
||||||
|
const colors = TIER_COLORS[tierName]
|
||||||
|
return (
|
||||||
|
<div key={tierName} style={{ marginBottom: '2rem' }}>
|
||||||
|
<h3 style={{ color: colors.text }}>{colors.emoji} {tierName}</h3>
|
||||||
|
|
||||||
|
<p><strong>Sessions</strong> ({tier.sessions_count}) :</p>
|
||||||
|
<p style={{ color: '#9ca3af' }}>{tier.sessions_total.join(' · ')}</p>
|
||||||
|
|
||||||
|
<p><strong>Agents</strong> ({tier.agents_count}) :</p>
|
||||||
|
<p style={{ color: '#9ca3af', fontSize: '0.875rem', lineHeight: '1.8' }}>
|
||||||
|
{tier.agents_total.join(' · ')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Coach</strong> : {COACH_LABELS[tier.coach_level] || tier.coach_level}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vue single tier
|
||||||
|
export function TierSingle({ tierName }: { tierName: string }) {
|
||||||
|
const [data, setData] = useState<TiersResponse | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/brain-compose/tiers`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setData)
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (error) return <div style={{ color: '#ef4444' }}>Erreur: {error}</div>
|
||||||
|
if (!data) return <div style={{ color: '#4b5563' }}>Chargement...</div>
|
||||||
|
|
||||||
|
const tier = data.tiers[tierName]
|
||||||
|
if (!tier) return <div style={{ color: '#ef4444' }}>Tier "{tierName}" introuvable</div>
|
||||||
|
|
||||||
|
const colors = TIER_COLORS[tierName]
|
||||||
|
const chain = data.tier_chain
|
||||||
|
const tierIndex = chain.indexOf(tierName)
|
||||||
|
|
||||||
|
// Trouver les agents/sessions "nouveaux" par rapport au tier precedent
|
||||||
|
const prevTier = tierIndex > 0 ? data.tiers[chain[tierIndex - 1]] : null
|
||||||
|
const prevAgents = new Set(prevTier?.agents_total || [])
|
||||||
|
const newAgents = tier.agents_total.filter(a => !prevAgents.has(a))
|
||||||
|
const prevSessions = new Set(prevTier?.sessions_total || [])
|
||||||
|
const newSessions = tier.sessions_total.filter(s => !prevSessions.has(s))
|
||||||
|
|
||||||
|
// Tier suivant pour le "ce que tu n'as pas encore"
|
||||||
|
const nextTierName = tierIndex < chain.length - 1 ? chain[tierIndex + 1] : null
|
||||||
|
const nextTier = nextTierName ? data.tiers[nextTierName] : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{colors.emoji} {tierName} — Ce que tu as</h1>
|
||||||
|
<p style={{ color: '#9ca3af' }}>
|
||||||
|
Donnees live depuis <code>brain-compose.yml</code> v{data.version}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<blockquote
|
||||||
|
style={{
|
||||||
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
|
background: colors.bg,
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
margin: '1rem 0',
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<strong style={{ color: colors.text }}>
|
||||||
|
{tier.agents_count} agents. {tier.sessions_count} sessions.
|
||||||
|
</strong>
|
||||||
|
{tier.distillation && <span style={{ color: '#60a5fa' }}> Distillation RAG active.</span>}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#9ca3af' }}>{tier.description}</p>
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<h2>Sessions {tierIndex > 0 && newSessions.length > 0 ? `(+${newSessions.length} nouvelles)` : ''}</h2>
|
||||||
|
{tierIndex > 0 && newSessions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p><strong>Ajoutees a ce tier :</strong></p>
|
||||||
|
<ul>{newSessions.map(s => <li key={s}><code>{s}</code></li>)}</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p><strong>Toutes les sessions disponibles :</strong></p>
|
||||||
|
<p style={{ color: '#9ca3af' }}>{tier.sessions_total.join(' · ')}</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<h2>Agents {tierIndex > 0 && newAgents.length > 0 ? `(+${newAgents.length} nouveaux)` : ''}</h2>
|
||||||
|
{tierIndex > 0 && newAgents.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p><strong>Ajoutes a ce tier :</strong></p>
|
||||||
|
<p style={{ color: '#9ca3af', fontSize: '0.875rem', lineHeight: '1.8' }}>
|
||||||
|
{newAgents.join(' · ')}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p><strong>Tous les agents disponibles ({tier.agents_count}) :</strong></p>
|
||||||
|
<p style={{ color: '#9ca3af', fontSize: '0.875rem', lineHeight: '1.8' }}>
|
||||||
|
{tier.agents_total.join(' · ')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<h2>Coach</h2>
|
||||||
|
<blockquote
|
||||||
|
style={{
|
||||||
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
|
background: colors.bg,
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p><strong style={{ color: colors.text }}>{tier.coach_level}</strong> — {COACH_LABELS[tier.coach_level] || tier.coach_level}</p>
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
{nextTier && nextTierName && (
|
||||||
|
<>
|
||||||
|
<hr />
|
||||||
|
<h2>Ce que tu n'as pas encore</h2>
|
||||||
|
<blockquote
|
||||||
|
style={{
|
||||||
|
borderLeft: `3px solid ${TIER_COLORS[nextTierName].border}`,
|
||||||
|
background: TIER_COLORS[nextTierName].bg,
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<strong style={{ color: TIER_COLORS[nextTierName].text }}>
|
||||||
|
{TIER_COLORS[nextTierName].emoji} {nextTierName}
|
||||||
|
</strong>
|
||||||
|
{' '}te donne : +{nextTier.agents_count - tier.agents_count} agents, +{nextTier.sessions_count - tier.sessions_count} sessions.
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#9ca3af' }}>{nextTier.description}</p>
|
||||||
|
</blockquote>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tierName === 'full' && (
|
||||||
|
<>
|
||||||
|
<hr />
|
||||||
|
<h2>Tu as tout</h2>
|
||||||
|
<p>C'est ton brain. Tu peux modifier n'importe quel agent, forger les tiens, restructurer le kernel. Le seul gate c'est toi.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user