Wire all integrations + production hardening: 15 recommendations
Integration & Wiring: - useStore/useAppState wired into App.tsx (replaces 8 useState calls) - React Router wired at app root (URL-based navigation) - SparklineChart/MetricCard/BarChart integrated into Admin + Ethics pages - useNotifications.handleWSEvent wired into WebSocket handler - Notification center dropdown in header with unread badge - Locale selector added to Settings page (6 languages) - Dashboard data fetching with 10s polling into MetricCards - File drag-and-drop support on chat area Production Hardening: - PostgresStateBackend with connection pooling (psycopg2) - App lifespan wires backend from FUSIONAGI_DB_BACKEND env (memory|sqlite|postgres) - Redis cache wired from FUSIONAGI_REDIS_URL env at startup - Multi-process uvicorn config for horizontal scaling Testing: - Playwright visual regression tests (12 stories x 2 viewports) - k6 load test script with ramp/spike/ramp-down stages - 7 new Python tests (postgres fallback, app wiring) 575 Python tests + 45 frontend tests = 620 total, 0 ruff errors. Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
29
frontend/.storybook/visual-regression.ts
Normal file
29
frontend/.storybook/visual-regression.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Visual regression testing configuration for Storybook + Chromatic.
|
||||
*
|
||||
* To run:
|
||||
* npx chromatic --project-token=YOUR_TOKEN
|
||||
*
|
||||
* Or using Playwright for local visual regression:
|
||||
* npx playwright test --config=e2e/visual.config.ts
|
||||
*/
|
||||
|
||||
export const visualRegressionConfig = {
|
||||
// Chromatic settings
|
||||
chromatic: {
|
||||
viewports: [375, 768, 1280],
|
||||
delay: 300,
|
||||
diffThreshold: 0.05,
|
||||
},
|
||||
|
||||
// Snapshot targets (components to test)
|
||||
components: [
|
||||
'Components/Avatar',
|
||||
'Components/ChatMessage',
|
||||
'Components/Markdown',
|
||||
'Components/Skeleton',
|
||||
'Components/Toast',
|
||||
'Components/FilePreview',
|
||||
'Components/SearchFilter',
|
||||
],
|
||||
}
|
||||
33
frontend/e2e/visual.config.ts
Normal file
33
frontend/e2e/visual.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Visual regression testing with Playwright screenshots.
|
||||
*
|
||||
* Run: npx playwright test --config=e2e/visual.config.ts
|
||||
*/
|
||||
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: 'visual.spec.ts',
|
||||
timeout: 30000,
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
maxDiffPixelRatio: 0.05,
|
||||
threshold: 0.2,
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://localhost:6006', // Storybook
|
||||
screenshot: 'on',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npx storybook dev -p 6006 --no-open',
|
||||
port: 6006,
|
||||
reuseExistingServer: true,
|
||||
timeout: 60000,
|
||||
},
|
||||
})
|
||||
31
frontend/e2e/visual.spec.ts
Normal file
31
frontend/e2e/visual.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Visual regression tests against Storybook stories.
|
||||
*
|
||||
* Run: npx playwright test --config=e2e/visual.config.ts
|
||||
* First run creates baseline screenshots; subsequent runs compare.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const STORIES = [
|
||||
{ name: 'Avatar', path: '/iframe.html?id=components-avatar--default' },
|
||||
{ name: 'ChatMessage-User', path: '/iframe.html?id=components-chatmessage--user-message' },
|
||||
{ name: 'ChatMessage-Assistant', path: '/iframe.html?id=components-chatmessage--assistant-message' },
|
||||
{ name: 'ChatMessage-Code', path: '/iframe.html?id=components-chatmessage--with-code-block' },
|
||||
{ name: 'Markdown-Basic', path: '/iframe.html?id=components-markdown--basic-text' },
|
||||
{ name: 'Markdown-Code', path: '/iframe.html?id=components-markdown--code-block' },
|
||||
{ name: 'Skeleton-Single', path: '/iframe.html?id=components-skeleton--single-line' },
|
||||
{ name: 'Skeleton-Multi', path: '/iframe.html?id=components-skeleton--multiple-lines' },
|
||||
{ name: 'Toast-Info', path: '/iframe.html?id=components-toast--info' },
|
||||
{ name: 'Toast-Error', path: '/iframe.html?id=components-toast--error' },
|
||||
{ name: 'FilePreview-Text', path: '/iframe.html?id=components-filepreview--text-file' },
|
||||
{ name: 'FilePreview-Image', path: '/iframe.html?id=components-filepreview--image-file' },
|
||||
]
|
||||
|
||||
for (const story of STORIES) {
|
||||
test(`Visual: ${story.name}`, async ({ page }) => {
|
||||
await page.goto(story.path)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page).toHaveScreenshot(`${story.name}.png`)
|
||||
})
|
||||
}
|
||||
@@ -867,3 +867,12 @@ body {
|
||||
.notification-item.unread { background: var(--bg-tertiary); }
|
||||
.notification-item .title { font-weight: 600; }
|
||||
.notification-item .body { color: var(--text-muted); margin-top: 0.15rem; }
|
||||
|
||||
/* ========== Notification Dropdown ========== */
|
||||
.notification-dropdown { position: absolute; top: 100%; right: 0; width: 320px; max-height: 400px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 100; overflow: hidden; }
|
||||
@media (max-width: 768px) { .notification-dropdown { width: calc(100vw - 2rem); right: -1rem; } }
|
||||
|
||||
/* ========== Drag & Drop ========== */
|
||||
.chat-layout.drag-over { outline: 2px dashed var(--accent); outline-offset: -4px; }
|
||||
.drop-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; z-index: 50; pointer-events: none; border-radius: 8px; }
|
||||
.drop-overlay span { background: var(--bg-secondary); padding: 1rem 2rem; border-radius: 8px; font-weight: 600; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef, useReducer, lazy, Suspense } from 'react'
|
||||
import { AvatarGrid } from './components/AvatarGrid'
|
||||
import { ConsensusPanel } from './components/ConsensusPanel'
|
||||
import { VirtualMessages } from './components/VirtualMessages'
|
||||
@@ -7,13 +7,16 @@ 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 { RouterProvider, AppRoutes, usePageNavigation } from './Router'
|
||||
import { StoreContext, appReducer, initialState, useAppState } from './hooks/useStore'
|
||||
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 { useNotifications } from './hooks/useNotifications'
|
||||
import { t, getLocale } from './i18n'
|
||||
import type { FinalResponse, ViewMode, WSEvent } from './types'
|
||||
import './App.css'
|
||||
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
|
||||
@@ -33,32 +36,39 @@ function PageSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { theme, toggle: toggleTheme } = useTheme()
|
||||
function AppInner() {
|
||||
const { page, viewMode, theme, loading, networkError, sessionId, isMobile, prompt,
|
||||
setPage, setViewMode, toggleTheme, setLoading, setError, setPrompt, dispatch } = useAppState()
|
||||
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, 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 [isMobile, setIsMobile] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { speakingHead, headSummaries, onHeadSpeak, clearSpeaking } = useVoicePlayback()
|
||||
const ws = useWebSocket(sessionId)
|
||||
const { notifications, unreadCount, handleWSEvent: handleNotifEvent, markAllRead } = useNotifications()
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
|
||||
// Use router for page navigation
|
||||
let routerNav: ReturnType<typeof usePageNavigation> | null = null
|
||||
try {
|
||||
routerNav = usePageNavigation()
|
||||
} catch {
|
||||
// Router not available (fallback mode)
|
||||
}
|
||||
|
||||
const currentPage = routerNav?.currentPage ?? page
|
||||
const navigateTo = routerNav?.setPage ?? setPage
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth <= 768)
|
||||
const check = () => dispatch({ type: 'SET_MOBILE', isMobile: window.innerWidth <= 768 })
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
@@ -69,10 +79,12 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (ws.events.length === 0) return
|
||||
const last = ws.events[ws.events.length - 1]
|
||||
handleWSEvent(last)
|
||||
handleWSEventInternal(last)
|
||||
// Also forward to notification handler
|
||||
handleNotifEvent({ type: last.type, data: last as unknown as Record<string, unknown> })
|
||||
}, [ws.events])
|
||||
|
||||
const handleWSEvent = (event: WSEvent) => {
|
||||
const handleWSEventInternal = (event: WSEvent) => {
|
||||
switch (event.type) {
|
||||
case 'heads_running':
|
||||
setActiveHeads(HEAD_IDS.slice(0, 6))
|
||||
@@ -121,14 +133,14 @@ function App() {
|
||||
if (!r.ok) throw new Error(`Session creation failed: ${r.status}`)
|
||||
const j = await parseJson(r)
|
||||
if (!j.session_id) throw new Error('No session_id in response')
|
||||
setSessionId(j.session_id)
|
||||
setNetworkError(null)
|
||||
dispatch({ type: 'SET_SESSION', sessionId: j.session_id })
|
||||
setError(null)
|
||||
return j.session_id
|
||||
} catch (e) {
|
||||
setNetworkError((e as Error).message)
|
||||
setError((e as Error).message)
|
||||
return null
|
||||
}
|
||||
}, [sessionId, parseJson, authHeaders])
|
||||
}, [sessionId, parseJson, authHeaders, dispatch, setError])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!prompt.trim() || loading) return
|
||||
@@ -139,12 +151,28 @@ function App() {
|
||||
const currentPrompt = prompt
|
||||
setPrompt('')
|
||||
setLoading(true)
|
||||
setNetworkError(null)
|
||||
setError(null)
|
||||
clearSpeaking()
|
||||
setActiveHeads(HEAD_IDS.slice(0, 6))
|
||||
|
||||
if (useStreaming && ws.status === 'connected') {
|
||||
ws.send({ prompt: currentPrompt })
|
||||
ws.sendPrompt(currentPrompt, {
|
||||
onToken: (token) => {
|
||||
// streaming token received
|
||||
},
|
||||
onComplete: (response) => {
|
||||
const data = response as FinalResponse
|
||||
setLastResponse(data)
|
||||
addMessage('assistant', data.final_answer, data)
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
},
|
||||
onError: (error) => {
|
||||
addMessage('assistant', `Error: ${error}`)
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
},
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
const r = await fetch(`/v1/sessions/${sid}/prompt`, {
|
||||
@@ -163,23 +191,23 @@ function App() {
|
||||
contribs.forEach((c: { head_id: string; summary: string }) =>
|
||||
onHeadSpeak(c.head_id, c.summary, null))
|
||||
addMessage('assistant', data.final_answer, data)
|
||||
setNetworkError(null)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message
|
||||
setNetworkError(msg)
|
||||
setError(msg)
|
||||
addMessage('assistant', `Error: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
}
|
||||
}
|
||||
}, [prompt, loading, ensureSession, useStreaming, ws, authHeaders, parseJson, clearSpeaking, onHeadSpeak, addMessage])
|
||||
}, [prompt, loading, ensureSession, useStreaming, ws, authHeaders, parseJson, clearSpeaking, onHeadSpeak, addMessage, setPrompt, setLoading, setError, setViewMode])
|
||||
|
||||
const handleRetry = () => {
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
|
||||
if (lastUser) {
|
||||
setPrompt(lastUser.content)
|
||||
setNetworkError(null)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,9 +215,9 @@ function App() {
|
||||
const msg = messages[index]
|
||||
if (msg?.role === 'user') {
|
||||
setPrompt(msg.content)
|
||||
toast('Message loaded for editing', 'info')
|
||||
toast(t('common.copy'), 'info')
|
||||
}
|
||||
}, [messages, toast])
|
||||
}, [messages, toast, setPrompt])
|
||||
|
||||
const handleDeleteMessage = useCallback((index: number) => {
|
||||
deleteMessage(index)
|
||||
@@ -204,10 +232,34 @@ function App() {
|
||||
return
|
||||
}
|
||||
const text = await file.text()
|
||||
setPrompt((p) => p + (p ? '\n' : '') + `[File: ${file.name}]\n${text.slice(0, 5000)}`)
|
||||
setPrompt(prompt + (prompt ? '\n' : '') + `[File: ${file.name}]\n${text.slice(0, 5000)}`)
|
||||
toast(`Attached: ${file.name}`, 'success')
|
||||
e.target.value = ''
|
||||
}, [toast])
|
||||
}, [toast, prompt, setPrompt])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (!file) return
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast('File too large (max 10MB)', 'error')
|
||||
return
|
||||
}
|
||||
if (file.type.startsWith('image/')) {
|
||||
setPrompt(prompt + (prompt ? '\n' : '') + `[Image: ${file.name}]`)
|
||||
toast(`Image attached: ${file.name}`, 'success')
|
||||
} else {
|
||||
const text = await file.text()
|
||||
setPrompt(prompt + (prompt ? '\n' : '') + `[File: ${file.name}]\n${text.slice(0, 5000)}`)
|
||||
toast(`Attached: ${file.name}`, 'success')
|
||||
}
|
||||
}, [toast, prompt, setPrompt])
|
||||
|
||||
const syncPreferences = useCallback(async () => {
|
||||
try {
|
||||
@@ -225,32 +277,116 @@ function App() {
|
||||
useKeyboard({
|
||||
onSend: handleSubmit,
|
||||
onSearch: () => inputRef.current?.focus(),
|
||||
onDismiss: () => setNetworkError(null),
|
||||
onDismiss: () => setError(null),
|
||||
onToggleTheme: toggleTheme,
|
||||
})
|
||||
|
||||
if (!isAuthenticated && !token && token !== '') {
|
||||
return <LoginPage onLogin={login} error={authError} />
|
||||
}
|
||||
const chatPage = (
|
||||
<div className="chat-layout" onDragOver={handleDragOver} onDrop={handleDrop}>
|
||||
<div className="chat-area">
|
||||
<AvatarGrid
|
||||
headIds={HEAD_IDS}
|
||||
activeHeads={activeHeads}
|
||||
speakingHead={speakingHead}
|
||||
headSummaries={headSummaries}
|
||||
/>
|
||||
{messages.length === 0 ? (
|
||||
<div className="messages">
|
||||
<div className="empty-state">
|
||||
<h2>{t('chat.empty') === 'Start a conversation' ? 'Welcome to FusionAGI Dvadasa' : t('chat.empty')}</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)}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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={t('chat.placeholder')}
|
||||
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,.png,.jpg,.jpeg,.gif,.webp,.svg"
|
||||
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">
|
||||
{t('chat.send')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="input-meta">
|
||||
<label className="streaming-toggle">
|
||||
<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>
|
||||
{!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>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="app" data-theme={theme} lang="en">
|
||||
<div className="app" data-theme={theme} lang={getLocale()}>
|
||||
<header className="header" role="banner">
|
||||
<div className="header-left">
|
||||
<h1 className="logo">FusionAGI</h1>
|
||||
<h1 className="logo">{t('app.title')}</h1>
|
||||
<nav className="nav-tabs" role="tablist" aria-label="Main navigation">
|
||||
{(['chat', 'admin', 'ethics', 'settings'] as Page[]).map((p) => (
|
||||
<button key={p} className={page === p ? 'active' : ''} onClick={() => setPage(p)}
|
||||
role="tab" aria-selected={page === p} aria-controls={`page-${p}`}>
|
||||
{p === 'chat' ? 'Chat' : p === 'admin' ? 'Admin' : p === 'ethics' ? 'Ethics' : 'Settings'}
|
||||
{(['chat', 'admin', 'ethics', 'settings'] as const).map((p) => (
|
||||
<button key={p} className={currentPage === p ? 'active' : ''} onClick={() => navigateTo(p)}
|
||||
role="tab" aria-selected={currentPage === p} aria-controls={`page-${p}`}>
|
||||
{t(`nav.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{page === 'chat' && (
|
||||
{currentPage === 'chat' && (
|
||||
<div className="mode-toggle" role="tablist" aria-label="View mode">
|
||||
{(['normal', 'explain', 'developer'] as const).map((m) => (
|
||||
{(['normal', 'explain', 'developer'] as ViewMode[]).map((m) => (
|
||||
<button key={m} className={viewMode === m ? 'active' : ''} onClick={() => setViewMode(m)}
|
||||
role="tab" aria-selected={viewMode === m}>
|
||||
{m}
|
||||
@@ -258,127 +394,99 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="notification-center" style={{ position: 'relative' }}>
|
||||
<button
|
||||
className={`icon-btn ${unreadCount > 0 ? 'notification-badge' : ''}`}
|
||||
data-count={unreadCount > 0 ? unreadCount : undefined}
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
aria-label={`Notifications (${unreadCount} unread)`}
|
||||
title="Notifications"
|
||||
>
|
||||
{'\u{1F514}'}
|
||||
</button>
|
||||
{showNotifications && (
|
||||
<div className="notification-dropdown" role="region" aria-label="Notifications">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem', borderBottom: '1px solid var(--border)' }}>
|
||||
<strong>Notifications</strong>
|
||||
{unreadCount > 0 && <button className="icon-btn" onClick={markAllRead} style={{ fontSize: '0.75rem' }}>Mark all read</button>}
|
||||
</div>
|
||||
<div className="notification-list">
|
||||
{notifications.length === 0 && <p style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)' }}>No notifications</p>}
|
||||
{notifications.slice(0, 20).map((n) => (
|
||||
<div key={n.id} className={`notification-item ${n.read ? '' : 'unread'}`}>
|
||||
<div className="title">{n.title}</div>
|
||||
<div className="body">{n.body}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="icon-btn" onClick={toggleTheme} aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
|
||||
{theme === 'dark' ? '\u2600' : '\u263E'}
|
||||
</button>
|
||||
{token && <button className="icon-btn" onClick={logout} title="Logout" aria-label="Logout">Exit</button>}
|
||||
{token && <button className="icon-btn" onClick={logout} title={t('common.logout')} aria-label={t('common.logout')}>Exit</button>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{networkError && (
|
||||
<div className="error-bar" role="alert">
|
||||
<span>{networkError}</span>
|
||||
<button onClick={handleRetry}>Retry</button>
|
||||
<button onClick={() => setNetworkError(null)}>Dismiss</button>
|
||||
<button onClick={handleRetry}>{t('common.retry')}</button>
|
||||
<button onClick={() => setError(null)}>{t('common.close')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="main">
|
||||
{page === 'chat' && (
|
||||
<div className="chat-layout">
|
||||
<div className="chat-area">
|
||||
<AvatarGrid
|
||||
headIds={HEAD_IDS}
|
||||
activeHeads={activeHeads}
|
||||
speakingHead={speakingHead}
|
||||
headSummaries={headSummaries}
|
||||
/>
|
||||
{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)}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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... (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>
|
||||
</div>
|
||||
<div className="input-meta">
|
||||
<label className="streaming-toggle">
|
||||
<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>
|
||||
{!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>
|
||||
)}
|
||||
<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>
|
||||
<AppRoutes
|
||||
chatPage={chatPage}
|
||||
adminPage={
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<ErrorBoundary>
|
||||
<AdminPage authHeaders={authHeaders} />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
}
|
||||
ethicsPage={
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<ErrorBoundary>
|
||||
<EthicsPage authHeaders={authHeaders} />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
}
|
||||
settingsPage={
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<ErrorBoundary>
|
||||
<SettingsPage theme={theme} toggleTheme={toggleTheme} authHeaders={authHeaders} />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
}
|
||||
loginPage={<LoginPage onLogin={login} error={authError} />}
|
||||
isAuthenticated={isAuthenticated || !!token || token === ''}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [state, dispatch] = useReducer(appReducer, initialState)
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={{ state, dispatch }}>
|
||||
<AppInner />
|
||||
</StoreContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function AppWithProviders() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
<RouterProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</RouterProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { MetricCard, Sparkline, BarChart } from '../components/SparklineChart'
|
||||
import { t } from '../i18n'
|
||||
import type { SystemStatus, VoiceProfile } from '../types'
|
||||
|
||||
function StatusCard({ label, value, unit, statusClass }: {
|
||||
@@ -15,6 +17,13 @@ function StatusCard({ label, value, unit, statusClass }: {
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusHistory {
|
||||
cpu: number[]
|
||||
memory: number[]
|
||||
tasks: number[]
|
||||
sessions: number[]
|
||||
}
|
||||
|
||||
export function AdminPage({ authHeaders }: { authHeaders: () => Record<string, string> }) {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||
const [voices, setVoices] = useState<VoiceProfile[]>([])
|
||||
@@ -23,11 +32,21 @@ export function AdminPage({ authHeaders }: { authHeaders: () => Record<string, s
|
||||
const [newVoiceName, setNewVoiceName] = useState('')
|
||||
const [newVoiceLang, setNewVoiceLang] = useState('en-US')
|
||||
const [tab, setTab] = useState<'overview' | 'voices' | 'agents' | 'governance'>('overview')
|
||||
const [history, setHistory] = useState<StatusHistory>({ cpu: [], memory: [], tasks: [], sessions: [] })
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch('/v1/admin/status', { headers: authHeaders() })
|
||||
if (r.ok) setStatus(await r.json())
|
||||
if (r.ok) {
|
||||
const data = await r.json()
|
||||
setStatus(data)
|
||||
setHistory((h) => ({
|
||||
cpu: [...h.cpu, data.cpu_usage_percent ?? 0].slice(-20),
|
||||
memory: [...h.memory, data.memory_usage_mb ?? 0].slice(-20),
|
||||
tasks: [...h.tasks, data.active_tasks ?? 0].slice(-20),
|
||||
sessions: [...h.sessions, data.active_sessions ?? 0].slice(-20),
|
||||
}))
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
}, [authHeaders])
|
||||
|
||||
@@ -70,21 +89,24 @@ export function AdminPage({ authHeaders }: { authHeaders: () => Record<string, s
|
||||
|
||||
const statusClass = status?.status === 'healthy' ? 'healthy' : status?.status === 'degraded' ? 'degraded' : status?.status === 'offline' ? 'offline' : ''
|
||||
|
||||
if (loading) return <div className="page-loading" role="status" aria-live="polite">Loading admin dashboard...</div>
|
||||
const cpuTrend = history.cpu.length >= 2 ? (history.cpu[history.cpu.length - 1] > history.cpu[history.cpu.length - 2] ? 'up' : history.cpu[history.cpu.length - 1] < history.cpu[history.cpu.length - 2] ? 'down' : 'flat') as 'up' | 'down' | 'flat' : undefined
|
||||
const memTrend = history.memory.length >= 2 ? (history.memory[history.memory.length - 1] > history.memory[history.memory.length - 2] ? 'up' : 'down') as 'up' | 'down' : undefined
|
||||
|
||||
if (loading) return <div className="page-loading" role="status" aria-live="polite">{t('common.loading')}</div>
|
||||
|
||||
return (
|
||||
<div className="admin-page" role="main" aria-label="Admin Dashboard">
|
||||
<div className="admin-page" role="main" aria-label={t('admin.title')}>
|
||||
<div className="admin-tabs" role="tablist" aria-label="Admin sections">
|
||||
{(['overview', 'voices', 'agents', 'governance'] as const).map((t) => (
|
||||
{(['overview', 'voices', 'agents', 'governance'] as const).map((tb) => (
|
||||
<button
|
||||
key={t}
|
||||
className={tab === t ? 'active' : ''}
|
||||
onClick={() => setTab(t)}
|
||||
key={tb}
|
||||
className={tab === tb ? 'active' : ''}
|
||||
onClick={() => setTab(tb)}
|
||||
role="tab"
|
||||
aria-selected={tab === t}
|
||||
aria-controls={`panel-${t}`}
|
||||
aria-selected={tab === tb}
|
||||
aria-controls={`panel-${tb}`}
|
||||
>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
{tb.charAt(0).toUpperCase() + tb.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -93,22 +115,62 @@ export function AdminPage({ authHeaders }: { authHeaders: () => Record<string, s
|
||||
|
||||
{tab === 'overview' && (
|
||||
<div className="admin-section" role="tabpanel" id="panel-overview" aria-label="System Overview">
|
||||
<h2>System Overview</h2>
|
||||
<div className="status-grid" role="group" aria-label="System metrics">
|
||||
<h2>{t('admin.status')}</h2>
|
||||
<div className="metrics-grid">
|
||||
<MetricCard
|
||||
title="CPU Usage"
|
||||
value={status?.cpu_usage_percent ?? 0}
|
||||
unit="%"
|
||||
data={history.cpu}
|
||||
trend={cpuTrend}
|
||||
color="var(--color-warning, #ff9800)"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Memory"
|
||||
value={status?.memory_usage_mb ?? 0}
|
||||
unit=" MB"
|
||||
data={history.memory}
|
||||
trend={memTrend}
|
||||
color="var(--accent)"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Active Tasks"
|
||||
value={status?.active_tasks ?? 0}
|
||||
data={history.tasks}
|
||||
color="var(--color-success, #4caf50)"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Sessions"
|
||||
value={status?.active_sessions ?? 0}
|
||||
data={history.sessions}
|
||||
color="var(--color-info, #2196f3)"
|
||||
/>
|
||||
</div>
|
||||
<div className="status-grid" role="group" aria-label="System metrics" style={{ marginTop: '1rem' }}>
|
||||
<StatusCard label="Status" value={status?.status ?? 'unknown'} statusClass={statusClass} />
|
||||
<StatusCard label="Uptime" value={status ? formatUptime(status.uptime_seconds) : 'N/A'} />
|
||||
<StatusCard label="Active Tasks" value={status?.active_tasks ?? 0} />
|
||||
<StatusCard label="Active Agents" value={status?.active_agents ?? 0} />
|
||||
<StatusCard label="Sessions" value={status?.active_sessions ?? 0} />
|
||||
<StatusCard label="Memory" value={status?.memory_usage_mb} unit=" MB" />
|
||||
<StatusCard label="CPU" value={status?.cpu_usage_percent} unit="%" />
|
||||
</div>
|
||||
{status && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<h3>Agent Distribution</h3>
|
||||
<BarChart
|
||||
data={[
|
||||
{ label: 'Tasks', value: status.active_tasks ?? 0, color: 'var(--color-success, #4caf50)' },
|
||||
{ label: 'Agents', value: status.active_agents ?? 0, color: 'var(--accent)' },
|
||||
{ label: 'Sessions', value: status.active_sessions ?? 0, color: 'var(--color-info, #2196f3)' },
|
||||
]}
|
||||
width={300}
|
||||
height={80}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'voices' && (
|
||||
<div className="admin-section" role="tabpanel" id="panel-voices" aria-label="Voice Library">
|
||||
<h2>Voice Library</h2>
|
||||
<h2>{t('admin.voices')}</h2>
|
||||
<div className="add-form" role="form" aria-label="Add voice">
|
||||
<label htmlFor="voice-name" className="sr-only">Voice name</label>
|
||||
<input id="voice-name" placeholder="Voice name" value={newVoiceName} onChange={(e) => setNewVoiceName(e.target.value)} />
|
||||
@@ -138,7 +200,7 @@ export function AdminPage({ authHeaders }: { authHeaders: () => Record<string, s
|
||||
|
||||
{tab === 'agents' && (
|
||||
<div className="admin-section" role="tabpanel" id="panel-agents" aria-label="Agent Configuration">
|
||||
<h2>Agent Configuration</h2>
|
||||
<h2>{t('admin.agents')}</h2>
|
||||
<div className="agent-grid" role="list" aria-label="Active agents">
|
||||
{['Planner', 'Reasoner', 'Executor', 'Critic', '12 Heads', 'Witness'].map((a) => (
|
||||
<div key={a} className="agent-card" role="listitem">
|
||||
@@ -152,7 +214,7 @@ export function AdminPage({ authHeaders }: { authHeaders: () => Record<string, s
|
||||
|
||||
{tab === 'governance' && (
|
||||
<div className="admin-section" role="tabpanel" id="panel-governance" aria-label="Governance Mode">
|
||||
<h2>Governance Mode</h2>
|
||||
<h2>{t('admin.governance')}</h2>
|
||||
<div className="governance-info">
|
||||
<div className="governance-mode" role="status" aria-label="Current governance mode: Advisory">
|
||||
<span className="mode-label">Current Mode:</span>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { MetricCard, BarChart } from '../components/SparklineChart'
|
||||
import { t } from '../i18n'
|
||||
import type { EthicalLesson, ConsequenceRecord, InsightRecord } from '../types'
|
||||
|
||||
export function EthicsPage({ authHeaders }: { authHeaders: () => Record<string, string> }) {
|
||||
@@ -26,28 +28,43 @@ export function EthicsPage({ authHeaders }: { authHeaders: () => Record<string,
|
||||
fetchData().finally(() => setLoading(false))
|
||||
}, [fetchData])
|
||||
|
||||
if (loading) return <div className="page-loading" role="status" aria-live="polite">Loading ethics dashboard...</div>
|
||||
const positiveOutcomes = consequences.filter((c) => c.outcome_positive === true).length
|
||||
const negativeOutcomes = consequences.filter((c) => c.outcome_positive === false).length
|
||||
const pendingOutcomes = consequences.filter((c) => c.outcome_positive === null).length
|
||||
const avgRisk = consequences.length > 0 ? consequences.reduce((s, c) => s + c.estimated_risk, 0) / consequences.length : 0
|
||||
const avgReward = consequences.length > 0 ? consequences.reduce((s, c) => s + c.estimated_reward, 0) / consequences.length : 0
|
||||
|
||||
const lessonWeights = lessons.map((l) => l.weight)
|
||||
const insightConfidences = insights.map((i) => i.confidence)
|
||||
|
||||
if (loading) return <div className="page-loading" role="status" aria-live="polite">{t('common.loading')}</div>
|
||||
|
||||
return (
|
||||
<div className="ethics-page" role="main" aria-label="Ethics Dashboard">
|
||||
<div className="ethics-page" role="main" aria-label={t('ethics.title')}>
|
||||
<div className="admin-tabs" role="tablist" aria-label="Ethics sections">
|
||||
{(['ethics', 'consequences', 'insights'] as const).map((t) => (
|
||||
{(['ethics', 'consequences', 'insights'] as const).map((tb) => (
|
||||
<button
|
||||
key={t}
|
||||
className={tab === t ? 'active' : ''}
|
||||
onClick={() => setTab(t)}
|
||||
key={tb}
|
||||
className={tab === tb ? 'active' : ''}
|
||||
onClick={() => setTab(tb)}
|
||||
role="tab"
|
||||
aria-selected={tab === t}
|
||||
aria-controls={`ethics-panel-${t}`}
|
||||
aria-selected={tab === tb}
|
||||
aria-controls={`ethics-panel-${tb}`}
|
||||
>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
{tb.charAt(0).toUpperCase() + tb.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'ethics' && (
|
||||
<div className="admin-section" role="tabpanel" id="ethics-panel-ethics" aria-label="Learned Lessons">
|
||||
<h2>Adaptive Ethics — Learned Lessons</h2>
|
||||
<h2>{t('ethics.lessons')}</h2>
|
||||
<div className="metrics-grid">
|
||||
<MetricCard title="Total Lessons" value={lessons.length} data={lessonWeights} color="var(--accent)" />
|
||||
<MetricCard title="Avg Weight" value={lessons.length > 0 ? (lessons.reduce((s, l) => s + l.weight, 0) / lessons.length).toFixed(2) : '0'} color="var(--color-warning, #ff9800)" />
|
||||
<MetricCard title="High Weight" value={lessons.filter((l) => l.weight > 1).length} color="var(--color-success, #4caf50)" />
|
||||
<MetricCard title="Negative Signal" value={lessons.filter((l) => l.weight < 0).length} color="var(--color-error, #f44336)" />
|
||||
</div>
|
||||
{lessons.length === 0 ? (
|
||||
<p className="muted">No ethical lessons recorded yet. The system learns from choices and their consequences.</p>
|
||||
) : (
|
||||
@@ -76,7 +93,28 @@ export function EthicsPage({ authHeaders }: { authHeaders: () => Record<string,
|
||||
|
||||
{tab === 'consequences' && (
|
||||
<div className="admin-section" role="tabpanel" id="ethics-panel-consequences" aria-label="Choice History">
|
||||
<h2>Consequence Engine — Choice History</h2>
|
||||
<h2>{t('ethics.consequences')}</h2>
|
||||
<div className="metrics-grid">
|
||||
<MetricCard title="Total Choices" value={consequences.length} color="var(--accent)" />
|
||||
<MetricCard title="Positive" value={positiveOutcomes} trend={positiveOutcomes > negativeOutcomes ? 'up' : 'down'} color="var(--color-success, #4caf50)" />
|
||||
<MetricCard title="Negative" value={negativeOutcomes} color="var(--color-error, #f44336)" />
|
||||
<MetricCard title="Pending" value={pendingOutcomes} color="var(--text-muted)" />
|
||||
</div>
|
||||
{consequences.length > 0 && (
|
||||
<div style={{ margin: '1rem 0' }}>
|
||||
<h3>Risk vs Reward</h3>
|
||||
<BarChart
|
||||
data={[
|
||||
{ label: 'Avg Risk', value: Math.round(avgRisk * 100), color: 'var(--color-error, #f44336)' },
|
||||
{ label: 'Avg Reward', value: Math.round(avgReward * 100), color: 'var(--color-success, #4caf50)' },
|
||||
{ label: 'Positive', value: positiveOutcomes, color: 'var(--accent)' },
|
||||
{ label: 'Negative', value: negativeOutcomes, color: 'var(--color-warning, #ff9800)' },
|
||||
]}
|
||||
width={300}
|
||||
height={80}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{consequences.length === 0 ? (
|
||||
<p className="muted">No consequences recorded yet. Every choice creates a consequence record.</p>
|
||||
) : (
|
||||
@@ -117,7 +155,15 @@ export function EthicsPage({ authHeaders }: { authHeaders: () => Record<string,
|
||||
|
||||
{tab === 'insights' && (
|
||||
<div className="admin-section" role="tabpanel" id="ethics-panel-insights" aria-label="Cross-Head Learning">
|
||||
<h2>InsightBus — Cross-Head Learning</h2>
|
||||
<h2>{t('ethics.insights')}</h2>
|
||||
<div className="metrics-grid">
|
||||
<MetricCard title="Total Insights" value={insights.length} data={insightConfidences} color="var(--accent)" />
|
||||
<MetricCard
|
||||
title="Avg Confidence"
|
||||
value={insights.length > 0 ? `${(insights.reduce((s, i) => s + i.confidence, 0) / insights.length * 100).toFixed(0)}%` : 'N/A'}
|
||||
color="var(--color-info, #2196f3)"
|
||||
/>
|
||||
</div>
|
||||
{insights.length === 0 ? (
|
||||
<p className="muted">No cross-head insights yet. Heads share observations through the InsightBus.</p>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useToast } from '../components/Toast'
|
||||
import { t, getLocale, setLocale, getAvailableLocales } from '../i18n'
|
||||
import type { Locale } from '../i18n'
|
||||
import type { ConversationStyle, Theme } from '../types'
|
||||
|
||||
interface SettingsPageProps {
|
||||
@@ -8,6 +10,15 @@ interface SettingsPageProps {
|
||||
authHeaders: () => Record<string, string>
|
||||
}
|
||||
|
||||
const LOCALE_LABELS: Record<Locale, string> = {
|
||||
en: 'English',
|
||||
es: 'Espanol',
|
||||
fr: 'Francais',
|
||||
de: 'Deutsch',
|
||||
ja: 'Japanese',
|
||||
zh: 'Chinese',
|
||||
}
|
||||
|
||||
function Slider({ label, value, onChange, min = 0, max = 1, step = 0.1, id }: {
|
||||
label: string; value: number; onChange: (v: number) => void; min?: number; max?: number; step?: number; id: string
|
||||
}) {
|
||||
@@ -25,6 +36,7 @@ function Slider({ label, value, onChange, min = 0, max = 1, step = 0.1, id }: {
|
||||
|
||||
export function SettingsPage({ theme, toggleTheme, authHeaders }: SettingsPageProps) {
|
||||
const { toast } = useToast()
|
||||
const [locale, setLocaleState] = useState<Locale>(getLocale())
|
||||
const [style, setStyle] = useState<ConversationStyle>({
|
||||
formality: 'neutral',
|
||||
verbosity: 'balanced',
|
||||
@@ -34,6 +46,12 @@ export function SettingsPage({ theme, toggleTheme, authHeaders }: SettingsPagePr
|
||||
technical_depth: 0.5,
|
||||
})
|
||||
|
||||
const handleLocaleChange = (newLocale: Locale) => {
|
||||
setLocale(newLocale)
|
||||
setLocaleState(newLocale)
|
||||
toast(`Language set to ${LOCALE_LABELS[newLocale]}`, 'success')
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
const r = await fetch('/v1/admin/conversation-style', {
|
||||
@@ -42,7 +60,7 @@ export function SettingsPage({ theme, toggleTheme, authHeaders }: SettingsPagePr
|
||||
body: JSON.stringify(style),
|
||||
})
|
||||
if (r.ok) {
|
||||
toast('Settings saved successfully', 'success')
|
||||
toast(t('common.save') + ' — ' + t('settings.title'), 'success')
|
||||
} else {
|
||||
toast('Failed to save settings', 'error')
|
||||
}
|
||||
@@ -64,23 +82,40 @@ export function SettingsPage({ theme, toggleTheme, authHeaders }: SettingsPagePr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page" role="main" aria-label="Settings">
|
||||
<h2>Settings</h2>
|
||||
<div className="settings-page" role="main" aria-label={t('settings.title')}>
|
||||
<h2>{t('settings.title')}</h2>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Appearance</h3>
|
||||
<div className="setting-row">
|
||||
<label>Theme</label>
|
||||
<label>{t('settings.theme')}</label>
|
||||
<button className="theme-toggle" onClick={toggleTheme} aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
|
||||
{theme === 'dark' ? 'Switch to Light' : 'Switch to Dark'}
|
||||
{theme === 'dark' ? t('settings.theme.light') : t('settings.theme.dark')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section" role="group" aria-label="Conversation style settings">
|
||||
<h3>Conversation Style</h3>
|
||||
<div className="settings-section">
|
||||
<h3>Language</h3>
|
||||
<div className="setting-row">
|
||||
<label htmlFor="formality">Formality</label>
|
||||
<label htmlFor="locale-select">Display Language</label>
|
||||
<select
|
||||
id="locale-select"
|
||||
value={locale}
|
||||
onChange={(e) => handleLocaleChange(e.target.value as Locale)}
|
||||
aria-label="Select display language"
|
||||
>
|
||||
{getAvailableLocales().map((loc) => (
|
||||
<option key={loc} value={loc}>{LOCALE_LABELS[loc]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section" role="group" aria-label="Conversation style settings">
|
||||
<h3>{t('settings.conversation')}</h3>
|
||||
<div className="setting-row">
|
||||
<label htmlFor="formality">{t('settings.formality')}</label>
|
||||
<select id="formality" value={style.formality} onChange={(e) => setStyle({ ...style, formality: e.target.value as ConversationStyle['formality'] })}>
|
||||
<option value="casual">Casual</option>
|
||||
<option value="neutral">Neutral</option>
|
||||
@@ -88,21 +123,21 @@ export function SettingsPage({ theme, toggleTheme, authHeaders }: SettingsPagePr
|
||||
</select>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<label htmlFor="verbosity">Verbosity</label>
|
||||
<label htmlFor="verbosity">{t('settings.verbosity')}</label>
|
||||
<select id="verbosity" value={style.verbosity} onChange={(e) => setStyle({ ...style, verbosity: e.target.value as ConversationStyle['verbosity'] })}>
|
||||
<option value="concise">Concise</option>
|
||||
<option value="balanced">Balanced</option>
|
||||
<option value="detailed">Detailed</option>
|
||||
</select>
|
||||
</div>
|
||||
<Slider id="empathy" label="Empathy" value={style.empathy_level} onChange={(v) => setStyle({ ...style, empathy_level: v })} />
|
||||
<Slider id="empathy" label={t('settings.empathy')} value={style.empathy_level} onChange={(v) => setStyle({ ...style, empathy_level: v })} />
|
||||
<Slider id="proactivity" label="Proactivity" value={style.proactivity} onChange={(v) => setStyle({ ...style, proactivity: v })} />
|
||||
<Slider id="humor" label="Humor" value={style.humor_level} onChange={(v) => setStyle({ ...style, humor_level: v })} />
|
||||
<Slider id="technical-depth" label="Technical Depth" value={style.technical_depth} onChange={(v) => setStyle({ ...style, technical_depth: v })} />
|
||||
<Slider id="humor" label={t('settings.humor')} value={style.humor_level} onChange={(v) => setStyle({ ...style, humor_level: v })} />
|
||||
<Slider id="technical-depth" label={t('settings.technical')} value={style.technical_depth} onChange={(v) => setStyle({ ...style, technical_depth: v })} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button className="save-btn" onClick={saveSettings}>Save Settings</button>
|
||||
<button className="save-btn" onClick={saveSettings}>{t('common.save')} Settings</button>
|
||||
<button className="theme-toggle" onClick={resetDefaults}>Reset to Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user