Complete all 37 items: frontend UI, backend stubs, infrastructure, docs, tests
Frontend (items 1-10):
- WebSocket streaming integration with useWebSocket hook
- Admin Dashboard UI (status, voices, agents, governance tabs)
- Voice playback UI (TTS/STT integration)
- Settings/Preferences page (conversation style, sliders)
- Responsive/mobile layout (breakpoints at 480px, 768px)
- Dark/light theme with CSS variables and localStorage
- Error handling & loading states (retry, empty state, disabled input)
- Authentication UI (login page, Bearer token, logout)
- Head visualization improvements (active/speaking states, animations)
- Consequence/Ethics dashboard (lessons, consequences, insights tabs)
Backend stubs (items 11-21):
- Tool connectors: DocsConnector (text/md/PDF), DBConnector (SQLite/Postgres), CodeRunnerConnector (Python/JS/Bash/Ruby sandboxed)
- STT adapter: WhisperSTTAdapter, AzureSTTAdapter
- Multi-modal interface adapters: Visual, Haptic, Gesture, Biometric
- SSE streaming endpoint (/v1/sessions/{id}/stream/sse)
- Multi-tenant support (X-Tenant-ID header, tenant CRUD)
- Plugin marketplace/registry (register, install, list)
- Backup/restore endpoints
- Versioned API negotiation (Accept-Version header, deprecation)
Infrastructure (items 22-26):
- docker-compose.yml (API + Postgres + Redis + frontend)
- .env.example with all configurable vars
- gunicorn.conf.py production ASGI config
- Prometheus metrics collector and /metrics endpoint
- Structured JSON logging configuration
Documentation (items 27-29):
- Architecture docs with module layout and subsystem descriptions
- Quickstart guide with setup, API tour, and test instructions
Tests (items 30-32):
- Integration tests: 25 end-to-end API tests
- Frontend tests: 10 Vitest tests for hooks (useTheme, useAuth)
- Load/performance tests: latency and throughput benchmarks
- Connector tests: 16 tests for Docs, DB, CodeRunner
- Multi-modal adapter tests: 9 tests
- Metrics collector tests: 5 tests
- STT adapter tests: 2 tests
511 Python tests passing, 10 frontend tests passing, 0 ruff errors.
Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
19
frontend/nginx.conf
Normal file
19
frontend/nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /v1/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,18 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -24,8 +28,10 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^17.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,151 @@
|
||||
/* ========== CSS Variables / Theming ========== */
|
||||
:root, [data-theme="dark"] {
|
||||
--bg-primary: #0f0f14;
|
||||
--bg-secondary: #18181b;
|
||||
--bg-tertiary: #27272a;
|
||||
--border: #3f3f46;
|
||||
--text-primary: #e4e4e7;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-muted: #71717a;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--accent-glow: rgba(59, 130, 246, 0.3);
|
||||
--success: #22c55e;
|
||||
--warning: #f97316;
|
||||
--danger: #ef4444;
|
||||
--card-bg: #18181b;
|
||||
--input-bg: #18181b;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--border: #e2e8f0;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--accent-glow: rgba(59, 130, 246, 0.15);
|
||||
--success: #16a34a;
|
||||
--warning: #ea580c;
|
||||
--danger: #dc2626;
|
||||
--card-bg: #ffffff;
|
||||
--input-bg: #ffffff;
|
||||
}
|
||||
|
||||
/* ========== Reset & Base ========== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ========== App Shell ========== */
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0f0f14;
|
||||
color: #e4e4e7;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
.header-left { display: flex; align-items: center; gap: 1.5rem; }
|
||||
.header-right { display: flex; align-items: center; gap: 0.75rem; }
|
||||
|
||||
.logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.mode-toggle button {
|
||||
.nav-tabs { display: flex; gap: 0.25rem; }
|
||||
.nav-tabs button {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: #27272a;
|
||||
border: 1px solid #3f3f46;
|
||||
color: #a1a1aa;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.mode-toggle button.active {
|
||||
background: #3b82f6;
|
||||
.nav-tabs button:hover { background: var(--bg-tertiary); }
|
||||
.nav-tabs button.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.main {
|
||||
.mode-toggle { display: flex; gap: 0.25rem; }
|
||||
.mode-toggle button {
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.mode-toggle button.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-tertiary); }
|
||||
|
||||
/* ========== Error Bar ========== */
|
||||
.error-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-bottom: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.error-bar button {
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ========== Main Layout ========== */
|
||||
.main { flex: 1; display: flex; overflow: hidden; }
|
||||
|
||||
.chat-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
@@ -44,42 +155,18 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head-ring {
|
||||
flex-shrink: 0;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.head-ring-svg {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.head-glyph {
|
||||
fill: #3f3f46;
|
||||
stroke: #52525b;
|
||||
stroke-width: 1;
|
||||
transition: fill 0.2s, filter 0.2s;
|
||||
}
|
||||
|
||||
.head-glyph.active {
|
||||
fill: #3b82f6;
|
||||
filter: drop-shadow(0 0 6px #3b82f6);
|
||||
}
|
||||
|
||||
/* ========== Avatar Grid ========== */
|
||||
.avatar-grid {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
min-height: 100px;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@@ -88,187 +175,384 @@
|
||||
align-items: center;
|
||||
padding: 0.4rem;
|
||||
border-radius: 8px;
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.2s;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.avatar.active {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.avatar.active { border-color: var(--accent); }
|
||||
.avatar.speaking {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 12px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.avatar-face {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.avatar-face { position: relative; width: 36px; height: 36px; }
|
||||
.avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #27272a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.65rem; font-weight: 600; color: var(--text-secondary);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
.avatar-img { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; }
|
||||
.avatar.active .avatar-placeholder, .avatar.speaking .avatar-placeholder {
|
||||
background: var(--accent); color: white;
|
||||
}
|
||||
|
||||
.avatar-mouth {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 12px;
|
||||
height: 4px;
|
||||
background: #3b82f6;
|
||||
border-radius: 2px;
|
||||
animation: avatar-speak 0.4s ease-in-out infinite alternate;
|
||||
position: absolute; bottom: 4px; left: 50%;
|
||||
transform: translateX(-50%); width: 10px; height: 3px;
|
||||
background: var(--accent); border-radius: 2px;
|
||||
animation: speak 0.4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.avatar.active .avatar-placeholder,
|
||||
.avatar.speaking .avatar-placeholder {
|
||||
background: #3b82f6;
|
||||
@keyframes speak {
|
||||
from { transform: translateX(-50%) scaleY(0.5); }
|
||||
to { transform: translateX(-50%) scaleY(1.3); }
|
||||
}
|
||||
|
||||
@keyframes avatar-speak {
|
||||
from {
|
||||
transform: translateX(-50%) scaleY(0.5);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) scaleY(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-label {
|
||||
font-size: 0.65rem;
|
||||
margin-top: 0.25rem;
|
||||
color: #71717a;
|
||||
font-size: 0.6rem; margin-top: 0.2rem;
|
||||
color: var(--text-muted); text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* ========== Messages ========== */
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 1rem; display: flex;
|
||||
flex-direction: column; gap: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
text-align: center; padding: 2rem;
|
||||
}
|
||||
.empty-state h2 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.empty-state p { color: var(--text-secondary); margin-bottom: 1.5rem; }
|
||||
.suggestions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }
|
||||
.suggestion {
|
||||
padding: 0.5rem 1rem; background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
color: var(--text-primary); cursor: pointer; font-size: 0.85rem;
|
||||
}
|
||||
.suggestion:hover { border-color: var(--accent); }
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 10px;
|
||||
align-self: flex-start;
|
||||
max-width: 80%; padding: 0.75rem 1rem;
|
||||
border-radius: 12px; line-height: 1.6;
|
||||
font-size: 0.9rem; word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #71717a;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.input-row input {
|
||||
flex: 1;
|
||||
padding: 0.6rem 1rem;
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
color: #e4e4e7;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.input-row button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.message.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.message-meta {
|
||||
margin-top: 0.5rem; font-size: 0.75rem;
|
||||
color: var(--text-muted); display: flex; gap: 1rem;
|
||||
}
|
||||
|
||||
.input-row button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
.loading-indicator {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
color: var(--text-muted); font-size: 0.85rem;
|
||||
}
|
||||
.loading-dots { display: flex; gap: 4px; }
|
||||
.loading-dots span {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: dot-pulse 1.2s infinite ease-in-out both;
|
||||
}
|
||||
.loading-dots span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.loading-dots span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes dot-pulse {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ========== Input Area ========== */
|
||||
.input-area { flex-shrink: 0; padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
|
||||
.input-row { display: flex; gap: 0.5rem; }
|
||||
.input-row input {
|
||||
flex: 1; padding: 0.6rem 1rem;
|
||||
background: var(--input-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--text-primary); font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
.input-row input:focus { border-color: var(--accent); }
|
||||
.input-row input:disabled { opacity: 0.5; }
|
||||
.send-btn {
|
||||
padding: 0.6rem 1.2rem; background: var(--accent);
|
||||
border: none; border-radius: 8px;
|
||||
color: white; cursor: pointer; font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.send-btn:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.input-meta {
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
margin-top: 0.25rem; font-size: 0.75rem; color: var(--text-muted);
|
||||
}
|
||||
.streaming-toggle {
|
||||
display: flex; align-items: center; gap: 0.3rem; cursor: pointer;
|
||||
}
|
||||
.streaming-toggle input { cursor: pointer; }
|
||||
.session-id { opacity: 0.6; }
|
||||
|
||||
/* ========== Consensus Panel ========== */
|
||||
.consensus-panel {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid #27272a;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
background: #18181b;
|
||||
width: 320px; flex-shrink: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
padding: 1rem; overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.consensus-panel h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.consensus-panel h4 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.confidence {
|
||||
font-size: 0.9rem;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.consensus-panel h3 { margin: 0 0 0.5rem; font-size: 1rem; }
|
||||
.consensus-panel h4 { margin: 1rem 0 0.5rem; font-size: 0.85rem; color: var(--text-secondary); }
|
||||
.confidence { font-size: 0.9rem; color: var(--accent); font-weight: 600; }
|
||||
.head-contribution {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem; margin-bottom: 0.4rem;
|
||||
padding: 0.4rem 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.claim { font-size: 0.8rem; margin-bottom: 0.25rem; padding: 0.25rem 0; }
|
||||
.claim.disputed { color: var(--warning); }
|
||||
.safety-report { font-size: 0.8rem; color: var(--text-muted); }
|
||||
|
||||
/* ========== Login Page ========== */
|
||||
.login-page {
|
||||
min-height: 100vh; display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
.login-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 2rem;
|
||||
width: 100%; max-width: 380px; text-align: center;
|
||||
}
|
||||
.login-card h1 {
|
||||
font-size: 1.8rem; margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.login-card form { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem; }
|
||||
.login-card input {
|
||||
padding: 0.6rem 1rem; background: var(--input-bg);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
color: var(--text-primary); font-size: 0.9rem;
|
||||
}
|
||||
.login-card button[type="submit"] {
|
||||
padding: 0.6rem; background: var(--accent);
|
||||
border: none; border-radius: 8px; color: white;
|
||||
cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.login-card button[type="submit"]:disabled { opacity: 0.5; }
|
||||
.skip-btn {
|
||||
margin-top: 0.75rem; padding: 0.4rem 0.8rem;
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
color: var(--text-secondary); border-radius: 6px;
|
||||
cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
.small { font-size: 0.75rem; }
|
||||
|
||||
/* ========== Admin Page ========== */
|
||||
.admin-page, .ethics-page, .settings-page {
|
||||
flex: 1; padding: 1.5rem; overflow-y: auto;
|
||||
max-width: 1000px; margin: 0 auto; width: 100%;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex; gap: 0.25rem; margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border); padding-bottom: 0.5rem;
|
||||
}
|
||||
.admin-tabs button {
|
||||
padding: 0.4rem 1rem; background: transparent;
|
||||
border: 1px solid transparent; color: var(--text-secondary);
|
||||
border-radius: 6px 6px 0 0; cursor: pointer; font-size: 0.85rem;
|
||||
}
|
||||
.admin-tabs button.active {
|
||||
background: var(--bg-tertiary); color: var(--text-primary);
|
||||
border-color: var(--border); border-bottom-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.admin-section h2 { font-size: 1.2rem; margin-bottom: 1rem; }
|
||||
.admin-section h3 { font-size: 1rem; margin: 1.5rem 0 0.75rem; color: var(--text-secondary); }
|
||||
|
||||
.status-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.status-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 1rem;
|
||||
display: flex; flex-direction: column; gap: 0.25rem;
|
||||
}
|
||||
.status-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; }
|
||||
.status-value { font-size: 1.2rem; font-weight: 600; }
|
||||
|
||||
.add-form {
|
||||
display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap;
|
||||
}
|
||||
.add-form input, .add-form select {
|
||||
padding: 0.5rem 0.75rem; background: var(--input-bg);
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text-primary); font-size: 0.85rem;
|
||||
}
|
||||
.add-form button {
|
||||
padding: 0.5rem 1rem; background: var(--accent);
|
||||
border: none; border-radius: 6px; color: white;
|
||||
cursor: pointer; font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.voice-list, .agent-grid { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.voice-card, .agent-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 0.75rem 1rem;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.agent-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
|
||||
.status-badge {
|
||||
padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.7rem; font-weight: 600;
|
||||
}
|
||||
.status-badge.active { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
|
||||
.governance-mode {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 1rem; background: var(--card-bg);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.mode-label { font-weight: 600; }
|
||||
.mode-value.advisory {
|
||||
padding: 0.2rem 0.75rem; background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success); border-radius: 4px; font-weight: 600; font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ========== Ethics Page ========== */
|
||||
.lesson-list, .consequence-list, .insight-list {
|
||||
display: flex; flex-direction: column; gap: 0.75rem;
|
||||
}
|
||||
.lesson-card, .consequence-card, .insight-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 1rem;
|
||||
}
|
||||
.lesson-header, .consequence-header, .insight-header {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.weight-badge {
|
||||
padding: 0.1rem 0.5rem; border-radius: 4px;
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
background: rgba(59, 130, 246, 0.15); color: var(--accent);
|
||||
}
|
||||
.weight-badge.high { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.weight-badge.negative { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
|
||||
.lesson-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 0.75rem;
|
||||
font-size: 0.8rem; color: var(--text-muted);
|
||||
}
|
||||
.outcome-badge {
|
||||
padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600;
|
||||
}
|
||||
.outcome-badge.positive { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.outcome-badge.negative { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
|
||||
|
||||
.risk-reward-bar {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
margin: 0.25rem 0; font-size: 0.8rem;
|
||||
}
|
||||
.bar-label { width: 50px; color: var(--text-muted); }
|
||||
.bar-track {
|
||||
flex: 1; height: 8px; background: var(--bg-tertiary);
|
||||
border-radius: 4px; overflow: hidden;
|
||||
}
|
||||
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
|
||||
.bar-fill.risk { background: var(--danger); }
|
||||
.bar-fill.reward { background: var(--success); }
|
||||
|
||||
.insight-source {
|
||||
padding: 0.1rem 0.5rem; background: var(--bg-tertiary);
|
||||
border-radius: 4px; font-size: 0.75rem; font-weight: 600;
|
||||
}
|
||||
.insight-domain {
|
||||
padding: 0.1rem 0.5rem; background: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6; border-radius: 4px; font-size: 0.75rem;
|
||||
}
|
||||
.insight-confidence { font-size: 0.75rem; color: var(--accent); margin-left: auto; }
|
||||
|
||||
/* ========== Settings Page ========== */
|
||||
.settings-section {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem;
|
||||
}
|
||||
.settings-section h3 { margin: 0 0 1rem; font-size: 1rem; }
|
||||
.setting-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.5rem 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
.setting-row label { font-size: 0.9rem; color: var(--text-secondary); }
|
||||
.setting-row select {
|
||||
padding: 0.4rem 0.75rem; background: var(--input-bg);
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text-primary); font-size: 0.85rem;
|
||||
}
|
||||
.theme-toggle {
|
||||
padding: 0.4rem 0.75rem; background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text-primary); cursor: pointer; font-size: 0.85rem;
|
||||
}
|
||||
.slider-row {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.5rem 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.slider-row:last-child { border-bottom: none; }
|
||||
.slider-row label { flex: 0 0 120px; font-size: 0.9rem; color: var(--text-secondary); }
|
||||
.slider-row input[type="range"] { flex: 1; }
|
||||
.slider-value { width: 35px; text-align: right; font-size: 0.85rem; color: var(--accent); }
|
||||
|
||||
.save-btn {
|
||||
padding: 0.6rem 1.5rem; background: var(--accent);
|
||||
border: none; border-radius: 8px; color: white;
|
||||
cursor: pointer; font-weight: 600; font-size: 0.9rem;
|
||||
}
|
||||
.save-btn:hover { background: var(--accent-hover); }
|
||||
|
||||
/* ========== Utilities ========== */
|
||||
.muted { color: var(--text-muted); font-size: 0.85rem; }
|
||||
.error-banner {
|
||||
padding: 0.5rem 1rem; background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--danger); border-radius: 6px;
|
||||
color: var(--danger); font-size: 0.85rem;
|
||||
margin-bottom: 1rem; cursor: pointer;
|
||||
}
|
||||
.page-loading {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-muted); font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.claim {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.3rem;
|
||||
padding: 0.3rem 0;
|
||||
/* ========== Responsive ========== */
|
||||
@media (max-width: 768px) {
|
||||
.header { flex-direction: column; gap: 0.5rem; padding: 0.5rem 1rem; }
|
||||
.header-left { width: 100%; justify-content: space-between; }
|
||||
.header-right { width: 100%; justify-content: flex-end; }
|
||||
.consensus-panel { display: none; }
|
||||
.avatar-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.messages { padding: 0.75rem; }
|
||||
.message { max-width: 95%; }
|
||||
.admin-page, .ethics-page, .settings-page { padding: 1rem; }
|
||||
.status-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.add-form { flex-direction: column; }
|
||||
.setting-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
|
||||
}
|
||||
|
||||
.claim.disputed {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.safety-report {
|
||||
font-size: 0.8rem;
|
||||
color: #71717a;
|
||||
@media (max-width: 480px) {
|
||||
.avatar-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.nav-tabs button { font-size: 0.75rem; padding: 0.3rem 0.5rem; }
|
||||
.mode-toggle { display: none; }
|
||||
}
|
||||
|
||||
@@ -1,153 +1,277 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { AvatarGrid } from './components/AvatarGrid'
|
||||
import { ConsensusPanel } from './components/ConsensusPanel'
|
||||
import { ChatMessage } from './components/ChatMessage'
|
||||
import type { HeadContribution, FinalResponse } from './types'
|
||||
import { AdminPage } from './pages/AdminPage'
|
||||
import { EthicsPage } from './pages/EthicsPage'
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
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 type { FinalResponse, Page, ViewMode, WSEvent } from './types'
|
||||
import './App.css'
|
||||
|
||||
type ViewMode = 'normal' | 'explain' | 'developer'
|
||||
const HEAD_IDS = [
|
||||
'logic', 'research', 'systems', 'strategy', 'product',
|
||||
'security', 'safety', 'reliability', 'cost', 'data', 'devex', 'witness',
|
||||
]
|
||||
|
||||
function App() {
|
||||
const { theme, toggle: toggleTheme } = useTheme()
|
||||
const { token, error: authError, setError: setAuthError, 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 [loading, setLoading] = useState(false)
|
||||
const [activeHeads, setActiveHeads] = useState<string[]>([])
|
||||
const [speakingHead, setSpeakingHead] = useState<string | null>(null) // current head "speaking" in UI
|
||||
const [headSummaries, setHeadSummaries] = useState<Record<string, 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 { speakingHead, headSummaries, onHeadSpeak, clearSpeaking } = useVoicePlayback()
|
||||
const ws = useWebSocket(sessionId)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Handle WS events
|
||||
useEffect(() => {
|
||||
if (ws.events.length === 0) return
|
||||
const last = ws.events[ws.events.length - 1]
|
||||
handleWSEvent(last)
|
||||
}, [ws.events])
|
||||
|
||||
const handleWSEvent = (event: WSEvent) => {
|
||||
switch (event.type) {
|
||||
case 'heads_running':
|
||||
setActiveHeads(HEAD_IDS.slice(0, 6))
|
||||
break
|
||||
case 'head_complete':
|
||||
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)
|
||||
}
|
||||
break
|
||||
case 'witness_running':
|
||||
clearSpeaking()
|
||||
break
|
||||
case 'complete':
|
||||
if (event.final_answer) {
|
||||
const resp: FinalResponse = {
|
||||
final_answer: event.final_answer,
|
||||
transparency_report: event.transparency_report!,
|
||||
head_contributions: event.head_contributions || [],
|
||||
confidence_score: event.confidence_score || 0,
|
||||
}
|
||||
setLastResponse(resp)
|
||||
setMessages((m) => [...m, { role: 'assistant', content: event.final_answer!, data: resp }])
|
||||
}
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
break
|
||||
case 'error':
|
||||
setMessages((m) => [...m, { role: 'assistant', content: `Error: ${event.message}` }])
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const parseJson = useCallback(async (r: Response) => {
|
||||
const text = await r.text()
|
||||
if (!text.trim()) throw new Error('Empty response from API')
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON from API: ${text.slice(0, 100)}`)
|
||||
}
|
||||
try { return JSON.parse(text) } catch { throw new Error(`Invalid JSON: ${text.slice(0, 100)}`) }
|
||||
}, [])
|
||||
|
||||
const ensureSession = useCallback(async () => {
|
||||
if (sessionId) return sessionId
|
||||
const r = await fetch('/v1/sessions', { method: 'POST' })
|
||||
const j = await parseJson(r)
|
||||
if (!j.session_id) throw new Error('No session_id in response')
|
||||
setSessionId(j.session_id)
|
||||
return j.session_id
|
||||
}, [sessionId, parseJson])
|
||||
try {
|
||||
const r = await fetch('/v1/sessions', { method: 'POST', headers: authHeaders() })
|
||||
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)
|
||||
return j.session_id
|
||||
} catch (e) {
|
||||
setNetworkError((e as Error).message)
|
||||
return null
|
||||
}
|
||||
}, [sessionId, parseJson, authHeaders])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!prompt.trim()) return
|
||||
if (!prompt.trim() || loading) return
|
||||
const sid = await ensureSession()
|
||||
if (!sid) return
|
||||
|
||||
setMessages((m) => [...m, { role: 'user', content: prompt }])
|
||||
const currentPrompt = prompt
|
||||
setPrompt('')
|
||||
setLoading(true)
|
||||
setSpeakingHead(null)
|
||||
setActiveHeads(['logic', 'research', 'strategy', 'security', 'safety'])
|
||||
setNetworkError(null)
|
||||
clearSpeaking()
|
||||
setActiveHeads(HEAD_IDS.slice(0, 6))
|
||||
|
||||
try {
|
||||
const r = await fetch(`/v1/sessions/${sid}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt }),
|
||||
})
|
||||
const data = await parseJson(r)
|
||||
if (!r.ok) throw new Error(data.detail || 'Request failed')
|
||||
if (useStreaming && ws.status === 'connected') {
|
||||
ws.send({ prompt: currentPrompt })
|
||||
} else {
|
||||
try {
|
||||
const r = await fetch(`/v1/sessions/${sid}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ prompt: currentPrompt }),
|
||||
})
|
||||
const data = await parseJson(r)
|
||||
if (!r.ok) throw new Error(data.detail || `Request failed: ${r.status}`)
|
||||
|
||||
setLastResponse(data)
|
||||
if (data.response_mode === 'show_dissent' || data.response_mode === 'explain') {
|
||||
setViewMode('explain')
|
||||
setLastResponse(data)
|
||||
if (data.response_mode === 'show_dissent' || data.response_mode === 'explain') {
|
||||
setViewMode('explain')
|
||||
}
|
||||
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 }])
|
||||
setNetworkError(null)
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message
|
||||
setNetworkError(msg)
|
||||
setMessages((m) => [...m, { role: 'assistant', content: `Error: ${msg}` }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
}
|
||||
const contribs = data.head_contributions || []
|
||||
setHeadSummaries(
|
||||
Object.fromEntries(contribs.map((c: { head_id: string; summary: string }) => [c.head_id, c.summary]))
|
||||
)
|
||||
setSpeakingHead(contribs[0]?.head_id ?? null)
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: data.final_answer,
|
||||
data,
|
||||
},
|
||||
])
|
||||
} catch (e) {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{ role: 'assistant', content: `Error: ${(e as Error).message}`, data: undefined },
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
}
|
||||
}, [prompt, ensureSession, parseJson])
|
||||
}, [prompt, loading, ensureSession, useStreaming, ws, authHeaders, parseJson, clearSpeaking, onHeadSpeak])
|
||||
|
||||
const HEAD_IDS = [
|
||||
'logic', 'research', 'systems', 'strategy', 'product',
|
||||
'security', 'safety', 'reliability', 'cost', 'data', 'devex', 'witness',
|
||||
]
|
||||
const handleRetry = () => {
|
||||
if (messages.length >= 2) {
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
|
||||
if (lastUser) {
|
||||
setPrompt(lastUser.content)
|
||||
setNetworkError(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Login screen
|
||||
if (!isAuthenticated && !token && token !== '') {
|
||||
return <LoginPage onLogin={login} error={authError} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="app" data-theme={theme}>
|
||||
<header className="header">
|
||||
<h1>FusionAGI Dvādaśa</h1>
|
||||
<div className="mode-toggle">
|
||||
{(['normal', 'explain', 'developer'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={viewMode === m ? 'active' : ''}
|
||||
onClick={() => setViewMode(m)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
<div className="header-left">
|
||||
<h1 className="logo">FusionAGI</h1>
|
||||
<nav className="nav-tabs">
|
||||
{(['chat', 'admin', 'ethics', 'settings'] as Page[]).map((p) => (
|
||||
<button key={p} className={page === p ? 'active' : ''} onClick={() => setPage(p)}>
|
||||
{p === 'chat' ? 'Chat' : p === 'admin' ? 'Admin' : p === 'ethics' ? 'Ethics' : 'Settings'}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{page === 'chat' && (
|
||||
<div className="mode-toggle">
|
||||
{(['normal', 'explain', 'developer'] as const).map((m) => (
|
||||
<button key={m} className={viewMode === m ? 'active' : ''} onClick={() => setViewMode(m)}>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button className="icon-btn" onClick={toggleTheme} title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
|
||||
{theme === 'dark' ? '\u2600' : '\u263E'}
|
||||
</button>
|
||||
{token && <button className="icon-btn" onClick={logout} title="Logout">Exit</button>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="main">
|
||||
<div className="chat-area">
|
||||
<AvatarGrid
|
||||
headIds={HEAD_IDS}
|
||||
activeHeads={activeHeads}
|
||||
speakingHead={speakingHead}
|
||||
headSummaries={headSummaries}
|
||||
/>
|
||||
<div className="messages">
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
message={msg}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
{loading && <div className="loading">Heads running…</div>}
|
||||
</div>
|
||||
<div className="input-row">
|
||||
<input
|
||||
id="prompt-input"
|
||||
name="prompt"
|
||||
type="text"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
placeholder="Ask FusionAGI… (/head strategy, /show dissent)"
|
||||
autoComplete="off"
|
||||
aria-label="Ask FusionAGI"
|
||||
/>
|
||||
<button onClick={handleSubmit} disabled={loading}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{networkError && (
|
||||
<div className="error-bar">
|
||||
<span>{networkError}</span>
|
||||
<button onClick={handleRetry}>Retry</button>
|
||||
<button onClick={() => setNetworkError(null)}>Dismiss</button>
|
||||
</div>
|
||||
<ConsensusPanel
|
||||
response={lastResponse}
|
||||
viewMode={viewMode}
|
||||
expanded={viewMode !== 'normal'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="main">
|
||||
{page === 'chat' && (
|
||||
<div className="chat-layout">
|
||||
<div className="chat-area">
|
||||
<AvatarGrid
|
||||
headIds={HEAD_IDS}
|
||||
activeHeads={activeHeads}
|
||||
speakingHead={speakingHead}
|
||||
headSummaries={headSummaries}
|
||||
/>
|
||||
<div className="messages">
|
||||
{messages.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage key={i} message={msg} viewMode={viewMode} />
|
||||
))}
|
||||
{loading && (
|
||||
<div className="loading-indicator">
|
||||
<div className="loading-dots"><span /><span /><span /></div>
|
||||
<span>Heads analyzing...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className="input-area">
|
||||
<div className="input-row">
|
||||
<input
|
||||
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)"
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button onClick={handleSubmit} disabled={loading || !prompt.trim()} className="send-btn">
|
||||
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>
|
||||
{sessionId && <span className="session-id">Session: {sessionId.slice(0, 8)}...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConsensusPanel response={lastResponse} viewMode={viewMode} expanded={viewMode !== 'normal'} />
|
||||
</div>
|
||||
)}
|
||||
{page === 'admin' && <AdminPage authHeaders={authHeaders} />}
|
||||
{page === 'ethics' && <EthicsPage authHeaders={authHeaders} />}
|
||||
{page === 'settings' && <SettingsPage theme={theme} toggleTheme={toggleTheme} authHeaders={authHeaders} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
51
frontend/src/hooks/useAuth.test.ts
Normal file
51
frontend/src/hooks/useAuth.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
describe('useAuth', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('starts unauthenticated', () => {
|
||||
const { result } = renderHook(() => useAuth())
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(result.current.token).toBeNull()
|
||||
})
|
||||
|
||||
it('login sets token and persists', () => {
|
||||
const { result } = renderHook(() => useAuth())
|
||||
act(() => result.current.login('test-api-key'))
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
expect(result.current.token).toBe('test-api-key')
|
||||
expect(localStorage.getItem('fusionagi-token')).toBe('test-api-key')
|
||||
})
|
||||
|
||||
it('logout clears token', () => {
|
||||
const { result } = renderHook(() => useAuth())
|
||||
act(() => result.current.login('test-key'))
|
||||
act(() => result.current.logout())
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('fusionagi-token')).toBeNull()
|
||||
})
|
||||
|
||||
it('authHeaders includes bearer token when authenticated', () => {
|
||||
const { result } = renderHook(() => useAuth())
|
||||
act(() => result.current.login('my-key'))
|
||||
const headers = result.current.authHeaders()
|
||||
expect(headers['Authorization']).toBe('Bearer my-key')
|
||||
})
|
||||
|
||||
it('authHeaders has no auth when unauthenticated', () => {
|
||||
const { result } = renderHook(() => useAuth())
|
||||
const headers = result.current.authHeaders()
|
||||
expect(headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores token from localStorage', () => {
|
||||
localStorage.setItem('fusionagi-token', 'saved-key')
|
||||
const { result } = renderHook(() => useAuth())
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
expect(result.current.token).toBe('saved-key')
|
||||
})
|
||||
})
|
||||
27
frontend/src/hooks/useAuth.ts
Normal file
27
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export function useAuth() {
|
||||
const [token, setToken] = useState<string | null>(() =>
|
||||
localStorage.getItem('fusionagi-token')
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const login = useCallback((apiKey: string) => {
|
||||
localStorage.setItem('fusionagi-token', apiKey)
|
||||
setToken(apiKey)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('fusionagi-token')
|
||||
setToken(null)
|
||||
}, [])
|
||||
|
||||
const authHeaders = useCallback((): Record<string, string> => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
return headers
|
||||
}, [token])
|
||||
|
||||
return { token, error, setError, login, logout, authHeaders, isAuthenticated: !!token }
|
||||
}
|
||||
34
frontend/src/hooks/useTheme.test.ts
Normal file
34
frontend/src/hooks/useTheme.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useTheme } from './useTheme'
|
||||
|
||||
describe('useTheme', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaults to dark theme', () => {
|
||||
const { result } = renderHook(() => useTheme())
|
||||
expect(result.current.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('toggles between dark and light', () => {
|
||||
const { result } = renderHook(() => useTheme())
|
||||
act(() => result.current.toggle())
|
||||
expect(result.current.theme).toBe('light')
|
||||
act(() => result.current.toggle())
|
||||
expect(result.current.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('persists to localStorage', () => {
|
||||
const { result } = renderHook(() => useTheme())
|
||||
act(() => result.current.toggle())
|
||||
expect(localStorage.getItem('fusionagi-theme')).toBe('light')
|
||||
})
|
||||
|
||||
it('restores from localStorage', () => {
|
||||
localStorage.setItem('fusionagi-theme', 'light')
|
||||
const { result } = renderHook(() => useTheme())
|
||||
expect(result.current.theme).toBe('light')
|
||||
})
|
||||
})
|
||||
20
frontend/src/hooks/useTheme.ts
Normal file
20
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { Theme } from '../types'
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('fusionagi-theme')
|
||||
return (saved === 'light' ? 'light' : 'dark') as Theme
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('fusionagi-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme((t) => (t === 'dark' ? 'light' : 'dark'))
|
||||
}, [])
|
||||
|
||||
return { theme, setTheme, toggle }
|
||||
}
|
||||
46
frontend/src/hooks/useWebSocket.ts
Normal file
46
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { WSEvent } from '../types'
|
||||
|
||||
type WSStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
export function useWebSocket(sessionId: string | null) {
|
||||
const [status, setStatus] = useState<WSStatus>('disconnected')
|
||||
const [events, setEvents] = useState<WSEvent[]>([])
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
const connect = useCallback((sid: string) => {
|
||||
if (wsRef.current) wsRef.current.close()
|
||||
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.onerror = () => setStatus('error')
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const event: WSEvent = JSON.parse(e.data)
|
||||
setEvents((prev) => [...prev, event])
|
||||
} catch { /* ignore malformed */ }
|
||||
}
|
||||
}, [])
|
||||
|
||||
const send = useCallback((data: Record<string, unknown>) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(data))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
wsRef.current?.close()
|
||||
wsRef.current = null
|
||||
setStatus('disconnected')
|
||||
}, [])
|
||||
|
||||
const clearEvents = useCallback(() => setEvents([]), [])
|
||||
|
||||
useEffect(() => () => { wsRef.current?.close() }, [])
|
||||
|
||||
return { status, events, connect, send, disconnect, clearEvents }
|
||||
}
|
||||
156
frontend/src/pages/AdminPage.tsx
Normal file
156
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { SystemStatus, VoiceProfile } from '../types'
|
||||
|
||||
function StatusCard({ label, value, unit }: { label: string; value: string | number | null; unit?: string }) {
|
||||
return (
|
||||
<div className="status-card">
|
||||
<span className="status-label">{label}</span>
|
||||
<span className="status-value">{value ?? 'N/A'}{unit && value != null ? unit : ''}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminPage({ authHeaders }: { authHeaders: () => Record<string, string> }) {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||
const [voices, setVoices] = useState<VoiceProfile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [newVoiceName, setNewVoiceName] = useState('')
|
||||
const [newVoiceLang, setNewVoiceLang] = useState('en-US')
|
||||
const [tab, setTab] = useState<'overview' | 'voices' | 'agents' | 'governance'>('overview')
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch('/v1/admin/status', { headers: authHeaders() })
|
||||
if (r.ok) setStatus(await r.json())
|
||||
} catch { /* offline */ }
|
||||
}, [authHeaders])
|
||||
|
||||
const fetchVoices = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch('/v1/admin/voices', { headers: authHeaders() })
|
||||
if (r.ok) setVoices(await r.json())
|
||||
} catch { /* offline */ }
|
||||
}, [authHeaders])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
Promise.all([fetchStatus(), fetchVoices()]).finally(() => setLoading(false))
|
||||
const interval = setInterval(fetchStatus, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus, fetchVoices])
|
||||
|
||||
const addVoice = async () => {
|
||||
if (!newVoiceName.trim()) return
|
||||
try {
|
||||
const r = await fetch('/v1/admin/voices', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ name: newVoiceName, language: newVoiceLang }),
|
||||
})
|
||||
if (r.ok) {
|
||||
setNewVoiceName('')
|
||||
fetchVoices()
|
||||
} else {
|
||||
setError('Failed to add voice')
|
||||
}
|
||||
} catch { setError('Network error') }
|
||||
}
|
||||
|
||||
const formatUptime = (s: number) => {
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
return `${h}h ${m}m`
|
||||
}
|
||||
|
||||
if (loading) return <div className="page-loading">Loading admin dashboard...</div>
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-tabs">
|
||||
{(['overview', 'voices', 'agents', 'governance'] as const).map((t) => (
|
||||
<button key={t} className={tab === t ? 'active' : ''} onClick={() => setTab(t)}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner" onClick={() => setError(null)}>{error}</div>}
|
||||
|
||||
{tab === 'overview' && (
|
||||
<div className="admin-section">
|
||||
<h2>System Overview</h2>
|
||||
<div className="status-grid">
|
||||
<StatusCard label="Status" value={status?.status ?? 'unknown'} />
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'voices' && (
|
||||
<div className="admin-section">
|
||||
<h2>Voice Library</h2>
|
||||
<div className="add-form">
|
||||
<input placeholder="Voice name" value={newVoiceName} onChange={(e) => setNewVoiceName(e.target.value)} />
|
||||
<select value={newVoiceLang} onChange={(e) => setNewVoiceLang(e.target.value)}>
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="en-GB">English (UK)</option>
|
||||
<option value="es-ES">Spanish</option>
|
||||
<option value="fr-FR">French</option>
|
||||
<option value="de-DE">German</option>
|
||||
<option value="ja-JP">Japanese</option>
|
||||
</select>
|
||||
<button onClick={addVoice}>Add Voice</button>
|
||||
</div>
|
||||
<div className="voice-list">
|
||||
{voices.length === 0 && <p className="muted">No voice profiles configured</p>}
|
||||
{voices.map((v) => (
|
||||
<div key={v.id} className="voice-card">
|
||||
<strong>{v.name}</strong>
|
||||
<span className="muted">{v.language} | {v.provider}</span>
|
||||
<span className="muted">Pitch: {v.pitch}x | Speed: {v.speed}x</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'agents' && (
|
||||
<div className="admin-section">
|
||||
<h2>Agent Configuration</h2>
|
||||
<div className="agent-grid">
|
||||
{['Planner', 'Reasoner', 'Executor', 'Critic', '12 Heads', 'Witness'].map((a) => (
|
||||
<div key={a} className="agent-card">
|
||||
<strong>{a}</strong>
|
||||
<span className="status-badge active">Active</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'governance' && (
|
||||
<div className="admin-section">
|
||||
<h2>Governance Mode</h2>
|
||||
<div className="governance-info">
|
||||
<div className="governance-mode">
|
||||
<span className="mode-label">Current Mode:</span>
|
||||
<span className="mode-value advisory">ADVISORY</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
All governance checks are advisory — violations are logged but actions proceed.
|
||||
The system learns from outcomes through the Consequence Engine and Adaptive Ethics.
|
||||
</p>
|
||||
</div>
|
||||
<h3>Audit Trail</h3>
|
||||
<p className="muted">Full audit trail available via /v1/admin/telemetry endpoint</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
frontend/src/pages/EthicsPage.tsx
Normal file
134
frontend/src/pages/EthicsPage.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { EthicalLesson, ConsequenceRecord, InsightRecord } from '../types'
|
||||
|
||||
export function EthicsPage({ authHeaders }: { authHeaders: () => Record<string, string> }) {
|
||||
const [lessons, setLessons] = useState<EthicalLesson[]>([])
|
||||
const [consequences, setConsequences] = useState<ConsequenceRecord[]>([])
|
||||
const [insights, setInsights] = useState<InsightRecord[]>([])
|
||||
const [tab, setTab] = useState<'ethics' | 'consequences' | 'insights'>('ethics')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [ethR, conR, insR] = await Promise.all([
|
||||
fetch('/v1/admin/ethics', { headers: authHeaders() }).catch(() => null),
|
||||
fetch('/v1/admin/consequences', { headers: authHeaders() }).catch(() => null),
|
||||
fetch('/v1/admin/insights', { headers: authHeaders() }).catch(() => null),
|
||||
])
|
||||
if (ethR?.ok) setLessons(await ethR.json())
|
||||
if (conR?.ok) setConsequences(await conR.json())
|
||||
if (insR?.ok) setInsights(await insR.json())
|
||||
} catch { /* offline */ }
|
||||
}, [authHeaders])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchData().finally(() => setLoading(false))
|
||||
}, [fetchData])
|
||||
|
||||
if (loading) return <div className="page-loading">Loading ethics dashboard...</div>
|
||||
|
||||
return (
|
||||
<div className="ethics-page">
|
||||
<div className="admin-tabs">
|
||||
{(['ethics', 'consequences', 'insights'] as const).map((t) => (
|
||||
<button key={t} className={tab === t ? 'active' : ''} onClick={() => setTab(t)}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'ethics' && (
|
||||
<div className="admin-section">
|
||||
<h2>Adaptive Ethics — Learned Lessons</h2>
|
||||
{lessons.length === 0 ? (
|
||||
<p className="muted">No ethical lessons recorded yet. The system learns from choices and their consequences.</p>
|
||||
) : (
|
||||
<div className="lesson-list">
|
||||
{lessons.map((l, i) => (
|
||||
<div key={i} className="lesson-card">
|
||||
<div className="lesson-header">
|
||||
<strong>{l.action_type}</strong>
|
||||
<span className={`weight-badge ${l.weight > 1 ? 'high' : l.weight < 0 ? 'negative' : ''}`}>
|
||||
Weight: {l.weight.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted">{l.context_summary}</p>
|
||||
<div className="lesson-meta">
|
||||
<span>Advisory: {l.advisory_reason}</span>
|
||||
<span>Proceeded: {l.proceeded ? 'Yes' : 'No'}</span>
|
||||
<span>Outcome: {l.outcome_positive === null ? 'Pending' : l.outcome_positive ? 'Positive' : 'Negative'}</span>
|
||||
<span>Occurrences: {l.occurrences}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'consequences' && (
|
||||
<div className="admin-section">
|
||||
<h2>Consequence Engine — Choice History</h2>
|
||||
{consequences.length === 0 ? (
|
||||
<p className="muted">No consequences recorded yet. Every choice creates a consequence record.</p>
|
||||
) : (
|
||||
<div className="consequence-list">
|
||||
{consequences.map((c, i) => (
|
||||
<div key={i} className="consequence-card">
|
||||
<div className="consequence-header">
|
||||
<strong>{c.action_taken}</strong>
|
||||
{c.outcome_positive !== null && (
|
||||
<span className={`outcome-badge ${c.outcome_positive ? 'positive' : 'negative'}`}>
|
||||
{c.outcome_positive ? 'Positive' : 'Negative'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="risk-reward-bar">
|
||||
<div className="bar-label">Risk</div>
|
||||
<div className="bar-track">
|
||||
<div className="bar-fill risk" style={{ width: `${c.estimated_risk * 100}%` }} />
|
||||
</div>
|
||||
<span>{(c.estimated_risk * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="risk-reward-bar">
|
||||
<div className="bar-label">Reward</div>
|
||||
<div className="bar-track">
|
||||
<div className="bar-fill reward" style={{ width: `${c.estimated_reward * 100}%` }} />
|
||||
</div>
|
||||
<span>{(c.estimated_reward * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
{c.surprise_factor !== null && (
|
||||
<span className="muted">Surprise factor: {c.surprise_factor.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'insights' && (
|
||||
<div className="admin-section">
|
||||
<h2>InsightBus — Cross-Head Learning</h2>
|
||||
{insights.length === 0 ? (
|
||||
<p className="muted">No cross-head insights yet. Heads share observations through the InsightBus.</p>
|
||||
) : (
|
||||
<div className="insight-list">
|
||||
{insights.map((ins, i) => (
|
||||
<div key={i} className="insight-card">
|
||||
<div className="insight-header">
|
||||
<span className="insight-source">{ins.source}</span>
|
||||
{ins.domain && <span className="insight-domain">{ins.domain}</span>}
|
||||
<span className="insight-confidence">{(ins.confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<p>{ins.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
frontend/src/pages/LoginPage.tsx
Normal file
41
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (token: string) => void
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function LoginPage({ onLogin, error }: LoginPageProps) {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (apiKey.trim()) onLogin(apiKey.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1>FusionAGI</h1>
|
||||
<p className="muted">Enter your API key to connect</p>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="API Key (Bearer token)"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={!apiKey.trim()}>Connect</button>
|
||||
</form>
|
||||
<p className="muted small">
|
||||
No API key? Set FUSIONAGI_API_KEY env var on the server, or leave blank for open access.
|
||||
</p>
|
||||
<button className="skip-btn" onClick={() => onLogin('')}>
|
||||
Skip (no auth)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
frontend/src/pages/SettingsPage.tsx
Normal file
89
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react'
|
||||
import type { ConversationStyle, Theme } from '../types'
|
||||
|
||||
interface SettingsPageProps {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
authHeaders: () => Record<string, string>
|
||||
}
|
||||
|
||||
function Slider({ label, value, onChange, min = 0, max = 1, step = 0.1 }: {
|
||||
label: string; value: number; onChange: (v: number) => void; min?: number; max?: number; step?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="slider-row">
|
||||
<label>{label}</label>
|
||||
<input type="range" min={min} max={max} step={step} value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))} />
|
||||
<span className="slider-value">{value.toFixed(1)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsPage({ theme, toggleTheme, authHeaders }: SettingsPageProps) {
|
||||
const [style, setStyle] = useState<ConversationStyle>({
|
||||
formality: 'neutral',
|
||||
verbosity: 'balanced',
|
||||
empathy_level: 0.7,
|
||||
proactivity: 0.5,
|
||||
humor_level: 0.3,
|
||||
technical_depth: 0.5,
|
||||
})
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await fetch('/v1/admin/conversation-style', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(style),
|
||||
})
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch { /* offline */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<h2>Settings</h2>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Appearance</h3>
|
||||
<div className="setting-row">
|
||||
<label>Theme</label>
|
||||
<button className="theme-toggle" onClick={toggleTheme}>
|
||||
{theme === 'dark' ? 'Switch to Light' : 'Switch to Dark'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Conversation Style</h3>
|
||||
<div className="setting-row">
|
||||
<label>Formality</label>
|
||||
<select value={style.formality} onChange={(e) => setStyle({ ...style, formality: e.target.value as ConversationStyle['formality'] })}>
|
||||
<option value="casual">Casual</option>
|
||||
<option value="neutral">Neutral</option>
|
||||
<option value="formal">Formal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<label>Verbosity</label>
|
||||
<select 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 label="Empathy" value={style.empathy_level} onChange={(v) => setStyle({ ...style, empathy_level: v })} />
|
||||
<Slider label="Proactivity" value={style.proactivity} onChange={(v) => setStyle({ ...style, proactivity: v })} />
|
||||
<Slider label="Humor" value={style.humor_level} onChange={(v) => setStyle({ ...style, humor_level: v })} />
|
||||
<Slider label="Technical Depth" value={style.technical_depth} onChange={(v) => setStyle({ ...style, technical_depth: v })} />
|
||||
</div>
|
||||
|
||||
<button className="save-btn" onClick={saveSettings}>
|
||||
{saved ? 'Saved' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
frontend/src/test-setup.ts
Normal file
1
frontend/src/test-setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -2,6 +2,7 @@ export interface HeadContribution {
|
||||
head_id: string
|
||||
summary: string
|
||||
key_claims?: string[]
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export interface AgreementMap {
|
||||
@@ -18,8 +19,82 @@ export interface TransparencyReport {
|
||||
}
|
||||
|
||||
export interface FinalResponse {
|
||||
task_id?: string
|
||||
final_answer: string
|
||||
transparency_report: TransparencyReport
|
||||
head_contributions: HeadContribution[]
|
||||
confidence_score: number
|
||||
response_mode?: string
|
||||
}
|
||||
|
||||
export interface WSEvent {
|
||||
type: 'heads_running' | 'head_complete' | 'head_speak' | 'witness_running' | 'complete' | 'error'
|
||||
message?: string
|
||||
head_id?: string
|
||||
summary?: string
|
||||
audio_base64?: string | null
|
||||
final_answer?: string
|
||||
transparency_report?: TransparencyReport
|
||||
head_contributions?: HeadContribution[]
|
||||
confidence_score?: number
|
||||
}
|
||||
|
||||
export interface VoiceProfile {
|
||||
id: string
|
||||
name: string
|
||||
language: string
|
||||
gender: string | null
|
||||
style: string | null
|
||||
pitch: number
|
||||
speed: number
|
||||
provider: string
|
||||
}
|
||||
|
||||
export interface ConversationStyle {
|
||||
formality: 'casual' | 'neutral' | 'formal'
|
||||
verbosity: 'concise' | 'balanced' | 'detailed'
|
||||
empathy_level: number
|
||||
proactivity: number
|
||||
humor_level: number
|
||||
technical_depth: number
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
status: 'healthy' | 'degraded' | 'offline'
|
||||
uptime_seconds: number
|
||||
active_tasks: number
|
||||
active_agents: number
|
||||
active_sessions: number
|
||||
memory_usage_mb: number | null
|
||||
cpu_usage_percent: number | null
|
||||
}
|
||||
|
||||
export interface EthicalLesson {
|
||||
action_type: string
|
||||
context_summary: string
|
||||
advisory_reason: string
|
||||
weight: number
|
||||
occurrences: number
|
||||
proceeded: boolean
|
||||
outcome_positive: boolean | null
|
||||
}
|
||||
|
||||
export interface ConsequenceRecord {
|
||||
choice_id: string
|
||||
action_taken: string
|
||||
estimated_risk: number
|
||||
estimated_reward: number
|
||||
outcome_positive: boolean | null
|
||||
surprise_factor: number | null
|
||||
}
|
||||
|
||||
export interface InsightRecord {
|
||||
source: string
|
||||
message: string
|
||||
domain: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
export type ViewMode = 'normal' | 'explain' | 'developer'
|
||||
export type Page = 'chat' | 'admin' | 'ethics' | 'settings'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
@@ -9,4 +10,9 @@ export default defineConfig({
|
||||
"/v1": process.env.VITE_API_URL || "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test-setup.ts',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user