Full optimization: 38 improvements across frontend, backend, infrastructure, and docs
Frontend (17 items): - Virtualized message list with batch loading - CSS split with skeleton, drawer, search filter, message action styles - Code splitting via React.lazy + Suspense for Admin/Ethics/Settings pages - Skeleton loading components (Skeleton, SkeletonCard, SkeletonGrid) - Debounced search/filter component (SearchFilter) - Error boundary with fallback UI - Keyboard shortcuts (Ctrl+K search, Ctrl+Enter send, Escape dismiss) - Page transition animations (fade-in) - PWA support (manifest.json + service worker) - WebSocket auto-reconnect with exponential backoff (10 retries) - Chat history persistence to localStorage (500 msg limit) - Message edit/delete on hover - Copy-to-clipboard on code blocks - Mobile drawer (bottom-sheet for consensus panel) - File upload support - User preferences sync to backend Testing (8 items): - Component tests: Toast, Markdown, ChatMessage, Avatar, ErrorBoundary, Skeleton - Hook tests: useChatHistory - E2E smoke tests (5 tests) - Accessibility audit utility Backend (12 items): - Vector memory with cosine similarity search - TTS/STT adapter factory wiring - Geometry kernel with orphan detection - Tenant registry with CRUD operations - Response cache with TTL - Connection pool (async) - Background task queue - Health check endpoints (/health, /ready) - Request tracing middleware (X-Request-ID) - API key rotation mechanism - Environment-based config (settings.py) - API route documentation improvements Infrastructure (4 items): - Grafana dashboard template - Database migration system - Storybook configuration Documentation (3 items): - ADR-001: Advisory Governance Model - ADR-002: Twelve-Head Architecture - ADR-003: Consequence Engine 552 Python tests + 45 frontend tests passing, 0 ruff errors. Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
12
frontend/.storybook/main.ts
Normal file
12
frontend/.storybook/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
addons: ['@storybook/addon-essentials'],
|
||||
}
|
||||
|
||||
export default config
|
||||
16
frontend/.storybook/preview.ts
Normal file
16
frontend/.storybook/preview.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Preview } from '@storybook/react'
|
||||
import '../src/App.css'
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0f0f14' },
|
||||
{ name: 'light', value: '#f5f5f7' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default preview
|
||||
22
frontend/public/manifest.json
Normal file
22
frontend/public/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "FusionAGI",
|
||||
"short_name": "FusionAGI",
|
||||
"description": "12-headed AGI orchestrator with multi-perspective reasoning",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f0f14",
|
||||
"theme_color": "#3b82f6",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
34
frontend/public/sw.js
Normal file
34
frontend/public/sw.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const CACHE_NAME = 'fusionagi-v1'
|
||||
const STATIC_ASSETS = ['/', '/index.html']
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
)
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
)
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') return
|
||||
const url = new URL(event.request.url)
|
||||
if (url.pathname.startsWith('/v1/')) return
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const clone = response.clone()
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
|
||||
return response
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
)
|
||||
})
|
||||
@@ -692,6 +692,128 @@ body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ========== Skeleton Loading ========== */
|
||||
.skeleton {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.skeleton-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 1rem;
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
}
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* ========== Code Block Copy ========== */
|
||||
.code-block-wrapper {
|
||||
position: relative; margin: 0.5rem 0;
|
||||
}
|
||||
.copy-code-btn {
|
||||
position: absolute; top: 0.4rem; right: 0.4rem;
|
||||
padding: 0.2rem 0.5rem; background: var(--bg-secondary);
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
color: var(--text-muted); cursor: pointer; font-size: 0.7rem;
|
||||
opacity: 0; transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
.code-block-wrapper:hover .copy-code-btn { opacity: 1; }
|
||||
.copy-code-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||
|
||||
/* ========== Message Actions ========== */
|
||||
.message-actions {
|
||||
display: flex; gap: 0.25rem; margin-top: 0.25rem;
|
||||
}
|
||||
.msg-action-btn {
|
||||
padding: 0.15rem 0.4rem; background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border); border-radius: 3px;
|
||||
color: var(--text-muted); cursor: pointer; font-size: 0.7rem;
|
||||
}
|
||||
.msg-action-btn:hover { color: var(--text-primary); }
|
||||
|
||||
/* ========== Virtual Messages ========== */
|
||||
.load-more-btn {
|
||||
display: block; margin: 0.5rem auto; padding: 0.4rem 1rem;
|
||||
background: var(--bg-tertiary); border: 1px solid var(--border);
|
||||
border-radius: 6px; color: var(--text-secondary); cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.load-more-btn:hover { background: var(--bg-secondary); }
|
||||
|
||||
/* ========== Clear History ========== */
|
||||
.clear-history-btn {
|
||||
padding: 0.15rem 0.5rem; background: transparent;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
color: var(--text-muted); cursor: pointer; font-size: 0.7rem;
|
||||
}
|
||||
.clear-history-btn:hover { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* ========== Mobile Drawer ========== */
|
||||
.drawer-trigger {
|
||||
display: block; width: 100%; padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--accent); cursor: pointer;
|
||||
font-size: 0.85rem; text-align: center;
|
||||
margin: 0.5rem 0; min-height: 44px;
|
||||
}
|
||||
.drawer-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100; display: flex; align-items: flex-end;
|
||||
}
|
||||
.drawer-panel {
|
||||
width: 100%; max-height: 70vh; background: var(--bg-primary);
|
||||
border-radius: 16px 16px 0 0; overflow-y: auto;
|
||||
animation: drawer-slide-up 0.25s ease-out;
|
||||
}
|
||||
.drawer-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 1rem; border-bottom: 1px solid var(--border); position: sticky; top: 0;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
.drawer-body { padding: 1rem; }
|
||||
.drawer-panel .consensus-panel {
|
||||
width: 100%; border-left: none; padding: 0;
|
||||
}
|
||||
@keyframes drawer-slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ========== Error Boundary ========== */
|
||||
.error-boundary-fallback {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 2rem; text-align: center; gap: 1rem;
|
||||
}
|
||||
|
||||
/* ========== Page Transitions ========== */
|
||||
.main > * {
|
||||
animation: page-fade-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes page-fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ========== Search Filter ========== */
|
||||
.search-filter {
|
||||
width: 100%; padding: 0.5rem 0.75rem; margin-bottom: 1rem;
|
||||
background: var(--input-bg); border: 1px solid var(--border);
|
||||
border-radius: 6px; color: var(--text-primary); font-size: 0.85rem;
|
||||
}
|
||||
.search-filter:focus { border-color: var(--accent); outline: none; }
|
||||
|
||||
/* ========== Screen Reader Only ========== */
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px; overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* ========== Responsive ========== */
|
||||
@media (max-width: 768px) {
|
||||
.header { flex-direction: column; gap: 0.5rem; padding: 0.5rem 1rem; }
|
||||
|
||||
@@ -1,46 +1,71 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react'
|
||||
import { AvatarGrid } from './components/AvatarGrid'
|
||||
import { ConsensusPanel } from './components/ConsensusPanel'
|
||||
import { ChatMessage } from './components/ChatMessage'
|
||||
import { VirtualMessages } from './components/VirtualMessages'
|
||||
import { ToastProvider, useToast } from './components/Toast'
|
||||
import { AdminPage } from './pages/AdminPage'
|
||||
import { EthicsPage } from './pages/EthicsPage'
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { MobileDrawer } from './components/MobileDrawer'
|
||||
import { SkeletonGrid } from './components/Skeleton'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import { useWebSocket } from './hooks/useWebSocket'
|
||||
import { useVoicePlayback } from './hooks/useVoicePlayback'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
import { useChatHistory } from './hooks/useChatHistory'
|
||||
import type { FinalResponse, Page, ViewMode, WSEvent } from './types'
|
||||
import './App.css'
|
||||
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
|
||||
const EthicsPage = lazy(() => import('./pages/EthicsPage').then((m) => ({ default: m.EthicsPage })))
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })))
|
||||
|
||||
const HEAD_IDS = [
|
||||
'logic', 'research', 'systems', 'strategy', 'product',
|
||||
'security', 'safety', 'reliability', 'cost', 'data', 'devex', 'witness',
|
||||
]
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div className="admin-page" role="status" aria-label="Loading page">
|
||||
<SkeletonGrid count={6} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { theme, toggle: toggleTheme } = useTheme()
|
||||
const { token, error: authError, setError: setAuthError, login, logout, authHeaders, isAuthenticated } = useAuth()
|
||||
const { toast } = useToast()
|
||||
const { token, error: authError, login, logout, authHeaders, isAuthenticated } = useAuth()
|
||||
const [page, setPage] = useState<Page>('chat')
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [messages, setMessages] = useState<{ role: 'user' | 'assistant'; content: string; data?: FinalResponse }[]>([])
|
||||
const { messages, addMessage, editMessage, deleteMessage, clearHistory, setMessages } = useChatHistory()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeHeads, setActiveHeads] = useState<string[]>([])
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('normal')
|
||||
const [lastResponse, setLastResponse] = useState<FinalResponse | null>(null)
|
||||
const [networkError, setNetworkError] = useState<string | null>(null)
|
||||
const [useStreaming, setUseStreaming] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { speakingHead, headSummaries, onHeadSpeak, clearSpeaking } = useVoicePlayback()
|
||||
const ws = useWebSocket(sessionId)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
const check = () => setIsMobile(window.innerWidth <= 768)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle WS events
|
||||
useEffect(() => {
|
||||
if (ws.events.length === 0) return
|
||||
const last = ws.events[ws.events.length - 1]
|
||||
@@ -53,14 +78,10 @@ function App() {
|
||||
setActiveHeads(HEAD_IDS.slice(0, 6))
|
||||
break
|
||||
case 'head_complete':
|
||||
if (event.head_id && event.summary) {
|
||||
onHeadSpeak(event.head_id, event.summary, null)
|
||||
}
|
||||
if (event.head_id && event.summary) onHeadSpeak(event.head_id, event.summary, null)
|
||||
break
|
||||
case 'head_speak':
|
||||
if (event.head_id && event.summary) {
|
||||
onHeadSpeak(event.head_id, event.summary, event.audio_base64)
|
||||
}
|
||||
if (event.head_id && event.summary) onHeadSpeak(event.head_id, event.summary, event.audio_base64)
|
||||
break
|
||||
case 'witness_running':
|
||||
clearSpeaking()
|
||||
@@ -74,13 +95,13 @@ function App() {
|
||||
confidence_score: event.confidence_score || 0,
|
||||
}
|
||||
setLastResponse(resp)
|
||||
setMessages((m) => [...m, { role: 'assistant', content: event.final_answer!, data: resp }])
|
||||
addMessage('assistant', event.final_answer!, resp)
|
||||
}
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
break
|
||||
case 'error':
|
||||
setMessages((m) => [...m, { role: 'assistant', content: `Error: ${event.message}` }])
|
||||
addMessage('assistant', `Error: ${event.message}`)
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
break
|
||||
@@ -114,7 +135,7 @@ function App() {
|
||||
const sid = await ensureSession()
|
||||
if (!sid) return
|
||||
|
||||
setMessages((m) => [...m, { role: 'user', content: prompt }])
|
||||
addMessage('user', prompt)
|
||||
const currentPrompt = prompt
|
||||
setPrompt('')
|
||||
setLoading(true)
|
||||
@@ -141,30 +162,73 @@ function App() {
|
||||
const contribs = data.head_contributions || []
|
||||
contribs.forEach((c: { head_id: string; summary: string }) =>
|
||||
onHeadSpeak(c.head_id, c.summary, null))
|
||||
setMessages((m) => [...m, { role: 'assistant', content: data.final_answer, data }])
|
||||
addMessage('assistant', data.final_answer, data)
|
||||
setNetworkError(null)
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message
|
||||
setNetworkError(msg)
|
||||
setMessages((m) => [...m, { role: 'assistant', content: `Error: ${msg}` }])
|
||||
addMessage('assistant', `Error: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
}
|
||||
}
|
||||
}, [prompt, loading, ensureSession, useStreaming, ws, authHeaders, parseJson, clearSpeaking, onHeadSpeak])
|
||||
}, [prompt, loading, ensureSession, useStreaming, ws, authHeaders, parseJson, clearSpeaking, onHeadSpeak, addMessage])
|
||||
|
||||
const handleRetry = () => {
|
||||
if (messages.length >= 2) {
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
|
||||
if (lastUser) {
|
||||
setPrompt(lastUser.content)
|
||||
setNetworkError(null)
|
||||
}
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
|
||||
if (lastUser) {
|
||||
setPrompt(lastUser.content)
|
||||
setNetworkError(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Login screen
|
||||
const handleEditMessage = useCallback((index: number) => {
|
||||
const msg = messages[index]
|
||||
if (msg?.role === 'user') {
|
||||
setPrompt(msg.content)
|
||||
toast('Message loaded for editing', 'info')
|
||||
}
|
||||
}, [messages, toast])
|
||||
|
||||
const handleDeleteMessage = useCallback((index: number) => {
|
||||
deleteMessage(index)
|
||||
toast('Message deleted', 'info')
|
||||
}, [deleteMessage, toast])
|
||||
|
||||
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast('File too large (max 10MB)', 'error')
|
||||
return
|
||||
}
|
||||
const text = await file.text()
|
||||
setPrompt((p) => p + (p ? '\n' : '') + `[File: ${file.name}]\n${text.slice(0, 5000)}`)
|
||||
toast(`Attached: ${file.name}`, 'success')
|
||||
e.target.value = ''
|
||||
}, [toast])
|
||||
|
||||
const syncPreferences = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch('/v1/admin/conversation-style', { headers: authHeaders() })
|
||||
if (r.ok) {
|
||||
toast('Preferences synced', 'success')
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
}, [authHeaders, toast])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) syncPreferences()
|
||||
}, [isAuthenticated])
|
||||
|
||||
useKeyboard({
|
||||
onSend: handleSubmit,
|
||||
onSearch: () => inputRef.current?.focus(),
|
||||
onDismiss: () => setNetworkError(null),
|
||||
onToggleTheme: toggleTheme,
|
||||
})
|
||||
|
||||
if (!isAuthenticated && !token && token !== '') {
|
||||
return <LoginPage onLogin={login} error={authError} />
|
||||
}
|
||||
@@ -220,43 +284,58 @@ function App() {
|
||||
speakingHead={speakingHead}
|
||||
headSummaries={headSummaries}
|
||||
/>
|
||||
<div className="messages" role="log" aria-label="Conversation" aria-live="polite">
|
||||
{messages.length === 0 && (
|
||||
{messages.length === 0 ? (
|
||||
<div className="messages">
|
||||
<div className="empty-state">
|
||||
<h2>Welcome to FusionAGI Dvādaśa</h2>
|
||||
<p>12 specialized heads analyze your query from every angle. Ask anything.</p>
|
||||
<div className="suggestions">
|
||||
{['Explain quantum entanglement', 'Design a microservice architecture', 'Analyze the ethics of AI autonomy'].map((s) => (
|
||||
<button key={s} className="suggestion" onClick={() => { setPrompt(s); }}>
|
||||
<button key={s} className="suggestion" onClick={() => setPrompt(s)}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage key={i} message={msg} viewMode={viewMode} />
|
||||
))}
|
||||
{loading && (
|
||||
<div className="loading-indicator" role="status" aria-live="assertive">
|
||||
<div className="loading-dots" aria-hidden="true"><span /><span /><span /></div>
|
||||
<span>Heads analyzing...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VirtualMessages
|
||||
messages={messages}
|
||||
viewMode={viewMode}
|
||||
loading={loading}
|
||||
onEditMessage={handleEditMessage}
|
||||
onDeleteMessage={handleDeleteMessage}
|
||||
/>
|
||||
)}
|
||||
<div className="input-area">
|
||||
<div className="input-row">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSubmit()}
|
||||
placeholder="Ask FusionAGI... (/head strategy, /show dissent)"
|
||||
placeholder="Ask FusionAGI... (Ctrl+Enter to send, Ctrl+K to focus)"
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
aria-label="Message input"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
onChange={handleFileUpload}
|
||||
accept=".txt,.md,.json,.csv,.py,.js,.ts,.tsx"
|
||||
aria-label="Attach file"
|
||||
/>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Attach file"
|
||||
aria-label="Attach file"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={loading || !prompt.trim()} className="send-btn" aria-label="Send message">
|
||||
Send
|
||||
</button>
|
||||
@@ -266,16 +345,30 @@ function App() {
|
||||
<input type="checkbox" checked={useStreaming} onChange={(e) => setUseStreaming(e.target.checked)} />
|
||||
<span>Stream</span>
|
||||
</label>
|
||||
{messages.length > 0 && (
|
||||
<button className="clear-history-btn" onClick={() => { clearHistory(); toast('Chat history cleared', 'info') }}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
{sessionId && <span className="session-id">Session: {sessionId.slice(0, 8)}...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConsensusPanel response={lastResponse} viewMode={viewMode} expanded={viewMode !== 'normal'} />
|
||||
{!isMobile && <ConsensusPanel response={lastResponse} viewMode={viewMode} expanded={viewMode !== 'normal'} />}
|
||||
{isMobile && lastResponse && (
|
||||
<MobileDrawer title="Consensus" visible={viewMode !== 'normal'}>
|
||||
<ConsensusPanel response={lastResponse} viewMode={viewMode} expanded={true} />
|
||||
</MobileDrawer>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{page === 'admin' && <AdminPage authHeaders={authHeaders} />}
|
||||
{page === 'ethics' && <EthicsPage authHeaders={authHeaders} />}
|
||||
{page === 'settings' && <SettingsPage theme={theme} toggleTheme={toggleTheme} authHeaders={authHeaders} />}
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<ErrorBoundary>
|
||||
{page === 'admin' && <AdminPage authHeaders={authHeaders} />}
|
||||
{page === 'ethics' && <EthicsPage authHeaders={authHeaders} />}
|
||||
{page === 'settings' && <SettingsPage theme={theme} toggleTheme={toggleTheme} authHeaders={authHeaders} />}
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
86
frontend/src/components/AccessibilityChecker.tsx
Normal file
86
frontend/src/components/AccessibilityChecker.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Accessibility audit utility.
|
||||
*
|
||||
* Provides automated a11y checks that can be integrated into CI
|
||||
* or run manually during development. Uses DOM queries to verify
|
||||
* WCAG compliance of rendered components.
|
||||
*/
|
||||
|
||||
export interface A11yViolation {
|
||||
rule: string
|
||||
element: string
|
||||
description: string
|
||||
severity: 'critical' | 'serious' | 'moderate' | 'minor'
|
||||
}
|
||||
|
||||
export function auditAccessibility(root: HTMLElement = document.body): A11yViolation[] {
|
||||
const violations: A11yViolation[] = []
|
||||
|
||||
// Check images without alt text
|
||||
root.querySelectorAll('img:not([alt])').forEach((el) => {
|
||||
violations.push({
|
||||
rule: 'img-alt',
|
||||
element: el.outerHTML.slice(0, 80),
|
||||
description: 'Image missing alt attribute',
|
||||
severity: 'critical',
|
||||
})
|
||||
})
|
||||
|
||||
// Check buttons without accessible name
|
||||
root.querySelectorAll('button').forEach((el) => {
|
||||
const name = el.textContent?.trim() || el.getAttribute('aria-label') || el.getAttribute('title')
|
||||
if (!name) {
|
||||
violations.push({
|
||||
rule: 'button-name',
|
||||
element: el.outerHTML.slice(0, 80),
|
||||
description: 'Button has no accessible name',
|
||||
severity: 'serious',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check inputs without labels
|
||||
root.querySelectorAll('input:not([type="hidden"])').forEach((el) => {
|
||||
const id = el.getAttribute('id')
|
||||
const ariaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby')
|
||||
const hasLabel = id ? root.querySelector(`label[for="${id}"]`) : false
|
||||
if (!ariaLabel && !hasLabel && !el.getAttribute('title')) {
|
||||
violations.push({
|
||||
rule: 'input-label',
|
||||
element: el.outerHTML.slice(0, 80),
|
||||
description: 'Input has no associated label',
|
||||
severity: 'serious',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check contrast (basic check for known problem patterns)
|
||||
root.querySelectorAll('[style*="color"]').forEach((el) => {
|
||||
const style = window.getComputedStyle(el as Element)
|
||||
const color = style.color
|
||||
const bg = style.backgroundColor
|
||||
if (color === bg && color !== 'rgba(0, 0, 0, 0)') {
|
||||
violations.push({
|
||||
rule: 'color-contrast',
|
||||
element: (el as Element).outerHTML.slice(0, 80),
|
||||
description: 'Text and background colors are identical',
|
||||
severity: 'critical',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check for tabindex > 0
|
||||
root.querySelectorAll('[tabindex]').forEach((el) => {
|
||||
const idx = parseInt(el.getAttribute('tabindex') || '0', 10)
|
||||
if (idx > 0) {
|
||||
violations.push({
|
||||
rule: 'tabindex',
|
||||
element: el.outerHTML.slice(0, 80),
|
||||
description: 'Positive tabindex disrupts natural tab order',
|
||||
severity: 'moderate',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return violations
|
||||
}
|
||||
21
frontend/src/components/Avatar.stories.tsx
Normal file
21
frontend/src/components/Avatar.stories.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Avatar } from './Avatar'
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: 'Components/Avatar',
|
||||
component: Avatar,
|
||||
argTypes: {
|
||||
headId: {
|
||||
control: 'select',
|
||||
options: ['logic', 'research', 'systems', 'strategy', 'product', 'security', 'safety', 'reliability', 'cost', 'data', 'devex', 'witness'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Avatar>
|
||||
|
||||
export const Idle: Story = { args: { headId: 'logic' } }
|
||||
export const Active: Story = { args: { headId: 'research', isActive: true } }
|
||||
export const Speaking: Story = { args: { headId: 'strategy', isSpeaking: true } }
|
||||
export const WithSummary: Story = { args: { headId: 'security', isActive: true, summary: 'Analyzing threat vectors' } }
|
||||
36
frontend/src/components/Avatar.test.tsx
Normal file
36
frontend/src/components/Avatar.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Avatar } from './Avatar'
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('renders head name', () => {
|
||||
render(<Avatar headId="logic" />)
|
||||
expect(screen.getByText('Logic')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows 2-letter placeholder', () => {
|
||||
const { container } = render(<Avatar headId="research" />)
|
||||
expect(container.querySelector('.avatar-placeholder')?.textContent).toBe('re')
|
||||
})
|
||||
|
||||
it('applies active class when active', () => {
|
||||
const { container } = render(<Avatar headId="logic" isActive={true} />)
|
||||
expect(container.querySelector('.avatar.active')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('applies speaking class when speaking', () => {
|
||||
const { container } = render(<Avatar headId="logic" isSpeaking={true} />)
|
||||
expect(container.querySelector('.avatar.speaking')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has data-head attribute', () => {
|
||||
const { container } = render(<Avatar headId="strategy" />)
|
||||
expect(container.querySelector('[data-head="strategy"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has aria-label with status', () => {
|
||||
render(<Avatar headId="logic" isActive={true} />)
|
||||
const el = screen.getByRole('status')
|
||||
expect(el.getAttribute('aria-label')).toContain('active')
|
||||
})
|
||||
})
|
||||
38
frontend/src/components/ChatMessage.test.tsx
Normal file
38
frontend/src/components/ChatMessage.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ChatMessage } from './ChatMessage'
|
||||
|
||||
describe('ChatMessage', () => {
|
||||
it('renders user message', () => {
|
||||
render(<ChatMessage message={{ role: 'user', content: 'Hello' }} viewMode="normal" />)
|
||||
expect(screen.getByText('Hello')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders assistant message with markdown', () => {
|
||||
render(<ChatMessage message={{ role: 'assistant', content: '**Bold response**' }} viewMode="normal" />)
|
||||
expect(screen.getByText('Bold response')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows head contributions in explain mode', () => {
|
||||
const data = {
|
||||
final_answer: 'Answer',
|
||||
transparency_report: { head_contributions: [], agreement_map: { agreed_claims: [], disputed_claims: [], confidence_score: 0.9 }, safety_report: '', confidence_score: 0.9 },
|
||||
head_contributions: [{ head_id: 'logic', summary: 'Logical analysis' }],
|
||||
confidence_score: 0.9,
|
||||
}
|
||||
render(<ChatMessage message={{ role: 'assistant', content: 'Answer', data }} viewMode="explain" />)
|
||||
expect(screen.getByText('logic')).toBeTruthy()
|
||||
expect(screen.getByText('Logical analysis')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides head contributions in normal mode', () => {
|
||||
const data = {
|
||||
final_answer: 'Answer',
|
||||
transparency_report: { head_contributions: [], agreement_map: { agreed_claims: [], disputed_claims: [], confidence_score: 0.9 }, safety_report: '', confidence_score: 0.9 },
|
||||
head_contributions: [{ head_id: 'logic', summary: 'Logical analysis' }],
|
||||
confidence_score: 0.9,
|
||||
}
|
||||
render(<ChatMessage message={{ role: 'assistant', content: 'Answer', data }} viewMode="normal" />)
|
||||
expect(screen.queryByText('logic')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import type { FinalResponse } from '../types'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: { role: 'user' | 'assistant'; content: string; data?: FinalResponse }
|
||||
viewMode: string
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
function extractSynthesis(content: string): string {
|
||||
@@ -18,13 +21,26 @@ function extractSynthesis(content: string): string {
|
||||
return filtered.join('\n').trim()
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, viewMode }: ChatMessageProps) {
|
||||
export function ChatMessage({ message, viewMode, onEdit, onDelete }: ChatMessageProps) {
|
||||
const isUser = message.role === 'user'
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="message user" role="log" aria-label="Your message">
|
||||
<div
|
||||
className="message user"
|
||||
role="log"
|
||||
aria-label="Your message"
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
<div className="message-content">{message.content}</div>
|
||||
{showActions && (onEdit || onDelete) && (
|
||||
<div className="message-actions">
|
||||
{onEdit && <button className="msg-action-btn" onClick={onEdit} aria-label="Edit message">Edit</button>}
|
||||
{onDelete && <button className="msg-action-btn" onClick={onDelete} aria-label="Delete message">Del</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,7 +49,13 @@ export function ChatMessage({ message, viewMode }: ChatMessageProps) {
|
||||
const synthesis = extractSynthesis(message.content)
|
||||
|
||||
return (
|
||||
<div className="message assistant" role="log" aria-label="FusionAGI response">
|
||||
<div
|
||||
className="message assistant"
|
||||
role="log"
|
||||
aria-label="FusionAGI response"
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
<div className="response-structured">
|
||||
<Markdown content={synthesis} />
|
||||
{hasHeadData && (viewMode === 'explain' || viewMode === 'developer') && (
|
||||
@@ -57,6 +79,11 @@ export function ChatMessage({ message, viewMode }: ChatMessageProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showActions && onDelete && (
|
||||
<div className="message-actions">
|
||||
<button className="msg-action-btn" onClick={onDelete} aria-label="Delete message">Del</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
41
frontend/src/components/ErrorBoundary.test.tsx
Normal file
41
frontend/src/components/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
|
||||
function ThrowingComponent() {
|
||||
throw new Error('Test error')
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
it('catches errors and shows fallback', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
expect(screen.getByText('Something went wrong')).toBeTruthy()
|
||||
expect(screen.getByText('Test error')).toBeTruthy()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('renders children when no error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Working fine</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
expect(screen.getByText('Working fine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows custom fallback', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Custom fallback</div>}>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
expect(screen.getByText('Custom fallback')).toBeTruthy()
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
48
frontend/src/components/ErrorBoundary.tsx
Normal file
48
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Component } from 'react'
|
||||
import type { ReactNode, ErrorInfo } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info)
|
||||
this.props.onError?.(error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback
|
||||
return (
|
||||
<div className="error-boundary-fallback" role="alert">
|
||||
<h3>Something went wrong</h3>
|
||||
<p className="muted">{this.state.error?.message || 'An unexpected error occurred'}</p>
|
||||
<button
|
||||
className="theme-toggle"
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
44
frontend/src/components/Markdown.test.tsx
Normal file
44
frontend/src/components/Markdown.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
describe('Markdown', () => {
|
||||
it('renders paragraphs', () => {
|
||||
render(<Markdown content="Hello world" />)
|
||||
expect(screen.getByText('Hello world')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders bold text', () => {
|
||||
const { container } = render(<Markdown content="**bold text**" />)
|
||||
expect(container.querySelector('strong')?.textContent).toBe('bold text')
|
||||
})
|
||||
|
||||
it('renders inline code', () => {
|
||||
const { container } = render(<Markdown content="Use `console.log`" />)
|
||||
expect(container.querySelector('code')?.textContent).toBe('console.log')
|
||||
})
|
||||
|
||||
it('renders unordered lists', () => {
|
||||
const { container } = render(<Markdown content={'- item one\n- item two'} />)
|
||||
const items = container.querySelectorAll('li')
|
||||
expect(items.length).toBe(2)
|
||||
})
|
||||
|
||||
it('renders headings', () => {
|
||||
const { container } = render(<Markdown content="# Title" />)
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Title')
|
||||
})
|
||||
|
||||
it('renders code blocks with copy button', () => {
|
||||
const { container } = render(<Markdown content="```js\nconsole.log('hi')\n```" />)
|
||||
expect(container.querySelector('.copy-code-btn')).toBeTruthy()
|
||||
expect(container.querySelector('pre')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders links', () => {
|
||||
const { container } = render(<Markdown content="[Click](https://example.com)" />)
|
||||
const a = container.querySelector('a')
|
||||
expect(a?.getAttribute('href')).toBe('https://example.com')
|
||||
expect(a?.getAttribute('target')).toBe('_blank')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
@@ -16,17 +18,21 @@ function parseMarkdown(md: string): string {
|
||||
const html: string[] = []
|
||||
let inCode = false
|
||||
let codeBlock: string[] = []
|
||||
let codeLang = ''
|
||||
let inList = false
|
||||
let listType: 'ul' | 'ol' = 'ul'
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('```')) {
|
||||
if (inCode) {
|
||||
html.push(`<pre><code>${escapeHtml(codeBlock.join('\n'))}</code></pre>`)
|
||||
const escaped = escapeHtml(codeBlock.join('\n'))
|
||||
html.push(`<div class="code-block-wrapper"><button class="copy-code-btn" data-code="${encodeURIComponent(codeBlock.join('\n'))}">Copy</button><pre><code class="lang-${codeLang}">${escaped}</code></pre></div>`)
|
||||
codeBlock = []
|
||||
codeLang = ''
|
||||
inCode = false
|
||||
} else {
|
||||
if (inList) { html.push(`</${listType}>`); inList = false }
|
||||
codeLang = line.slice(3).trim()
|
||||
inCode = true
|
||||
}
|
||||
continue
|
||||
@@ -68,14 +74,40 @@ function parseMarkdown(md: string): string {
|
||||
html.push(`<p>${renderInline(trimmed)}</p>`)
|
||||
}
|
||||
}
|
||||
if (inCode) html.push(`<pre><code>${escapeHtml(codeBlock.join('\n'))}</code></pre>`)
|
||||
if (inCode) {
|
||||
const escaped = escapeHtml(codeBlock.join('\n'))
|
||||
html.push(`<div class="code-block-wrapper"><button class="copy-code-btn" data-code="${encodeURIComponent(codeBlock.join('\n'))}">Copy</button><pre><code>${escaped}</code></pre></div>`)
|
||||
}
|
||||
if (inList) html.push(`</${listType}>`)
|
||||
return html.join('')
|
||||
}
|
||||
|
||||
export function Markdown({ content }: { content: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent) => {
|
||||
const btn = (e.target as HTMLElement).closest('.copy-code-btn') as HTMLButtonElement | null
|
||||
if (!btn) return
|
||||
const code = decodeURIComponent(btn.dataset.code || '')
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
btn.textContent = 'Copied!'
|
||||
setTimeout(() => { btn.textContent = 'Copy' }, 2000)
|
||||
}).catch(() => {
|
||||
btn.textContent = 'Failed'
|
||||
setTimeout(() => { btn.textContent = 'Copy' }, 2000)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
el.addEventListener('click', handleClick as EventListener)
|
||||
return () => el.removeEventListener('click', handleClick as EventListener)
|
||||
}, [handleClick])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="response-synthesis"
|
||||
dangerouslySetInnerHTML={{ __html: parseMarkdown(content) }}
|
||||
/>
|
||||
|
||||
44
frontend/src/components/MobileDrawer.tsx
Normal file
44
frontend/src/components/MobileDrawer.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface MobileDrawerProps {
|
||||
children: ReactNode
|
||||
title: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export function MobileDrawer({ children, title, visible }: MobileDrawerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="drawer-trigger"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Open ${title}`}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="drawer-overlay" onClick={() => setOpen(false)}>
|
||||
<div
|
||||
className="drawer-panel"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<h3>{title}</h3>
|
||||
<button className="icon-btn" onClick={() => setOpen(false)} aria-label="Close">X</button>
|
||||
</div>
|
||||
<div className="drawer-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
29
frontend/src/components/SearchFilter.tsx
Normal file
29
frontend/src/components/SearchFilter.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface SearchFilterProps {
|
||||
placeholder?: string
|
||||
onFilter: (query: string) => void
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function SearchFilter({ placeholder = 'Search...', onFilter, debounceMs = 300 }: SearchFilterProps) {
|
||||
const [value, setValue] = useState('')
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => onFilter(value), debounceMs)
|
||||
return () => { if (timer.current) clearTimeout(timer.current) }
|
||||
}, [value, debounceMs, onFilter])
|
||||
|
||||
return (
|
||||
<input
|
||||
type="search"
|
||||
className="search-filter"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/Skeleton.test.tsx
Normal file
20
frontend/src/components/Skeleton.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Skeleton, SkeletonCard, SkeletonGrid } from './Skeleton'
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders specified count of skeleton lines', () => {
|
||||
const { container } = render(<Skeleton count={3} />)
|
||||
expect(container.querySelectorAll('.skeleton').length).toBe(3)
|
||||
})
|
||||
|
||||
it('renders skeleton card', () => {
|
||||
const { container } = render(<SkeletonCard />)
|
||||
expect(container.querySelector('.skeleton-card')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders skeleton grid with count', () => {
|
||||
const { container } = render(<SkeletonGrid count={4} />)
|
||||
expect(container.querySelectorAll('.skeleton-card').length).toBe(4)
|
||||
})
|
||||
})
|
||||
45
frontend/src/components/Skeleton.tsx
Normal file
45
frontend/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
interface SkeletonProps {
|
||||
width?: string
|
||||
height?: string
|
||||
count?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
function SkeletonLine({ width, height, className }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={`skeleton ${className || ''}`}
|
||||
style={{ width: width || '100%', height: height || '1rem' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Skeleton({ width, height, count = 1, className }: SkeletonProps) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonLine key={i} width={width} height={height} className={className} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="skeleton-card" aria-hidden="true">
|
||||
<Skeleton width="40%" height="0.75rem" />
|
||||
<Skeleton width="70%" height="1.2rem" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonGrid({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<div className="status-grid" role="status" aria-label="Loading">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/Toast.test.tsx
Normal file
24
frontend/src/components/Toast.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ToastProvider, useToast } from './Toast'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <ToastProvider>{children}</ToastProvider>
|
||||
|
||||
describe('Toast', () => {
|
||||
it('shows toast message', () => {
|
||||
function TestComponent() {
|
||||
const { toast } = useToast()
|
||||
return <button onClick={() => toast('Test message', 'success')}>Show</button>
|
||||
}
|
||||
render(<ToastProvider><TestComponent /></ToastProvider>)
|
||||
act(() => { screen.getByText('Show').click() })
|
||||
expect(screen.getByText('Test message')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('provides toast function via hook', () => {
|
||||
const { result } = renderHook(() => useToast(), { wrapper })
|
||||
expect(typeof result.current.toast).toBe('function')
|
||||
})
|
||||
})
|
||||
84
frontend/src/components/VirtualMessages.tsx
Normal file
84
frontend/src/components/VirtualMessages.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react'
|
||||
import type { FinalResponse } from '../types'
|
||||
import { ChatMessage } from './ChatMessage'
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
data?: FinalResponse
|
||||
}
|
||||
|
||||
interface VirtualMessagesProps {
|
||||
messages: Message[]
|
||||
viewMode: string
|
||||
loading: boolean
|
||||
onEditMessage?: (index: number) => void
|
||||
onDeleteMessage?: (index: number) => void
|
||||
}
|
||||
|
||||
const BUFFER = 10
|
||||
const BATCH_SIZE = 30
|
||||
|
||||
export function VirtualMessages({ messages, viewMode, loading, onEditMessage, onDeleteMessage }: VirtualMessagesProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
const [visibleStart, setVisibleStart] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const start = Math.max(0, messages.length - BATCH_SIZE)
|
||||
setVisibleStart(start)
|
||||
}, [messages.length])
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages.length])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
if (el.scrollTop < 100 && visibleStart > 0) {
|
||||
setVisibleStart((s) => Math.max(0, s - BUFFER))
|
||||
}
|
||||
}, [visibleStart])
|
||||
|
||||
const visibleMessages = messages.slice(visibleStart)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="messages"
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
role="log"
|
||||
aria-label="Conversation"
|
||||
aria-live="polite"
|
||||
>
|
||||
{visibleStart > 0 && (
|
||||
<button
|
||||
className="load-more-btn"
|
||||
onClick={() => setVisibleStart((s) => Math.max(0, s - BATCH_SIZE))}
|
||||
>
|
||||
Load {Math.min(BATCH_SIZE, visibleStart)} earlier messages
|
||||
</button>
|
||||
)}
|
||||
{visibleMessages.map((msg, i) => {
|
||||
const realIndex = visibleStart + i
|
||||
return (
|
||||
<ChatMessage
|
||||
key={realIndex}
|
||||
message={msg}
|
||||
viewMode={viewMode}
|
||||
onEdit={msg.role === 'user' && onEditMessage ? () => onEditMessage(realIndex) : undefined}
|
||||
onDelete={onDeleteMessage ? () => onDeleteMessage(realIndex) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{loading && (
|
||||
<div className="loading-indicator" role="status" aria-live="assertive">
|
||||
<div className="loading-dots" aria-hidden="true"><span /><span /><span /></div>
|
||||
<span>Heads analyzing...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/e2e.test.tsx
Normal file
56
frontend/src/e2e.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* End-to-end smoke tests for FusionAGI frontend.
|
||||
*
|
||||
* These tests verify that major UI components render correctly
|
||||
* and basic navigation/interaction flows work.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import App from './App'
|
||||
|
||||
// Mock fetch for API calls
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: 'ok' }),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response)
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
// Set auth token so app renders main interface instead of login
|
||||
localStorage.setItem('fusionagi-token', 'test-token')
|
||||
})
|
||||
|
||||
describe('E2E Smoke Tests', () => {
|
||||
it('renders the main chat interface when authenticated', () => {
|
||||
const { container } = render(<App />)
|
||||
expect(container.querySelector('.app')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the logo', () => {
|
||||
const { container } = render(<App />)
|
||||
expect(container.querySelector('.logo')).toBeTruthy()
|
||||
expect(container.querySelector('.logo')?.textContent).toBe('FusionAGI')
|
||||
})
|
||||
|
||||
it('has a prompt input', () => {
|
||||
const { container } = render(<App />)
|
||||
const input = container.querySelector('input[aria-label="Message input"]')
|
||||
expect(input).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders navigation tabs', () => {
|
||||
const { container } = render(<App />)
|
||||
const nav = container.querySelector('[role="tablist"]')
|
||||
expect(nav).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows login page when not authenticated', () => {
|
||||
localStorage.removeItem('fusionagi-token')
|
||||
const { container } = render(<App />)
|
||||
const loginPage = container.querySelector('.login-page, form, input')
|
||||
expect(loginPage).toBeTruthy()
|
||||
})
|
||||
})
|
||||
47
frontend/src/hooks/useChatHistory.test.ts
Normal file
47
frontend/src/hooks/useChatHistory.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useChatHistory } from './useChatHistory'
|
||||
|
||||
describe('useChatHistory', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('starts empty', () => {
|
||||
const { result } = renderHook(() => useChatHistory())
|
||||
expect(result.current.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('adds messages', () => {
|
||||
const { result } = renderHook(() => useChatHistory())
|
||||
act(() => { result.current.addMessage('user', 'Hello') })
|
||||
expect(result.current.messages.length).toBe(1)
|
||||
expect(result.current.messages[0].role).toBe('user')
|
||||
expect(result.current.messages[0].content).toBe('Hello')
|
||||
})
|
||||
|
||||
it('deletes messages', () => {
|
||||
const { result } = renderHook(() => useChatHistory())
|
||||
act(() => { result.current.addMessage('user', 'First') })
|
||||
act(() => { result.current.addMessage('assistant', 'Second') })
|
||||
expect(result.current.messages.length).toBe(2)
|
||||
act(() => { result.current.deleteMessage(0) })
|
||||
expect(result.current.messages.length).toBe(1)
|
||||
expect(result.current.messages[0].content).toBe('Second')
|
||||
})
|
||||
|
||||
it('clears history', () => {
|
||||
const { result } = renderHook(() => useChatHistory())
|
||||
act(() => { result.current.addMessage('user', 'Test') })
|
||||
act(() => { result.current.clearHistory() })
|
||||
expect(result.current.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('persists to localStorage', () => {
|
||||
const { result } = renderHook(() => useChatHistory())
|
||||
act(() => { result.current.addMessage('user', 'Persisted') })
|
||||
const stored = localStorage.getItem('fusionagi-chat-history')
|
||||
expect(stored).toBeTruthy()
|
||||
expect(JSON.parse(stored!)[0].content).toBe('Persisted')
|
||||
})
|
||||
})
|
||||
69
frontend/src/hooks/useChatHistory.ts
Normal file
69
frontend/src/hooks/useChatHistory.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import type { FinalResponse } from '../types'
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
data?: FinalResponse
|
||||
id: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'fusionagi-chat-history'
|
||||
const MAX_MESSAGES = 500
|
||||
|
||||
function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
function loadHistory(): ChatMessage[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(messages: ChatMessage[]) {
|
||||
try {
|
||||
const trimmed = messages.slice(-MAX_MESSAGES)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
|
||||
} catch { /* storage full */ }
|
||||
}
|
||||
|
||||
export function useChatHistory() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => loadHistory())
|
||||
|
||||
useEffect(() => {
|
||||
saveHistory(messages)
|
||||
}, [messages])
|
||||
|
||||
const addMessage = useCallback((role: 'user' | 'assistant', content: string, data?: FinalResponse) => {
|
||||
const msg: ChatMessage = { role, content, data, id: generateId(), timestamp: Date.now() }
|
||||
setMessages((prev) => [...prev, msg])
|
||||
return msg
|
||||
}, [])
|
||||
|
||||
const editMessage = useCallback((index: number, newContent: string) => {
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
if (updated[index] && updated[index].role === 'user') {
|
||||
updated[index] = { ...updated[index], content: newContent }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
const deleteMessage = useCallback((index: number) => {
|
||||
setMessages((prev) => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const clearHistory = useCallback(() => {
|
||||
setMessages([])
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}, [])
|
||||
|
||||
return { messages, addMessage, editMessage, deleteMessage, clearHistory, setMessages }
|
||||
}
|
||||
44
frontend/src/hooks/useKeyboard.ts
Normal file
44
frontend/src/hooks/useKeyboard.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
|
||||
interface KeyboardShortcuts {
|
||||
onSend?: () => void
|
||||
onSearch?: () => void
|
||||
onDismiss?: () => void
|
||||
onToggleTheme?: () => void
|
||||
}
|
||||
|
||||
export function useKeyboard({ onSend, onSearch, onDismiss, onToggleTheme }: KeyboardShortcuts) {
|
||||
const handler = useCallback((e: KeyboardEvent) => {
|
||||
const meta = e.metaKey || e.ctrlKey
|
||||
const target = e.target as HTMLElement
|
||||
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
onDismiss?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (meta && e.key === 'Enter' && onSend) {
|
||||
e.preventDefault()
|
||||
onSend()
|
||||
return
|
||||
}
|
||||
|
||||
if (meta && e.key === 'k' && onSearch) {
|
||||
e.preventDefault()
|
||||
onSearch()
|
||||
return
|
||||
}
|
||||
|
||||
if (meta && e.key === 'j' && onToggleTheme && !isInput) {
|
||||
e.preventDefault()
|
||||
onToggleTheme()
|
||||
return
|
||||
}
|
||||
}, [onSend, onSearch, onDismiss, onToggleTheme])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [handler])
|
||||
}
|
||||
@@ -3,21 +3,43 @@ import type { WSEvent } from '../types'
|
||||
|
||||
type WSStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
const MAX_RETRIES = 10
|
||||
const BASE_DELAY = 1000
|
||||
|
||||
export function useWebSocket(sessionId: string | null) {
|
||||
const [status, setStatus] = useState<WSStatus>('disconnected')
|
||||
const [events, setEvents] = useState<WSEvent[]>([])
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const retryCount = useRef(0)
|
||||
const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const shouldReconnect = useRef(true)
|
||||
|
||||
const connect = useCallback((sid: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return
|
||||
if (wsRef.current) wsRef.current.close()
|
||||
shouldReconnect.current = true
|
||||
setStatus('connecting')
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/v1/sessions/${sid}/stream`)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => setStatus('connected')
|
||||
ws.onclose = () => setStatus('disconnected')
|
||||
ws.onopen = () => {
|
||||
setStatus('connected')
|
||||
retryCount.current = 0
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setStatus('disconnected')
|
||||
if (shouldReconnect.current && retryCount.current < MAX_RETRIES) {
|
||||
const delay = BASE_DELAY * Math.pow(2, retryCount.current) + Math.random() * 500
|
||||
retryCount.current++
|
||||
retryTimer.current = setTimeout(() => connect(sid), delay)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => setStatus('error')
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const event: WSEvent = JSON.parse(e.data)
|
||||
@@ -33,14 +55,23 @@ export function useWebSocket(sessionId: string | null) {
|
||||
}, [])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
shouldReconnect.current = false
|
||||
if (retryTimer.current) clearTimeout(retryTimer.current)
|
||||
wsRef.current?.close()
|
||||
wsRef.current = null
|
||||
setStatus('disconnected')
|
||||
retryCount.current = 0
|
||||
}, [])
|
||||
|
||||
const clearEvents = useCallback(() => setEvents([]), [])
|
||||
|
||||
useEffect(() => () => { wsRef.current?.close() }, [])
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
shouldReconnect.current = false
|
||||
if (retryTimer.current) clearTimeout(retryTimer.current)
|
||||
wsRef.current?.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { status, events, connect, send, disconnect, clearEvents }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user