feat: docs live — brain-engine sert docs/ en API, DocsView fetch dynamique avec fallback statique
This commit is contained in:
@@ -211,7 +211,7 @@ _ws_clients: list[WebSocket] = []
|
||||
# Racine du brain (un niveau au-dessus de brain-engine/)
|
||||
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) ────────────────────────────
|
||||
|
||||
@@ -275,6 +275,80 @@ def health():
|
||||
return JSONResponse(status_code=503, content={'status': 'error', 'detail': str(e), 'uptime': uptime})
|
||||
|
||||
|
||||
# ── 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')
|
||||
def search(
|
||||
q: str = Query(..., description='Requête en langage naturel'),
|
||||
|
||||
@@ -5,17 +5,20 @@ interface DocFile {
|
||||
name: string
|
||||
label: 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: '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: '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: '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-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' },
|
||||
@@ -26,6 +29,9 @@ const DOCS: DocFile[] = [
|
||||
{ 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
|
||||
const TIER_MARKERS: Record<string, string> = {
|
||||
'\u{1F7E2}': 'tier-free', // 🟢
|
||||
@@ -58,42 +64,91 @@ const mdComponents: Components = {
|
||||
}
|
||||
|
||||
export default function DocsView() {
|
||||
const [docs, setDocs] = useState<DocFile[]>(STATIC_DOCS)
|
||||
const [activeDoc, setActiveDoc] = useState<string>('getting-started')
|
||||
const [content, setContent] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [liveMode, setLiveMode] = useState(false)
|
||||
|
||||
// Fetch docs list from brain-engine API — fallback to static
|
||||
useEffect(() => {
|
||||
const doc = DOCS.find((d) => d.name === activeDoc)
|
||||
if (!doc) return
|
||||
fetch(`${API_BASE}/docs`)
|
||||
.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)
|
||||
setError(null)
|
||||
|
||||
fetch(doc.path)
|
||||
.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])
|
||||
if (liveMode) {
|
||||
// Mode live — fetch depuis l'API brain-engine
|
||||
fetch(`${API_BASE}/docs/${activeDoc}.md`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
setContent(data.content)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(`Impossible de charger ${activeDoc}: ${err.message}`)
|
||||
setLoading(false)
|
||||
})
|
||||
} 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
|
||||
const groups = DOCS.reduce<Record<string, DocFile[]>>((acc, doc) => {
|
||||
fetch(doc.path)
|
||||
.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'
|
||||
if (!acc[g]) acc[g] = []
|
||||
acc[g].push(doc)
|
||||
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 (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Sidebar docs */}
|
||||
@@ -101,13 +156,22 @@ export default function DocsView() {
|
||||
className="flex flex-col flex-shrink-0 border-r overflow-y-auto"
|
||||
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' }}>
|
||||
Documentation
|
||||
</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>
|
||||
<nav className="flex flex-col gap-0.5 p-2">
|
||||
{Object.entries(groups).map(([group, docs]) => (
|
||||
{sortedGroups.map(([group, groupDocs]) => (
|
||||
<div key={group}>
|
||||
<div
|
||||
className="text-xs font-mono px-3 py-1.5 mt-2"
|
||||
@@ -115,7 +179,7 @@ export default function DocsView() {
|
||||
>
|
||||
{group.toUpperCase()}
|
||||
</div>
|
||||
{docs.map((doc) => (
|
||||
{groupDocs.map((doc) => (
|
||||
<button
|
||||
key={doc.name}
|
||||
onClick={() => setActiveDoc(doc.name)}
|
||||
|
||||
Reference in New Issue
Block a user