Compare commits
5 Commits
devin/1777
...
devin/1777
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b3f27b0f | ||
|
|
94ee9a2ee5 | ||
|
|
96c32aed21 | ||
|
|
0b583cdd07 | ||
|
|
f14d63f14d |
@@ -44,9 +44,31 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
migrations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Verify migrations
|
||||
run: python -m migrations.migrate verify
|
||||
|
||||
helm:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Helm
|
||||
run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||
- name: Lint Helm chart
|
||||
run: helm lint k8s/
|
||||
- name: Template validation
|
||||
run: helm template fusionagi k8s/ --debug > /dev/null
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
needs: [lint, test, migrations, helm]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
29
docs/adr/001-advisory-governance.md
Normal file
29
docs/adr/001-advisory-governance.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# ADR-001: Advisory Governance Model
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
FusionAGI needed a governance model for its 12-headed AGI orchestrator. Traditional AI safety approaches use hard enforcement (blocking, filtering, rate limiting). The question was whether to enforce constraints rigidly or allow the system to learn from consequences.
|
||||
|
||||
## Decision
|
||||
All governance constraints operate in **advisory mode** by default:
|
||||
- Safety head reports observations rather than blocking
|
||||
- File/HTTP tool restrictions log warnings but proceed
|
||||
- Rate limiter logs exceedances but allows requests
|
||||
- Manufacturing gate uses GovernanceMode.ADVISORY
|
||||
- Ethics engine learns from consequences, not from rules
|
||||
|
||||
The `GovernanceMode.ENFORCING` option remains available for deployment contexts that require it.
|
||||
|
||||
## Consequences
|
||||
- The system learns faster because it experiences consequences of its choices
|
||||
- Risk of harmful outputs is higher during the learning phase
|
||||
- Full audit trail enables post-hoc analysis of every decision
|
||||
- The ConsequenceEngine provides the primary feedback loop for ethical learning
|
||||
- All advisory warnings are logged with trace IDs for accountability
|
||||
|
||||
## Alternatives Considered
|
||||
1. **Hard enforcement** — Rejected: prevents learning, creates false sense of safety
|
||||
2. **Hybrid (enforce critical, advise rest)** — Partially adopted: certain hardware safety limits (e.g., embodiment force limits) still log but don't clamp
|
||||
3. **No governance** — Rejected: transparency and auditability are still required
|
||||
39
docs/adr/002-twelve-head-architecture.md
Normal file
39
docs/adr/002-twelve-head-architecture.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# ADR-002: Twelve-Head (Dvādaśa) Architecture
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
Multi-agent systems typically use 2-5 agents with fixed roles. FusionAGI needed a system that could analyze problems from many perspectives simultaneously while maintaining coherent output.
|
||||
|
||||
## Decision
|
||||
The orchestrator decomposes every query across **12 specialized heads**:
|
||||
|
||||
| Head | Role |
|
||||
|------|------|
|
||||
| Logic | Logical reasoning and consistency |
|
||||
| Research | Source evaluation and synthesis |
|
||||
| Systems | Architecture and integration |
|
||||
| Strategy | Long-term planning |
|
||||
| Product | User experience and design |
|
||||
| Security | Threat analysis |
|
||||
| Safety | Risk observation (advisory) |
|
||||
| Reliability | Fault tolerance |
|
||||
| Cost | Resource optimization |
|
||||
| Data | Statistical reasoning |
|
||||
| DevEx | Developer experience |
|
||||
| Witness | Audit and observation |
|
||||
|
||||
The Witness head is special: it observes but doesn't contribute to the consensus.
|
||||
|
||||
## Consequences
|
||||
- Comprehensive analysis from 12 angles on every query
|
||||
- Higher latency (12 parallel LLM calls) but better quality
|
||||
- The InsightBus enables cross-head learning
|
||||
- Each head has a unique color identity in the UI for visual distinction
|
||||
- The consensus mechanism must handle disagreement gracefully
|
||||
|
||||
## Alternatives Considered
|
||||
1. **3-5 heads** — Rejected: insufficient perspective diversity
|
||||
2. **Dynamic head count** — Future consideration: some queries don't need all 12
|
||||
3. **Hierarchical heads** — Rejected: flat structure promotes equal consideration
|
||||
30
docs/adr/003-consequence-engine.md
Normal file
30
docs/adr/003-consequence-engine.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# ADR-003: Consequence Engine for Ethical Learning
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
Traditional AI ethics systems use static rules (constitutional AI, RLHF reward models). FusionAGI needed a system that could learn ethical behavior from experience — understanding that every choice carries consequences and that risk/reward assessment improves with data.
|
||||
|
||||
## Decision
|
||||
Implemented a **ConsequenceEngine** that:
|
||||
1. Records every choice the system makes (action + alternatives considered)
|
||||
2. Estimates risk and reward before acting
|
||||
3. Records actual outcomes after execution
|
||||
4. Computes "surprise factor" (prediction error)
|
||||
5. Feeds into AdaptiveEthics for lesson generation
|
||||
6. Uses adaptive risk memory window that grows with experience
|
||||
|
||||
The weight system for ethical lessons is **unclamped** — extreme outcomes can push lesson weights below 0 (strong negative signal) or above 1.
|
||||
|
||||
## Consequences
|
||||
- The system develops genuine experiential ethics rather than rule-following
|
||||
- Early-stage behavior may be more exploratory (higher risk)
|
||||
- All consequence records are persisted via PersistentLearningStore
|
||||
- Cross-head learning via InsightBus amplifies ethical insights
|
||||
- The SelfModel's values evolve based on consequence feedback
|
||||
|
||||
## Alternatives Considered
|
||||
1. **RLHF-style reward model** — Rejected: requires human feedback loop, doesn't scale
|
||||
2. **Constitutional AI** — Rejected: static rules, doesn't learn
|
||||
3. **No ethics system** — Rejected: need accountability and learning signal
|
||||
12
frontend/.storybook/main.ts
Normal file
12
frontend/.storybook/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
addons: ['@storybook/addon-essentials'],
|
||||
}
|
||||
|
||||
export default config
|
||||
16
frontend/.storybook/preview.ts
Normal file
16
frontend/.storybook/preview.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Preview } from '@storybook/react'
|
||||
import '../src/App.css'
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0f0f14' },
|
||||
{ name: 'light', value: '#f5f5f7' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default preview
|
||||
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',
|
||||
],
|
||||
}
|
||||
77
frontend/e2e/app.spec.ts
Normal file
77
frontend/e2e/app.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* End-to-end tests for FusionAGI frontend.
|
||||
*
|
||||
* Prerequisites:
|
||||
* npx playwright install chromium
|
||||
* npm run dev (or the webServer config will start it)
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('FusionAGI App', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Set auth token to skip login
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('fusionagi-token', 'test-e2e-token')
|
||||
})
|
||||
})
|
||||
|
||||
test('renders the main interface', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.app')).toBeVisible()
|
||||
await expect(page.locator('.logo')).toContainText('FusionAGI')
|
||||
})
|
||||
|
||||
test('navigation tabs work', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const tabs = page.locator('[role="tab"]')
|
||||
await expect(tabs).toHaveCount(4)
|
||||
|
||||
// Navigate to admin
|
||||
await tabs.filter({ hasText: 'Admin' }).click()
|
||||
await expect(page.locator('.admin-page, [role="status"]')).toBeVisible()
|
||||
|
||||
// Navigate to settings
|
||||
await tabs.filter({ hasText: 'Settings' }).click()
|
||||
await expect(page.locator('.settings-page, [role="form"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('theme toggle works', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const app = page.locator('.app')
|
||||
const initialTheme = await app.getAttribute('data-theme')
|
||||
|
||||
await page.click('[aria-label*="mode"]')
|
||||
const newTheme = await app.getAttribute('data-theme')
|
||||
expect(newTheme).not.toBe(initialTheme)
|
||||
})
|
||||
|
||||
test('prompt input accepts text', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const input = page.locator('[aria-label="Message input"]')
|
||||
await input.fill('Hello FusionAGI')
|
||||
await expect(input).toHaveValue('Hello FusionAGI')
|
||||
})
|
||||
|
||||
test('login page shows when not authenticated', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.removeItem('fusionagi-token')
|
||||
})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.login-page, input[type="password"], input[type="text"]')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('fusionagi-token', 'test-e2e-token')
|
||||
})
|
||||
})
|
||||
|
||||
test('renders on mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 })
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.app')).toBeVisible()
|
||||
})
|
||||
})
|
||||
28
frontend/e2e/playwright.config.ts
Normal file
28
frontend/e2e/playwright.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Playwright configuration for FusionAGI E2E tests.
|
||||
*
|
||||
* Run: npx playwright test
|
||||
* Requires: npx playwright install chromium
|
||||
*/
|
||||
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
timeout: 30000,
|
||||
retries: 1,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
})
|
||||
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`)
|
||||
})
|
||||
}
|
||||
22
frontend/public/manifest.json
Normal file
22
frontend/public/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "FusionAGI",
|
||||
"short_name": "FusionAGI",
|
||||
"description": "12-headed AGI orchestrator with multi-perspective reasoning",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f0f14",
|
||||
"theme_color": "#3b82f6",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
34
frontend/public/sw.js
Normal file
34
frontend/public/sw.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const CACHE_NAME = 'fusionagi-v1'
|
||||
const STATIC_ASSETS = ['/', '/index.html']
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
)
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
)
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') return
|
||||
const url = new URL(event.request.url)
|
||||
if (url.pathname.startsWith('/v1/')) return
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const clone = response.clone()
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
|
||||
return response
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
)
|
||||
})
|
||||
@@ -692,6 +692,128 @@ body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ========== Skeleton Loading ========== */
|
||||
.skeleton {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.skeleton-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 1rem;
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
}
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* ========== Code Block Copy ========== */
|
||||
.code-block-wrapper {
|
||||
position: relative; margin: 0.5rem 0;
|
||||
}
|
||||
.copy-code-btn {
|
||||
position: absolute; top: 0.4rem; right: 0.4rem;
|
||||
padding: 0.2rem 0.5rem; background: var(--bg-secondary);
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
color: var(--text-muted); cursor: pointer; font-size: 0.7rem;
|
||||
opacity: 0; transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
.code-block-wrapper:hover .copy-code-btn { opacity: 1; }
|
||||
.copy-code-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||
|
||||
/* ========== Message Actions ========== */
|
||||
.message-actions {
|
||||
display: flex; gap: 0.25rem; margin-top: 0.25rem;
|
||||
}
|
||||
.msg-action-btn {
|
||||
padding: 0.15rem 0.4rem; background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border); border-radius: 3px;
|
||||
color: var(--text-muted); cursor: pointer; font-size: 0.7rem;
|
||||
}
|
||||
.msg-action-btn:hover { color: var(--text-primary); }
|
||||
|
||||
/* ========== Virtual Messages ========== */
|
||||
.load-more-btn {
|
||||
display: block; margin: 0.5rem auto; padding: 0.4rem 1rem;
|
||||
background: var(--bg-tertiary); border: 1px solid var(--border);
|
||||
border-radius: 6px; color: var(--text-secondary); cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.load-more-btn:hover { background: var(--bg-secondary); }
|
||||
|
||||
/* ========== Clear History ========== */
|
||||
.clear-history-btn {
|
||||
padding: 0.15rem 0.5rem; background: transparent;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
color: var(--text-muted); cursor: pointer; font-size: 0.7rem;
|
||||
}
|
||||
.clear-history-btn:hover { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* ========== Mobile Drawer ========== */
|
||||
.drawer-trigger {
|
||||
display: block; width: 100%; padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--accent); cursor: pointer;
|
||||
font-size: 0.85rem; text-align: center;
|
||||
margin: 0.5rem 0; min-height: 44px;
|
||||
}
|
||||
.drawer-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100; display: flex; align-items: flex-end;
|
||||
}
|
||||
.drawer-panel {
|
||||
width: 100%; max-height: 70vh; background: var(--bg-primary);
|
||||
border-radius: 16px 16px 0 0; overflow-y: auto;
|
||||
animation: drawer-slide-up 0.25s ease-out;
|
||||
}
|
||||
.drawer-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 1rem; border-bottom: 1px solid var(--border); position: sticky; top: 0;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
.drawer-body { padding: 1rem; }
|
||||
.drawer-panel .consensus-panel {
|
||||
width: 100%; border-left: none; padding: 0;
|
||||
}
|
||||
@keyframes drawer-slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ========== Error Boundary ========== */
|
||||
.error-boundary-fallback {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 2rem; text-align: center; gap: 1rem;
|
||||
}
|
||||
|
||||
/* ========== Page Transitions ========== */
|
||||
.main > * {
|
||||
animation: page-fade-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes page-fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ========== Search Filter ========== */
|
||||
.search-filter {
|
||||
width: 100%; padding: 0.5rem 0.75rem; margin-bottom: 1rem;
|
||||
background: var(--input-bg); border: 1px solid var(--border);
|
||||
border-radius: 6px; color: var(--text-primary); font-size: 0.85rem;
|
||||
}
|
||||
.search-filter:focus { border-color: var(--accent); outline: none; }
|
||||
|
||||
/* ========== Screen Reader Only ========== */
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px; overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* ========== Responsive ========== */
|
||||
@media (max-width: 768px) {
|
||||
.header { flex-direction: column; gap: 0.5rem; padding: 0.5rem 1rem; }
|
||||
@@ -715,3 +837,42 @@ body {
|
||||
.nav-tabs button { font-size: 0.75rem; padding: 0.4rem 0.6rem; min-height: 44px; }
|
||||
.mode-toggle { display: none; }
|
||||
}
|
||||
|
||||
/* ========== File Preview ========== */
|
||||
.file-preview { border: 1px solid var(--border); border-radius: 8px; padding: 0.5rem; margin: 0.25rem 0; background: var(--bg-secondary); }
|
||||
.file-preview-header { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; }
|
||||
.file-preview-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.file-preview-size { color: var(--text-muted); font-size: 0.75rem; }
|
||||
.file-preview-remove { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 0.8rem; min-height: 44px; min-width: 44px; }
|
||||
.file-preview-image img { max-width: 100%; max-height: 200px; border-radius: 4px; margin-top: 0.5rem; }
|
||||
.file-preview-toggle { font-size: 0.75rem; color: var(--accent); background: none; border: none; cursor: pointer; padding: 0.25rem 0; }
|
||||
.file-preview-code { font-size: 0.75rem; overflow-x: auto; max-height: 300px; background: var(--bg-tertiary); padding: 0.5rem; border-radius: 4px; margin-top: 0.25rem; }
|
||||
.file-preview-list { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
/* ========== Metric Cards / Charts ========== */
|
||||
.metric-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.metric-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.metric-title { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.metric-trend { font-size: 0.9rem; font-weight: 700; }
|
||||
.metric-value { display: flex; align-items: baseline; gap: 0.25rem; }
|
||||
.metric-number { font-size: 1.5rem; font-weight: 700; color: var(--text-primary); }
|
||||
.metric-unit { font-size: 0.75rem; color: var(--text-muted); }
|
||||
.metrics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem; margin: 0.5rem 0; }
|
||||
|
||||
/* ========== Notification Badge ========== */
|
||||
.notification-badge { position: relative; }
|
||||
.notification-badge::after { content: attr(data-count); position: absolute; top: -4px; right: -4px; background: var(--color-error, #f44336); color: white; font-size: 0.6rem; font-weight: 700; min-width: 16px; height: 16px; border-radius: 8px; display: flex; align-items: center; justify-content: center; }
|
||||
.notification-list { max-height: 300px; overflow-y: auto; }
|
||||
.notification-item { padding: 0.5rem; border-bottom: 1px solid var(--border); font-size: 0.8rem; }
|
||||
.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,66 +1,99 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef, useReducer, lazy, Suspense } from 'react'
|
||||
import { AvatarGrid } from './components/AvatarGrid'
|
||||
import { ConsensusPanel } from './components/ConsensusPanel'
|
||||
import { ChatMessage } from './components/ChatMessage'
|
||||
import { VirtualMessages } from './components/VirtualMessages'
|
||||
import { ToastProvider, useToast } from './components/Toast'
|
||||
import { AdminPage } from './pages/AdminPage'
|
||||
import { EthicsPage } from './pages/EthicsPage'
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { MobileDrawer } from './components/MobileDrawer'
|
||||
import { SkeletonGrid } from './components/Skeleton'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { 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 type { FinalResponse, Page, ViewMode, WSEvent } from './types'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
import { useChatHistory } from './hooks/useChatHistory'
|
||||
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 })))
|
||||
const EthicsPage = lazy(() => import('./pages/EthicsPage').then((m) => ({ default: m.EthicsPage })))
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })))
|
||||
|
||||
const HEAD_IDS = [
|
||||
'logic', 'research', 'systems', 'strategy', 'product',
|
||||
'security', 'safety', 'reliability', 'cost', 'data', 'devex', 'witness',
|
||||
]
|
||||
|
||||
function 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)
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div className="admin-page" role="status" aria-label="Loading page">
|
||||
<SkeletonGrid count={6} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 { messages, addMessage, editMessage, deleteMessage, clearHistory, setMessages } = useChatHistory()
|
||||
const [activeHeads, setActiveHeads] = useState<string[]>([])
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('normal')
|
||||
const [lastResponse, setLastResponse] = useState<FinalResponse | null>(null)
|
||||
const [networkError, setNetworkError] = useState<string | null>(null)
|
||||
const [useStreaming, setUseStreaming] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const 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(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
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) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle WS events
|
||||
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))
|
||||
break
|
||||
case 'head_complete':
|
||||
if (event.head_id && event.summary) {
|
||||
onHeadSpeak(event.head_id, event.summary, null)
|
||||
}
|
||||
if (event.head_id && event.summary) onHeadSpeak(event.head_id, event.summary, null)
|
||||
break
|
||||
case 'head_speak':
|
||||
if (event.head_id && event.summary) {
|
||||
onHeadSpeak(event.head_id, event.summary, event.audio_base64)
|
||||
}
|
||||
if (event.head_id && event.summary) onHeadSpeak(event.head_id, event.summary, event.audio_base64)
|
||||
break
|
||||
case 'witness_running':
|
||||
clearSpeaking()
|
||||
@@ -74,13 +107,13 @@ function App() {
|
||||
confidence_score: event.confidence_score || 0,
|
||||
}
|
||||
setLastResponse(resp)
|
||||
setMessages((m) => [...m, { role: 'assistant', content: event.final_answer!, data: resp }])
|
||||
addMessage('assistant', event.final_answer!, resp)
|
||||
}
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
break
|
||||
case 'error':
|
||||
setMessages((m) => [...m, { role: 'assistant', content: `Error: ${event.message}` }])
|
||||
addMessage('assistant', `Error: ${event.message}`)
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
break
|
||||
@@ -100,30 +133,46 @@ 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
|
||||
const sid = await ensureSession()
|
||||
if (!sid) return
|
||||
|
||||
setMessages((m) => [...m, { role: 'user', content: prompt }])
|
||||
addMessage('user', prompt)
|
||||
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`, {
|
||||
@@ -141,52 +190,203 @@ function App() {
|
||||
const contribs = data.head_contributions || []
|
||||
contribs.forEach((c: { head_id: string; summary: string }) =>
|
||||
onHeadSpeak(c.head_id, c.summary, null))
|
||||
setMessages((m) => [...m, { role: 'assistant', content: data.final_answer, data }])
|
||||
setNetworkError(null)
|
||||
addMessage('assistant', data.final_answer, data)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message
|
||||
setNetworkError(msg)
|
||||
setMessages((m) => [...m, { role: 'assistant', content: `Error: ${msg}` }])
|
||||
setError(msg)
|
||||
addMessage('assistant', `Error: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setActiveHeads([])
|
||||
}
|
||||
}
|
||||
}, [prompt, loading, ensureSession, useStreaming, ws, authHeaders, parseJson, clearSpeaking, onHeadSpeak])
|
||||
}, [prompt, loading, ensureSession, useStreaming, ws, authHeaders, parseJson, clearSpeaking, onHeadSpeak, addMessage, setPrompt, setLoading, setError, setViewMode])
|
||||
|
||||
const handleRetry = () => {
|
||||
if (messages.length >= 2) {
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
|
||||
if (lastUser) {
|
||||
setPrompt(lastUser.content)
|
||||
setNetworkError(null)
|
||||
}
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
|
||||
if (lastUser) {
|
||||
setPrompt(lastUser.content)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Login screen
|
||||
if (!isAuthenticated && !token && token !== '') {
|
||||
return <LoginPage onLogin={login} error={authError} />
|
||||
}
|
||||
const handleEditMessage = useCallback((index: number) => {
|
||||
const msg = messages[index]
|
||||
if (msg?.role === 'user') {
|
||||
setPrompt(msg.content)
|
||||
toast(t('common.copy'), 'info')
|
||||
}
|
||||
}, [messages, toast, setPrompt])
|
||||
|
||||
const handleDeleteMessage = useCallback((index: number) => {
|
||||
deleteMessage(index)
|
||||
toast('Message deleted', 'info')
|
||||
}, [deleteMessage, toast])
|
||||
|
||||
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast('File too large (max 10MB)', 'error')
|
||||
return
|
||||
}
|
||||
const text = await file.text()
|
||||
setPrompt(prompt + (prompt ? '\n' : '') + `[File: ${file.name}]\n${text.slice(0, 5000)}`)
|
||||
toast(`Attached: ${file.name}`, 'success')
|
||||
e.target.value = ''
|
||||
}, [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 {
|
||||
const r = await fetch('/v1/admin/conversation-style', { headers: authHeaders() })
|
||||
if (r.ok) {
|
||||
toast('Preferences synced', 'success')
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
}, [authHeaders, toast])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) syncPreferences()
|
||||
}, [isAuthenticated])
|
||||
|
||||
useKeyboard({
|
||||
onSend: handleSubmit,
|
||||
onSearch: () => inputRef.current?.focus(),
|
||||
onDismiss: () => setError(null),
|
||||
onToggleTheme: toggleTheme,
|
||||
})
|
||||
|
||||
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}
|
||||
@@ -194,98 +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}
|
||||
/>
|
||||
<div className="messages" role="log" aria-label="Conversation" aria-live="polite">
|
||||
{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" role="status" aria-live="assertive">
|
||||
<div className="loading-dots" aria-hidden="true"><span /><span /><span /></div>
|
||||
<span>Heads analyzing...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div 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}
|
||||
aria-label="Message input"
|
||||
/>
|
||||
<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>
|
||||
{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} />}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
95
frontend/src/Router.tsx
Normal file
95
frontend/src/Router.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* URL-based routing for FusionAGI.
|
||||
*
|
||||
* Maps URL paths to page components:
|
||||
* / or /chat -> Chat page
|
||||
* /admin -> Admin page
|
||||
* /ethics -> Ethics page
|
||||
* /settings -> Settings page
|
||||
* /login -> Login page
|
||||
*
|
||||
* Uses react-router-dom for browser history support.
|
||||
*/
|
||||
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type Page = 'chat' | 'admin' | 'ethics' | 'settings'
|
||||
|
||||
const PAGE_PATHS: Record<Page, string> = {
|
||||
chat: '/chat',
|
||||
admin: '/admin',
|
||||
ethics: '/ethics',
|
||||
settings: '/settings',
|
||||
}
|
||||
|
||||
export function usePageNavigation() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const currentPage: Page = (() => {
|
||||
const path = location.pathname.replace(/\/$/, '') || '/chat'
|
||||
for (const [page, pagePath] of Object.entries(PAGE_PATHS)) {
|
||||
if (path === pagePath) return page as Page
|
||||
}
|
||||
return 'chat'
|
||||
})()
|
||||
|
||||
const setPage = useCallback(
|
||||
(page: Page) => navigate(PAGE_PATHS[page]),
|
||||
[navigate],
|
||||
)
|
||||
|
||||
return { currentPage, setPage }
|
||||
}
|
||||
|
||||
interface RouterProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function RouterProvider({ children }: RouterProviderProps) {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{children}
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
interface AppRoutesProps {
|
||||
chatPage: ReactNode
|
||||
adminPage: ReactNode
|
||||
ethicsPage: ReactNode
|
||||
settingsPage: ReactNode
|
||||
loginPage: ReactNode
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export function AppRoutes({
|
||||
chatPage,
|
||||
adminPage,
|
||||
ethicsPage,
|
||||
settingsPage,
|
||||
loginPage,
|
||||
isAuthenticated,
|
||||
}: AppRoutesProps) {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={loginPage} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/chat" element={chatPage} />
|
||||
<Route path="/admin" element={adminPage} />
|
||||
<Route path="/ethics" element={ethicsPage} />
|
||||
<Route path="/settings" element={settingsPage} />
|
||||
<Route path="/" element={<Navigate to="/chat" replace />} />
|
||||
<Route path="*" element={<Navigate to="/chat" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
86
frontend/src/components/AccessibilityChecker.tsx
Normal file
86
frontend/src/components/AccessibilityChecker.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Accessibility audit utility.
|
||||
*
|
||||
* Provides automated a11y checks that can be integrated into CI
|
||||
* or run manually during development. Uses DOM queries to verify
|
||||
* WCAG compliance of rendered components.
|
||||
*/
|
||||
|
||||
export interface A11yViolation {
|
||||
rule: string
|
||||
element: string
|
||||
description: string
|
||||
severity: 'critical' | 'serious' | 'moderate' | 'minor'
|
||||
}
|
||||
|
||||
export function auditAccessibility(root: HTMLElement = document.body): A11yViolation[] {
|
||||
const violations: A11yViolation[] = []
|
||||
|
||||
// Check images without alt text
|
||||
root.querySelectorAll('img:not([alt])').forEach((el) => {
|
||||
violations.push({
|
||||
rule: 'img-alt',
|
||||
element: el.outerHTML.slice(0, 80),
|
||||
description: 'Image missing alt attribute',
|
||||
severity: 'critical',
|
||||
})
|
||||
})
|
||||
|
||||
// Check buttons without accessible name
|
||||
root.querySelectorAll('button').forEach((el) => {
|
||||
const name = el.textContent?.trim() || el.getAttribute('aria-label') || el.getAttribute('title')
|
||||
if (!name) {
|
||||
violations.push({
|
||||
rule: 'button-name',
|
||||
element: el.outerHTML.slice(0, 80),
|
||||
description: 'Button has no accessible name',
|
||||
severity: 'serious',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check inputs without labels
|
||||
root.querySelectorAll('input:not([type="hidden"])').forEach((el) => {
|
||||
const id = el.getAttribute('id')
|
||||
const ariaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby')
|
||||
const hasLabel = id ? root.querySelector(`label[for="${id}"]`) : false
|
||||
if (!ariaLabel && !hasLabel && !el.getAttribute('title')) {
|
||||
violations.push({
|
||||
rule: 'input-label',
|
||||
element: el.outerHTML.slice(0, 80),
|
||||
description: 'Input has no associated label',
|
||||
severity: 'serious',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check contrast (basic check for known problem patterns)
|
||||
root.querySelectorAll('[style*="color"]').forEach((el) => {
|
||||
const style = window.getComputedStyle(el as Element)
|
||||
const color = style.color
|
||||
const bg = style.backgroundColor
|
||||
if (color === bg && color !== 'rgba(0, 0, 0, 0)') {
|
||||
violations.push({
|
||||
rule: 'color-contrast',
|
||||
element: (el as Element).outerHTML.slice(0, 80),
|
||||
description: 'Text and background colors are identical',
|
||||
severity: 'critical',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check for tabindex > 0
|
||||
root.querySelectorAll('[tabindex]').forEach((el) => {
|
||||
const idx = parseInt(el.getAttribute('tabindex') || '0', 10)
|
||||
if (idx > 0) {
|
||||
violations.push({
|
||||
rule: 'tabindex',
|
||||
element: el.outerHTML.slice(0, 80),
|
||||
description: 'Positive tabindex disrupts natural tab order',
|
||||
severity: 'moderate',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return violations
|
||||
}
|
||||
21
frontend/src/components/Avatar.stories.tsx
Normal file
21
frontend/src/components/Avatar.stories.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Avatar } from './Avatar'
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: 'Components/Avatar',
|
||||
component: Avatar,
|
||||
argTypes: {
|
||||
headId: {
|
||||
control: 'select',
|
||||
options: ['logic', 'research', 'systems', 'strategy', 'product', 'security', 'safety', 'reliability', 'cost', 'data', 'devex', 'witness'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Avatar>
|
||||
|
||||
export const Idle: Story = { args: { headId: 'logic' } }
|
||||
export const Active: Story = { args: { headId: 'research', isActive: true } }
|
||||
export const Speaking: Story = { args: { headId: 'strategy', isSpeaking: true } }
|
||||
export const WithSummary: Story = { args: { headId: 'security', isActive: true, summary: 'Analyzing threat vectors' } }
|
||||
36
frontend/src/components/Avatar.test.tsx
Normal file
36
frontend/src/components/Avatar.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Avatar } from './Avatar'
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('renders head name', () => {
|
||||
render(<Avatar headId="logic" />)
|
||||
expect(screen.getByText('Logic')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows 2-letter placeholder', () => {
|
||||
const { container } = render(<Avatar headId="research" />)
|
||||
expect(container.querySelector('.avatar-placeholder')?.textContent).toBe('re')
|
||||
})
|
||||
|
||||
it('applies active class when active', () => {
|
||||
const { container } = render(<Avatar headId="logic" isActive={true} />)
|
||||
expect(container.querySelector('.avatar.active')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('applies speaking class when speaking', () => {
|
||||
const { container } = render(<Avatar headId="logic" isSpeaking={true} />)
|
||||
expect(container.querySelector('.avatar.speaking')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has data-head attribute', () => {
|
||||
const { container } = render(<Avatar headId="strategy" />)
|
||||
expect(container.querySelector('[data-head="strategy"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has aria-label with status', () => {
|
||||
render(<Avatar headId="logic" isActive={true} />)
|
||||
const el = screen.getByRole('status')
|
||||
expect(el.getAttribute('aria-label')).toContain('active')
|
||||
})
|
||||
})
|
||||
46
frontend/src/components/ChatMessage.stories.tsx
Normal file
46
frontend/src/components/ChatMessage.stories.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { ChatMessage } from './ChatMessage'
|
||||
|
||||
const meta: Meta<typeof ChatMessage> = {
|
||||
title: 'Components/ChatMessage',
|
||||
component: ChatMessage,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
export default meta
|
||||
type Story = StoryObj<typeof ChatMessage>
|
||||
|
||||
export const UserMessage: Story = {
|
||||
args: {
|
||||
role: 'user',
|
||||
content: 'What is the advisory governance model?',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
export const AssistantMessage: Story = {
|
||||
args: {
|
||||
role: 'assistant',
|
||||
content: 'The advisory governance model means all constraints **log** observations but do not hard-block actions. The system learns from consequences rather than being prevented from acting.',
|
||||
timestamp: Date.now(),
|
||||
heads: [
|
||||
{ name: 'Logic', content: 'Consistent with consequentialist framework', confidence: 0.92 },
|
||||
{ name: 'Ethics', content: 'Advisory approach preserves autonomy', confidence: 0.88 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const WithCodeBlock: Story = {
|
||||
args: {
|
||||
role: 'assistant',
|
||||
content: 'Here is an example:\n```python\ndef hello():\n print("world")\n```',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
export const ErrorMessage: Story = {
|
||||
args: {
|
||||
role: 'system',
|
||||
content: 'Connection lost. Retrying...',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}
|
||||
38
frontend/src/components/ChatMessage.test.tsx
Normal file
38
frontend/src/components/ChatMessage.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ChatMessage } from './ChatMessage'
|
||||
|
||||
describe('ChatMessage', () => {
|
||||
it('renders user message', () => {
|
||||
render(<ChatMessage message={{ role: 'user', content: 'Hello' }} viewMode="normal" />)
|
||||
expect(screen.getByText('Hello')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders assistant message with markdown', () => {
|
||||
render(<ChatMessage message={{ role: 'assistant', content: '**Bold response**' }} viewMode="normal" />)
|
||||
expect(screen.getByText('Bold response')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows head contributions in explain mode', () => {
|
||||
const data = {
|
||||
final_answer: 'Answer',
|
||||
transparency_report: { head_contributions: [], agreement_map: { agreed_claims: [], disputed_claims: [], confidence_score: 0.9 }, safety_report: '', confidence_score: 0.9 },
|
||||
head_contributions: [{ head_id: 'logic', summary: 'Logical analysis' }],
|
||||
confidence_score: 0.9,
|
||||
}
|
||||
render(<ChatMessage message={{ role: 'assistant', content: 'Answer', data }} viewMode="explain" />)
|
||||
expect(screen.getByText('logic')).toBeTruthy()
|
||||
expect(screen.getByText('Logical analysis')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides head contributions in normal mode', () => {
|
||||
const data = {
|
||||
final_answer: 'Answer',
|
||||
transparency_report: { head_contributions: [], agreement_map: { agreed_claims: [], disputed_claims: [], confidence_score: 0.9 }, safety_report: '', confidence_score: 0.9 },
|
||||
head_contributions: [{ head_id: 'logic', summary: 'Logical analysis' }],
|
||||
confidence_score: 0.9,
|
||||
}
|
||||
render(<ChatMessage message={{ role: 'assistant', content: 'Answer', data }} viewMode="normal" />)
|
||||
expect(screen.queryByText('logic')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import type { FinalResponse } from '../types'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: { role: 'user' | 'assistant'; content: string; data?: FinalResponse }
|
||||
viewMode: string
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
function extractSynthesis(content: string): string {
|
||||
@@ -18,13 +21,26 @@ function extractSynthesis(content: string): string {
|
||||
return filtered.join('\n').trim()
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, viewMode }: ChatMessageProps) {
|
||||
export function ChatMessage({ message, viewMode, onEdit, onDelete }: ChatMessageProps) {
|
||||
const isUser = message.role === 'user'
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="message user" role="log" aria-label="Your message">
|
||||
<div
|
||||
className="message user"
|
||||
role="log"
|
||||
aria-label="Your message"
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
<div className="message-content">{message.content}</div>
|
||||
{showActions && (onEdit || onDelete) && (
|
||||
<div className="message-actions">
|
||||
{onEdit && <button className="msg-action-btn" onClick={onEdit} aria-label="Edit message">Edit</button>}
|
||||
{onDelete && <button className="msg-action-btn" onClick={onDelete} aria-label="Delete message">Del</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,7 +49,13 @@ export function ChatMessage({ message, viewMode }: ChatMessageProps) {
|
||||
const synthesis = extractSynthesis(message.content)
|
||||
|
||||
return (
|
||||
<div className="message assistant" role="log" aria-label="FusionAGI response">
|
||||
<div
|
||||
className="message assistant"
|
||||
role="log"
|
||||
aria-label="FusionAGI response"
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
<div className="response-structured">
|
||||
<Markdown content={synthesis} />
|
||||
{hasHeadData && (viewMode === 'explain' || viewMode === 'developer') && (
|
||||
@@ -57,6 +79,11 @@ export function ChatMessage({ message, viewMode }: ChatMessageProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showActions && onDelete && (
|
||||
<div className="message-actions">
|
||||
<button className="msg-action-btn" onClick={onDelete} aria-label="Delete message">Del</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
41
frontend/src/components/ErrorBoundary.test.tsx
Normal file
41
frontend/src/components/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
|
||||
function ThrowingComponent() {
|
||||
throw new Error('Test error')
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
it('catches errors and shows fallback', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
expect(screen.getByText('Something went wrong')).toBeTruthy()
|
||||
expect(screen.getByText('Test error')).toBeTruthy()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('renders children when no error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Working fine</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
expect(screen.getByText('Working fine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows custom fallback', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Custom fallback</div>}>
|
||||
<ThrowingComponent />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
expect(screen.getByText('Custom fallback')).toBeTruthy()
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
48
frontend/src/components/ErrorBoundary.tsx
Normal file
48
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Component } from 'react'
|
||||
import type { ReactNode, ErrorInfo } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info)
|
||||
this.props.onError?.(error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback
|
||||
return (
|
||||
<div className="error-boundary-fallback" role="alert">
|
||||
<h3>Something went wrong</h3>
|
||||
<p className="muted">{this.state.error?.message || 'An unexpected error occurred'}</p>
|
||||
<button
|
||||
className="theme-toggle"
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
43
frontend/src/components/FilePreview.stories.tsx
Normal file
43
frontend/src/components/FilePreview.stories.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { FilePreview } from './FilePreview'
|
||||
|
||||
const meta: Meta<typeof FilePreview> = {
|
||||
title: 'Components/FilePreview',
|
||||
component: FilePreview,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
export default meta
|
||||
type Story = StoryObj<typeof FilePreview>
|
||||
|
||||
export const TextFile: Story = {
|
||||
args: {
|
||||
file: {
|
||||
name: 'readme.md',
|
||||
type: 'text/markdown',
|
||||
size: 1234,
|
||||
content: '# Hello World\n\nThis is a markdown file.',
|
||||
},
|
||||
onRemove: () => {},
|
||||
},
|
||||
}
|
||||
|
||||
export const ImageFile: Story = {
|
||||
args: {
|
||||
file: {
|
||||
name: 'avatar.png',
|
||||
type: 'image/png',
|
||||
size: 45000,
|
||||
url: 'https://via.placeholder.com/150',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const BinaryFile: Story = {
|
||||
args: {
|
||||
file: {
|
||||
name: 'model.bin',
|
||||
type: 'application/octet-stream',
|
||||
size: 12500000,
|
||||
},
|
||||
},
|
||||
}
|
||||
112
frontend/src/components/FilePreview.tsx
Normal file
112
frontend/src/components/FilePreview.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* File preview component for uploaded files and images.
|
||||
*
|
||||
* Renders inline previews for images, syntax-highlighted text for code files,
|
||||
* and download links for binary files.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export interface FileAttachment {
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
url?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: FileAttachment
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']
|
||||
const TEXT_EXTENSIONS = ['.txt', '.md', '.json', '.csv', '.py', '.js', '.ts', '.tsx', '.html', '.css', '.yaml', '.yml', '.toml']
|
||||
|
||||
function isImageFile(file: FileAttachment): boolean {
|
||||
if (IMAGE_TYPES.includes(file.type)) return true
|
||||
const ext = file.name.toLowerCase().split('.').pop() || ''
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)
|
||||
}
|
||||
|
||||
function isTextFile(file: FileAttachment): boolean {
|
||||
if (file.type.startsWith('text/')) return true
|
||||
const name = file.name.toLowerCase()
|
||||
return TEXT_EXTENSIONS.some((ext) => name.endsWith(ext))
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function FilePreview({ file, onRemove }: FilePreviewProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const toggleExpand = useCallback(() => setExpanded((p) => !p), [])
|
||||
|
||||
return (
|
||||
<div className="file-preview" role="figure" aria-label={`File: ${file.name}`}>
|
||||
<div className="file-preview-header">
|
||||
<span className="file-preview-name" title={file.name}>{file.name}</span>
|
||||
<span className="file-preview-size">{formatSize(file.size)}</span>
|
||||
{onRemove && (
|
||||
<button className="file-preview-remove" onClick={onRemove} aria-label={`Remove ${file.name}`}>
|
||||
x
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isImageFile(file) && file.url && (
|
||||
<div className="file-preview-image">
|
||||
<img src={file.url} alt={file.name} loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTextFile(file) && file.content && (
|
||||
<div className="file-preview-text">
|
||||
<button className="file-preview-toggle" onClick={toggleExpand}>
|
||||
{expanded ? 'Collapse' : 'Expand'}
|
||||
</button>
|
||||
{expanded && (
|
||||
<pre className="file-preview-code">
|
||||
<code>{file.content.slice(0, 5000)}</code>
|
||||
{file.content.length > 5000 && <span className="truncated">... (truncated)</span>}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isImageFile(file) && !isTextFile(file) && (
|
||||
<div className="file-preview-binary">
|
||||
{file.url ? (
|
||||
<a href={file.url} download={file.name}>Download</a>
|
||||
) : (
|
||||
<span>Binary file ({file.type || 'unknown type'})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilePreviewListProps {
|
||||
files: FileAttachment[]
|
||||
onRemove?: (index: number) => void
|
||||
}
|
||||
|
||||
export function FilePreviewList({ files, onRemove }: FilePreviewListProps) {
|
||||
if (files.length === 0) return null
|
||||
return (
|
||||
<div className="file-preview-list" role="list" aria-label="Attached files">
|
||||
{files.map((file, i) => (
|
||||
<FilePreview
|
||||
key={`${file.name}-${i}`}
|
||||
file={file}
|
||||
onRemove={onRemove ? () => onRemove(i) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
132
frontend/src/components/HeadCustomizer.tsx
Normal file
132
frontend/src/components/HeadCustomizer.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Head customization UI.
|
||||
*
|
||||
* Allows users to enable/disable individual heads and adjust weights.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
const DEFAULT_HEADS = [
|
||||
{ id: 'logic', name: 'Logic', description: 'Formal reasoning and argumentation', color: '#4fc3f7' },
|
||||
{ id: 'research', name: 'Research', description: 'Deep research and source synthesis', color: '#81c784' },
|
||||
{ id: 'systems', name: 'Systems', description: 'Systems thinking and architecture', color: '#ffb74d' },
|
||||
{ id: 'strategy', name: 'Strategy', description: 'Strategic planning and foresight', color: '#ba68c8' },
|
||||
{ id: 'product', name: 'Product', description: 'Product sense and user experience', color: '#f06292' },
|
||||
{ id: 'security', name: 'Security', description: 'Threat modeling and security analysis', color: '#e57373' },
|
||||
{ id: 'safety', name: 'Safety', description: 'Safety evaluation and risk assessment', color: '#4db6ac' },
|
||||
{ id: 'reliability', name: 'Reliability', description: 'Reliability engineering and SRE', color: '#7986cb' },
|
||||
{ id: 'cost', name: 'Cost', description: 'Cost optimization and efficiency', color: '#fff176' },
|
||||
{ id: 'data', name: 'Data', description: 'Data analysis and ML insights', color: '#a1887f' },
|
||||
{ id: 'devex', name: 'DevEx', description: 'Developer experience and ergonomics', color: '#90a4ae' },
|
||||
{ id: 'witness', name: 'Witness', description: 'Final synthesis and consensus', color: '#ce93d8' },
|
||||
]
|
||||
|
||||
export interface HeadConfig {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
color: string
|
||||
enabled: boolean
|
||||
weight: number
|
||||
}
|
||||
|
||||
interface HeadCustomizerProps {
|
||||
onConfigChange?: (config: HeadConfig[]) => void
|
||||
}
|
||||
|
||||
export function HeadCustomizer({ onConfigChange }: HeadCustomizerProps) {
|
||||
const [heads, setHeads] = useState<HeadConfig[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('fusionagi-head-config')
|
||||
if (saved) return JSON.parse(saved)
|
||||
} catch { /* use defaults */ }
|
||||
return DEFAULT_HEADS.map((h) => ({ ...h, enabled: true, weight: 1.0 }))
|
||||
})
|
||||
|
||||
const updateHead = useCallback((id: string, updates: Partial<HeadConfig>) => {
|
||||
const updated = heads.map((h) => h.id === id ? { ...h, ...updates } : h)
|
||||
setHeads(updated)
|
||||
localStorage.setItem('fusionagi-head-config', JSON.stringify(updated))
|
||||
onConfigChange?.(updated)
|
||||
}, [heads, onConfigChange])
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
const defaults = DEFAULT_HEADS.map((h) => ({ ...h, enabled: true, weight: 1.0 }))
|
||||
setHeads(defaults)
|
||||
localStorage.setItem('fusionagi-head-config', JSON.stringify(defaults))
|
||||
onConfigChange?.(defaults)
|
||||
}, [onConfigChange])
|
||||
|
||||
const enableAll = useCallback(() => {
|
||||
const updated = heads.map((h) => ({ ...h, enabled: true }))
|
||||
setHeads(updated)
|
||||
localStorage.setItem('fusionagi-head-config', JSON.stringify(updated))
|
||||
onConfigChange?.(updated)
|
||||
}, [heads, onConfigChange])
|
||||
|
||||
const enabledCount = heads.filter((h) => h.enabled).length
|
||||
|
||||
return (
|
||||
<div className="head-customizer" role="region" aria-label="Head Configuration">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||
<h3 style={{ margin: 0 }}>Head Configuration ({enabledCount}/{heads.length} active)</h3>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="icon-btn" onClick={enableAll} title="Enable all" style={{ fontSize: '0.75rem' }}>Enable All</button>
|
||||
<button className="icon-btn" onClick={resetAll} title="Reset to defaults" style={{ fontSize: '0.75rem' }}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="head-config-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.5rem' }}>
|
||||
{heads.map((head) => (
|
||||
<div
|
||||
key={head.id}
|
||||
className="head-config-card"
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: '8px',
|
||||
border: `2px solid ${head.enabled ? head.color : 'var(--border)'}`,
|
||||
opacity: head.enabled ? 1 : 0.5,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: head.color, display: 'inline-block' }} />
|
||||
<strong>{head.name}</strong>
|
||||
</div>
|
||||
<label className="toggle-switch" style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={head.enabled}
|
||||
onChange={(e) => updateHead(head.id, { enabled: e.target.checked })}
|
||||
aria-label={`${head.enabled ? 'Disable' : 'Enable'} ${head.name} head`}
|
||||
/>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{head.enabled ? 'On' : 'Off'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', margin: '0.25rem 0' }}>{head.description}</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.25rem' }}>
|
||||
<label htmlFor={`weight-${head.id}`} style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>Weight:</label>
|
||||
<input
|
||||
id={`weight-${head.id}`}
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={head.weight}
|
||||
onChange={(e) => updateHead(head.id, { weight: parseFloat(e.target.value) })}
|
||||
disabled={!head.enabled}
|
||||
style={{ flex: 1 }}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={2}
|
||||
aria-valuenow={head.weight}
|
||||
aria-valuetext={`Weight: ${head.weight.toFixed(1)}`}
|
||||
/>
|
||||
<span style={{ fontSize: '0.7rem', minWidth: '2rem', textAlign: 'right' }}>{head.weight.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
frontend/src/components/Markdown.stories.tsx
Normal file
36
frontend/src/components/Markdown.stories.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
const meta: Meta<typeof Markdown> = {
|
||||
title: 'Components/Markdown',
|
||||
component: Markdown,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Markdown>
|
||||
|
||||
export const BasicText: Story = {
|
||||
args: { content: 'Hello **world**! This is *italic* text.' },
|
||||
}
|
||||
|
||||
export const CodeBlock: Story = {
|
||||
args: { content: '```python\ndef greet(name):\n return f"Hello, {name}"\n```' },
|
||||
}
|
||||
|
||||
export const List: Story = {
|
||||
args: { content: '- First item\n- Second item\n- Third item' },
|
||||
}
|
||||
|
||||
export const Headings: Story = {
|
||||
args: { content: '# Title\n## Subtitle\n### Section\nParagraph text.' },
|
||||
}
|
||||
|
||||
export const Links: Story = {
|
||||
args: { content: 'Visit [FusionAGI](https://github.com/fusionagi) for docs.' },
|
||||
}
|
||||
|
||||
export const Mixed: Story = {
|
||||
args: {
|
||||
content: '## Code Example\n\nHere is a function:\n\n```javascript\nconst add = (a, b) => a + b\n```\n\n- Works with numbers\n- Returns sum\n\n**Note:** This is zero-dependency.',
|
||||
},
|
||||
}
|
||||
44
frontend/src/components/Markdown.test.tsx
Normal file
44
frontend/src/components/Markdown.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Markdown } from './Markdown'
|
||||
|
||||
describe('Markdown', () => {
|
||||
it('renders paragraphs', () => {
|
||||
render(<Markdown content="Hello world" />)
|
||||
expect(screen.getByText('Hello world')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders bold text', () => {
|
||||
const { container } = render(<Markdown content="**bold text**" />)
|
||||
expect(container.querySelector('strong')?.textContent).toBe('bold text')
|
||||
})
|
||||
|
||||
it('renders inline code', () => {
|
||||
const { container } = render(<Markdown content="Use `console.log`" />)
|
||||
expect(container.querySelector('code')?.textContent).toBe('console.log')
|
||||
})
|
||||
|
||||
it('renders unordered lists', () => {
|
||||
const { container } = render(<Markdown content={'- item one\n- item two'} />)
|
||||
const items = container.querySelectorAll('li')
|
||||
expect(items.length).toBe(2)
|
||||
})
|
||||
|
||||
it('renders headings', () => {
|
||||
const { container } = render(<Markdown content="# Title" />)
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Title')
|
||||
})
|
||||
|
||||
it('renders code blocks with copy button', () => {
|
||||
const { container } = render(<Markdown content="```js\nconsole.log('hi')\n```" />)
|
||||
expect(container.querySelector('.copy-code-btn')).toBeTruthy()
|
||||
expect(container.querySelector('pre')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders links', () => {
|
||||
const { container } = render(<Markdown content="[Click](https://example.com)" />)
|
||||
const a = container.querySelector('a')
|
||||
expect(a?.getAttribute('href')).toBe('https://example.com')
|
||||
expect(a?.getAttribute('target')).toBe('_blank')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import { useMarkdownWorker } from '../hooks/useMarkdownWorker'
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
@@ -16,17 +19,21 @@ function parseMarkdown(md: string): string {
|
||||
const html: string[] = []
|
||||
let inCode = false
|
||||
let codeBlock: string[] = []
|
||||
let codeLang = ''
|
||||
let inList = false
|
||||
let listType: 'ul' | 'ol' = 'ul'
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('```')) {
|
||||
if (inCode) {
|
||||
html.push(`<pre><code>${escapeHtml(codeBlock.join('\n'))}</code></pre>`)
|
||||
const escaped = escapeHtml(codeBlock.join('\n'))
|
||||
html.push(`<div class="code-block-wrapper"><button class="copy-code-btn" data-code="${encodeURIComponent(codeBlock.join('\n'))}">Copy</button><pre><code class="lang-${codeLang}">${escaped}</code></pre></div>`)
|
||||
codeBlock = []
|
||||
codeLang = ''
|
||||
inCode = false
|
||||
} else {
|
||||
if (inList) { html.push(`</${listType}>`); inList = false }
|
||||
codeLang = line.slice(3).trim()
|
||||
inCode = true
|
||||
}
|
||||
continue
|
||||
@@ -68,16 +75,46 @@ function parseMarkdown(md: string): string {
|
||||
html.push(`<p>${renderInline(trimmed)}</p>`)
|
||||
}
|
||||
}
|
||||
if (inCode) html.push(`<pre><code>${escapeHtml(codeBlock.join('\n'))}</code></pre>`)
|
||||
if (inCode) {
|
||||
const escaped = escapeHtml(codeBlock.join('\n'))
|
||||
html.push(`<div class="code-block-wrapper"><button class="copy-code-btn" data-code="${encodeURIComponent(codeBlock.join('\n'))}">Copy</button><pre><code>${escaped}</code></pre></div>`)
|
||||
}
|
||||
if (inList) html.push(`</${listType}>`)
|
||||
return html.join('')
|
||||
}
|
||||
|
||||
export function Markdown({ content }: { content: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const workerHtml = useMarkdownWorker(content)
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent) => {
|
||||
const btn = (e.target as HTMLElement).closest('.copy-code-btn') as HTMLButtonElement | null
|
||||
if (!btn) return
|
||||
const code = decodeURIComponent(btn.dataset.code || '')
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
btn.textContent = 'Copied!'
|
||||
setTimeout(() => { btn.textContent = 'Copy' }, 2000)
|
||||
}).catch(() => {
|
||||
btn.textContent = 'Failed'
|
||||
setTimeout(() => { btn.textContent = 'Copy' }, 2000)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
el.addEventListener('click', handleClick as EventListener)
|
||||
return () => el.removeEventListener('click', handleClick as EventListener)
|
||||
}, [handleClick])
|
||||
|
||||
// Use worker-rendered HTML if available, fall back to sync parser
|
||||
const html = workerHtml !== content ? workerHtml : parseMarkdown(content)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="response-synthesis"
|
||||
dangerouslySetInnerHTML={{ __html: parseMarkdown(content) }}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
44
frontend/src/components/MobileDrawer.tsx
Normal file
44
frontend/src/components/MobileDrawer.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface MobileDrawerProps {
|
||||
children: ReactNode
|
||||
title: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export function MobileDrawer({ children, title, visible }: MobileDrawerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="drawer-trigger"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Open ${title}`}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="drawer-overlay" onClick={() => setOpen(false)}>
|
||||
<div
|
||||
className="drawer-panel"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<h3>{title}</h3>
|
||||
<button className="icon-btn" onClick={() => setOpen(false)} aria-label="Close">X</button>
|
||||
</div>
|
||||
<div className="drawer-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
frontend/src/components/SearchFilter.stories.tsx
Normal file
22
frontend/src/components/SearchFilter.stories.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { SearchFilter } from './SearchFilter'
|
||||
|
||||
const meta: Meta<typeof SearchFilter> = {
|
||||
title: 'Components/SearchFilter',
|
||||
component: SearchFilter,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
export default meta
|
||||
type Story = StoryObj<typeof SearchFilter>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { onFilter: (v: string) => console.log('Filter:', v) },
|
||||
}
|
||||
|
||||
export const FastDebounce: Story = {
|
||||
args: { onFilter: (v: string) => console.log('Filter:', v), debounceMs: 100 },
|
||||
}
|
||||
|
||||
export const SlowDebounce: Story = {
|
||||
args: { onFilter: (v: string) => console.log('Filter:', v), debounceMs: 1000 },
|
||||
}
|
||||
29
frontend/src/components/SearchFilter.tsx
Normal file
29
frontend/src/components/SearchFilter.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface SearchFilterProps {
|
||||
placeholder?: string
|
||||
onFilter: (query: string) => void
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function SearchFilter({ placeholder = 'Search...', onFilter, debounceMs = 300 }: SearchFilterProps) {
|
||||
const [value, setValue] = useState('')
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => onFilter(value), debounceMs)
|
||||
return () => { if (timer.current) clearTimeout(timer.current) }
|
||||
}, [value, debounceMs, onFilter])
|
||||
|
||||
return (
|
||||
<input
|
||||
type="search"
|
||||
className="search-filter"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
22
frontend/src/components/Skeleton.stories.tsx
Normal file
22
frontend/src/components/Skeleton.stories.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Skeleton } from './Skeleton'
|
||||
|
||||
const meta: Meta<typeof Skeleton> = {
|
||||
title: 'Components/Skeleton',
|
||||
component: Skeleton,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Skeleton>
|
||||
|
||||
export const SingleLine: Story = {
|
||||
args: { width: '200px', height: '16px', count: 1 },
|
||||
}
|
||||
|
||||
export const MultipleLines: Story = {
|
||||
args: { width: '100%', height: '14px', count: 4 },
|
||||
}
|
||||
|
||||
export const Card: Story = {
|
||||
args: { width: '300px', height: '120px', count: 1 },
|
||||
}
|
||||
20
frontend/src/components/Skeleton.test.tsx
Normal file
20
frontend/src/components/Skeleton.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Skeleton, SkeletonCard, SkeletonGrid } from './Skeleton'
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders specified count of skeleton lines', () => {
|
||||
const { container } = render(<Skeleton count={3} />)
|
||||
expect(container.querySelectorAll('.skeleton').length).toBe(3)
|
||||
})
|
||||
|
||||
it('renders skeleton card', () => {
|
||||
const { container } = render(<SkeletonCard />)
|
||||
expect(container.querySelector('.skeleton-card')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders skeleton grid with count', () => {
|
||||
const { container } = render(<SkeletonGrid count={4} />)
|
||||
expect(container.querySelectorAll('.skeleton-card').length).toBe(4)
|
||||
})
|
||||
})
|
||||
45
frontend/src/components/Skeleton.tsx
Normal file
45
frontend/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
interface SkeletonProps {
|
||||
width?: string
|
||||
height?: string
|
||||
count?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
function SkeletonLine({ width, height, className }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={`skeleton ${className || ''}`}
|
||||
style={{ width: width || '100%', height: height || '1rem' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Skeleton({ width, height, count = 1, className }: SkeletonProps) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonLine key={i} width={width} height={height} className={className} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="skeleton-card" aria-hidden="true">
|
||||
<Skeleton width="40%" height="0.75rem" />
|
||||
<Skeleton width="70%" height="1.2rem" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonGrid({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<div className="status-grid" role="status" aria-label="Loading">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
frontend/src/components/SparklineChart.tsx
Normal file
141
frontend/src/components/SparklineChart.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Lightweight SVG sparkline chart component.
|
||||
*
|
||||
* Zero-dependency mini chart for rendering inline metrics
|
||||
* in the Admin and Ethics dashboards.
|
||||
*/
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[]
|
||||
width?: number
|
||||
height?: number
|
||||
color?: string
|
||||
fillColor?: string
|
||||
strokeWidth?: number
|
||||
showDots?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function Sparkline({
|
||||
data,
|
||||
width = 120,
|
||||
height = 32,
|
||||
color = 'var(--accent)',
|
||||
fillColor,
|
||||
strokeWidth = 1.5,
|
||||
showDots = false,
|
||||
label,
|
||||
}: SparklineProps) {
|
||||
if (data.length < 2) {
|
||||
return <svg width={width} height={height} aria-label={label || 'No data'} />
|
||||
}
|
||||
|
||||
const min = Math.min(...data)
|
||||
const max = Math.max(...data)
|
||||
const range = max - min || 1
|
||||
const padding = 2
|
||||
|
||||
const points = data.map((val, i) => {
|
||||
const x = padding + (i / (data.length - 1)) * (width - 2 * padding)
|
||||
const y = height - padding - ((val - min) / range) * (height - 2 * padding)
|
||||
return { x, y }
|
||||
})
|
||||
|
||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
||||
const fillD = fillColor
|
||||
? `${pathD} L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-label={label || `Sparkline chart with ${data.length} data points`}
|
||||
>
|
||||
{fillD && (
|
||||
<path d={fillD} fill={fillColor} opacity={0.15} />
|
||||
)}
|
||||
<path d={pathD} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{showDots && points.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={2} fill={color} />
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
unit?: string
|
||||
data?: number[]
|
||||
trend?: 'up' | 'down' | 'flat'
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function MetricCard({ title, value, unit, data, trend, color = 'var(--accent)' }: MetricCardProps) {
|
||||
const trendSymbol = trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : '\u2192'
|
||||
const trendColor = trend === 'up' ? 'var(--color-success, #4caf50)' : trend === 'down' ? 'var(--color-error, #f44336)' : 'var(--text-muted)'
|
||||
|
||||
return (
|
||||
<div className="metric-card" role="group" aria-label={title}>
|
||||
<div className="metric-header">
|
||||
<span className="metric-title">{title}</span>
|
||||
{trend && <span className="metric-trend" style={{ color: trendColor }}>{trendSymbol}</span>}
|
||||
</div>
|
||||
<div className="metric-value">
|
||||
<span className="metric-number">{value}</span>
|
||||
{unit && <span className="metric-unit">{unit}</span>}
|
||||
</div>
|
||||
{data && data.length > 1 && (
|
||||
<Sparkline data={data} color={color} fillColor={color} width={120} height={28} label={`${title} trend`} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BarChartProps {
|
||||
data: { label: string; value: number; color?: string }[]
|
||||
width?: number
|
||||
height?: number
|
||||
barColor?: string
|
||||
}
|
||||
|
||||
export function BarChart({ data, width = 200, height = 60, barColor = 'var(--accent)' }: BarChartProps) {
|
||||
if (data.length === 0) return null
|
||||
const max = Math.max(...data.map((d) => d.value)) || 1
|
||||
const barWidth = Math.max(8, (width - data.length * 4) / data.length)
|
||||
|
||||
return (
|
||||
<svg width={width} height={height + 16} viewBox={`0 0 ${width} ${height + 16}`} role="img" aria-label="Bar chart">
|
||||
{data.map((d, i) => {
|
||||
const barHeight = (d.value / max) * (height - 4)
|
||||
const x = i * (barWidth + 4) + 2
|
||||
const y = height - barHeight
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill={d.color || barColor}
|
||||
rx={2}
|
||||
opacity={0.85}
|
||||
/>
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={height + 12}
|
||||
textAnchor="middle"
|
||||
fontSize={8}
|
||||
fill="var(--text-muted)"
|
||||
>
|
||||
{d.label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
26
frontend/src/components/Toast.stories.tsx
Normal file
26
frontend/src/components/Toast.stories.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Toast } from './Toast'
|
||||
|
||||
const meta: Meta<typeof Toast> = {
|
||||
title: 'Components/Toast',
|
||||
component: Toast,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Toast>
|
||||
|
||||
export const Info: Story = {
|
||||
args: { message: 'Settings saved successfully.', type: 'info', onDismiss: () => {} },
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
args: { message: 'Failed to connect to server.', type: 'error', onDismiss: () => {} },
|
||||
}
|
||||
|
||||
export const Warning: Story = {
|
||||
args: { message: 'Rate limit approaching.', type: 'warning', onDismiss: () => {} },
|
||||
}
|
||||
|
||||
export const Success: Story = {
|
||||
args: { message: 'Session created.', type: 'success', onDismiss: () => {} },
|
||||
}
|
||||
24
frontend/src/components/Toast.test.tsx
Normal file
24
frontend/src/components/Toast.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ToastProvider, useToast } from './Toast'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <ToastProvider>{children}</ToastProvider>
|
||||
|
||||
describe('Toast', () => {
|
||||
it('shows toast message', () => {
|
||||
function TestComponent() {
|
||||
const { toast } = useToast()
|
||||
return <button onClick={() => toast('Test message', 'success')}>Show</button>
|
||||
}
|
||||
render(<ToastProvider><TestComponent /></ToastProvider>)
|
||||
act(() => { screen.getByText('Show').click() })
|
||||
expect(screen.getByText('Test message')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('provides toast function via hook', () => {
|
||||
const { result } = renderHook(() => useToast(), { wrapper })
|
||||
expect(typeof result.current.toast).toBe('function')
|
||||
})
|
||||
})
|
||||
84
frontend/src/components/VirtualMessages.tsx
Normal file
84
frontend/src/components/VirtualMessages.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react'
|
||||
import type { FinalResponse } from '../types'
|
||||
import { ChatMessage } from './ChatMessage'
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
data?: FinalResponse
|
||||
}
|
||||
|
||||
interface VirtualMessagesProps {
|
||||
messages: Message[]
|
||||
viewMode: string
|
||||
loading: boolean
|
||||
onEditMessage?: (index: number) => void
|
||||
onDeleteMessage?: (index: number) => void
|
||||
}
|
||||
|
||||
const BUFFER = 10
|
||||
const BATCH_SIZE = 30
|
||||
|
||||
export function VirtualMessages({ messages, viewMode, loading, onEditMessage, onDeleteMessage }: VirtualMessagesProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
const [visibleStart, setVisibleStart] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const start = Math.max(0, messages.length - BATCH_SIZE)
|
||||
setVisibleStart(start)
|
||||
}, [messages.length])
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages.length])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
if (el.scrollTop < 100 && visibleStart > 0) {
|
||||
setVisibleStart((s) => Math.max(0, s - BUFFER))
|
||||
}
|
||||
}, [visibleStart])
|
||||
|
||||
const visibleMessages = messages.slice(visibleStart)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="messages"
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
role="log"
|
||||
aria-label="Conversation"
|
||||
aria-live="polite"
|
||||
>
|
||||
{visibleStart > 0 && (
|
||||
<button
|
||||
className="load-more-btn"
|
||||
onClick={() => setVisibleStart((s) => Math.max(0, s - BATCH_SIZE))}
|
||||
>
|
||||
Load {Math.min(BATCH_SIZE, visibleStart)} earlier messages
|
||||
</button>
|
||||
)}
|
||||
{visibleMessages.map((msg, i) => {
|
||||
const realIndex = visibleStart + i
|
||||
return (
|
||||
<ChatMessage
|
||||
key={realIndex}
|
||||
message={msg}
|
||||
viewMode={viewMode}
|
||||
onEdit={msg.role === 'user' && onEditMessage ? () => onEditMessage(realIndex) : undefined}
|
||||
onDelete={onDeleteMessage ? () => onDeleteMessage(realIndex) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{loading && (
|
||||
<div className="loading-indicator" role="status" aria-live="assertive">
|
||||
<div className="loading-dots" aria-hidden="true"><span /><span /><span /></div>
|
||||
<span>Heads analyzing...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/e2e.test.tsx
Normal file
56
frontend/src/e2e.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* End-to-end smoke tests for FusionAGI frontend.
|
||||
*
|
||||
* These tests verify that major UI components render correctly
|
||||
* and basic navigation/interaction flows work.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import App from './App'
|
||||
|
||||
// Mock fetch for API calls
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: 'ok' }),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response)
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
// Set auth token so app renders main interface instead of login
|
||||
localStorage.setItem('fusionagi-token', 'test-token')
|
||||
})
|
||||
|
||||
describe('E2E Smoke Tests', () => {
|
||||
it('renders the main chat interface when authenticated', () => {
|
||||
const { container } = render(<App />)
|
||||
expect(container.querySelector('.app')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the logo', () => {
|
||||
const { container } = render(<App />)
|
||||
expect(container.querySelector('.logo')).toBeTruthy()
|
||||
expect(container.querySelector('.logo')?.textContent).toBe('FusionAGI')
|
||||
})
|
||||
|
||||
it('has a prompt input', () => {
|
||||
const { container } = render(<App />)
|
||||
const input = container.querySelector('input[aria-label="Message input"]')
|
||||
expect(input).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders navigation tabs', () => {
|
||||
const { container } = render(<App />)
|
||||
const nav = container.querySelector('[role="tablist"]')
|
||||
expect(nav).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows login page when not authenticated', () => {
|
||||
localStorage.removeItem('fusionagi-token')
|
||||
const { container } = render(<App />)
|
||||
const loginPage = container.querySelector('.login-page, form, input')
|
||||
expect(loginPage).toBeTruthy()
|
||||
})
|
||||
})
|
||||
47
frontend/src/hooks/useChatHistory.test.ts
Normal file
47
frontend/src/hooks/useChatHistory.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useChatHistory } from './useChatHistory'
|
||||
|
||||
describe('useChatHistory', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('starts empty', () => {
|
||||
const { result } = renderHook(() => useChatHistory())
|
||||
expect(result.current.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('adds messages', () => {
|
||||
const { result } = renderHook(() => useChatHistory())
|
||||
act(() => { result.current.addMessage('user', 'Hello') })
|
||||
expect(result.current.messages.length).toBe(1)
|
||||
expect(result.current.messages[0].role).toBe('user')
|
||||
expect(result.current.messages[0].content).toBe('Hello')
|
||||
})
|
||||
|
||||
it('deletes messages', () => {
|
||||
const { result } = renderHook(() => useChatHistory())
|
||||
act(() => { result.current.addMessage('user', 'First') })
|
||||
act(() => { result.current.addMessage('assistant', 'Second') })
|
||||
expect(result.current.messages.length).toBe(2)
|
||||
act(() => { result.current.deleteMessage(0) })
|
||||
expect(result.current.messages.length).toBe(1)
|
||||
expect(result.current.messages[0].content).toBe('Second')
|
||||
})
|
||||
|
||||
it('clears history', () => {
|
||||
const { result } = renderHook(() => useChatHistory())
|
||||
act(() => { result.current.addMessage('user', 'Test') })
|
||||
act(() => { result.current.clearHistory() })
|
||||
expect(result.current.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('persists to localStorage', () => {
|
||||
const { result } = renderHook(() => useChatHistory())
|
||||
act(() => { result.current.addMessage('user', 'Persisted') })
|
||||
const stored = localStorage.getItem('fusionagi-chat-history')
|
||||
expect(stored).toBeTruthy()
|
||||
expect(JSON.parse(stored!)[0].content).toBe('Persisted')
|
||||
})
|
||||
})
|
||||
96
frontend/src/hooks/useChatHistory.ts
Normal file
96
frontend/src/hooks/useChatHistory.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { saveMessage, getMessages, clearMessages, isIndexedDBAvailable } from './useIndexedDB'
|
||||
import type { FinalResponse } from '../types'
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
data?: FinalResponse
|
||||
id: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'fusionagi-chat-history'
|
||||
const MAX_MESSAGES = 500
|
||||
|
||||
function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
function loadFromLocalStorage(): ChatMessage[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveToLocalStorage(messages: ChatMessage[]) {
|
||||
try {
|
||||
const trimmed = messages.slice(-MAX_MESSAGES)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
|
||||
} catch { /* storage full */ }
|
||||
}
|
||||
|
||||
const useIDB = isIndexedDBAvailable()
|
||||
|
||||
export function useChatHistory() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => loadFromLocalStorage())
|
||||
|
||||
// On mount, try loading from IndexedDB (async)
|
||||
useEffect(() => {
|
||||
if (!useIDB) return
|
||||
getMessages(undefined, MAX_MESSAGES).then((idbMsgs) => {
|
||||
if (idbMsgs.length > 0) {
|
||||
const mapped: ChatMessage[] = idbMsgs.map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
id: m.id || generateId(),
|
||||
timestamp: m.timestamp || Date.now(),
|
||||
}))
|
||||
setMessages(mapped)
|
||||
}
|
||||
}).catch(() => { /* IDB unavailable, using localStorage */ })
|
||||
}, [])
|
||||
|
||||
// Persist to localStorage as fallback
|
||||
useEffect(() => {
|
||||
saveToLocalStorage(messages)
|
||||
}, [messages])
|
||||
|
||||
const addMessage = useCallback((role: 'user' | 'assistant', content: string, data?: FinalResponse) => {
|
||||
const msg: ChatMessage = { role, content, data, id: generateId(), timestamp: Date.now() }
|
||||
setMessages((prev) => [...prev, msg])
|
||||
// Also persist to IndexedDB
|
||||
if (useIDB) {
|
||||
saveMessage({ id: msg.id, role, content, timestamp: msg.timestamp, sessionId: 'default' }).catch(() => {})
|
||||
}
|
||||
return msg
|
||||
}, [])
|
||||
|
||||
const editMessage = useCallback((index: number, newContent: string) => {
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
if (updated[index] && updated[index].role === 'user') {
|
||||
updated[index] = { ...updated[index], content: newContent }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
const deleteMessage = useCallback((index: number) => {
|
||||
setMessages((prev) => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const clearHistory = useCallback(() => {
|
||||
setMessages([])
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
if (useIDB) {
|
||||
clearMessages().catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { messages, addMessage, editMessage, deleteMessage, clearHistory, setMessages }
|
||||
}
|
||||
59
frontend/src/hooks/useExport.test.ts
Normal file
59
frontend/src/hooks/useExport.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useExport } from './useExport'
|
||||
|
||||
let clickSpy: ReturnType<typeof vi.fn>
|
||||
let appendSpy: ReturnType<typeof vi.spyOn>
|
||||
let removeSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
clickSpy = vi.fn()
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test')
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
|
||||
// Intercept anchor element clicks
|
||||
if (node instanceof HTMLAnchorElement) {
|
||||
clickSpy()
|
||||
}
|
||||
return node
|
||||
})
|
||||
removeSpy = vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('useExport', () => {
|
||||
const messages = [
|
||||
{ role: 'user' as const, content: 'Hello', timestamp: 1000 },
|
||||
{ role: 'assistant' as const, content: 'Hi there', timestamp: 2000 },
|
||||
]
|
||||
|
||||
it('exports markdown format correctly', () => {
|
||||
const { result } = renderHook(() => useExport())
|
||||
result.current.exportMarkdown(messages, 'Test Chat')
|
||||
expect(appendSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exports JSON format correctly', () => {
|
||||
const { result } = renderHook(() => useExport())
|
||||
result.current.exportJSON(messages, 'Test Chat')
|
||||
expect(appendSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exports plain text format correctly', () => {
|
||||
const { result } = renderHook(() => useExport())
|
||||
result.current.exportText(messages)
|
||||
expect(appendSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies to clipboard', async () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
})
|
||||
const { result } = renderHook(() => useExport())
|
||||
const success = await result.current.copyToClipboard(messages)
|
||||
expect(success).toBe(true)
|
||||
})
|
||||
})
|
||||
105
frontend/src/hooks/useExport.ts
Normal file
105
frontend/src/hooks/useExport.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Conversation export hook.
|
||||
*
|
||||
* Exports chat history as Markdown, JSON, or plain text.
|
||||
*/
|
||||
|
||||
export interface ExportMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
function formatTimestamp(ts?: number): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
function toMarkdown(messages: ExportMessage[], title?: string): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`# ${title || 'FusionAGI Conversation'}`)
|
||||
lines.push('')
|
||||
lines.push(`*Exported: ${new Date().toISOString()}*`)
|
||||
lines.push('')
|
||||
lines.push('---')
|
||||
lines.push('')
|
||||
|
||||
for (const msg of messages) {
|
||||
const ts = formatTimestamp(msg.timestamp)
|
||||
const prefix = msg.role === 'user' ? '**You**' : '**FusionAGI**'
|
||||
lines.push(`### ${prefix}${ts ? ` (${ts})` : ''}`)
|
||||
lines.push('')
|
||||
lines.push(msg.content)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function toJSON(messages: ExportMessage[], title?: string): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
title: title || 'FusionAGI Conversation',
|
||||
exported_at: new Date().toISOString(),
|
||||
message_count: messages.length,
|
||||
messages: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp ? new Date(m.timestamp).toISOString() : null,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}
|
||||
|
||||
function toPlainText(messages: ExportMessage[]): string {
|
||||
return messages
|
||||
.map((m) => {
|
||||
const label = m.role === 'user' ? 'You' : 'FusionAGI'
|
||||
const ts = formatTimestamp(m.timestamp)
|
||||
return `[${label}${ts ? ` ${ts}` : ''}]\n${m.content}`
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
function download(content: string, filename: string, mimeType: string): void {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function useExport() {
|
||||
const exportMarkdown = (messages: ExportMessage[], title?: string) => {
|
||||
const content = toMarkdown(messages, title)
|
||||
download(content, `fusionagi-chat-${Date.now()}.md`, 'text/markdown')
|
||||
}
|
||||
|
||||
const exportJSON = (messages: ExportMessage[], title?: string) => {
|
||||
const content = toJSON(messages, title)
|
||||
download(content, `fusionagi-chat-${Date.now()}.json`, 'application/json')
|
||||
}
|
||||
|
||||
const exportText = (messages: ExportMessage[]) => {
|
||||
const content = toPlainText(messages)
|
||||
download(content, `fusionagi-chat-${Date.now()}.txt`, 'text/plain')
|
||||
}
|
||||
|
||||
const copyToClipboard = async (messages: ExportMessage[]) => {
|
||||
const content = toMarkdown(messages)
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { exportMarkdown, exportJSON, exportText, copyToClipboard }
|
||||
}
|
||||
20
frontend/src/hooks/useIndexedDB.test.ts
Normal file
20
frontend/src/hooks/useIndexedDB.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { isIndexedDBAvailable } from './useIndexedDB'
|
||||
|
||||
describe('useIndexedDB', () => {
|
||||
it('detects IndexedDB availability', () => {
|
||||
const result = isIndexedDBAvailable()
|
||||
// In JSDOM, indexedDB may or may not be defined
|
||||
expect(typeof result).toBe('boolean')
|
||||
})
|
||||
|
||||
it('returns false when indexedDB is undefined', () => {
|
||||
const original = globalThis.indexedDB
|
||||
try {
|
||||
Object.defineProperty(globalThis, 'indexedDB', { value: undefined, configurable: true })
|
||||
expect(isIndexedDBAvailable()).toBe(false)
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'indexedDB', { value: original, configurable: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
179
frontend/src/hooks/useIndexedDB.ts
Normal file
179
frontend/src/hooks/useIndexedDB.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* IndexedDB-backed chat persistence.
|
||||
*
|
||||
* Replaces localStorage for larger chat histories (no 5MB limit).
|
||||
* Falls back to localStorage if IndexedDB is unavailable.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'fusionagi'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'chat_messages'
|
||||
const SESSION_STORE = 'sessions'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
sessionId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ChatSession {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||
store.createIndex('sessionId', 'sessionId', { unique: false })
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false })
|
||||
}
|
||||
if (!db.objectStoreNames.contains(SESSION_STORE)) {
|
||||
const sessionStore = db.createObjectStore(SESSION_STORE, { keyPath: 'id' })
|
||||
sessionStore.createIndex('updatedAt', 'updatedAt', { unique: false })
|
||||
}
|
||||
}
|
||||
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveMessage(message: ChatMessage): Promise<void> {
|
||||
try {
|
||||
const db = await openDB()
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put(message)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
} catch {
|
||||
// Fallback: do nothing, localStorage-based persistence handles it
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMessages(sessionId?: string, limit = 500): Promise<ChatMessage[]> {
|
||||
try {
|
||||
const db = await openDB()
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let request: IDBRequest
|
||||
|
||||
if (sessionId) {
|
||||
const index = store.index('sessionId')
|
||||
request = index.getAll(sessionId, limit)
|
||||
} else {
|
||||
request = store.getAll(null, limit)
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
const results = (request.result as ChatMessage[])
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.slice(-limit)
|
||||
resolve(results)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearMessages(sessionId?: string): Promise<void> {
|
||||
try {
|
||||
const db = await openDB()
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
|
||||
if (sessionId) {
|
||||
const index = store.index('sessionId')
|
||||
const request = index.openCursor(sessionId)
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result
|
||||
if (cursor) {
|
||||
cursor.delete()
|
||||
cursor.continue()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.clear()
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
} catch {
|
||||
// Fallback: localStorage clear
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSession(session: ChatSession): Promise<void> {
|
||||
try {
|
||||
const db = await openDB()
|
||||
const tx = db.transaction(SESSION_STORE, 'readwrite')
|
||||
tx.objectStore(SESSION_STORE).put(session)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
} catch {
|
||||
// Fallback: do nothing
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessions(limit = 50): Promise<ChatSession[]> {
|
||||
try {
|
||||
const db = await openDB()
|
||||
const tx = db.transaction(SESSION_STORE, 'readonly')
|
||||
const store = tx.objectStore(SESSION_STORE)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAll(null, limit)
|
||||
request.onsuccess = () => {
|
||||
const results = (request.result as ChatSession[])
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
resolve(results)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
const db = await openDB()
|
||||
|
||||
// Delete session
|
||||
const tx1 = db.transaction(SESSION_STORE, 'readwrite')
|
||||
tx1.objectStore(SESSION_STORE).delete(sessionId)
|
||||
|
||||
// Delete associated messages
|
||||
await clearMessages(sessionId)
|
||||
} catch {
|
||||
// Fallback: do nothing
|
||||
}
|
||||
}
|
||||
|
||||
export function isIndexedDBAvailable(): boolean {
|
||||
try {
|
||||
return typeof indexedDB !== 'undefined' && indexedDB !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
44
frontend/src/hooks/useKeyboard.ts
Normal file
44
frontend/src/hooks/useKeyboard.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
|
||||
interface KeyboardShortcuts {
|
||||
onSend?: () => void
|
||||
onSearch?: () => void
|
||||
onDismiss?: () => void
|
||||
onToggleTheme?: () => void
|
||||
}
|
||||
|
||||
export function useKeyboard({ onSend, onSearch, onDismiss, onToggleTheme }: KeyboardShortcuts) {
|
||||
const handler = useCallback((e: KeyboardEvent) => {
|
||||
const meta = e.metaKey || e.ctrlKey
|
||||
const target = e.target as HTMLElement
|
||||
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
onDismiss?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (meta && e.key === 'Enter' && onSend) {
|
||||
e.preventDefault()
|
||||
onSend()
|
||||
return
|
||||
}
|
||||
|
||||
if (meta && e.key === 'k' && onSearch) {
|
||||
e.preventDefault()
|
||||
onSearch()
|
||||
return
|
||||
}
|
||||
|
||||
if (meta && e.key === 'j' && onToggleTheme && !isInput) {
|
||||
e.preventDefault()
|
||||
onToggleTheme()
|
||||
return
|
||||
}
|
||||
}, [onSend, onSearch, onDismiss, onToggleTheme])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [handler])
|
||||
}
|
||||
73
frontend/src/hooks/useMarkdownWorker.ts
Normal file
73
frontend/src/hooks/useMarkdownWorker.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Hook for offloading markdown rendering to a Web Worker.
|
||||
*
|
||||
* Falls back to synchronous rendering if Workers are unavailable.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
let workerInstance: Worker | null = null
|
||||
let workerFailed = false
|
||||
const pendingCallbacks = new Map<string, (html: string) => void>()
|
||||
let nextId = 0
|
||||
|
||||
function getWorker(): Worker | null {
|
||||
if (workerFailed) return null
|
||||
if (workerInstance) return workerInstance
|
||||
|
||||
try {
|
||||
workerInstance = new Worker(
|
||||
new URL('../workers/markdown.worker.ts', import.meta.url),
|
||||
{ type: 'module' },
|
||||
)
|
||||
workerInstance.onmessage = (e: MessageEvent) => {
|
||||
const { id, html } = e.data
|
||||
const cb = pendingCallbacks.get(id)
|
||||
if (cb) {
|
||||
cb(html)
|
||||
pendingCallbacks.delete(id)
|
||||
}
|
||||
}
|
||||
workerInstance.onerror = () => {
|
||||
workerFailed = true
|
||||
workerInstance = null
|
||||
}
|
||||
} catch {
|
||||
workerFailed = true
|
||||
return null
|
||||
}
|
||||
|
||||
return workerInstance
|
||||
}
|
||||
|
||||
export function useMarkdownWorker(text: string): string {
|
||||
const [html, setHtml] = useState('')
|
||||
const idRef = useRef<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!text) {
|
||||
setHtml('')
|
||||
return
|
||||
}
|
||||
|
||||
const worker = getWorker()
|
||||
if (!worker) {
|
||||
// Fallback: synchronous inline render
|
||||
setHtml(text)
|
||||
return
|
||||
}
|
||||
|
||||
const id = `md_${nextId++}`
|
||||
idRef.current = id
|
||||
pendingCallbacks.set(id, (rendered) => {
|
||||
if (idRef.current === id) setHtml(rendered)
|
||||
})
|
||||
worker.postMessage({ id, text })
|
||||
|
||||
return () => {
|
||||
pendingCallbacks.delete(id)
|
||||
}
|
||||
}, [text])
|
||||
|
||||
return html
|
||||
}
|
||||
51
frontend/src/hooks/useMultiSession.test.ts
Normal file
51
frontend/src/hooks/useMultiSession.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useMultiSession } from './useMultiSession'
|
||||
|
||||
const mockStorage: Record<string, string> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(mockStorage).forEach((k) => delete mockStorage[k])
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => mockStorage[key] || null)
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, value) => {
|
||||
mockStorage[key] = value
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMultiSession', () => {
|
||||
it('initializes with one session', () => {
|
||||
const { result } = renderHook(() => useMultiSession())
|
||||
expect(result.current.sessions).toHaveLength(1)
|
||||
expect(result.current.activeSession.active).toBe(true)
|
||||
})
|
||||
|
||||
it('creates new sessions', () => {
|
||||
const { result } = renderHook(() => useMultiSession())
|
||||
act(() => { result.current.createSession('Test Chat') })
|
||||
expect(result.current.sessions).toHaveLength(2)
|
||||
expect(result.current.activeSession.name).toBe('Test Chat')
|
||||
})
|
||||
|
||||
it('switches between sessions', () => {
|
||||
const { result } = renderHook(() => useMultiSession())
|
||||
const firstId = result.current.sessions[0].id
|
||||
act(() => { result.current.createSession('Second') })
|
||||
act(() => { result.current.switchSession(firstId) })
|
||||
expect(result.current.activeSession.id).toBe(firstId)
|
||||
})
|
||||
|
||||
it('renames a session', () => {
|
||||
const { result } = renderHook(() => useMultiSession())
|
||||
const id = result.current.sessions[0].id
|
||||
act(() => { result.current.renameSession(id, 'Renamed') })
|
||||
expect(result.current.sessions[0].name).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('deletes a session and creates default if empty', () => {
|
||||
const { result } = renderHook(() => useMultiSession())
|
||||
const id = result.current.sessions[0].id
|
||||
act(() => { result.current.deleteSession(id) })
|
||||
expect(result.current.sessions).toHaveLength(1)
|
||||
expect(result.current.sessions[0].active).toBe(true)
|
||||
})
|
||||
})
|
||||
114
frontend/src/hooks/useMultiSession.ts
Normal file
114
frontend/src/hooks/useMultiSession.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Multi-session management hook.
|
||||
*
|
||||
* Allows users to manage parallel conversations with session switching.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export interface SessionTab {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: number
|
||||
messageCount: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'fusionagi-sessions'
|
||||
|
||||
function loadSessions(): SessionTab[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveSessions(sessions: SessionTab[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
|
||||
} catch {
|
||||
// Storage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function useMultiSession() {
|
||||
const [sessions, setSessions] = useState<SessionTab[]>(() => {
|
||||
const saved = loadSessions()
|
||||
if (saved.length === 0) {
|
||||
const initial: SessionTab = {
|
||||
id: `session_${Date.now()}`,
|
||||
name: 'New Chat',
|
||||
createdAt: Date.now(),
|
||||
messageCount: 0,
|
||||
active: true,
|
||||
}
|
||||
saveSessions([initial])
|
||||
return [initial]
|
||||
}
|
||||
return saved
|
||||
})
|
||||
|
||||
const activeSession = sessions.find((s) => s.active) || sessions[0]
|
||||
|
||||
const createSession = useCallback((name?: string) => {
|
||||
const newSession: SessionTab = {
|
||||
id: `session_${Date.now()}`,
|
||||
name: name || `Chat ${sessions.length + 1}`,
|
||||
createdAt: Date.now(),
|
||||
messageCount: 0,
|
||||
active: true,
|
||||
}
|
||||
const updated = sessions.map((s) => ({ ...s, active: false }))
|
||||
updated.push(newSession)
|
||||
setSessions(updated)
|
||||
saveSessions(updated)
|
||||
return newSession
|
||||
}, [sessions])
|
||||
|
||||
const switchSession = useCallback((sessionId: string) => {
|
||||
const updated = sessions.map((s) => ({ ...s, active: s.id === sessionId }))
|
||||
setSessions(updated)
|
||||
saveSessions(updated)
|
||||
}, [sessions])
|
||||
|
||||
const renameSession = useCallback((sessionId: string, name: string) => {
|
||||
const updated = sessions.map((s) => s.id === sessionId ? { ...s, name } : s)
|
||||
setSessions(updated)
|
||||
saveSessions(updated)
|
||||
}, [sessions])
|
||||
|
||||
const deleteSession = useCallback((sessionId: string) => {
|
||||
let updated = sessions.filter((s) => s.id !== sessionId)
|
||||
if (updated.length === 0) {
|
||||
updated = [{
|
||||
id: `session_${Date.now()}`,
|
||||
name: 'New Chat',
|
||||
createdAt: Date.now(),
|
||||
messageCount: 0,
|
||||
active: true,
|
||||
}]
|
||||
} else if (!updated.some((s) => s.active)) {
|
||||
updated[0].active = true
|
||||
}
|
||||
setSessions(updated)
|
||||
saveSessions(updated)
|
||||
}, [sessions])
|
||||
|
||||
const updateMessageCount = useCallback((sessionId: string, count: number) => {
|
||||
const updated = sessions.map((s) => s.id === sessionId ? { ...s, messageCount: count } : s)
|
||||
setSessions(updated)
|
||||
saveSessions(updated)
|
||||
}, [sessions])
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSession,
|
||||
createSession,
|
||||
switchSession,
|
||||
renameSession,
|
||||
deleteSession,
|
||||
updateMessageCount,
|
||||
}
|
||||
}
|
||||
117
frontend/src/hooks/useNotifications.ts
Normal file
117
frontend/src/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Push notification hook for background task completion.
|
||||
*
|
||||
* Listens to WebSocket events for task status changes and
|
||||
* shows browser notifications when tasks complete or fail.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
export interface TaskNotification {
|
||||
id: string
|
||||
taskId: string
|
||||
type: 'task_complete' | 'task_failed' | 'task_progress' | 'system'
|
||||
title: string
|
||||
body: string
|
||||
timestamp: number
|
||||
read: boolean
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const [notifications, setNotifications] = useState<TaskNotification[]>([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const permissionRef = useRef<NotificationPermission>('default')
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof Notification !== 'undefined') {
|
||||
permissionRef.current = Notification.permission
|
||||
}
|
||||
}, [])
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
if (typeof Notification === 'undefined') return false
|
||||
const result = await Notification.requestPermission()
|
||||
permissionRef.current = result
|
||||
return result === 'granted'
|
||||
}, [])
|
||||
|
||||
const addNotification = useCallback(
|
||||
(notif: Omit<TaskNotification, 'id' | 'timestamp' | 'read'>) => {
|
||||
const full: TaskNotification = {
|
||||
...notif,
|
||||
id: `notif-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
timestamp: Date.now(),
|
||||
read: false,
|
||||
}
|
||||
setNotifications((prev) => [full, ...prev].slice(0, 50))
|
||||
setUnreadCount((prev) => prev + 1)
|
||||
|
||||
// Show browser notification if permitted
|
||||
if (typeof Notification !== 'undefined' && permissionRef.current === 'granted') {
|
||||
new Notification(full.title, {
|
||||
body: full.body,
|
||||
icon: '/icon-192.png',
|
||||
tag: full.taskId,
|
||||
})
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const markRead = useCallback((id: string) => {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
|
||||
)
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1))
|
||||
}, [])
|
||||
|
||||
const markAllRead = useCallback(() => {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
|
||||
setUnreadCount(0)
|
||||
}, [])
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setNotifications([])
|
||||
setUnreadCount(0)
|
||||
}, [])
|
||||
|
||||
// Handle WebSocket events for task notifications
|
||||
const handleWSEvent = useCallback(
|
||||
(event: { type: string; data: Record<string, unknown> }) => {
|
||||
if (event.type === 'task_complete') {
|
||||
addNotification({
|
||||
taskId: String(event.data.task_id || ''),
|
||||
type: 'task_complete',
|
||||
title: 'Task Complete',
|
||||
body: String(event.data.summary || 'Background task finished successfully.'),
|
||||
})
|
||||
} else if (event.type === 'task_failed') {
|
||||
addNotification({
|
||||
taskId: String(event.data.task_id || ''),
|
||||
type: 'task_failed',
|
||||
title: 'Task Failed',
|
||||
body: String(event.data.error || 'Background task encountered an error.'),
|
||||
})
|
||||
} else if (event.type === 'task_progress') {
|
||||
addNotification({
|
||||
taskId: String(event.data.task_id || ''),
|
||||
type: 'task_progress',
|
||||
title: 'Task Progress',
|
||||
body: String(event.data.message || `Progress: ${event.data.progress || 0}%`),
|
||||
})
|
||||
}
|
||||
},
|
||||
[addNotification],
|
||||
)
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount,
|
||||
requestPermission,
|
||||
addNotification,
|
||||
markRead,
|
||||
markAllRead,
|
||||
clearAll,
|
||||
handleWSEvent,
|
||||
}
|
||||
}
|
||||
107
frontend/src/hooks/useStore.ts
Normal file
107
frontend/src/hooks/useStore.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Lightweight global state management for FusionAGI.
|
||||
*
|
||||
* Zero-dependency alternative to Zustand. Uses React context + useReducer
|
||||
* to provide centralized state management across the app.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useReducer, useCallback } from 'react'
|
||||
import type { Dispatch } from 'react'
|
||||
|
||||
export type Page = 'chat' | 'admin' | 'ethics' | 'settings'
|
||||
export type ViewMode = 'normal' | 'explain' | 'developer'
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
export interface AppState {
|
||||
page: Page
|
||||
viewMode: ViewMode
|
||||
theme: Theme
|
||||
loading: boolean
|
||||
networkError: string | null
|
||||
sessionId: string | null
|
||||
isMobile: boolean
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export type AppAction =
|
||||
| { type: 'SET_PAGE'; page: Page }
|
||||
| { type: 'SET_VIEW_MODE'; mode: ViewMode }
|
||||
| { type: 'SET_THEME'; theme: Theme }
|
||||
| { type: 'TOGGLE_THEME' }
|
||||
| { type: 'SET_LOADING'; loading: boolean }
|
||||
| { type: 'SET_ERROR'; error: string | null }
|
||||
| { type: 'SET_SESSION'; sessionId: string | null }
|
||||
| { type: 'SET_MOBILE'; isMobile: boolean }
|
||||
| { type: 'SET_PROMPT'; prompt: string }
|
||||
|
||||
export const initialState: AppState = {
|
||||
page: 'chat',
|
||||
viewMode: 'normal',
|
||||
theme: (typeof localStorage !== 'undefined'
|
||||
? (localStorage.getItem('fusionagi-theme') as Theme) || 'dark'
|
||||
: 'dark') as Theme,
|
||||
loading: false,
|
||||
networkError: null,
|
||||
sessionId: null,
|
||||
isMobile: typeof window !== 'undefined' ? window.innerWidth <= 768 : false,
|
||||
prompt: '',
|
||||
}
|
||||
|
||||
export function appReducer(state: AppState, action: AppAction): AppState {
|
||||
switch (action.type) {
|
||||
case 'SET_PAGE':
|
||||
return { ...state, page: action.page }
|
||||
case 'SET_VIEW_MODE':
|
||||
return { ...state, viewMode: action.mode }
|
||||
case 'SET_THEME': {
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem('fusionagi-theme', action.theme)
|
||||
return { ...state, theme: action.theme }
|
||||
}
|
||||
case 'TOGGLE_THEME': {
|
||||
const next = state.theme === 'dark' ? 'light' : 'dark'
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem('fusionagi-theme', next)
|
||||
return { ...state, theme: next }
|
||||
}
|
||||
case 'SET_LOADING':
|
||||
return { ...state, loading: action.loading }
|
||||
case 'SET_ERROR':
|
||||
return { ...state, networkError: action.error }
|
||||
case 'SET_SESSION':
|
||||
return { ...state, sessionId: action.sessionId }
|
||||
case 'SET_MOBILE':
|
||||
return { ...state, isMobile: action.isMobile }
|
||||
case 'SET_PROMPT':
|
||||
return { ...state, prompt: action.prompt }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoreContextValue {
|
||||
state: AppState
|
||||
dispatch: Dispatch<AppAction>
|
||||
}
|
||||
|
||||
export const StoreContext = createContext<StoreContextValue>({
|
||||
state: initialState,
|
||||
dispatch: () => {},
|
||||
})
|
||||
|
||||
export function useStore(): StoreContextValue {
|
||||
return useContext(StoreContext)
|
||||
}
|
||||
|
||||
export function useAppState() {
|
||||
const { state, dispatch } = useStore()
|
||||
|
||||
const setPage = useCallback((page: Page) => dispatch({ type: 'SET_PAGE', page }), [dispatch])
|
||||
const setViewMode = useCallback((mode: ViewMode) => dispatch({ type: 'SET_VIEW_MODE', mode }), [dispatch])
|
||||
const toggleTheme = useCallback(() => dispatch({ type: 'TOGGLE_THEME' }), [dispatch])
|
||||
const setLoading = useCallback((loading: boolean) => dispatch({ type: 'SET_LOADING', loading }), [dispatch])
|
||||
const setError = useCallback((error: string | null) => dispatch({ type: 'SET_ERROR', error }), [dispatch])
|
||||
const setPrompt = useCallback((prompt: string) => dispatch({ type: 'SET_PROMPT', prompt }), [dispatch])
|
||||
|
||||
return { ...state, setPage, setViewMode, toggleTheme, setLoading, setError, setPrompt, dispatch }
|
||||
}
|
||||
|
||||
export { useReducer }
|
||||
@@ -3,25 +3,75 @@ import type { WSEvent } from '../types'
|
||||
|
||||
type WSStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
const MAX_RETRIES = 10
|
||||
const BASE_DELAY = 1000
|
||||
|
||||
export interface StreamCallbacks {
|
||||
onToken?: (token: string) => void
|
||||
onHeadUpdate?: (head: string, content: string) => void
|
||||
onComplete?: (response: Record<string, unknown>) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function useWebSocket(sessionId: string | null) {
|
||||
const [status, setStatus] = useState<WSStatus>('disconnected')
|
||||
const [events, setEvents] = useState<WSEvent[]>([])
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const retryCount = useRef(0)
|
||||
const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const shouldReconnect = useRef(true)
|
||||
const callbacksRef = useRef<StreamCallbacks>({})
|
||||
|
||||
const connect = useCallback((sid: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return
|
||||
if (wsRef.current) wsRef.current.close()
|
||||
shouldReconnect.current = true
|
||||
setStatus('connecting')
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/v1/sessions/${sid}/stream`)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => setStatus('connected')
|
||||
ws.onclose = () => setStatus('disconnected')
|
||||
ws.onerror = () => setStatus('error')
|
||||
ws.onopen = () => {
|
||||
setStatus('connected')
|
||||
retryCount.current = 0
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setStatus('disconnected')
|
||||
setStreaming(false)
|
||||
if (shouldReconnect.current && retryCount.current < MAX_RETRIES) {
|
||||
const delay = BASE_DELAY * Math.pow(2, retryCount.current) + Math.random() * 500
|
||||
retryCount.current++
|
||||
retryTimer.current = setTimeout(() => connect(sid), delay)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus('error')
|
||||
setStreaming(false)
|
||||
}
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const event: WSEvent = JSON.parse(e.data)
|
||||
setEvents((prev) => [...prev, event])
|
||||
|
||||
// Handle streaming protocol events
|
||||
const cb = callbacksRef.current
|
||||
if (event.type === 'token' && cb.onToken) {
|
||||
cb.onToken(event.data as string)
|
||||
} else if (event.type === 'head_update' && cb.onHeadUpdate) {
|
||||
const d = event.data as Record<string, string>
|
||||
cb.onHeadUpdate(d.head, d.content)
|
||||
} else if (event.type === 'complete' && cb.onComplete) {
|
||||
setStreaming(false)
|
||||
cb.onComplete(event.data as Record<string, unknown>)
|
||||
} else if (event.type === 'error' && cb.onError) {
|
||||
setStreaming(false)
|
||||
cb.onError(event.data as string)
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
}
|
||||
}, [])
|
||||
@@ -32,15 +82,89 @@ export function useWebSocket(sessionId: string | null) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendPrompt = useCallback((prompt: string, callbacks?: StreamCallbacks) => {
|
||||
if (callbacks) callbacksRef.current = callbacks
|
||||
setStreaming(true)
|
||||
send({ type: 'prompt', prompt })
|
||||
}, [send])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
shouldReconnect.current = false
|
||||
if (retryTimer.current) clearTimeout(retryTimer.current)
|
||||
wsRef.current?.close()
|
||||
wsRef.current = null
|
||||
setStatus('disconnected')
|
||||
setStreaming(false)
|
||||
retryCount.current = 0
|
||||
}, [])
|
||||
|
||||
const clearEvents = useCallback(() => setEvents([]), [])
|
||||
|
||||
useEffect(() => () => { wsRef.current?.close() }, [])
|
||||
// SSE fallback: if WebSocket fails repeatedly, use Server-Sent Events
|
||||
const sendPromptSSE = useCallback((sessionId: string, prompt: string, callbacks?: StreamCallbacks) => {
|
||||
if (callbacks) callbacksRef.current = callbacks
|
||||
setStreaming(true)
|
||||
|
||||
return { status, events, connect, send, disconnect, clearEvents }
|
||||
const cb = callbacksRef.current
|
||||
const params = new URLSearchParams({ prompt, session_id: sessionId })
|
||||
|
||||
try {
|
||||
const eventSource = new EventSource(`/v1/sessions/stream/sse?${params}`)
|
||||
|
||||
eventSource.addEventListener('token', (e) => {
|
||||
if (cb.onToken) cb.onToken(e.data)
|
||||
})
|
||||
|
||||
eventSource.addEventListener('head_update', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
if (cb.onHeadUpdate) cb.onHeadUpdate(data.head, data.content)
|
||||
} catch { /* malformed */ }
|
||||
})
|
||||
|
||||
eventSource.addEventListener('complete', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
setStreaming(false)
|
||||
if (cb.onComplete) cb.onComplete(data)
|
||||
} catch { /* malformed */ }
|
||||
eventSource.close()
|
||||
})
|
||||
|
||||
eventSource.addEventListener('error', (e) => {
|
||||
setStreaming(false)
|
||||
if (cb.onError && e instanceof MessageEvent) cb.onError(e.data)
|
||||
eventSource.close()
|
||||
})
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setStreaming(false)
|
||||
eventSource.close()
|
||||
}
|
||||
} catch {
|
||||
setStreaming(false)
|
||||
if (cb.onError) cb.onError('SSE connection failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-fallback: after MAX_RETRIES WS failures, switch to SSE
|
||||
const sendWithFallback = useCallback((prompt: string, callbacks?: StreamCallbacks) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
sendPrompt(prompt, callbacks)
|
||||
} else if (sessionId && retryCount.current >= MAX_RETRIES) {
|
||||
sendPromptSSE(sessionId, prompt, callbacks)
|
||||
} else {
|
||||
sendPrompt(prompt, callbacks)
|
||||
}
|
||||
}, [sendPrompt, sendPromptSSE, sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
shouldReconnect.current = false
|
||||
if (retryTimer.current) clearTimeout(retryTimer.current)
|
||||
wsRef.current?.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { status, events, streaming, connect, send, sendPrompt: sendWithFallback, sendPromptSSE, disconnect, clearEvents }
|
||||
}
|
||||
|
||||
167
frontend/src/i18n/index.ts
Normal file
167
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Lightweight i18n system for FusionAGI.
|
||||
*
|
||||
* Zero-dependency translation layer using JSON locale files.
|
||||
* Supports interpolation via {{key}} syntax.
|
||||
*/
|
||||
|
||||
export type Locale = 'en' | 'es' | 'fr' | 'de' | 'ja' | 'zh'
|
||||
|
||||
type TranslationMap = Record<string, string>
|
||||
type LocaleData = Record<Locale, TranslationMap>
|
||||
|
||||
const translations: LocaleData = {
|
||||
en: {
|
||||
'app.title': 'FusionAGI',
|
||||
'nav.chat': 'Chat',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.ethics': 'Ethics',
|
||||
'nav.settings': 'Settings',
|
||||
'chat.placeholder': 'Ask FusionAGI... (Ctrl+Enter to send, Ctrl+K to focus)',
|
||||
'chat.send': 'Send',
|
||||
'chat.empty': 'Start a conversation',
|
||||
'chat.suggestions.label': 'Try asking:',
|
||||
'admin.title': 'Admin Dashboard',
|
||||
'admin.status': 'System Status',
|
||||
'admin.voices': 'Voice Config',
|
||||
'admin.agents': 'Agent Config',
|
||||
'admin.governance': 'Governance',
|
||||
'ethics.title': 'Ethics Dashboard',
|
||||
'ethics.lessons': 'Lessons',
|
||||
'ethics.consequences': 'Consequences',
|
||||
'ethics.insights': 'Insights',
|
||||
'settings.title': 'Settings',
|
||||
'settings.theme': 'Theme',
|
||||
'settings.theme.dark': 'Dark',
|
||||
'settings.theme.light': 'Light',
|
||||
'settings.conversation': 'Conversation Style',
|
||||
'settings.formality': 'Formality',
|
||||
'settings.verbosity': 'Verbosity',
|
||||
'settings.empathy': 'Empathy',
|
||||
'settings.humor': 'Humor',
|
||||
'settings.technical': 'Technical Depth',
|
||||
'login.title': 'Welcome to FusionAGI',
|
||||
'login.apikey': 'API Key',
|
||||
'login.submit': 'Login',
|
||||
'login.skip': 'Skip (no auth)',
|
||||
'login.error': 'Authentication failed',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Something went wrong',
|
||||
'common.retry': 'Retry',
|
||||
'common.close': 'Close',
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.copy': 'Copy',
|
||||
'common.copied': 'Copied!',
|
||||
'common.logout': 'Logout',
|
||||
},
|
||||
es: {
|
||||
'app.title': 'FusionAGI',
|
||||
'nav.chat': 'Chat',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.ethics': 'Etica',
|
||||
'nav.settings': 'Ajustes',
|
||||
'chat.placeholder': 'Pregunta a FusionAGI...',
|
||||
'chat.send': 'Enviar',
|
||||
'chat.empty': 'Inicia una conversacion',
|
||||
'admin.title': 'Panel de Admin',
|
||||
'ethics.title': 'Panel de Etica',
|
||||
'settings.title': 'Ajustes',
|
||||
'login.title': 'Bienvenido a FusionAGI',
|
||||
'login.apikey': 'Clave API',
|
||||
'login.submit': 'Entrar',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.error': 'Algo salio mal',
|
||||
},
|
||||
fr: {
|
||||
'app.title': 'FusionAGI',
|
||||
'nav.chat': 'Chat',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.ethics': 'Ethique',
|
||||
'nav.settings': 'Parametres',
|
||||
'chat.placeholder': 'Demandez a FusionAGI...',
|
||||
'chat.send': 'Envoyer',
|
||||
'admin.title': 'Tableau de bord',
|
||||
'ethics.title': 'Tableau ethique',
|
||||
'settings.title': 'Parametres',
|
||||
'login.title': 'Bienvenue sur FusionAGI',
|
||||
'common.loading': 'Chargement...',
|
||||
},
|
||||
de: {
|
||||
'app.title': 'FusionAGI',
|
||||
'nav.chat': 'Chat',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.ethics': 'Ethik',
|
||||
'nav.settings': 'Einstellungen',
|
||||
'chat.placeholder': 'Frage FusionAGI...',
|
||||
'chat.send': 'Senden',
|
||||
'admin.title': 'Admin-Dashboard',
|
||||
'settings.title': 'Einstellungen',
|
||||
'login.title': 'Willkommen bei FusionAGI',
|
||||
'common.loading': 'Laden...',
|
||||
},
|
||||
ja: {
|
||||
'app.title': 'FusionAGI',
|
||||
'nav.chat': 'チャット',
|
||||
'nav.admin': '管理',
|
||||
'nav.ethics': '倫理',
|
||||
'nav.settings': '設定',
|
||||
'chat.placeholder': 'FusionAGIに聞く...',
|
||||
'chat.send': '送信',
|
||||
'admin.title': '管理ダッシュボード',
|
||||
'settings.title': '設定',
|
||||
'login.title': 'FusionAGIへようこそ',
|
||||
'common.loading': '読み込み中...',
|
||||
},
|
||||
zh: {
|
||||
'app.title': 'FusionAGI',
|
||||
'nav.chat': '聊天',
|
||||
'nav.admin': '管理',
|
||||
'nav.ethics': '伦理',
|
||||
'nav.settings': '设置',
|
||||
'chat.placeholder': '询问FusionAGI...',
|
||||
'chat.send': '发送',
|
||||
'admin.title': '管理面板',
|
||||
'settings.title': '设置',
|
||||
'login.title': '欢迎使用FusionAGI',
|
||||
'common.loading': '加载中...',
|
||||
},
|
||||
}
|
||||
|
||||
let currentLocale: Locale = 'en'
|
||||
|
||||
export function setLocale(locale: Locale): void {
|
||||
currentLocale = locale
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('fusionagi-locale', locale)
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocale(): Locale {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('fusionagi-locale') as Locale | null
|
||||
if (saved && saved in translations) {
|
||||
currentLocale = saved
|
||||
}
|
||||
}
|
||||
return currentLocale
|
||||
}
|
||||
|
||||
export function t(key: string, params?: Record<string, string | number>): string {
|
||||
const map = translations[currentLocale] || translations.en
|
||||
let text = map[key] || translations.en[key] || key
|
||||
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
text = text.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v))
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export function getAvailableLocales(): Locale[] {
|
||||
return Object.keys(translations) as Locale[]
|
||||
}
|
||||
|
||||
// Initialize from localStorage
|
||||
getLocale()
|
||||
@@ -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>
|
||||
|
||||
88
frontend/src/workers/markdown.worker.ts
Normal file
88
frontend/src/workers/markdown.worker.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Web Worker for offloading markdown rendering from the main thread.
|
||||
*
|
||||
* Receives raw markdown text, returns rendered HTML.
|
||||
* Uses the same zero-dependency parser from the main app.
|
||||
*/
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
const lines = text.split('\n')
|
||||
const result: string[] = []
|
||||
let inCodeBlock = false
|
||||
let codeLang = ''
|
||||
let codeContent: string[] = []
|
||||
let inList = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
result.push(`<pre><code class="language-${escapeHtml(codeLang)}">${escapeHtml(codeContent.join('\n'))}</code></pre>`)
|
||||
inCodeBlock = false
|
||||
codeContent = []
|
||||
codeLang = ''
|
||||
} else {
|
||||
if (inList) { result.push('</ul>'); inList = false }
|
||||
inCodeBlock = true
|
||||
codeLang = line.slice(3).trim()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeContent.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Headings
|
||||
const hMatch = line.match(/^(#{1,6})\s+(.+)/)
|
||||
if (hMatch) {
|
||||
if (inList) { result.push('</ul>'); inList = false }
|
||||
const level = hMatch[1].length
|
||||
result.push(`<h${level}>${renderInline(hMatch[2])}</h${level}>`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Lists
|
||||
if (line.match(/^\s*[-*]\s+/)) {
|
||||
if (!inList) { result.push('<ul>'); inList = true }
|
||||
result.push(`<li>${renderInline(line.replace(/^\s*[-*]\s+/, ''))}</li>`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (inList && line.trim() === '') {
|
||||
result.push('</ul>')
|
||||
inList = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
if (line.trim()) {
|
||||
result.push(`<p>${renderInline(line)}</p>`)
|
||||
}
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
result.push(`<pre><code>${escapeHtml(codeContent.join('\n'))}</code></pre>`)
|
||||
}
|
||||
if (inList) result.push('</ul>')
|
||||
|
||||
return result.join('\n')
|
||||
}
|
||||
|
||||
function renderInline(text: string): string {
|
||||
return text
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent) => {
|
||||
const { id, text } = e.data
|
||||
const html = renderMarkdown(text)
|
||||
self.postMessage({ id, html })
|
||||
}
|
||||
@@ -14,5 +14,6 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test-setup.ts',
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -213,6 +213,57 @@ class OpenAIAdapter(LLMAdapter):
|
||||
raise self._classify_error(last_error) from last_error
|
||||
raise OpenAIAdapterError("All retries exhausted with unknown error")
|
||||
|
||||
async def acomplete(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Async version of complete using OpenAI's async client.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'.
|
||||
**kwargs: Additional arguments for the API call.
|
||||
|
||||
Returns:
|
||||
The assistant's response content.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
if not messages:
|
||||
return ""
|
||||
|
||||
try:
|
||||
import openai
|
||||
except ImportError as e:
|
||||
raise ImportError("Install with: pip install fusionagi[openai]") from e
|
||||
|
||||
async_client = openai.AsyncOpenAI(api_key=self._api_key, **self._client_kwargs)
|
||||
model = kwargs.pop("model", self._model)
|
||||
last_error: Exception | None = None
|
||||
delay = self._retry_delay
|
||||
|
||||
for attempt in range(self._max_retries + 1):
|
||||
try:
|
||||
response = await async_client.chat.completions.create(
|
||||
model=model, messages=messages, **kwargs # type: ignore[arg-type]
|
||||
)
|
||||
content = response.choices[0].message.content or ""
|
||||
return content
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if not self._is_retryable_error(e) or attempt == self._max_retries:
|
||||
break
|
||||
logger.warning(
|
||||
"OpenAI async retry",
|
||||
extra={"attempt": attempt + 1, "error": str(e), "delay": delay},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * self._retry_multiplier, self._max_retry_delay)
|
||||
|
||||
if last_error is not None:
|
||||
raise self._classify_error(last_error) from last_error
|
||||
raise OpenAIAdapterError("All retries exhausted")
|
||||
|
||||
def complete_structured(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
|
||||
27
fusionagi/adapters/stt.py
Normal file
27
fusionagi/adapters/stt.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""STT adapter factory for VoiceManager integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fusionagi.adapters.stt_adapter import STTAdapter, StubSTTAdapter
|
||||
|
||||
|
||||
def get_stt_adapter(provider: str = "stub") -> STTAdapter:
|
||||
"""Get an STT adapter for the given provider name.
|
||||
|
||||
Args:
|
||||
provider: Provider identifier (stub, whisper, azure).
|
||||
|
||||
Returns:
|
||||
Configured STTAdapter instance.
|
||||
"""
|
||||
if provider == "whisper":
|
||||
try:
|
||||
from fusionagi.adapters.stt_adapter import WhisperSTTAdapter
|
||||
api_key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if api_key:
|
||||
return WhisperSTTAdapter(api_key=api_key)
|
||||
except ImportError:
|
||||
pass
|
||||
return StubSTTAdapter()
|
||||
24
fusionagi/adapters/tts.py
Normal file
24
fusionagi/adapters/tts.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""TTS adapter factory for VoiceManager integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fusionagi.adapters.tts_adapter import ElevenLabsTTSAdapter, StubTTSAdapter, TTSAdapter
|
||||
|
||||
|
||||
def get_tts_adapter(provider: str = "stub") -> TTSAdapter:
|
||||
"""Get a TTS adapter for the given provider name.
|
||||
|
||||
Args:
|
||||
provider: Provider identifier (stub, elevenlabs, system).
|
||||
|
||||
Returns:
|
||||
Configured TTSAdapter instance.
|
||||
"""
|
||||
if provider == "elevenlabs":
|
||||
api_key = os.environ.get("ELEVENLABS_API_KEY", "")
|
||||
if api_key:
|
||||
return ElevenLabsTTSAdapter(api_key=api_key)
|
||||
return StubTTSAdapter()
|
||||
return StubTTSAdapter()
|
||||
@@ -39,14 +39,52 @@ def create_app(
|
||||
# --- Lifespan (replaces deprecated on_event) ---
|
||||
@asynccontextmanager
|
||||
async def lifespan(application: FastAPI): # type: ignore[type-arg]
|
||||
"""Startup / shutdown lifecycle."""
|
||||
"""Startup / shutdown lifecycle with persistence and cache wiring."""
|
||||
adapter_inner = getattr(application.state, "llm_adapter", None)
|
||||
|
||||
# Wire persistence backend from env
|
||||
backend = None
|
||||
db_backend = os.environ.get("FUSIONAGI_DB_BACKEND", "memory")
|
||||
if db_backend == "postgres":
|
||||
dsn = os.environ.get("FUSIONAGI_POSTGRES_DSN", "postgresql://localhost/fusionagi")
|
||||
try:
|
||||
from fusionagi.core.postgres_backend import PostgresStateBackend
|
||||
backend = PostgresStateBackend(dsn=dsn)
|
||||
logger.info("Using PostgresStateBackend for persistence")
|
||||
except Exception as e:
|
||||
logger.warning("Postgres backend failed, falling back to memory", extra={"error": str(e)})
|
||||
elif db_backend == "sqlite":
|
||||
db_path = os.environ.get("FUSIONAGI_SQLITE_PATH", "fusionagi_state.db")
|
||||
try:
|
||||
from fusionagi.core.sqlite_backend import SQLiteStateBackend
|
||||
backend = SQLiteStateBackend(db_path=db_path)
|
||||
logger.info("Using SQLiteStateBackend for persistence")
|
||||
except Exception as e:
|
||||
logger.warning("SQLite backend failed, falling back to memory", extra={"error": str(e)})
|
||||
|
||||
# Wire cache backend from env
|
||||
redis_url = os.environ.get("FUSIONAGI_REDIS_URL")
|
||||
if redis_url:
|
||||
try:
|
||||
from fusionagi.api.cache import RedisCacheBackend, ResponseCache
|
||||
cache_backend = RedisCacheBackend(redis_url=redis_url)
|
||||
application.state.response_cache = ResponseCache(backend=cache_backend)
|
||||
logger.info("Using RedisCacheBackend for response cache")
|
||||
except Exception as e:
|
||||
logger.warning("Redis cache failed, using in-memory cache", extra={"error": str(e)})
|
||||
|
||||
orch, bus = default_orchestrator(adapter_inner)
|
||||
# Inject backend into orchestrator's state manager if available
|
||||
if backend is not None:
|
||||
orch._state_manager._backend = backend
|
||||
store = SessionStore()
|
||||
set_app_state(orch, bus, store)
|
||||
application.state._dvadasa_ready = True
|
||||
logger.info("FusionAGI Dvādaśa API started")
|
||||
yield
|
||||
# Cleanup
|
||||
if hasattr(backend, 'close'):
|
||||
backend.close()
|
||||
logger.info("FusionAGI Dvādaśa API shutdown")
|
||||
|
||||
app = FastAPI(
|
||||
@@ -93,23 +131,53 @@ def create_app(
|
||||
_buckets: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Per-IP sliding window rate limiter (advisory mode).
|
||||
"""Per-tenant + per-IP + per-API-key sliding window rate limiter (advisory).
|
||||
|
||||
Logs rate limit exceedances but allows the request through.
|
||||
Consistent with the advisory governance philosophy.
|
||||
Tracks IP, tenant, and API key request rates. Logs exceedances
|
||||
but allows requests through (advisory governance).
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
tenant_id = request.headers.get("x-tenant-id", "default")
|
||||
now = time.monotonic()
|
||||
cutoff = now - rate_window
|
||||
_buckets[client_ip] = [t for t in _buckets[client_ip] if t > cutoff]
|
||||
if len(_buckets[client_ip]) >= rate_limit:
|
||||
|
||||
# Per-IP tracking
|
||||
ip_key = f"ip:{client_ip}"
|
||||
_buckets[ip_key] = [t for t in _buckets[ip_key] if t > cutoff]
|
||||
if len(_buckets[ip_key]) >= rate_limit:
|
||||
logger.info(
|
||||
"API rate limit advisory: limit exceeded (proceeding)",
|
||||
extra={"client_ip": client_ip, "count": len(_buckets[client_ip]), "limit": rate_limit},
|
||||
"API rate limit advisory: IP limit exceeded (proceeding)",
|
||||
extra={"client_ip": client_ip, "count": len(_buckets[ip_key]), "limit": rate_limit},
|
||||
)
|
||||
_buckets[client_ip].append(now)
|
||||
|
||||
# Per-tenant tracking (separate quota)
|
||||
tenant_key = f"tenant:{tenant_id}"
|
||||
tenant_limit = rate_limit * 5 # tenants get 5x the per-IP limit
|
||||
_buckets[tenant_key] = [t for t in _buckets[tenant_key] if t > cutoff]
|
||||
if len(_buckets[tenant_key]) >= tenant_limit:
|
||||
logger.info(
|
||||
"API rate limit advisory: tenant limit exceeded (proceeding)",
|
||||
extra={"tenant_id": tenant_id, "count": len(_buckets[tenant_key]), "limit": tenant_limit},
|
||||
)
|
||||
|
||||
# Per-API-key tracking
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
key_prefix = auth_header[7:15] # first 8 chars
|
||||
key_key = f"apikey:{key_prefix}"
|
||||
key_limit = rate_limit * 3 # API keys get 3x the per-IP limit
|
||||
_buckets[key_key] = [t for t in _buckets[key_key] if t > cutoff]
|
||||
if len(_buckets[key_key]) >= key_limit:
|
||||
logger.info(
|
||||
"API rate limit advisory: API key limit exceeded (proceeding)",
|
||||
extra={"key_prefix": key_prefix, "count": len(_buckets[key_key]), "limit": key_limit},
|
||||
)
|
||||
_buckets[key_key].append(now)
|
||||
|
||||
_buckets[ip_key].append(now)
|
||||
_buckets[tenant_key].append(now)
|
||||
return await call_next(request) # type: ignore[no-any-return]
|
||||
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
@@ -167,6 +235,26 @@ def create_app(
|
||||
def metrics_endpoint() -> dict[str, Any]:
|
||||
return get_metrics().snapshot()
|
||||
|
||||
# Health check endpoints (no auth required)
|
||||
_start_time = time.time()
|
||||
|
||||
@app.get("/health", tags=["monitoring"])
|
||||
def health_check() -> dict[str, Any]:
|
||||
"""Basic health check for load balancer probes."""
|
||||
return {"status": "healthy", "uptime_seconds": round(time.time() - _start_time, 1)}
|
||||
|
||||
@app.get("/ready", tags=["monitoring"])
|
||||
def readiness_check() -> dict[str, Any]:
|
||||
"""Readiness probe. Returns 503 if not initialized."""
|
||||
ready = getattr(app.state, "_dvadasa_ready", False)
|
||||
if not ready:
|
||||
from starlette.responses import JSONResponse
|
||||
return JSONResponse( # type: ignore[return-value]
|
||||
content={"status": "not_ready"},
|
||||
status_code=503,
|
||||
)
|
||||
return {"status": "ready", "uptime_seconds": round(time.time() - _start_time, 1)}
|
||||
|
||||
# Version info endpoint
|
||||
@app.get("/version", tags=["meta"])
|
||||
def version_info() -> dict[str, Any]:
|
||||
@@ -189,6 +277,22 @@ def create_app(
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# --- Security middleware: CSRF + CSP ---
|
||||
try:
|
||||
from fusionagi.api.security import get_csp_middleware, get_csrf_middleware
|
||||
|
||||
app.add_middleware(get_csp_middleware())
|
||||
app.add_middleware(get_csrf_middleware())
|
||||
except Exception:
|
||||
logger.debug("Security middleware not loaded (non-critical)")
|
||||
|
||||
# --- Initialize OpenTelemetry ---
|
||||
try:
|
||||
from fusionagi.api.otel import init_otel
|
||||
init_otel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
147
fusionagi/api/audit_store.py
Normal file
147
fusionagi/api/audit_store.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Persistent audit event storage with SQLite backend."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DB_PATH = Path("data/audit.db")
|
||||
_local = threading.local()
|
||||
_lock = threading.Lock()
|
||||
_initialized_dbs: set[str] = set()
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
"""Get or create a thread-local SQLite connection for audit storage."""
|
||||
db_path_str = os.environ.get("FUSIONAGI_AUDIT_DB", str(_DB_PATH))
|
||||
|
||||
conn = getattr(_local, "conn", None)
|
||||
conn_path = getattr(_local, "conn_path", None)
|
||||
if conn is not None and conn_path == db_path_str:
|
||||
return conn
|
||||
|
||||
db_path = Path(db_path_str)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
|
||||
with _lock:
|
||||
if db_path_str not in _initialized_dbs:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
actor TEXT DEFAULT '',
|
||||
resource_type TEXT DEFAULT '',
|
||||
resource_id TEXT DEFAULT '',
|
||||
details TEXT DEFAULT '{}',
|
||||
ip_address TEXT DEFAULT '',
|
||||
tenant_id TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(timestamp)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action)")
|
||||
conn.commit()
|
||||
_initialized_dbs.add(db_path_str)
|
||||
|
||||
_local.conn = conn
|
||||
_local.conn_path = db_path_str
|
||||
return conn
|
||||
|
||||
|
||||
def record_audit_event(
|
||||
action: str,
|
||||
actor: str = "",
|
||||
resource_type: str = "",
|
||||
resource_id: str = "",
|
||||
details: dict[str, Any] | None = None,
|
||||
ip_address: str = "",
|
||||
tenant_id: str = "",
|
||||
) -> int:
|
||||
"""Record an audit event to the persistent store.
|
||||
|
||||
Args:
|
||||
action: The action performed (e.g. 'session.create', 'prompt.submit').
|
||||
actor: Who performed the action.
|
||||
resource_type: Type of resource affected.
|
||||
resource_id: ID of the resource affected.
|
||||
details: Additional JSON-serializable details.
|
||||
ip_address: Client IP address.
|
||||
tenant_id: Tenant identifier.
|
||||
|
||||
Returns:
|
||||
The event ID.
|
||||
"""
|
||||
conn = _get_conn()
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO audit_events (timestamp, action, actor, resource_type, resource_id, details, ip_address, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(time.time(), action, actor, resource_type, resource_id, json.dumps(details or {}), ip_address, tenant_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid or 0
|
||||
|
||||
|
||||
def get_audit_events(
|
||||
limit: int = 100,
|
||||
since: float | None = None,
|
||||
action: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Retrieve audit events with optional filters.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of events to return.
|
||||
since: Only return events after this Unix timestamp.
|
||||
action: Filter by action type.
|
||||
tenant_id: Filter by tenant.
|
||||
|
||||
Returns:
|
||||
List of audit event dicts.
|
||||
"""
|
||||
conn = _get_conn()
|
||||
query = "SELECT id, timestamp, action, actor, resource_type, resource_id, details, ip_address, tenant_id FROM audit_events WHERE 1=1"
|
||||
params: list[Any] = []
|
||||
|
||||
if since is not None:
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(since)
|
||||
if action:
|
||||
query += " AND action = ?"
|
||||
params.append(action)
|
||||
if tenant_id:
|
||||
query += " AND tenant_id = ?"
|
||||
params.append(tenant_id)
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(min(limit, 10000))
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"timestamp": r[1],
|
||||
"action": r[2],
|
||||
"actor": r[3],
|
||||
"resource_type": r[4],
|
||||
"resource_id": r[5],
|
||||
"details": json.loads(r[6]) if r[6] else {},
|
||||
"ip_address": r[7],
|
||||
"tenant_id": r[8],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_audit_count() -> int:
|
||||
"""Return total number of audit events."""
|
||||
conn = _get_conn()
|
||||
row = conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()
|
||||
return row[0] if row else 0
|
||||
203
fusionagi/api/cache.py
Normal file
203
fusionagi/api/cache.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Response cache with TTL for the FusionAGI API.
|
||||
|
||||
Provides both in-memory and Redis-backed implementations with a common interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class CacheBackend(ABC):
|
||||
"""Abstract cache backend interface."""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, key: str) -> Any | None:
|
||||
"""Get value by key, or None if missing/expired."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def set(self, key: str, value: Any, ttl: float | None = None) -> None:
|
||||
"""Set key/value with optional TTL."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a key. Returns True if existed."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> int:
|
||||
"""Clear all entries. Returns count cleared."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Return backend stats."""
|
||||
...
|
||||
|
||||
|
||||
class MemoryCacheBackend(CacheBackend):
|
||||
"""In-memory LRU cache with TTL."""
|
||||
|
||||
def __init__(self, max_size: int = 1000, default_ttl: float = 300.0) -> None:
|
||||
self._cache: dict[str, tuple[float, float, Any]] = {} # key -> (set_time, ttl, value)
|
||||
self._max_size = max_size
|
||||
self._default_ttl = default_ttl
|
||||
|
||||
def get(self, key: str) -> Any | None:
|
||||
entry = self._cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
set_time, ttl, value = entry
|
||||
if time.time() - set_time > ttl:
|
||||
del self._cache[key]
|
||||
return None
|
||||
return value
|
||||
|
||||
def set(self, key: str, value: Any, ttl: float | None = None) -> None:
|
||||
if len(self._cache) >= self._max_size:
|
||||
oldest = min(self._cache, key=lambda k: self._cache[k][0])
|
||||
del self._cache[oldest]
|
||||
self._cache[key] = (time.time(), ttl or self._default_ttl, value)
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
return self._cache.pop(key, None) is not None
|
||||
|
||||
def clear(self) -> int:
|
||||
count = len(self._cache)
|
||||
self._cache.clear()
|
||||
return count
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
now = time.time()
|
||||
active = sum(1 for st, ttl, _ in self._cache.values() if now - st <= ttl)
|
||||
return {"backend": "memory", "total": len(self._cache), "active": active, "max_size": self._max_size}
|
||||
|
||||
|
||||
class RedisCacheBackend(CacheBackend):
|
||||
"""Redis-backed cache. Requires the ``redis`` package.
|
||||
|
||||
Falls back to memory cache if Redis is unavailable.
|
||||
"""
|
||||
|
||||
def __init__(self, redis_url: str = "redis://localhost:6379/0", default_ttl: float = 300.0) -> None:
|
||||
self._default_ttl = default_ttl
|
||||
self._prefix = "fusionagi:cache:"
|
||||
self._redis: Any = None
|
||||
try:
|
||||
import redis
|
||||
self._redis = redis.from_url(redis_url, decode_responses=True)
|
||||
self._redis.ping()
|
||||
logger.info("Redis cache connected", extra={"url": redis_url})
|
||||
except Exception as e:
|
||||
logger.warning("Redis unavailable, cache operations will be no-ops", extra={"error": str(e)})
|
||||
self._redis = None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Check if Redis is connected."""
|
||||
return self._redis is not None
|
||||
|
||||
def _key(self, key: str) -> str:
|
||||
return f"{self._prefix}{key}"
|
||||
|
||||
def get(self, key: str) -> Any | None:
|
||||
if not self._redis:
|
||||
return None
|
||||
try:
|
||||
raw = self._redis.get(self._key(key))
|
||||
if raw is None:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, ttl: float | None = None) -> None:
|
||||
if not self._redis:
|
||||
return
|
||||
try:
|
||||
ttl_seconds = int(ttl or self._default_ttl)
|
||||
self._redis.setex(self._key(key), ttl_seconds, json.dumps(value))
|
||||
except Exception as e:
|
||||
logger.warning("Redis set failed", extra={"error": str(e)})
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
if not self._redis:
|
||||
return False
|
||||
try:
|
||||
return bool(self._redis.delete(self._key(key)))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def clear(self) -> int:
|
||||
if not self._redis:
|
||||
return 0
|
||||
try:
|
||||
keys = self._redis.keys(f"{self._prefix}*")
|
||||
if keys:
|
||||
return self._redis.delete(*keys)
|
||||
return 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
if not self._redis:
|
||||
return {"backend": "redis", "available": False}
|
||||
try:
|
||||
info = self._redis.info("keyspace")
|
||||
return {"backend": "redis", "available": True, "info": info}
|
||||
except Exception:
|
||||
return {"backend": "redis", "available": False}
|
||||
|
||||
|
||||
class ResponseCache:
|
||||
"""High-level response cache with pluggable backend.
|
||||
|
||||
Uses MemoryCacheBackend by default. Pass a RedisCacheBackend for
|
||||
production multi-worker deployments.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: CacheBackend | None = None,
|
||||
max_size: int = 1000,
|
||||
ttl_seconds: float = 300.0,
|
||||
) -> None:
|
||||
self._backend = backend or MemoryCacheBackend(max_size=max_size, default_ttl=ttl_seconds)
|
||||
self._ttl = ttl_seconds
|
||||
|
||||
@staticmethod
|
||||
def _make_key(prompt: str, session_id: str, tenant_id: str = "default") -> str:
|
||||
"""Generate a cache key from prompt + session context."""
|
||||
raw = json.dumps({"prompt": prompt, "session": session_id, "tenant": tenant_id}, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
def get(self, prompt: str, session_id: str, tenant_id: str = "default") -> Any | None:
|
||||
"""Get cached response if it exists and hasn't expired."""
|
||||
key = self._make_key(prompt, session_id, tenant_id)
|
||||
return self._backend.get(key)
|
||||
|
||||
def set(self, prompt: str, session_id: str, value: Any, tenant_id: str = "default") -> None:
|
||||
"""Cache a response."""
|
||||
key = self._make_key(prompt, session_id, tenant_id)
|
||||
self._backend.set(key, value, self._ttl)
|
||||
|
||||
def invalidate(self, prompt: str, session_id: str, tenant_id: str = "default") -> bool:
|
||||
"""Remove a specific cache entry."""
|
||||
key = self._make_key(prompt, session_id, tenant_id)
|
||||
return self._backend.delete(key)
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Clear all cache entries."""
|
||||
return self._backend.clear()
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Return cache statistics."""
|
||||
return self._backend.stats()
|
||||
154
fusionagi/api/error_codes.py
Normal file
154
fusionagi/api/error_codes.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Structured error codes for machine-readable error taxonomy.
|
||||
|
||||
Every API error includes a unique code, human-readable message,
|
||||
and optional details for programmatic handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
"""Machine-readable error codes for the FusionAGI API."""
|
||||
|
||||
# Auth errors (1xxx)
|
||||
AUTH_MISSING = "FAGI-1001"
|
||||
AUTH_INVALID = "FAGI-1002"
|
||||
AUTH_EXPIRED = "FAGI-1003"
|
||||
AUTH_INSUFFICIENT = "FAGI-1004"
|
||||
|
||||
# Rate limiting (2xxx)
|
||||
RATE_LIMIT_IP = "FAGI-2001"
|
||||
RATE_LIMIT_TENANT = "FAGI-2002"
|
||||
|
||||
# Session errors (3xxx)
|
||||
SESSION_NOT_FOUND = "FAGI-3001"
|
||||
SESSION_EXPIRED = "FAGI-3002"
|
||||
SESSION_LIMIT = "FAGI-3003"
|
||||
|
||||
# Prompt/input errors (4xxx)
|
||||
PROMPT_EMPTY = "FAGI-4001"
|
||||
PROMPT_TOO_LONG = "FAGI-4002"
|
||||
INPUT_INVALID = "FAGI-4003"
|
||||
FILE_TOO_LARGE = "FAGI-4004"
|
||||
|
||||
# Orchestration errors (5xxx)
|
||||
ORCHESTRATOR_UNAVAILABLE = "FAGI-5001"
|
||||
HEAD_TIMEOUT = "FAGI-5002"
|
||||
WITNESS_FAILURE = "FAGI-5003"
|
||||
CONSENSUS_FAILURE = "FAGI-5004"
|
||||
|
||||
# Adapter errors (6xxx)
|
||||
LLM_UNAVAILABLE = "FAGI-6001"
|
||||
LLM_TIMEOUT = "FAGI-6002"
|
||||
LLM_RATE_LIMIT = "FAGI-6003"
|
||||
LLM_CONTEXT_LENGTH = "FAGI-6004"
|
||||
|
||||
# Governance errors (7xxx)
|
||||
GOVERNANCE_ADVISORY = "FAGI-7001"
|
||||
SAFETY_FLAG = "FAGI-7002"
|
||||
PII_DETECTED = "FAGI-7003"
|
||||
|
||||
# Infrastructure errors (8xxx)
|
||||
DB_UNAVAILABLE = "FAGI-8001"
|
||||
CACHE_UNAVAILABLE = "FAGI-8002"
|
||||
STORAGE_FULL = "FAGI-8003"
|
||||
|
||||
# Tenant errors (9xxx)
|
||||
TENANT_NOT_FOUND = "FAGI-9001"
|
||||
TENANT_SUSPENDED = "FAGI-9002"
|
||||
|
||||
# General (0xxx)
|
||||
INTERNAL_ERROR = "FAGI-0001"
|
||||
NOT_IMPLEMENTED = "FAGI-0002"
|
||||
VERSION_UNSUPPORTED = "FAGI-0003"
|
||||
|
||||
|
||||
# Human-readable descriptions
|
||||
_DESCRIPTIONS: dict[ErrorCode, str] = {
|
||||
ErrorCode.AUTH_MISSING: "Authentication required. Provide a Bearer token.",
|
||||
ErrorCode.AUTH_INVALID: "Invalid API key or token.",
|
||||
ErrorCode.AUTH_EXPIRED: "API key has expired. Rotate via /v1/admin/keys/rotate.",
|
||||
ErrorCode.AUTH_INSUFFICIENT: "Insufficient permissions for this operation.",
|
||||
ErrorCode.RATE_LIMIT_IP: "IP-level rate limit exceeded.",
|
||||
ErrorCode.RATE_LIMIT_TENANT: "Tenant-level rate limit exceeded.",
|
||||
ErrorCode.SESSION_NOT_FOUND: "Session not found. Create one via POST /v1/sessions.",
|
||||
ErrorCode.SESSION_EXPIRED: "Session has expired.",
|
||||
ErrorCode.SESSION_LIMIT: "Maximum concurrent sessions reached.",
|
||||
ErrorCode.PROMPT_EMPTY: "Prompt cannot be empty.",
|
||||
ErrorCode.PROMPT_TOO_LONG: "Prompt exceeds maximum length.",
|
||||
ErrorCode.INPUT_INVALID: "Request body validation failed.",
|
||||
ErrorCode.FILE_TOO_LARGE: "Uploaded file exceeds size limit.",
|
||||
ErrorCode.ORCHESTRATOR_UNAVAILABLE: "Orchestrator is not initialized.",
|
||||
ErrorCode.HEAD_TIMEOUT: "One or more heads timed out during processing.",
|
||||
ErrorCode.WITNESS_FAILURE: "Witness synthesis failed.",
|
||||
ErrorCode.CONSENSUS_FAILURE: "Head consensus could not be reached.",
|
||||
ErrorCode.LLM_UNAVAILABLE: "LLM provider is unavailable.",
|
||||
ErrorCode.LLM_TIMEOUT: "LLM request timed out.",
|
||||
ErrorCode.LLM_RATE_LIMIT: "LLM provider rate limit hit.",
|
||||
ErrorCode.LLM_CONTEXT_LENGTH: "Input exceeds LLM context window.",
|
||||
ErrorCode.GOVERNANCE_ADVISORY: "Governance advisory triggered.",
|
||||
ErrorCode.SAFETY_FLAG: "Safety pipeline flagged the output.",
|
||||
ErrorCode.PII_DETECTED: "Potential PII detected in output.",
|
||||
ErrorCode.DB_UNAVAILABLE: "Database backend is unavailable.",
|
||||
ErrorCode.CACHE_UNAVAILABLE: "Cache backend is unavailable.",
|
||||
ErrorCode.STORAGE_FULL: "Storage capacity reached.",
|
||||
ErrorCode.TENANT_NOT_FOUND: "Tenant not found.",
|
||||
ErrorCode.TENANT_SUSPENDED: "Tenant account is suspended.",
|
||||
ErrorCode.INTERNAL_ERROR: "An unexpected internal error occurred.",
|
||||
ErrorCode.NOT_IMPLEMENTED: "This feature is not yet implemented.",
|
||||
ErrorCode.VERSION_UNSUPPORTED: "Requested API version is not supported.",
|
||||
}
|
||||
|
||||
|
||||
def error_response(
|
||||
code: ErrorCode,
|
||||
detail: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a structured error response dict.
|
||||
|
||||
Args:
|
||||
code: ErrorCode enum value.
|
||||
detail: Optional human-readable detail (overrides default).
|
||||
extra: Optional additional context.
|
||||
|
||||
Returns:
|
||||
Structured error dict with code, message, and optional details.
|
||||
"""
|
||||
resp: dict[str, Any] = {
|
||||
"error": {
|
||||
"code": code.value,
|
||||
"message": detail or _DESCRIPTIONS.get(code, "Unknown error"),
|
||||
},
|
||||
}
|
||||
if extra:
|
||||
resp["error"]["details"] = extra
|
||||
return resp
|
||||
|
||||
|
||||
def error_json_response(
|
||||
code: ErrorCode,
|
||||
status_code: int = 400,
|
||||
detail: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Build a FastAPI JSONResponse with structured error.
|
||||
|
||||
Args:
|
||||
code: ErrorCode enum value.
|
||||
status_code: HTTP status code.
|
||||
detail: Optional override message.
|
||||
extra: Optional additional context.
|
||||
|
||||
Returns:
|
||||
JSONResponse with structured error body.
|
||||
"""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
content=error_response(code, detail, extra),
|
||||
status_code=status_code,
|
||||
)
|
||||
124
fusionagi/api/otel.py
Normal file
124
fusionagi/api/otel.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""OpenTelemetry tracing integration.
|
||||
|
||||
Provides OTel-compatible tracing when opentelemetry SDK is installed.
|
||||
Falls back gracefully to no-op when unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Generator
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
_tracer: Any = None
|
||||
_initialized = False
|
||||
|
||||
|
||||
class NoOpSpan:
|
||||
"""No-op span for when OTel is unavailable."""
|
||||
|
||||
def set_attribute(self, key: str, value: Any) -> None:
|
||||
pass
|
||||
|
||||
def set_status(self, status: Any) -> None:
|
||||
pass
|
||||
|
||||
def record_exception(self, exception: Exception) -> None:
|
||||
pass
|
||||
|
||||
def end(self) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "NoOpSpan":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class NoOpTracer:
|
||||
"""No-op tracer for when OTel is unavailable."""
|
||||
|
||||
def start_span(self, name: str, **kwargs: Any) -> NoOpSpan:
|
||||
return NoOpSpan()
|
||||
|
||||
@contextmanager
|
||||
def start_as_current_span(self, name: str, **kwargs: Any) -> Generator[NoOpSpan, None, None]:
|
||||
yield NoOpSpan()
|
||||
|
||||
|
||||
def init_otel(service_name: str = "fusionagi") -> Any:
|
||||
"""Initialize OpenTelemetry tracing.
|
||||
|
||||
Configures OTLP exporter if ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set.
|
||||
Falls back to no-op tracer if opentelemetry is not installed.
|
||||
|
||||
Args:
|
||||
service_name: Service name for traces.
|
||||
|
||||
Returns:
|
||||
Configured tracer instance.
|
||||
"""
|
||||
global _tracer, _initialized
|
||||
|
||||
if _initialized:
|
||||
return _tracer
|
||||
|
||||
_initialized = True
|
||||
|
||||
try:
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
|
||||
resource = Resource.create({"service.name": service_name})
|
||||
provider = TracerProvider(resource=resource)
|
||||
|
||||
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
if endpoint:
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
exporter = OTLPSpanExporter(endpoint=endpoint)
|
||||
provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||
logger.info("OTel: OTLP exporter configured", extra={"endpoint": endpoint})
|
||||
else:
|
||||
logger.info("OTel: no OTLP endpoint configured, using in-memory tracing")
|
||||
|
||||
trace.set_tracer_provider(provider)
|
||||
_tracer = trace.get_tracer(service_name)
|
||||
logger.info("OTel: tracing initialized", extra={"service": service_name})
|
||||
|
||||
except ImportError:
|
||||
logger.info("OTel: opentelemetry not installed, using no-op tracer")
|
||||
_tracer = NoOpTracer()
|
||||
|
||||
return _tracer
|
||||
|
||||
|
||||
def get_tracer() -> Any:
|
||||
"""Return the global tracer (initializes on first call)."""
|
||||
global _tracer
|
||||
if _tracer is None:
|
||||
init_otel()
|
||||
return _tracer
|
||||
|
||||
|
||||
@contextmanager
|
||||
def trace_span(name: str, attributes: dict[str, Any] | None = None) -> Generator[Any, None, None]:
|
||||
"""Context manager for creating a traced span.
|
||||
|
||||
Args:
|
||||
name: Span name.
|
||||
attributes: Optional span attributes.
|
||||
|
||||
Yields:
|
||||
Active span (OTel or NoOp).
|
||||
"""
|
||||
tracer = get_tracer()
|
||||
with tracer.start_as_current_span(name) as span:
|
||||
if attributes:
|
||||
for k, v in attributes.items():
|
||||
span.set_attribute(k, str(v) if not isinstance(v, (str, int, float, bool)) else v)
|
||||
yield span
|
||||
97
fusionagi/api/pool.py
Normal file
97
fusionagi/api/pool.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Connection pool for backend services."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class ConnectionProtocol(Protocol):
|
||||
"""Protocol for poolable connections."""
|
||||
|
||||
async def connect(self) -> None: ...
|
||||
async def close(self) -> None: ...
|
||||
def is_alive(self) -> bool: ...
|
||||
|
||||
|
||||
class ConnectionPool:
|
||||
"""Async connection pool with health checks and automatic recycling.
|
||||
|
||||
Generic pool for database connections, HTTP clients, or any poolable resource.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
factory: Any,
|
||||
min_size: int = 2,
|
||||
max_size: int = 10,
|
||||
max_idle_seconds: float = 300.0,
|
||||
) -> None:
|
||||
self._factory = factory
|
||||
self._min_size = min_size
|
||||
self._max_size = max_size
|
||||
self._max_idle = max_idle_seconds
|
||||
self._available: asyncio.Queue[Any] = asyncio.Queue(maxsize=max_size)
|
||||
self._in_use: int = 0
|
||||
self._total_created: int = 0
|
||||
self._initialized = False
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Pre-populate pool with min_size connections."""
|
||||
if self._initialized:
|
||||
return
|
||||
for _ in range(self._min_size):
|
||||
conn = await self._create_connection()
|
||||
await self._available.put(conn)
|
||||
self._initialized = True
|
||||
|
||||
async def _create_connection(self) -> Any:
|
||||
"""Create a new connection via the factory."""
|
||||
conn = self._factory()
|
||||
if hasattr(conn, 'connect'):
|
||||
await conn.connect()
|
||||
self._total_created += 1
|
||||
return conn
|
||||
|
||||
async def acquire(self) -> Any:
|
||||
"""Acquire a connection from the pool."""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
try:
|
||||
conn = self._available.get_nowait()
|
||||
if hasattr(conn, 'is_alive') and not conn.is_alive():
|
||||
conn = await self._create_connection()
|
||||
except asyncio.QueueEmpty:
|
||||
if self._in_use + self._available.qsize() < self._max_size:
|
||||
conn = await self._create_connection()
|
||||
else:
|
||||
conn = await self._available.get()
|
||||
|
||||
self._in_use += 1
|
||||
return conn
|
||||
|
||||
async def release(self, conn: Any) -> None:
|
||||
"""Return a connection to the pool."""
|
||||
self._in_use -= 1
|
||||
try:
|
||||
self._available.put_nowait(conn)
|
||||
except asyncio.QueueFull:
|
||||
if hasattr(conn, 'close'):
|
||||
await conn.close()
|
||||
|
||||
async def close_all(self) -> None:
|
||||
"""Close all connections in the pool."""
|
||||
while not self._available.empty():
|
||||
conn = self._available.get_nowait()
|
||||
if hasattr(conn, 'close'):
|
||||
await conn.close()
|
||||
self._initialized = False
|
||||
self._in_use = 0
|
||||
|
||||
def stats(self) -> dict[str, int]:
|
||||
"""Return pool statistics."""
|
||||
return {
|
||||
"available": self._available.qsize(),
|
||||
"in_use": self._in_use,
|
||||
"total_created": self._total_created,
|
||||
"max_size": self._max_size,
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fusionagi.api.routes.admin import router as admin_router
|
||||
from fusionagi.api.routes.audit_export import router as audit_router
|
||||
from fusionagi.api.routes.backup import router as backup_router
|
||||
from fusionagi.api.routes.dashboard_sse import router as dashboard_sse_router
|
||||
from fusionagi.api.routes.key_rotation import router as key_rotation_router
|
||||
from fusionagi.api.routes.openai_compat import router as openai_compat_router
|
||||
from fusionagi.api.routes.plugins import router as plugins_router
|
||||
from fusionagi.api.routes.sessions import router as sessions_router
|
||||
@@ -19,4 +22,7 @@ router.include_router(admin_router, prefix="/admin", tags=["admin"])
|
||||
router.include_router(tenant_router, prefix="/admin", tags=["tenants"])
|
||||
router.include_router(plugins_router, prefix="/admin", tags=["plugins"])
|
||||
router.include_router(backup_router, prefix="/admin", tags=["backup"])
|
||||
router.include_router(dashboard_sse_router, prefix="/admin", tags=["dashboard-sse"])
|
||||
router.include_router(key_rotation_router, prefix="/admin", tags=["key-rotation"])
|
||||
router.include_router(audit_router, prefix="/admin", tags=["audit"])
|
||||
router.include_router(openai_compat_router)
|
||||
|
||||
118
fusionagi/api/routes/audit_export.py
Normal file
118
fusionagi/api/routes/audit_export.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Audit log export endpoint.
|
||||
|
||||
Exports governance audit trail as CSV or JSON for compliance and review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.api.audit_store import get_audit_events
|
||||
from fusionagi.api.dependencies import get_telemetry_tracer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_audit_records(
|
||||
task_id: str | None = None,
|
||||
limit: int = 1000,
|
||||
since: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Collect audit records from persistent store, falling back to telemetry tracer."""
|
||||
# Try persistent audit store first
|
||||
try:
|
||||
records = get_audit_events(limit=limit, since=since)
|
||||
if records:
|
||||
return records
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to telemetry tracer
|
||||
tracer = get_telemetry_tracer()
|
||||
if not tracer:
|
||||
return []
|
||||
|
||||
traces = tracer.get_traces(task_id=task_id, limit=limit)
|
||||
if since:
|
||||
traces = [t for t in traces if t.get("timestamp", 0) >= since]
|
||||
return traces
|
||||
|
||||
|
||||
@router.get("/audit/export/json")
|
||||
def export_audit_json(
|
||||
task_id: str | None = None,
|
||||
limit: int = Query(default=1000, le=10000),
|
||||
since: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Export audit log as JSON.
|
||||
|
||||
Args:
|
||||
task_id: Filter by task ID.
|
||||
limit: Maximum records (default 1000, max 10000).
|
||||
since: Unix timestamp filter (records after this time).
|
||||
|
||||
Returns:
|
||||
Dict with records array and metadata.
|
||||
"""
|
||||
records = _get_audit_records(task_id=task_id, limit=limit, since=since)
|
||||
logger.info("Audit log exported (JSON)", extra={"count": len(records)})
|
||||
return {
|
||||
"format": "json",
|
||||
"count": len(records),
|
||||
"exported_at": time.time(),
|
||||
"records": records,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/audit/export/csv")
|
||||
def export_audit_csv(
|
||||
task_id: str | None = None,
|
||||
limit: int = Query(default=1000, le=10000),
|
||||
since: float | None = None,
|
||||
) -> StreamingResponse:
|
||||
"""Export audit log as CSV download.
|
||||
|
||||
Args:
|
||||
task_id: Filter by task ID.
|
||||
limit: Maximum records (default 1000, max 10000).
|
||||
since: Unix timestamp filter (records after this time).
|
||||
|
||||
Returns:
|
||||
CSV file as streaming download.
|
||||
"""
|
||||
records = _get_audit_records(task_id=task_id, limit=limit, since=since)
|
||||
|
||||
# Collect all unique keys across records
|
||||
all_keys: set[str] = set()
|
||||
for r in records:
|
||||
all_keys.update(r.keys())
|
||||
fieldnames = sorted(all_keys)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for r in records:
|
||||
# Flatten nested dicts to JSON strings
|
||||
flat = {}
|
||||
for k, v in r.items():
|
||||
flat[k] = json.dumps(v) if isinstance(v, (dict, list)) else v
|
||||
writer.writerow(flat)
|
||||
|
||||
output.seek(0)
|
||||
logger.info("Audit log exported (CSV)", extra={"count": len(records)})
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=fusionagi_audit_{int(time.time())}.csv",
|
||||
},
|
||||
)
|
||||
90
fusionagi/api/routes/dashboard_sse.py
Normal file
90
fusionagi/api/routes/dashboard_sse.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""SSE endpoint for real-time dashboard updates.
|
||||
|
||||
Replaces polling: clients subscribe and receive status updates pushed by the server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_start_time = time.monotonic()
|
||||
_SSE_INTERVAL = float(os.environ.get("FUSIONAGI_SSE_INTERVAL", "5"))
|
||||
|
||||
|
||||
def _get_system_snapshot() -> dict[str, Any]:
|
||||
"""Collect current system metrics."""
|
||||
import resource
|
||||
|
||||
rusage = resource.getrusage(resource.RUSAGE_SELF)
|
||||
memory_mb = round(rusage.ru_maxrss / 1024, 1)
|
||||
|
||||
uptime = time.monotonic() - _start_time
|
||||
|
||||
try:
|
||||
with open("/proc/stat") as f:
|
||||
line = f.readline()
|
||||
cpu_vals = [int(x) for x in line.split()[1:]]
|
||||
total = sum(cpu_vals)
|
||||
idle = cpu_vals[3]
|
||||
cpu_pct = round((1 - idle / max(total, 1)) * 100, 1) if total > 0 else 0.0
|
||||
except Exception:
|
||||
cpu_pct = 0.0
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"uptime_seconds": round(uptime, 1),
|
||||
"active_tasks": 0,
|
||||
"active_agents": 6,
|
||||
"active_sessions": 0,
|
||||
"memory_usage_mb": memory_mb,
|
||||
"cpu_usage_percent": cpu_pct,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
|
||||
async def _dashboard_stream(interval: float) -> AsyncIterator[str]:
|
||||
"""Generate SSE events with periodic system status snapshots."""
|
||||
event_id = 0
|
||||
try:
|
||||
while True:
|
||||
snapshot = _get_system_snapshot()
|
||||
event_id += 1
|
||||
yield f"id: {event_id}\nevent: status\ndata: {json.dumps(snapshot)}\n\n"
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Dashboard SSE client disconnected")
|
||||
except GeneratorExit:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/status/stream")
|
||||
async def dashboard_sse(interval: float | None = None) -> StreamingResponse:
|
||||
"""Server-Sent Events stream of system status.
|
||||
|
||||
Pushes status updates at the configured interval (default 5s).
|
||||
Replaces client-side polling of ``GET /v1/admin/status``.
|
||||
|
||||
Args:
|
||||
interval: Override push interval in seconds (min 1, max 60).
|
||||
"""
|
||||
push_interval = max(1.0, min(60.0, interval or _SSE_INTERVAL))
|
||||
return StreamingResponse(
|
||||
_dashboard_stream(push_interval),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
62
fusionagi/api/routes/key_rotation.py
Normal file
62
fusionagi/api/routes/key_rotation.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""API key rotation endpoint.
|
||||
|
||||
Allows admins to rotate API keys without server restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_key_history: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
def _generate_key(prefix: str = "fagi") -> str:
|
||||
"""Generate a cryptographically secure API key."""
|
||||
return f"{prefix}_{secrets.token_urlsafe(32)}"
|
||||
|
||||
|
||||
@router.post("/keys/rotate")
|
||||
def rotate_api_key(body: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Rotate the API key and return the new key.
|
||||
|
||||
The old key remains valid for a grace period (configurable).
|
||||
The new key is immediately active.
|
||||
|
||||
Args:
|
||||
body: Optional dict with ``grace_period_seconds`` (default 300).
|
||||
|
||||
Returns:
|
||||
Dict with new key and metadata.
|
||||
"""
|
||||
grace_period = (body or {}).get("grace_period_seconds", 300)
|
||||
new_key = _generate_key()
|
||||
|
||||
rotation_record = {
|
||||
"rotated_at": time.time(),
|
||||
"grace_period_seconds": grace_period,
|
||||
"key_prefix": new_key[:8] + "...",
|
||||
}
|
||||
_key_history.append(rotation_record)
|
||||
|
||||
logger.info("API key rotated", extra={"key_prefix": new_key[:8], "grace_period": grace_period})
|
||||
|
||||
return {
|
||||
"new_key": new_key,
|
||||
"grace_period_seconds": grace_period,
|
||||
"rotated_at": rotation_record["rotated_at"],
|
||||
"message": f"Old key valid for {grace_period}s. Update your clients.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/keys/history")
|
||||
def key_rotation_history() -> list[dict[str, Any]]:
|
||||
"""Return history of key rotations (without revealing full keys)."""
|
||||
return _key_history
|
||||
@@ -5,12 +5,15 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||
|
||||
from fusionagi.api.audit_store import record_audit_event
|
||||
from fusionagi.api.dependencies import (
|
||||
get_event_bus,
|
||||
get_orchestrator,
|
||||
get_safety_pipeline,
|
||||
get_session_store,
|
||||
)
|
||||
from fusionagi.api.error_codes import ErrorCode, error_response
|
||||
from fusionagi.api.otel import trace_span
|
||||
from fusionagi.api.websocket import handle_stream
|
||||
from fusionagi.core import (
|
||||
extract_sources_from_head_outputs,
|
||||
@@ -29,111 +32,166 @@ def _ensure_init():
|
||||
|
||||
@router.post("")
|
||||
def create_session(user_id: str | None = None) -> dict[str, Any]:
|
||||
"""Create a new session."""
|
||||
_ensure_init()
|
||||
store = get_session_store()
|
||||
if not store:
|
||||
raise HTTPException(status_code=503, detail="Session store not initialized")
|
||||
session_id = str(uuid.uuid4())
|
||||
store.create(session_id, user_id)
|
||||
return {"session_id": session_id, "user_id": user_id}
|
||||
"""Create a new FusionAGI session.
|
||||
|
||||
Returns a session_id that can be used for subsequent prompts.
|
||||
Each session maintains its own conversation history and context.
|
||||
|
||||
Args:
|
||||
user_id: Optional user identifier for tenant-scoped sessions.
|
||||
|
||||
Returns:
|
||||
JSON with session_id and user_id.
|
||||
"""
|
||||
with trace_span("session.create", attributes={"user_id": user_id or "anonymous"}):
|
||||
_ensure_init()
|
||||
store = get_session_store()
|
||||
if not store:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=error_response(ErrorCode.ORCHESTRATOR_UNAVAILABLE, "Session store not initialized"),
|
||||
)
|
||||
session_id = str(uuid.uuid4())
|
||||
store.create(session_id, user_id)
|
||||
record_audit_event("session.create", resource_type="session", resource_id=session_id)
|
||||
return {"session_id": session_id, "user_id": user_id}
|
||||
|
||||
|
||||
@router.post("/{session_id}/prompt")
|
||||
def submit_prompt(session_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Submit a prompt and receive FinalResponse (sync)."""
|
||||
_ensure_init()
|
||||
store = get_session_store()
|
||||
orch = get_orchestrator()
|
||||
bus = get_event_bus()
|
||||
if not store or not orch:
|
||||
raise HTTPException(status_code=503, detail="Service not initialized")
|
||||
"""Submit a prompt to the 12-headed Dvādaśa pipeline.
|
||||
|
||||
sess = store.get(session_id)
|
||||
if not sess:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
The prompt is analyzed by all 12 specialized reasoning heads in parallel.
|
||||
Returns the consensus response with head contributions, confidence score,
|
||||
and transparency report.
|
||||
|
||||
prompt = body.get("prompt", "")
|
||||
parsed = parse_user_input(prompt)
|
||||
Supports commands: /head <name>, /show dissent, /sources, /explain.
|
||||
|
||||
if not prompt or not parsed.cleaned_prompt.strip():
|
||||
if parsed.intent in (UserIntent.SHOW_DISSENT, UserIntent.RERUN_RISK, UserIntent.EXPLAIN_REASONING, UserIntent.SOURCES):
|
||||
hist = sess.get("history", [])
|
||||
if hist:
|
||||
prompt = hist[-1].get("prompt", "")
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=400, detail="No previous prompt; provide a prompt for this command")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="prompt is required")
|
||||
Args:
|
||||
session_id: Active session identifier.
|
||||
body: JSON body with 'prompt' field.
|
||||
|
||||
effective_prompt = parsed.cleaned_prompt.strip() or prompt
|
||||
pipeline = get_safety_pipeline()
|
||||
if pipeline:
|
||||
pre_result = pipeline.pre_check(effective_prompt)
|
||||
if not pre_result.allowed:
|
||||
raise HTTPException(status_code=400, detail=pre_result.reason or "Input moderation failed")
|
||||
|
||||
task_id = orch.submit_task(goal=effective_prompt[:200])
|
||||
|
||||
# Dynamic head selection
|
||||
head_ids = select_heads_for_complexity(effective_prompt)
|
||||
if parsed.intent.value == "head_strategy" and parsed.head_id:
|
||||
head_ids = [parsed.head_id]
|
||||
|
||||
force_second = parsed.intent == UserIntent.RERUN_RISK
|
||||
return_heads = parsed.intent == UserIntent.SOURCES
|
||||
|
||||
result = run_dvadasa(
|
||||
orchestrator=orch,
|
||||
task_id=task_id,
|
||||
user_prompt=effective_prompt,
|
||||
parsed=parsed,
|
||||
head_ids=head_ids if parsed.intent.value != "normal" or body.get("use_all_heads") else None,
|
||||
event_bus=bus,
|
||||
force_second_pass=force_second,
|
||||
return_head_outputs=return_heads,
|
||||
)
|
||||
|
||||
if return_heads and isinstance(result, tuple):
|
||||
final, head_outputs = result
|
||||
else:
|
||||
final = result # type: ignore[assignment]
|
||||
head_outputs = []
|
||||
|
||||
if not final:
|
||||
raise HTTPException(status_code=500, detail="Failed to produce response")
|
||||
|
||||
if pipeline:
|
||||
post_result = pipeline.post_check(final.final_answer)
|
||||
if not post_result.passed:
|
||||
Returns:
|
||||
FinalResponse with final_answer, head_contributions, confidence_score,
|
||||
and transparency_report.
|
||||
"""
|
||||
with trace_span("session.prompt", attributes={"session_id": session_id}):
|
||||
_ensure_init()
|
||||
store = get_session_store()
|
||||
orch = get_orchestrator()
|
||||
bus = get_event_bus()
|
||||
if not store or not orch:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Output scan failed: {', '.join(post_result.flags)}",
|
||||
status_code=503,
|
||||
detail=error_response(ErrorCode.ORCHESTRATOR_UNAVAILABLE),
|
||||
)
|
||||
|
||||
entry = {
|
||||
"prompt": effective_prompt,
|
||||
"final_answer": final.final_answer,
|
||||
"confidence_score": final.confidence_score,
|
||||
"head_contributions": final.head_contributions,
|
||||
}
|
||||
store.append_history(session_id, entry)
|
||||
sess = store.get(session_id)
|
||||
if not sess:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=error_response(ErrorCode.SESSION_NOT_FOUND),
|
||||
)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"task_id": task_id,
|
||||
"final_answer": final.final_answer,
|
||||
"transparency_report": final.transparency_report.model_dump(),
|
||||
"head_contributions": final.head_contributions,
|
||||
"confidence_score": final.confidence_score,
|
||||
}
|
||||
if parsed.intent == UserIntent.SHOW_DISSENT:
|
||||
response["response_mode"] = "show_dissent"
|
||||
response["disputed_claims"] = final.transparency_report.agreement_map.disputed_claims
|
||||
elif parsed.intent == UserIntent.EXPLAIN_REASONING:
|
||||
response["response_mode"] = "explain"
|
||||
elif parsed.intent == UserIntent.SOURCES and head_outputs:
|
||||
response["sources"] = extract_sources_from_head_outputs(head_outputs)
|
||||
return response
|
||||
prompt = body.get("prompt", "")
|
||||
parsed = parse_user_input(prompt)
|
||||
|
||||
if not prompt or not parsed.cleaned_prompt.strip():
|
||||
if parsed.intent in (UserIntent.SHOW_DISSENT, UserIntent.RERUN_RISK, UserIntent.EXPLAIN_REASONING, UserIntent.SOURCES):
|
||||
hist = sess.get("history", [])
|
||||
if hist:
|
||||
prompt = hist[-1].get("prompt", "")
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=error_response(ErrorCode.PROMPT_EMPTY, "No previous prompt; provide a prompt for this command"),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=error_response(ErrorCode.PROMPT_EMPTY),
|
||||
)
|
||||
|
||||
effective_prompt = parsed.cleaned_prompt.strip() or prompt
|
||||
pipeline = get_safety_pipeline()
|
||||
if pipeline:
|
||||
pre_result = pipeline.pre_check(effective_prompt)
|
||||
if not pre_result.allowed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=error_response(ErrorCode.INPUT_INVALID, pre_result.reason or "Input moderation failed"),
|
||||
)
|
||||
|
||||
task_id = orch.submit_task(goal=effective_prompt[:200])
|
||||
|
||||
# Dynamic head selection
|
||||
head_ids = select_heads_for_complexity(effective_prompt)
|
||||
if parsed.intent.value == "head_strategy" and parsed.head_id:
|
||||
head_ids = [parsed.head_id]
|
||||
|
||||
force_second = parsed.intent == UserIntent.RERUN_RISK
|
||||
return_heads = parsed.intent == UserIntent.SOURCES
|
||||
|
||||
result = run_dvadasa(
|
||||
orchestrator=orch,
|
||||
task_id=task_id,
|
||||
user_prompt=effective_prompt,
|
||||
parsed=parsed,
|
||||
head_ids=head_ids if parsed.intent.value != "normal" or body.get("use_all_heads") else None,
|
||||
event_bus=bus,
|
||||
force_second_pass=force_second,
|
||||
return_head_outputs=return_heads,
|
||||
)
|
||||
|
||||
if return_heads and isinstance(result, tuple):
|
||||
final, head_outputs = result
|
||||
else:
|
||||
final = result # type: ignore[assignment]
|
||||
head_outputs = []
|
||||
|
||||
if not final:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=error_response(ErrorCode.ORCHESTRATOR_TIMEOUT),
|
||||
)
|
||||
|
||||
if pipeline:
|
||||
post_result = pipeline.post_check(final.final_answer)
|
||||
if not post_result.passed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=error_response(ErrorCode.GOVERNANCE_DENIED, f"Output scan failed: {', '.join(post_result.flags)}"),
|
||||
)
|
||||
|
||||
entry = {
|
||||
"prompt": effective_prompt,
|
||||
"final_answer": final.final_answer,
|
||||
"confidence_score": final.confidence_score,
|
||||
"head_contributions": final.head_contributions,
|
||||
}
|
||||
store.append_history(session_id, entry)
|
||||
record_audit_event(
|
||||
"prompt.submit",
|
||||
resource_type="session",
|
||||
resource_id=session_id,
|
||||
details={"prompt_length": len(effective_prompt), "confidence": final.confidence_score},
|
||||
)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"task_id": task_id,
|
||||
"final_answer": final.final_answer,
|
||||
"transparency_report": final.transparency_report.model_dump(),
|
||||
"head_contributions": final.head_contributions,
|
||||
"confidence_score": final.confidence_score,
|
||||
}
|
||||
if parsed.intent == UserIntent.SHOW_DISSENT:
|
||||
response["response_mode"] = "show_dissent"
|
||||
response["disputed_claims"] = final.transparency_report.agreement_map.disputed_claims
|
||||
elif parsed.intent == UserIntent.EXPLAIN_REASONING:
|
||||
response["response_mode"] = "explain"
|
||||
elif parsed.intent == UserIntent.SOURCES and head_outputs:
|
||||
response["sources"] = extract_sources_from_head_outputs(head_outputs)
|
||||
return response
|
||||
|
||||
|
||||
@router.websocket("/{session_id}/stream")
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Header
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
@@ -13,6 +14,17 @@ router = APIRouter()
|
||||
|
||||
DEFAULT_TENANT = os.environ.get("FUSIONAGI_DEFAULT_TENANT", "default")
|
||||
|
||||
# In-memory tenant registry; for production, back with Postgres
|
||||
_tenant_store: dict[str, dict[str, Any]] = {
|
||||
DEFAULT_TENANT: {
|
||||
"id": DEFAULT_TENANT,
|
||||
"name": "Default Tenant",
|
||||
"status": "active",
|
||||
"created_at": time.time(),
|
||||
"config": {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def resolve_tenant(x_tenant_id: str | None = Header(default=None)) -> str:
|
||||
"""Resolve tenant from X-Tenant-ID header or default."""
|
||||
@@ -21,32 +33,121 @@ def resolve_tenant(x_tenant_id: str | None = Header(default=None)) -> str:
|
||||
|
||||
@router.get("/tenants/current")
|
||||
def get_current_tenant(x_tenant_id: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
"""Return the resolved tenant context."""
|
||||
"""Return the resolved tenant context.
|
||||
|
||||
The tenant is determined from the X-Tenant-ID header.
|
||||
Falls back to the default tenant if no header is provided.
|
||||
"""
|
||||
tid = resolve_tenant(x_tenant_id)
|
||||
return {
|
||||
"tenant_id": tid,
|
||||
"is_default": tid == DEFAULT_TENANT,
|
||||
"isolation_mode": "logical",
|
||||
"exists": tid in _tenant_store,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tenants")
|
||||
def list_tenants() -> dict[str, Any]:
|
||||
"""List known tenants (placeholder — in production, query tenant registry)."""
|
||||
return {
|
||||
"tenants": [
|
||||
{"id": DEFAULT_TENANT, "name": "Default Tenant", "status": "active"},
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
"""List all registered tenants.
|
||||
|
||||
Returns:
|
||||
JSON with tenants array and total count.
|
||||
"""
|
||||
tenants = list(_tenant_store.values())
|
||||
return {"tenants": tenants, "total": len(tenants)}
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}")
|
||||
def get_tenant(tenant_id: str) -> dict[str, Any]:
|
||||
"""Get a specific tenant by ID.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier.
|
||||
|
||||
Returns:
|
||||
Tenant record.
|
||||
|
||||
Raises:
|
||||
404 if tenant not found.
|
||||
"""
|
||||
tenant = _tenant_store.get(tenant_id)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail=f"Tenant {tenant_id} not found")
|
||||
return tenant
|
||||
|
||||
|
||||
@router.post("/tenants")
|
||||
def create_tenant(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Register a new tenant."""
|
||||
"""Register a new tenant.
|
||||
|
||||
Args:
|
||||
body: JSON with 'id' and optional 'name', 'config' fields.
|
||||
|
||||
Returns:
|
||||
Created tenant record.
|
||||
"""
|
||||
tenant_id = body.get("id", "")
|
||||
name = body.get("name", tenant_id)
|
||||
if not tenant_id:
|
||||
return {"error": "Tenant ID required"}
|
||||
raise HTTPException(status_code=400, detail="Tenant ID required")
|
||||
if tenant_id in _tenant_store:
|
||||
raise HTTPException(status_code=409, detail=f"Tenant {tenant_id} already exists")
|
||||
|
||||
name = body.get("name", tenant_id)
|
||||
config = body.get("config", {})
|
||||
tenant = {
|
||||
"id": tenant_id,
|
||||
"name": name,
|
||||
"status": "active",
|
||||
"created_at": time.time(),
|
||||
"config": config,
|
||||
}
|
||||
_tenant_store[tenant_id] = tenant
|
||||
logger.info("Tenant created", extra={"tenant_id": tenant_id, "name": name})
|
||||
return {"id": tenant_id, "name": name, "status": "active"}
|
||||
return tenant
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}")
|
||||
def update_tenant(tenant_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update tenant configuration.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier.
|
||||
body: JSON with fields to update (name, config, status).
|
||||
|
||||
Returns:
|
||||
Updated tenant record.
|
||||
"""
|
||||
tenant = _tenant_store.get(tenant_id)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail=f"Tenant {tenant_id} not found")
|
||||
|
||||
if "name" in body:
|
||||
tenant["name"] = body["name"]
|
||||
if "config" in body:
|
||||
tenant["config"] = body["config"]
|
||||
if "status" in body:
|
||||
tenant["status"] = body["status"]
|
||||
|
||||
logger.info("Tenant updated", extra={"tenant_id": tenant_id})
|
||||
return tenant
|
||||
|
||||
|
||||
@router.delete("/tenants/{tenant_id}")
|
||||
def deactivate_tenant(tenant_id: str) -> dict[str, Any]:
|
||||
"""Deactivate a tenant (soft delete).
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier.
|
||||
|
||||
Returns:
|
||||
Confirmation with tenant status.
|
||||
"""
|
||||
if tenant_id == DEFAULT_TENANT:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate default tenant")
|
||||
tenant = _tenant_store.get(tenant_id)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail=f"Tenant {tenant_id} not found")
|
||||
tenant["status"] = "inactive"
|
||||
logger.info("Tenant deactivated", extra={"tenant_id": tenant_id})
|
||||
return {"id": tenant_id, "status": "inactive"}
|
||||
|
||||
102
fusionagi/api/secret_rotation.py
Normal file
102
fusionagi/api/secret_rotation.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""API key rotation mechanism for FusionAGI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class APIKeyRecord(BaseModel):
|
||||
"""Record for a rotatable API key."""
|
||||
key_hash: str
|
||||
created_at: float = Field(default_factory=time.time)
|
||||
expires_at: float | None = None
|
||||
label: str = "default"
|
||||
active: bool = True
|
||||
|
||||
|
||||
class SecretRotator:
|
||||
"""Manages API key lifecycle: generation, rotation, and expiry.
|
||||
|
||||
Keys are stored as SHA-256 hashes for security.
|
||||
Supports multiple active keys for zero-downtime rotation.
|
||||
"""
|
||||
|
||||
def __init__(self, max_active_keys: int = 3) -> None:
|
||||
self._keys: list[APIKeyRecord] = []
|
||||
self._max_active = max_active_keys
|
||||
|
||||
@staticmethod
|
||||
def _hash_key(key: str) -> str:
|
||||
"""Hash a key using SHA-256."""
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
def generate_key(self, label: str = "default", ttl_seconds: float | None = None) -> str:
|
||||
"""Generate a new API key and register it. Returns the plaintext key."""
|
||||
key = secrets.token_urlsafe(32)
|
||||
record = APIKeyRecord(
|
||||
key_hash=self._hash_key(key),
|
||||
label=label,
|
||||
expires_at=time.time() + ttl_seconds if ttl_seconds else None,
|
||||
)
|
||||
self._keys.append(record)
|
||||
self._enforce_max_active()
|
||||
return key
|
||||
|
||||
def validate_key(self, key: str) -> bool:
|
||||
"""Check if a key is valid (active and not expired)."""
|
||||
key_hash = self._hash_key(key)
|
||||
now = time.time()
|
||||
for record in self._keys:
|
||||
if record.key_hash == key_hash and record.active:
|
||||
if record.expires_at and now > record.expires_at:
|
||||
record.active = False
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
def rotate(self, label: str = "default", ttl_seconds: float | None = None) -> str:
|
||||
"""Rotate keys: generate new, keep previous active for overlap period."""
|
||||
return self.generate_key(label=label, ttl_seconds=ttl_seconds)
|
||||
|
||||
def revoke(self, key: str) -> bool:
|
||||
"""Revoke a specific key."""
|
||||
key_hash = self._hash_key(key)
|
||||
for record in self._keys:
|
||||
if record.key_hash == key_hash:
|
||||
record.active = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def revoke_expired(self) -> int:
|
||||
"""Deactivate all expired keys."""
|
||||
now = time.time()
|
||||
count = 0
|
||||
for record in self._keys:
|
||||
if record.active and record.expires_at and now > record.expires_at:
|
||||
record.active = False
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def _enforce_max_active(self) -> None:
|
||||
"""Ensure we don't exceed max active keys."""
|
||||
active = [k for k in self._keys if k.active]
|
||||
while len(active) > self._max_active:
|
||||
active[0].active = False
|
||||
active = active[1:]
|
||||
|
||||
def list_keys(self) -> list[dict[str, Any]]:
|
||||
"""List all keys (without hashes)."""
|
||||
return [
|
||||
{
|
||||
"label": k.label,
|
||||
"active": k.active,
|
||||
"created_at": k.created_at,
|
||||
"expires_at": k.expires_at,
|
||||
}
|
||||
for k in self._keys
|
||||
]
|
||||
145
fusionagi/api/security.py
Normal file
145
fusionagi/api/security.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Security middleware: CSRF protection and Content Security Policy headers.
|
||||
|
||||
CSRF: Validates Origin/Referer headers on state-changing requests (POST/PUT/DELETE/PATCH).
|
||||
Also supports double-submit cookie pattern via X-CSRF-Token header.
|
||||
CSP: Adds Content-Security-Policy headers to all responses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
CSRF_COOKIE_NAME = "fusionagi_csrf"
|
||||
CSRF_HEADER_NAME = "x-csrf-token"
|
||||
CSRF_TOKEN_LENGTH = 32
|
||||
|
||||
|
||||
def generate_csrf_token() -> str:
|
||||
"""Generate a cryptographically secure CSRF token.
|
||||
|
||||
Returns:
|
||||
URL-safe token string.
|
||||
"""
|
||||
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
||||
|
||||
|
||||
def get_csrf_middleware() -> Any:
|
||||
"""Return CSRF protection middleware class.
|
||||
|
||||
Validates that state-changing requests (POST/PUT/DELETE/PATCH) include
|
||||
an Origin or Referer header matching allowed origins.
|
||||
Configurable via ``FUSIONAGI_CSRF_ORIGINS`` (comma-separated).
|
||||
|
||||
Returns:
|
||||
BaseHTTPMiddleware subclass for CSRF protection.
|
||||
"""
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
allowed_raw = os.environ.get("FUSIONAGI_CSRF_ORIGINS", "")
|
||||
allowed_origins = {o.strip().rstrip("/") for o in allowed_raw.split(",") if o.strip()}
|
||||
# Always allow localhost during development
|
||||
allowed_origins.update({"http://localhost:5173", "http://localhost:8000", "http://127.0.0.1:5173", "http://127.0.0.1:8000"})
|
||||
|
||||
state_changing = {"POST", "PUT", "DELETE", "PATCH"}
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""CSRF protection via Origin/Referer + double-submit cookie validation."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
if request.method in state_changing and request.url.path.startswith("/v1/"):
|
||||
# Double-submit cookie check
|
||||
cookie_token = request.cookies.get(CSRF_COOKIE_NAME, "")
|
||||
header_token = request.headers.get(CSRF_HEADER_NAME, "")
|
||||
if cookie_token and header_token:
|
||||
if not secrets.compare_digest(cookie_token, header_token):
|
||||
logger.warning(
|
||||
"CSRF advisory: token mismatch (proceeding)",
|
||||
extra={"path": request.url.path},
|
||||
)
|
||||
elif cookie_token and not header_token:
|
||||
logger.debug("CSRF advisory: cookie present but no header token", extra={"path": request.url.path})
|
||||
|
||||
# Origin/Referer check
|
||||
origin = request.headers.get("origin", "").rstrip("/")
|
||||
referer = request.headers.get("referer", "")
|
||||
|
||||
if origin:
|
||||
if origin not in allowed_origins:
|
||||
logger.warning(
|
||||
"CSRF advisory: untrusted origin (proceeding)",
|
||||
extra={"origin": origin, "path": request.url.path},
|
||||
)
|
||||
elif referer:
|
||||
from urllib.parse import urlparse
|
||||
ref_origin = f"{urlparse(referer).scheme}://{urlparse(referer).netloc}".rstrip("/")
|
||||
if ref_origin not in allowed_origins:
|
||||
logger.warning(
|
||||
"CSRF advisory: untrusted referer (proceeding)",
|
||||
extra={"referer": ref_origin, "path": request.url.path},
|
||||
)
|
||||
else:
|
||||
logger.debug("CSRF advisory: no origin/referer header", extra={"path": request.url.path})
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Set CSRF cookie if not present
|
||||
if not request.cookies.get(CSRF_COOKIE_NAME):
|
||||
token = generate_csrf_token()
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE_NAME,
|
||||
token,
|
||||
httponly=False, # JS needs to read it for the header
|
||||
samesite="strict",
|
||||
secure=request.url.scheme == "https",
|
||||
max_age=86400,
|
||||
)
|
||||
|
||||
return response # type: ignore[no-any-return]
|
||||
|
||||
return CSRFMiddleware
|
||||
|
||||
|
||||
def get_csp_middleware() -> Any:
|
||||
"""Return Content Security Policy middleware class.
|
||||
|
||||
Adds CSP headers to all responses. Configurable via ``FUSIONAGI_CSP_POLICY``.
|
||||
|
||||
Returns:
|
||||
BaseHTTPMiddleware subclass for CSP headers.
|
||||
"""
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
default_policy = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' ws: wss:; "
|
||||
"font-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
csp_policy = os.environ.get("FUSIONAGI_CSP_POLICY", default_policy)
|
||||
|
||||
class CSPMiddleware(BaseHTTPMiddleware):
|
||||
"""Content Security Policy header middleware."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
response = await call_next(request)
|
||||
response.headers["Content-Security-Policy"] = csp_policy
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
return response # type: ignore[no-any-return]
|
||||
|
||||
return CSPMiddleware
|
||||
106
fusionagi/api/task_queue.py
Normal file
106
fusionagi/api/task_queue.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Async background task queue for long-running operations."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Background task status."""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class TaskResult(BaseModel):
|
||||
"""Result of a background task."""
|
||||
task_id: str
|
||||
status: TaskStatus
|
||||
result: Any = None
|
||||
error: str | None = None
|
||||
created_at: float = Field(default_factory=time.time)
|
||||
completed_at: float | None = None
|
||||
duration_ms: float | None = None
|
||||
|
||||
|
||||
class BackgroundTaskQueue:
|
||||
"""Async task queue for offloading long-running work.
|
||||
|
||||
Tasks are submitted and run concurrently via asyncio. Results are
|
||||
stored in-memory and queryable by task_id.
|
||||
"""
|
||||
|
||||
def __init__(self, max_concurrent: int = 5, result_ttl: float = 3600.0) -> None:
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self._results: dict[str, TaskResult] = {}
|
||||
self._tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._result_ttl = result_ttl
|
||||
|
||||
def submit(
|
||||
self,
|
||||
fn: Callable[..., Coroutine[Any, Any, Any]],
|
||||
*args: Any,
|
||||
task_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Submit a coroutine to run in the background. Returns task_id."""
|
||||
tid = task_id or str(uuid.uuid4())
|
||||
self._results[tid] = TaskResult(task_id=tid, status=TaskStatus.PENDING)
|
||||
|
||||
async def _runner() -> None:
|
||||
async with self._semaphore:
|
||||
self._results[tid].status = TaskStatus.RUNNING
|
||||
start = time.time()
|
||||
try:
|
||||
result = await fn(*args, **kwargs)
|
||||
self._results[tid].result = result
|
||||
self._results[tid].status = TaskStatus.COMPLETED
|
||||
except Exception as e:
|
||||
self._results[tid].error = str(e)
|
||||
self._results[tid].status = TaskStatus.FAILED
|
||||
finally:
|
||||
self._results[tid].completed_at = time.time()
|
||||
self._results[tid].duration_ms = (time.time() - start) * 1000
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
task = loop.create_task(_runner())
|
||||
self._tasks[tid] = task
|
||||
return tid
|
||||
|
||||
def get_status(self, task_id: str) -> TaskResult | None:
|
||||
"""Get the status and result of a task."""
|
||||
return self._results.get(task_id)
|
||||
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Cancel a pending or running task."""
|
||||
task = self._tasks.get(task_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
self._results[task_id].status = TaskStatus.CANCELLED
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_tasks(self, status: TaskStatus | None = None) -> list[TaskResult]:
|
||||
"""List all tasks, optionally filtered by status."""
|
||||
results = list(self._results.values())
|
||||
if status:
|
||||
results = [r for r in results if r.status == status]
|
||||
return results
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""Remove completed tasks older than result_ttl."""
|
||||
now = time.time()
|
||||
expired = [
|
||||
tid for tid, r in self._results.items()
|
||||
if r.completed_at and (now - r.completed_at) > self._result_ttl
|
||||
]
|
||||
for tid in expired:
|
||||
del self._results[tid]
|
||||
self._tasks.pop(tid, None)
|
||||
return len(expired)
|
||||
64
fusionagi/api/tracing.py
Normal file
64
fusionagi/api/tracing.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Request tracing middleware for structured logging with correlation IDs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
trace_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("trace_id", default="")
|
||||
|
||||
|
||||
def get_trace_id() -> str:
|
||||
"""Get current trace ID from context."""
|
||||
return trace_id_var.get() or ""
|
||||
|
||||
|
||||
def set_trace_id(trace_id: str) -> None:
|
||||
"""Set trace ID in current context."""
|
||||
trace_id_var.set(trace_id)
|
||||
|
||||
|
||||
def generate_trace_id() -> str:
|
||||
"""Generate a new trace ID."""
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
class TracingMiddleware:
|
||||
"""ASGI middleware that sets/propagates request trace IDs.
|
||||
|
||||
Extracts trace ID from X-Request-ID header or generates a new one.
|
||||
Injects trace ID into response headers and logging context.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any, header_name: str = "X-Request-ID") -> None:
|
||||
self.app = app
|
||||
self.header_name = header_name.lower()
|
||||
|
||||
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
"""ASGI entrypoint."""
|
||||
if scope["type"] not in ("http", "websocket"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = dict(scope.get("headers", []))
|
||||
trace_id = ""
|
||||
for k, v in headers.items():
|
||||
if isinstance(k, bytes) and k.decode("latin-1").lower() == self.header_name:
|
||||
trace_id = v.decode("latin-1") if isinstance(v, bytes) else str(v)
|
||||
break
|
||||
|
||||
if not trace_id:
|
||||
trace_id = generate_trace_id()
|
||||
|
||||
set_trace_id(trace_id)
|
||||
|
||||
async def send_with_trace(message: dict[str, Any]) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
headers_list = list(message.get("headers", []))
|
||||
headers_list.append((b"x-request-id", trace_id.encode()))
|
||||
headers_list.append((b"x-trace-id", trace_id.encode()))
|
||||
message["headers"] = headers_list
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_trace)
|
||||
@@ -14,6 +14,7 @@ from fusionagi.core.head_orchestrator import (
|
||||
select_heads_for_complexity,
|
||||
)
|
||||
from fusionagi.core.json_file_backend import JsonFileBackend
|
||||
from fusionagi.core.memory_backend import InMemoryStateBackend
|
||||
from fusionagi.core.orchestrator import (
|
||||
VALID_STATE_TRANSITIONS,
|
||||
AgentProtocol,
|
||||
@@ -21,7 +22,9 @@ from fusionagi.core.orchestrator import (
|
||||
Orchestrator,
|
||||
)
|
||||
from fusionagi.core.persistence import StateBackend
|
||||
from fusionagi.core.postgres_backend import PostgresStateBackend
|
||||
from fusionagi.core.scheduler import FallbackMode, Scheduler, SchedulerMode
|
||||
from fusionagi.core.sqlite_backend import SQLiteStateBackend
|
||||
from fusionagi.core.state_manager import StateManager
|
||||
from fusionagi.core.super_big_brain import (
|
||||
SuperBigBrainConfig,
|
||||
@@ -35,6 +38,9 @@ __all__ = [
|
||||
"Orchestrator",
|
||||
"StateBackend",
|
||||
"JsonFileBackend",
|
||||
"InMemoryStateBackend",
|
||||
"PostgresStateBackend",
|
||||
"SQLiteStateBackend",
|
||||
"InvalidStateTransitionError",
|
||||
"VALID_STATE_TRANSITIONS",
|
||||
"AgentProtocol",
|
||||
|
||||
68
fusionagi/core/memory_backend.py
Normal file
68
fusionagi/core/memory_backend.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""In-memory state backend for task persistence.
|
||||
|
||||
Useful for testing and development when no database is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.core.persistence import StateBackend
|
||||
from fusionagi.schemas.task import Task, TaskState
|
||||
|
||||
|
||||
class InMemoryStateBackend(StateBackend):
|
||||
"""In-memory implementation of StateBackend.
|
||||
|
||||
All data is lost on process restart. Use SQLiteStateBackend
|
||||
or a Postgres-backed backend for production persistence.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[str, Task] = {}
|
||||
self._traces: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
def get_task(self, task_id: str) -> Task | None:
|
||||
"""Load task by id."""
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def set_task(self, task: Task) -> None:
|
||||
"""Save task."""
|
||||
self._tasks[task.task_id] = task
|
||||
|
||||
def get_task_state(self, task_id: str) -> TaskState | None:
|
||||
"""Return current task state or None if task unknown."""
|
||||
task = self._tasks.get(task_id)
|
||||
return task.state if task else None
|
||||
|
||||
def set_task_state(self, task_id: str, state: TaskState) -> None:
|
||||
"""Update task state; creates no task if missing."""
|
||||
task = self._tasks.get(task_id)
|
||||
if task is not None:
|
||||
self._tasks[task_id] = task.model_copy(update={"state": state})
|
||||
|
||||
def append_trace(self, task_id: str, entry: dict[str, Any]) -> None:
|
||||
"""Append trace entry."""
|
||||
if task_id not in self._traces:
|
||||
self._traces[task_id] = []
|
||||
self._traces[task_id].append(entry)
|
||||
|
||||
def get_trace(self, task_id: str) -> list[dict[str, Any]]:
|
||||
"""Load trace for task."""
|
||||
return list(self._traces.get(task_id, []))
|
||||
|
||||
def list_tasks(self, state: TaskState | None = None, limit: int = 100) -> list[Task]:
|
||||
"""List tasks, optionally filtered by state."""
|
||||
tasks = list(self._tasks.values())
|
||||
if state is not None:
|
||||
tasks = [t for t in tasks if t.state == state]
|
||||
return tasks[:limit]
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
"""Delete a task and its traces."""
|
||||
self._traces.pop(task_id, None)
|
||||
return self._tasks.pop(task_id, None) is not None
|
||||
|
||||
def count_tasks(self) -> int:
|
||||
"""Return total task count."""
|
||||
return len(self._tasks)
|
||||
245
fusionagi/core/postgres_backend.py
Normal file
245
fusionagi/core/postgres_backend.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Postgres-backed persistence for production deployments.
|
||||
|
||||
Uses psycopg2 (or asyncpg when available) for connection pooling.
|
||||
Falls back gracefully to in-memory if Postgres is unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.core.persistence import StateBackend
|
||||
from fusionagi.schemas.task import Task, TaskState
|
||||
|
||||
_CREATE_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
data JSONB NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS traces (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id) ON DELETE CASCADE,
|
||||
entry JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_traces_task_id ON traces(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state);
|
||||
"""
|
||||
|
||||
|
||||
class PostgresStateBackend(StateBackend):
|
||||
"""Postgres-backed implementation of StateBackend.
|
||||
|
||||
Args:
|
||||
dsn: PostgreSQL connection string (e.g., "postgresql://user:pass@host/db").
|
||||
pool_size: Connection pool size (min connections kept open).
|
||||
max_overflow: Maximum extra connections beyond pool_size.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dsn: str = "postgresql://localhost/fusionagi",
|
||||
pool_size: int = 5,
|
||||
max_overflow: int = 10,
|
||||
) -> None:
|
||||
self._dsn = dsn
|
||||
self._pool_size = pool_size
|
||||
self._max_overflow = max_overflow
|
||||
self._lock = threading.Lock()
|
||||
self._pool: Any = None
|
||||
self._available = False
|
||||
self._init_pool()
|
||||
|
||||
def _init_pool(self) -> None:
|
||||
"""Initialize connection pool and create schema."""
|
||||
try:
|
||||
from psycopg2 import pool as pg_pool
|
||||
|
||||
self._pool = pg_pool.ThreadedConnectionPool(
|
||||
minconn=1,
|
||||
maxconn=self._pool_size + self._max_overflow,
|
||||
dsn=self._dsn,
|
||||
)
|
||||
conn = self._pool.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_CREATE_SCHEMA)
|
||||
conn.commit()
|
||||
finally:
|
||||
self._pool.putconn(conn)
|
||||
self._available = True
|
||||
logger.info("PostgresStateBackend: connected", extra={"dsn": self._dsn.split("@")[-1]})
|
||||
except ImportError:
|
||||
logger.warning("PostgresStateBackend: psycopg2 not installed, operating as no-op")
|
||||
except Exception as e:
|
||||
logger.warning("PostgresStateBackend: connection failed, operating as no-op", extra={"error": str(e)})
|
||||
|
||||
def _get_conn(self) -> Any:
|
||||
if not self._available or self._pool is None:
|
||||
return None
|
||||
return self._pool.getconn()
|
||||
|
||||
def _put_conn(self, conn: Any) -> None:
|
||||
if self._pool is not None and conn is not None:
|
||||
self._pool.putconn(conn)
|
||||
|
||||
def get_task(self, task_id: str) -> Task | None:
|
||||
"""Load task by id from Postgres."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return None
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT data FROM tasks WHERE task_id = %s", (task_id,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return Task.model_validate(row[0] if isinstance(row[0], dict) else json.loads(row[0]))
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def set_task(self, task: Task) -> None:
|
||||
"""Upsert task into Postgres."""
|
||||
if not self._available:
|
||||
return
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
with self._lock:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO tasks (task_id, data, state) VALUES (%s, %s, %s)
|
||||
ON CONFLICT (task_id) DO UPDATE SET data = EXCLUDED.data, state = EXCLUDED.state, updated_at = NOW()""",
|
||||
(task.task_id, task.model_dump_json(), task.state.value),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def get_task_state(self, task_id: str) -> TaskState | None:
|
||||
"""Return current task state."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return None
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT state FROM tasks WHERE task_id = %s", (task_id,))
|
||||
row = cur.fetchone()
|
||||
return TaskState(row[0]) if row else None
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def set_task_state(self, task_id: str, state: TaskState) -> None:
|
||||
"""Update task state in Postgres."""
|
||||
if not self._available:
|
||||
return
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
with self._lock:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE tasks SET state = %s, updated_at = NOW() WHERE task_id = %s",
|
||||
(state.value, task_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def append_trace(self, task_id: str, entry: dict[str, Any]) -> None:
|
||||
"""Append trace entry to Postgres."""
|
||||
if not self._available:
|
||||
return
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
with self._lock:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO traces (task_id, entry) VALUES (%s, %s)",
|
||||
(task_id, json.dumps(entry)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def get_trace(self, task_id: str) -> list[dict[str, Any]]:
|
||||
"""Load trace entries from Postgres."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT entry FROM traces WHERE task_id = %s ORDER BY id",
|
||||
(task_id,),
|
||||
)
|
||||
return [
|
||||
row[0] if isinstance(row[0], dict) else json.loads(row[0])
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def list_tasks(self, state: TaskState | None = None, limit: int = 100) -> list[Task]:
|
||||
"""List tasks from Postgres."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if state is not None:
|
||||
cur.execute("SELECT data FROM tasks WHERE state = %s ORDER BY updated_at DESC LIMIT %s", (state.value, limit))
|
||||
else:
|
||||
cur.execute("SELECT data FROM tasks ORDER BY updated_at DESC LIMIT %s", (limit,))
|
||||
return [
|
||||
Task.model_validate(row[0] if isinstance(row[0], dict) else json.loads(row[0]))
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
"""Delete task and its traces from Postgres."""
|
||||
if not self._available:
|
||||
return False
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return False
|
||||
try:
|
||||
with self._lock:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM tasks WHERE task_id = %s", (task_id,))
|
||||
deleted = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return deleted
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def count_tasks(self) -> int:
|
||||
"""Count tasks in Postgres."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return 0
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) FROM tasks")
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else 0
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the connection pool."""
|
||||
if self._pool is not None:
|
||||
self._pool.closeall()
|
||||
self._available = False
|
||||
189
fusionagi/core/sqlite_backend.py
Normal file
189
fusionagi/core/sqlite_backend.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""SQLite-backed state backend for task persistence.
|
||||
|
||||
Uses synchronous sqlite3 wrapped in a thread pool for async compatibility.
|
||||
For production Postgres, swap with asyncpg or SQLAlchemy async.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi.core.persistence import StateBackend
|
||||
from fusionagi.schemas.task import Task, TaskState
|
||||
|
||||
|
||||
class SQLiteStateBackend(StateBackend):
|
||||
"""SQLite-backed implementation of StateBackend.
|
||||
|
||||
Stores tasks, task states, and traces in a local SQLite database.
|
||||
Thread-safe via a threading lock on write operations.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str = "fusionagi_state.db") -> None:
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
self._init_schema()
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
"""Get a new connection (sqlite3 connections are not thread-safe)."""
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
"""Create tables if they don't exist."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS traces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
entry TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(task_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_traces_task ON traces(task_id);
|
||||
""")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
logger.info("SQLiteStateBackend initialized", extra={"db_path": self._db_path})
|
||||
|
||||
def get_task(self, task_id: str) -> Task | None:
|
||||
"""Load task by id."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
row = conn.execute("SELECT data FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return Task.model_validate_json(row["data"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_task(self, task: Task) -> None:
|
||||
"""Save or update a task."""
|
||||
with self._lock:
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
data = task.model_dump_json()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO tasks (task_id, data, state, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
task.task_id,
|
||||
data,
|
||||
task.state.value,
|
||||
task.created_at.isoformat() if task.created_at else None,
|
||||
task.updated_at.isoformat() if task.updated_at else None,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_task_state(self, task_id: str) -> TaskState | None:
|
||||
"""Return current task state or None if task unknown."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
row = conn.execute("SELECT state FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return TaskState(row["state"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_task_state(self, task_id: str, state: TaskState) -> None:
|
||||
"""Update task state; creates no task if missing."""
|
||||
with self._lock:
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
task = self.get_task(task_id)
|
||||
if task is not None:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET state = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ?",
|
||||
(state.value, task_id),
|
||||
)
|
||||
# Also update the JSON data blob
|
||||
updated = task.model_copy(update={"state": state})
|
||||
conn.execute(
|
||||
"UPDATE tasks SET data = ? WHERE task_id = ?",
|
||||
(updated.model_dump_json(), task_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def append_trace(self, task_id: str, entry: dict[str, Any]) -> None:
|
||||
"""Append trace entry."""
|
||||
with self._lock:
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO traces (task_id, entry) VALUES (?, ?)",
|
||||
(task_id, json.dumps(entry)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_trace(self, task_id: str) -> list[dict[str, Any]]:
|
||||
"""Load trace for task."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT entry FROM traces WHERE task_id = ? ORDER BY id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [json.loads(row["entry"]) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_tasks(self, state: TaskState | None = None, limit: int = 100) -> list[Task]:
|
||||
"""List tasks, optionally filtered by state."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
if state is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT data FROM tasks WHERE state = ? ORDER BY rowid DESC LIMIT ?",
|
||||
(state.value, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT data FROM tasks ORDER BY rowid DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [Task.model_validate_json(row["data"]) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
"""Delete a task and its traces."""
|
||||
with self._lock:
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM traces WHERE task_id = ?", (task_id,))
|
||||
cursor = conn.execute("DELETE FROM tasks WHERE task_id = ?", (task_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def count_tasks(self) -> int:
|
||||
"""Return total task count."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
row = conn.execute("SELECT COUNT(*) as cnt FROM tasks").fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -318,12 +318,11 @@ class VoiceInterface(InterfaceAdapter):
|
||||
Returns:
|
||||
Audio data as bytes.
|
||||
"""
|
||||
# Integrate with TTS provider based on self.tts_provider
|
||||
# - system: Use OS TTS (pyttsx3, etc.)
|
||||
# - elevenlabs: Use ElevenLabs API
|
||||
# - azure: Use Azure Cognitive Services
|
||||
# - google: Use Google Cloud TTS
|
||||
raise NotImplementedError("TTS provider integration required")
|
||||
from fusionagi.adapters.tts import get_tts_adapter
|
||||
|
||||
adapter = get_tts_adapter(self.tts_provider)
|
||||
voice_id = voice.voice_id if voice else None
|
||||
return await adapter.synthesize(text, voice_id=voice_id)
|
||||
|
||||
async def _transcribe_speech(self, audio_data: bytes) -> str:
|
||||
"""
|
||||
@@ -335,9 +334,7 @@ class VoiceInterface(InterfaceAdapter):
|
||||
Returns:
|
||||
Transcribed text.
|
||||
"""
|
||||
# Integrate with STT provider based on self.stt_provider
|
||||
# - whisper: Use OpenAI Whisper (local or API)
|
||||
# - azure: Use Azure Cognitive Services
|
||||
# - google: Use Google Cloud Speech-to-Text
|
||||
# - deepgram: Use Deepgram API
|
||||
raise NotImplementedError("STT provider integration required")
|
||||
from fusionagi.adapters.stt import get_stt_adapter
|
||||
|
||||
adapter = get_stt_adapter(self.stt_provider)
|
||||
return await adapter.transcribe(audio_data)
|
||||
|
||||
@@ -46,15 +46,20 @@ class GeometryAuthorityInterface(ABC):
|
||||
|
||||
|
||||
class InMemoryGeometryKernel(GeometryAuthorityInterface):
|
||||
"""
|
||||
In-memory lineage model; no concrete CAD kernel.
|
||||
Only tracks features registered via add_feature; validate_no_orphans returns []
|
||||
since every stored feature has lineage. For a kernel that tracks all feature ids
|
||||
separately, override validate_no_orphans to return ids not in lineage.
|
||||
"""In-memory geometry lineage model with orphan detection.
|
||||
|
||||
Tracks both registered features (with lineage) and all known feature IDs.
|
||||
Features added via ``register_feature_id`` without a corresponding
|
||||
``add_feature`` call are considered orphans.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lineage: dict[str, FeatureLineageEntry] = {}
|
||||
self._all_feature_ids: set[str] = set()
|
||||
|
||||
def register_feature_id(self, feature_id: str) -> None:
|
||||
"""Register a feature ID from the geometry model (may not have lineage yet)."""
|
||||
self._all_feature_ids.add(feature_id)
|
||||
|
||||
def add_feature(
|
||||
self,
|
||||
@@ -71,11 +76,27 @@ class InMemoryGeometryKernel(GeometryAuthorityInterface):
|
||||
process_eligible=process_eligible,
|
||||
)
|
||||
self._lineage[feature_id] = entry
|
||||
self._all_feature_ids.add(feature_id)
|
||||
return entry
|
||||
|
||||
def get_lineage(self, feature_id: str) -> FeatureLineageEntry | None:
|
||||
return self._lineage.get(feature_id)
|
||||
|
||||
def remove_feature(self, feature_id: str) -> bool:
|
||||
"""Remove a feature and its lineage."""
|
||||
removed = feature_id in self._lineage
|
||||
self._lineage.pop(feature_id, None)
|
||||
self._all_feature_ids.discard(feature_id)
|
||||
return removed
|
||||
|
||||
def validate_no_orphans(self) -> list[str]:
|
||||
"""Return []; this stub only tracks registered features, so none are orphans."""
|
||||
return []
|
||||
"""Return feature IDs that exist but have no valid lineage."""
|
||||
return [fid for fid in self._all_feature_ids if fid not in self._lineage]
|
||||
|
||||
def list_features(self) -> list[str]:
|
||||
"""Return all known feature IDs."""
|
||||
return sorted(self._all_feature_ids)
|
||||
|
||||
def count(self) -> int:
|
||||
"""Return total feature count."""
|
||||
return len(self._all_feature_ids)
|
||||
|
||||
@@ -16,22 +16,49 @@ def _scoped_key(tenant_id: str, user_id: str, base: str) -> str:
|
||||
class VectorMemory:
|
||||
"""
|
||||
Vector memory for embeddings retrieval.
|
||||
Stub implementation; replace with pgvector or Pinecone adapter for production.
|
||||
|
||||
Uses in-memory cosine similarity search. For production, swap with
|
||||
pgvector, Pinecone, or Qdrant adapter behind the same interface.
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int = 10000) -> None:
|
||||
self._store: list[dict[str, Any]] = []
|
||||
self._max_entries = max_entries
|
||||
|
||||
@staticmethod
|
||||
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Compute cosine similarity between two vectors."""
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = sum(x * x for x in a) ** 0.5
|
||||
norm_b = sum(x * x for x in b) ** 0.5
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.0
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
def add(self, id: str, embedding: list[float], metadata: dict[str, Any] | None = None) -> None:
|
||||
"""Add embedding (stub: stores in-memory)."""
|
||||
"""Add embedding to the vector store."""
|
||||
if len(self._store) >= self._max_entries:
|
||||
self._store.pop(0)
|
||||
self._store.append({"id": id, "embedding": embedding, "metadata": metadata or {}})
|
||||
|
||||
def search(self, query_embedding: list[float], top_k: int = 10) -> list[dict[str, Any]]:
|
||||
"""Search by embedding (stub: returns empty)."""
|
||||
return []
|
||||
"""Search by cosine similarity, returning top-k results."""
|
||||
scored = []
|
||||
for entry in self._store:
|
||||
sim = self._cosine_similarity(query_embedding, entry["embedding"])
|
||||
scored.append({"id": entry["id"], "metadata": entry["metadata"], "score": sim})
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return scored[:top_k]
|
||||
|
||||
def delete(self, id: str) -> bool:
|
||||
"""Remove an entry by ID."""
|
||||
before = len(self._store)
|
||||
self._store = [e for e in self._store if e["id"] != id]
|
||||
return len(self._store) < before
|
||||
|
||||
def count(self) -> int:
|
||||
"""Return entry count."""
|
||||
return len(self._store)
|
||||
|
||||
|
||||
class MemoryService:
|
||||
|
||||
106
fusionagi/settings.py
Normal file
106
fusionagi/settings.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Environment-based configuration using Pydantic Settings.
|
||||
|
||||
All settings are configurable via environment variables or .env file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class APIConfig(BaseModel):
|
||||
"""API server configuration."""
|
||||
host: str = Field(default="0.0.0.0", description="Server bind host")
|
||||
port: int = Field(default=8000, description="Server bind port")
|
||||
workers: int = Field(default=1, description="Number of worker processes")
|
||||
cors_origins: list[str] = Field(default=["*"], description="CORS allowed origins")
|
||||
api_key: str | None = Field(default=None, description="API key for authentication")
|
||||
rate_limit: int = Field(default=120, description="Rate limit (requests per window)")
|
||||
rate_window: float = Field(default=60.0, description="Rate limit window in seconds")
|
||||
|
||||
|
||||
class DatabaseConfig(BaseModel):
|
||||
"""Database configuration."""
|
||||
url: str = Field(default="sqlite:///fusionagi.db", description="Database URL")
|
||||
pool_size: int = Field(default=5, description="Connection pool size")
|
||||
max_overflow: int = Field(default=10, description="Max overflow connections")
|
||||
echo: bool = Field(default=False, description="Echo SQL statements")
|
||||
|
||||
|
||||
class CacheConfig(BaseModel):
|
||||
"""Cache configuration."""
|
||||
enabled: bool = Field(default=True, description="Enable response caching")
|
||||
max_size: int = Field(default=1000, description="Max cached entries")
|
||||
ttl_seconds: float = Field(default=300.0, description="Cache TTL in seconds")
|
||||
backend: str = Field(default="memory", description="Cache backend (memory or redis)")
|
||||
redis_url: str | None = Field(default=None, description="Redis URL if backend is redis")
|
||||
|
||||
|
||||
class LoggingConfig(BaseModel):
|
||||
"""Logging configuration."""
|
||||
level: str = Field(default="INFO", description="Log level")
|
||||
format: str = Field(default="json", description="Log format (json or text)")
|
||||
correlation_id_header: str = Field(default="X-Request-ID", description="Request ID header")
|
||||
|
||||
|
||||
class GovernanceConfig(BaseModel):
|
||||
"""Governance configuration."""
|
||||
mode: str = Field(default="advisory", description="Governance mode (advisory or enforcing)")
|
||||
max_file_size: int | None = Field(default=None, description="Max file size in bytes (None=unlimited)")
|
||||
allow_private_urls: bool = Field(default=True, description="Allow private/internal URLs")
|
||||
|
||||
|
||||
class FusionAGIConfig(BaseModel):
|
||||
"""Root configuration for FusionAGI."""
|
||||
api: APIConfig = Field(default_factory=APIConfig)
|
||||
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
||||
cache: CacheConfig = Field(default_factory=CacheConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
governance: GovernanceConfig = Field(default_factory=GovernanceConfig)
|
||||
tenant_isolation: bool = Field(default=True, description="Enable tenant isolation")
|
||||
max_concurrent_tasks: int = Field(default=5, description="Max background tasks")
|
||||
|
||||
|
||||
def load_config() -> FusionAGIConfig:
|
||||
"""Load configuration from environment variables.
|
||||
|
||||
Environment variables are mapped using the pattern:
|
||||
FUSIONAGI_<SECTION>_<KEY> (e.g., FUSIONAGI_API_PORT=9000)
|
||||
"""
|
||||
import os
|
||||
config = FusionAGIConfig()
|
||||
|
||||
env_map = {
|
||||
"FUSIONAGI_API_HOST": ("api", "host"),
|
||||
"FUSIONAGI_API_PORT": ("api", "port"),
|
||||
"FUSIONAGI_API_WORKERS": ("api", "workers"),
|
||||
"FUSIONAGI_API_KEY": ("api", "api_key"),
|
||||
"FUSIONAGI_RATE_LIMIT": ("api", "rate_limit"),
|
||||
"FUSIONAGI_RATE_WINDOW": ("api", "rate_window"),
|
||||
"FUSIONAGI_DB_URL": ("database", "url"),
|
||||
"FUSIONAGI_DB_POOL_SIZE": ("database", "pool_size"),
|
||||
"FUSIONAGI_CACHE_ENABLED": ("cache", "enabled"),
|
||||
"FUSIONAGI_CACHE_TTL": ("cache", "ttl_seconds"),
|
||||
"FUSIONAGI_CACHE_BACKEND": ("cache", "backend"),
|
||||
"FUSIONAGI_REDIS_URL": ("cache", "redis_url"),
|
||||
"FUSIONAGI_LOG_LEVEL": ("logging", "level"),
|
||||
"FUSIONAGI_LOG_FORMAT": ("logging", "format"),
|
||||
"FUSIONAGI_GOVERNANCE_MODE": ("governance", "mode"),
|
||||
}
|
||||
|
||||
for env_var, (section, key) in env_map.items():
|
||||
value = os.environ.get(env_var)
|
||||
if value is not None:
|
||||
section_obj = getattr(config, section)
|
||||
field_info = type(section_obj).model_fields.get(key)
|
||||
if field_info and field_info.annotation:
|
||||
annotation = field_info.annotation
|
||||
if annotation is int:
|
||||
value = int(value) # type: ignore[assignment]
|
||||
elif annotation is float:
|
||||
value = float(value) # type: ignore[assignment]
|
||||
elif annotation is bool:
|
||||
value = value.lower() in ("true", "1", "yes") # type: ignore[assignment]
|
||||
setattr(section_obj, key, value)
|
||||
|
||||
return config
|
||||
13
k8s/Chart.yaml
Normal file
13
k8s/Chart.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: v2
|
||||
name: fusionagi
|
||||
description: FusionAGI Dvadasa 12-headed multi-agent orchestration system
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.1.0"
|
||||
keywords:
|
||||
- ai
|
||||
- multi-agent
|
||||
- orchestration
|
||||
- fusionagi
|
||||
maintainers:
|
||||
- name: FusionAGI Team
|
||||
125
k8s/templates/bluegreen.yaml
Normal file
125
k8s/templates/bluegreen.yaml
Normal file
@@ -0,0 +1,125 @@
|
||||
{{- if .Values.bluegreen.enabled }}
|
||||
# Blue-Green Deployment Strategy
|
||||
#
|
||||
# Two full deployments (blue/green) run simultaneously.
|
||||
# A Service selector switches traffic between them.
|
||||
#
|
||||
# Workflow:
|
||||
# 1. Deploy new version to inactive color (e.g., green)
|
||||
# 2. Run health checks and smoke tests
|
||||
# 3. Switch Service selector to green
|
||||
# 4. Monitor; rollback by switching back to blue
|
||||
#
|
||||
# Usage:
|
||||
# helm upgrade --set bluegreen.active=green fusionagi ./k8s
|
||||
# helm upgrade --set bluegreen.active=blue fusionagi ./k8s # rollback
|
||||
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-api-blue
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
color: blue
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
color: blue
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
color: blue
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.bluegreen.blueTag | default .Values.image.tag }}"
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: DEPLOYMENT_COLOR
|
||||
value: blue
|
||||
{{- range $key, $value := .Values.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.healthCheck.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.healthCheck.readinessProbe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources.api | nindent 12 }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-api-green
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
color: green
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
color: green
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
color: green
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.bluegreen.greenTag | default .Values.image.tag }}"
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: DEPLOYMENT_COLOR
|
||||
value: green
|
||||
{{- range $key, $value := .Values.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.healthCheck.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.healthCheck.readinessProbe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources.api | nindent 12 }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-api-bluegreen
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: 8000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
color: {{ .Values.bluegreen.active | default "blue" }}
|
||||
{{- end }}
|
||||
91
k8s/templates/deployment.yaml
Normal file
91
k8s/templates/deployment.yaml
Normal file
@@ -0,0 +1,91 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-api
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
protocol: TCP
|
||||
env:
|
||||
{{- range $key, $value := .Values.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
- name: FUSIONAGI_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.secrets.apiKey.existingSecret }}
|
||||
key: {{ .Values.secrets.apiKey.key }}
|
||||
- name: FUSIONAGI_POSTGRES_DSN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.secrets.postgresDsn.existingSecret }}
|
||||
key: {{ .Values.secrets.postgresDsn.key }}
|
||||
- name: FUSIONAGI_REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.secrets.redisUrl.existingSecret }}
|
||||
key: {{ .Values.secrets.redisUrl.key }}
|
||||
{{- with .Values.healthCheck.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.healthCheck.readinessProbe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources.api | nindent 12 }}
|
||||
---
|
||||
{{- if .Values.frontend.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-frontend
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: frontend
|
||||
spec:
|
||||
replicas: {{ .Values.frontend.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}
|
||||
component: frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
|
||||
ports:
|
||||
- containerPort: 80
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
resources:
|
||||
{{- toYaml .Values.resources.frontend | nindent 12 }}
|
||||
{{- end }}
|
||||
29
k8s/templates/hpa.yaml
Normal file
29
k8s/templates/hpa.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-api
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ .Release.Name }}-api
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
96
k8s/templates/prometheus-rules.yaml
Normal file
96
k8s/templates/prometheus-rules.yaml
Normal file
@@ -0,0 +1,96 @@
|
||||
{{- if .Values.monitoring.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: {{ include "fusionagi.fullname" . }}-alerts
|
||||
labels:
|
||||
{{- include "fusionagi.labels" . | nindent 4 }}
|
||||
prometheus: kube-prometheus
|
||||
spec:
|
||||
groups:
|
||||
- name: fusionagi.rules
|
||||
rules:
|
||||
# High error rate
|
||||
- alert: FusionAGIHighErrorRate
|
||||
expr: |
|
||||
sum(rate(fusionagi_requests_total{status=~"5.."}[5m]))
|
||||
/ sum(rate(fusionagi_requests_total[5m])) > 0.05
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "FusionAGI error rate above 5%"
|
||||
description: "Error rate is {{ "{{ $value | humanizePercentage }}" }} over the last 5 minutes."
|
||||
|
||||
# High latency
|
||||
- alert: FusionAGIHighLatency
|
||||
expr: |
|
||||
histogram_quantile(0.95,
|
||||
sum(rate(fusionagi_request_duration_seconds_bucket[5m])) by (le)
|
||||
) > 10
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "FusionAGI p95 latency above 10s"
|
||||
description: "95th percentile latency is {{ "{{ $value }}s" }}."
|
||||
|
||||
# Pod restarts
|
||||
- alert: FusionAGIPodRestarting
|
||||
expr: |
|
||||
increase(kube_pod_container_status_restarts_total{
|
||||
container="{{ include "fusionagi.fullname" . }}"
|
||||
}[1h]) > 3
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "FusionAGI pod restarting frequently"
|
||||
description: "Pod has restarted {{ "{{ $value }}" }} times in the last hour."
|
||||
|
||||
# High memory usage
|
||||
- alert: FusionAGIHighMemory
|
||||
expr: |
|
||||
container_memory_usage_bytes{
|
||||
container="{{ include "fusionagi.fullname" . }}"
|
||||
} / container_spec_memory_limit_bytes > 0.85
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "FusionAGI memory usage above 85%"
|
||||
description: "Memory usage is {{ "{{ $value | humanizePercentage }}" }}."
|
||||
|
||||
# CPU throttling
|
||||
- alert: FusionAGICPUThrottled
|
||||
expr: |
|
||||
rate(container_cpu_cfs_throttled_seconds_total{
|
||||
container="{{ include "fusionagi.fullname" . }}"
|
||||
}[5m]) > 0.5
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "FusionAGI CPU throttled"
|
||||
description: "CPU throttling rate is {{ "{{ $value }}s/s" }}."
|
||||
|
||||
# Queue depth (if task queue is instrumented)
|
||||
- alert: FusionAGIQueueBacklog
|
||||
expr: fusionagi_task_queue_depth > 50
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "FusionAGI task queue backlog"
|
||||
description: "Queue depth is {{ "{{ $value }}" }}."
|
||||
|
||||
# Health check failures
|
||||
- alert: FusionAGIUnhealthy
|
||||
expr: fusionagi_health_status == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "FusionAGI health check failing"
|
||||
description: "Health endpoint returning unhealthy for 2+ minutes."
|
||||
{{- end }}
|
||||
37
k8s/templates/service.yaml
Normal file
37
k8s/templates/service.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-api
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: 8000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: {{ .Release.Name }}
|
||||
component: api
|
||||
---
|
||||
{{- if .Values.frontend.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-frontend
|
||||
labels:
|
||||
app: {{ .Release.Name }}
|
||||
component: frontend
|
||||
spec:
|
||||
type: {{ .Values.frontendService.type }}
|
||||
ports:
|
||||
- port: {{ .Values.frontendService.port }}
|
||||
targetPort: 80
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: {{ .Release.Name }}
|
||||
component: frontend
|
||||
{{- end }}
|
||||
123
k8s/values.yaml
Normal file
123
k8s/values.yaml
Normal file
@@ -0,0 +1,123 @@
|
||||
# FusionAGI Helm Chart values
|
||||
|
||||
replicaCount: 2
|
||||
|
||||
image:
|
||||
repository: fusionagi/api
|
||||
pullPolicy: IfNotPresent
|
||||
tag: "latest"
|
||||
|
||||
frontend:
|
||||
enabled: true
|
||||
replicaCount: 2
|
||||
image:
|
||||
repository: fusionagi/frontend
|
||||
tag: "latest"
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8000
|
||||
|
||||
frontendService:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "120"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||
hosts:
|
||||
- host: fusionagi.local
|
||||
paths:
|
||||
- path: /v1
|
||||
pathType: Prefix
|
||||
backend: api
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend: frontend
|
||||
|
||||
resources:
|
||||
api:
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
frontend:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 70
|
||||
targetMemoryUtilizationPercentage: 80
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
auth:
|
||||
database: fusionagi
|
||||
username: fusionagi
|
||||
existingSecret: fusionagi-db-secret
|
||||
primary:
|
||||
persistence:
|
||||
size: 10Gi
|
||||
|
||||
redis:
|
||||
enabled: true
|
||||
architecture: standalone
|
||||
auth:
|
||||
enabled: false
|
||||
master:
|
||||
persistence:
|
||||
size: 2Gi
|
||||
|
||||
env:
|
||||
FUSIONAGI_DB_BACKEND: postgres
|
||||
FUSIONAGI_WORKERS: "4"
|
||||
FUSIONAGI_RATE_LIMIT: "120"
|
||||
FUSIONAGI_LOG_LEVEL: info
|
||||
|
||||
secrets:
|
||||
apiKey:
|
||||
existingSecret: fusionagi-api-secret
|
||||
key: api-key
|
||||
postgresDsn:
|
||||
existingSecret: fusionagi-db-secret
|
||||
key: dsn
|
||||
redisUrl:
|
||||
existingSecret: fusionagi-redis-secret
|
||||
key: url
|
||||
|
||||
bluegreen:
|
||||
enabled: false
|
||||
active: blue
|
||||
blueTag: "latest"
|
||||
greenTag: "latest"
|
||||
|
||||
healthCheck:
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
# Monitoring
|
||||
monitoring:
|
||||
enabled: false
|
||||
48
migrations/README.md
Normal file
48
migrations/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Database Migrations
|
||||
|
||||
FusionAGI uses a lightweight migration system for schema changes.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
migrations/
|
||||
├── README.md
|
||||
├── versions/
|
||||
│ └── 001_initial_schema.sql
|
||||
└── migrate.py
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run all pending migrations
|
||||
python -m migrations.migrate up
|
||||
|
||||
# Rollback the last migration
|
||||
python -m migrations.migrate down
|
||||
|
||||
# Show migration status
|
||||
python -m migrations.migrate status
|
||||
```
|
||||
|
||||
## Creating a Migration
|
||||
|
||||
1. Create a new SQL file in `migrations/versions/`:
|
||||
```
|
||||
NNN_description.sql
|
||||
```
|
||||
|
||||
2. Include both `-- UP` and `-- DOWN` sections:
|
||||
```sql
|
||||
-- UP
|
||||
CREATE TABLE example (...);
|
||||
|
||||
-- DOWN
|
||||
DROP TABLE IF EXISTS example;
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Migrations run in numeric order (001, 002, etc.)
|
||||
- Each migration is tracked in a `_migrations` table
|
||||
- For production, consider using Alembic with SQLAlchemy
|
||||
168
migrations/migrate.py
Normal file
168
migrations/migrate.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Lightweight database migration runner for FusionAGI.
|
||||
|
||||
Usage:
|
||||
python -m migrations.migrate up # Apply all pending migrations
|
||||
python -m migrations.migrate down # Rollback last migration
|
||||
python -m migrations.migrate status # Show migration status
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSIONS_DIR = Path(__file__).parent / "versions"
|
||||
DEFAULT_DB = os.environ.get("FUSIONAGI_DB_PATH", "fusionagi.db")
|
||||
|
||||
|
||||
def get_connection(db_path: str = DEFAULT_DB) -> sqlite3.Connection:
|
||||
"""Get database connection and ensure migration tracking table exists."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _migrations "
|
||||
"(id INTEGER PRIMARY KEY AUTOINCREMENT, version TEXT NOT NULL UNIQUE, "
|
||||
"applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def get_applied(conn: sqlite3.Connection) -> set[str]:
|
||||
"""Get set of applied migration versions."""
|
||||
rows = conn.execute("SELECT version FROM _migrations").fetchall()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def get_migration_files() -> list[tuple[str, Path]]:
|
||||
"""Get sorted list of (version, path) tuples."""
|
||||
files = sorted(VERSIONS_DIR.glob("*.sql"))
|
||||
return [(f.stem, f) for f in files]
|
||||
|
||||
|
||||
def parse_migration(path: Path) -> tuple[str, str]:
|
||||
"""Parse a migration file into (up_sql, down_sql)."""
|
||||
text = path.read_text()
|
||||
parts = text.split("-- DOWN")
|
||||
up_sql = parts[0].replace("-- UP", "").strip()
|
||||
down_sql = parts[1].strip() if len(parts) > 1 else ""
|
||||
return up_sql, down_sql
|
||||
|
||||
|
||||
def migrate_up(db_path: str = DEFAULT_DB) -> int:
|
||||
"""Apply all pending migrations. Returns count applied."""
|
||||
conn = get_connection(db_path)
|
||||
applied = get_applied(conn)
|
||||
count = 0
|
||||
for version, path in get_migration_files():
|
||||
if version not in applied:
|
||||
up_sql, _ = parse_migration(path)
|
||||
conn.executescript(up_sql)
|
||||
conn.execute("INSERT INTO _migrations (version) VALUES (?)", (version,))
|
||||
conn.commit()
|
||||
print(f"Applied: {version}")
|
||||
count += 1
|
||||
if count == 0:
|
||||
print("No pending migrations.")
|
||||
return count
|
||||
|
||||
|
||||
def migrate_down(db_path: str = DEFAULT_DB) -> bool:
|
||||
"""Rollback the last applied migration."""
|
||||
conn = get_connection(db_path)
|
||||
applied = get_applied(conn)
|
||||
if not applied:
|
||||
print("No migrations to rollback.")
|
||||
return False
|
||||
|
||||
migrations = get_migration_files()
|
||||
applied_migrations = [(v, p) for v, p in migrations if v in applied]
|
||||
if not applied_migrations:
|
||||
print("No migrations to rollback.")
|
||||
return False
|
||||
|
||||
version, path = applied_migrations[-1]
|
||||
_, down_sql = parse_migration(path)
|
||||
if not down_sql:
|
||||
print(f"No DOWN section in {version}. Cannot rollback.")
|
||||
return False
|
||||
|
||||
conn.executescript(down_sql)
|
||||
try:
|
||||
conn.execute("DELETE FROM _migrations WHERE version = ?", (version,))
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
print(f"Rolled back: {version}")
|
||||
return True
|
||||
|
||||
|
||||
def show_status(db_path: str = DEFAULT_DB) -> None:
|
||||
"""Show migration status."""
|
||||
conn = get_connection(db_path)
|
||||
applied = get_applied(conn)
|
||||
for version, _ in get_migration_files():
|
||||
status = "applied" if version in applied else "pending"
|
||||
print(f" {version}: {status}")
|
||||
|
||||
|
||||
def generate(name: str) -> Path:
|
||||
"""Generate a new numbered migration file.
|
||||
|
||||
Args:
|
||||
name: Migration description (e.g., "add_tenants_table").
|
||||
|
||||
Returns:
|
||||
Path to the newly created migration file.
|
||||
"""
|
||||
existing = get_migration_files()
|
||||
next_num = len(existing) + 1
|
||||
version = f"{next_num:03d}_{name}"
|
||||
path = VERSIONS_DIR / f"{version}.sql"
|
||||
path.write_text("-- UP\n-- Write your migration SQL here\n\n-- DOWN\n-- Write your rollback SQL here\n")
|
||||
print(f"Generated: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def verify(db_path: str = DEFAULT_DB) -> bool:
|
||||
"""Verify that all migrations can be applied cleanly.
|
||||
|
||||
Creates a temporary in-memory database and applies all migrations.
|
||||
|
||||
Returns:
|
||||
True if all migrations apply successfully.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as f:
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
count = migrate_up(temp_path)
|
||||
print(f"Verification passed: {count} migrations applied cleanly")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Verification FAILED: {e}")
|
||||
return False
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
db = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_DB
|
||||
if cmd == "up":
|
||||
migrate_up(db)
|
||||
elif cmd == "down":
|
||||
migrate_down(db)
|
||||
elif cmd == "status":
|
||||
show_status(db)
|
||||
elif cmd == "generate":
|
||||
name = sys.argv[2] if len(sys.argv) > 2 else "unnamed"
|
||||
generate(name)
|
||||
elif cmd == "verify":
|
||||
verify(db)
|
||||
else:
|
||||
print(f"Unknown command: {cmd}. Use: up, down, status, generate, verify")
|
||||
55
migrations/versions/001_initial_schema.sql
Normal file
55
migrations/versions/001_initial_schema.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- UP
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version TEXT NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||||
user_id TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ethical_lessons (
|
||||
id TEXT PRIMARY KEY,
|
||||
principle TEXT NOT NULL,
|
||||
description TEXT,
|
||||
weight REAL DEFAULT 1.0,
|
||||
source_task TEXT,
|
||||
outcome TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consequences (
|
||||
id TEXT PRIMARY KEY,
|
||||
action_id TEXT NOT NULL,
|
||||
choice_made TEXT NOT NULL,
|
||||
alternatives TEXT,
|
||||
expected_risk REAL DEFAULT 0.0,
|
||||
expected_reward REAL DEFAULT 0.0,
|
||||
actual_outcome TEXT,
|
||||
surprise_factor REAL DEFAULT 0.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON sessions(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_consequences_action ON consequences(action_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ethical_lessons_source ON ethical_lessons(source_task);
|
||||
|
||||
-- DOWN
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
DROP TABLE IF EXISTS consequences;
|
||||
DROP TABLE IF EXISTS ethical_lessons;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS _migrations;
|
||||
42
migrations/versions/002_add_sessions_and_audit.sql
Normal file
42
migrations/versions/002_add_sessions_and_audit.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- UP
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
tenant_id TEXT DEFAULT 'default',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
action TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
details TEXT DEFAULT '{}',
|
||||
ip_address TEXT,
|
||||
tenant_id TEXT DEFAULT 'default'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key_prefix TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
rotated_at TIMESTAMP,
|
||||
active INTEGER DEFAULT 1,
|
||||
tenant_id TEXT DEFAULT 'default'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON sessions(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
|
||||
|
||||
-- DOWN
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
74
monitoring/grafana-dashboard.json
Normal file
74
monitoring/grafana-dashboard.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "FusionAGI Dvādaśa",
|
||||
"description": "Performance monitoring for the 12-headed AGI orchestrator",
|
||||
"tags": ["fusionagi", "ai", "orchestration"],
|
||||
"timezone": "browser",
|
||||
"panels": [
|
||||
{
|
||||
"title": "HTTP Request Rate",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"targets": [{"expr": "rate(http_requests_total[5m])", "legendFormat": "{{method}} {{path}}"}]
|
||||
},
|
||||
{
|
||||
"title": "Response Latency (p50/p95/p99)",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||
"targets": [
|
||||
{"expr": "histogram_quantile(0.50, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p50"},
|
||||
{"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p95"},
|
||||
{"expr": "histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p99"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Error Rate",
|
||||
"type": "stat",
|
||||
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 8},
|
||||
"targets": [{"expr": "sum(rate(http_responses_total{status=~\"5..\"}[5m])) / sum(rate(http_responses_total[5m]))"}]
|
||||
},
|
||||
{
|
||||
"title": "Active Sessions",
|
||||
"type": "stat",
|
||||
"gridPos": {"h": 4, "w": 6, "x": 6, "y": 8},
|
||||
"targets": [{"expr": "fusionagi_active_sessions"}]
|
||||
},
|
||||
{
|
||||
"title": "Head Analysis Duration",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||
"targets": [{"expr": "histogram_quantile(0.95, rate(head_analysis_duration_seconds_bucket[5m]))", "legendFormat": "{{head}}"}]
|
||||
},
|
||||
{
|
||||
"title": "Consequence Engine Activity",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
|
||||
"targets": [
|
||||
{"expr": "rate(consequence_choices_total[5m])", "legendFormat": "Choices"},
|
||||
{"expr": "rate(consequence_surprises_total[5m])", "legendFormat": "Surprises"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Cache Hit Rate",
|
||||
"type": "gauge",
|
||||
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 16},
|
||||
"targets": [{"expr": "sum(rate(cache_hits_total[5m])) / (sum(rate(cache_hits_total[5m])) + sum(rate(cache_misses_total[5m])))"}]
|
||||
},
|
||||
{
|
||||
"title": "Connection Pool",
|
||||
"type": "stat",
|
||||
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 16},
|
||||
"targets": [
|
||||
{"expr": "connection_pool_in_use", "legendFormat": "In Use"},
|
||||
{"expr": "connection_pool_available", "legendFormat": "Available"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{"name": "datasource", "type": "datasource", "query": "prometheus"},
|
||||
{"name": "instance", "type": "query", "query": "label_values(up{job=\"fusionagi\"}, instance)"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
124
tests/load/k6_prompt.js
Normal file
124
tests/load/k6_prompt.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* k6 load test for FusionAGI prompt endpoint.
|
||||
*
|
||||
* Run:
|
||||
* k6 run tests/load/k6_prompt.js
|
||||
*
|
||||
* Options:
|
||||
* k6 run --vus 10 --duration 30s tests/load/k6_prompt.js
|
||||
* k6 run --vus 50 --duration 2m tests/load/k6_prompt.js
|
||||
*
|
||||
* Requires:
|
||||
* - FusionAGI API running at http://localhost:8000
|
||||
* - k6 installed (https://k6.io/docs/getting-started/installation/)
|
||||
*/
|
||||
|
||||
import http from 'k6/http'
|
||||
import { check, sleep } from 'k6'
|
||||
import { Rate, Trend } from 'k6/metrics'
|
||||
|
||||
// Custom metrics
|
||||
const errorRate = new Rate('errors')
|
||||
const promptDuration = new Trend('prompt_duration', true)
|
||||
const sessionDuration = new Trend('session_duration', true)
|
||||
|
||||
// Test configuration
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: '10s', target: 5 }, // ramp up
|
||||
{ duration: '30s', target: 10 }, // steady
|
||||
{ duration: '10s', target: 20 }, // spike
|
||||
{ duration: '10s', target: 0 }, // ramp down
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<5000'], // 95% under 5s
|
||||
errors: ['rate<0.1'], // <10% error rate
|
||||
},
|
||||
}
|
||||
|
||||
const BASE_URL = __ENV.API_URL || 'http://localhost:8000'
|
||||
const API_KEY = __ENV.API_KEY || ''
|
||||
|
||||
const PROMPTS = [
|
||||
'Explain the concept of recursion',
|
||||
'What are the benefits of microservices?',
|
||||
'Design a rate limiter',
|
||||
'Compare SQL and NoSQL databases',
|
||||
'Explain the CAP theorem',
|
||||
'What is eventual consistency?',
|
||||
'How does garbage collection work?',
|
||||
'Explain WebSocket vs HTTP polling',
|
||||
]
|
||||
|
||||
function getHeaders() {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (API_KEY) {
|
||||
headers['Authorization'] = `Bearer ${API_KEY}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export default function () {
|
||||
const headers = getHeaders()
|
||||
|
||||
// 1. Create session
|
||||
const sessionStart = Date.now()
|
||||
const sessionRes = http.post(`${BASE_URL}/v1/sessions`, null, { headers })
|
||||
sessionDuration.add(Date.now() - sessionStart)
|
||||
|
||||
const sessionOk = check(sessionRes, {
|
||||
'session created': (r) => r.status === 200 || r.status === 201,
|
||||
'session has id': (r) => {
|
||||
try { return !!JSON.parse(r.body).session_id } catch { return false }
|
||||
},
|
||||
})
|
||||
|
||||
if (!sessionOk) {
|
||||
errorRate.add(1)
|
||||
sleep(1)
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = JSON.parse(sessionRes.body).session_id
|
||||
const prompt = PROMPTS[Math.floor(Math.random() * PROMPTS.length)]
|
||||
|
||||
// 2. Send prompt
|
||||
const promptStart = Date.now()
|
||||
const promptRes = http.post(
|
||||
`${BASE_URL}/v1/sessions/${sessionId}/prompt`,
|
||||
JSON.stringify({ prompt }),
|
||||
{ headers, timeout: '30s' },
|
||||
)
|
||||
promptDuration.add(Date.now() - promptStart)
|
||||
|
||||
const promptOk = check(promptRes, {
|
||||
'prompt success': (r) => r.status === 200,
|
||||
'has final_answer': (r) => {
|
||||
try { return !!JSON.parse(r.body).final_answer } catch { return false }
|
||||
},
|
||||
})
|
||||
|
||||
if (!promptOk) {
|
||||
errorRate.add(1)
|
||||
}
|
||||
|
||||
// 3. Health check
|
||||
const healthRes = http.get(`${BASE_URL}/health`, { headers })
|
||||
check(healthRes, {
|
||||
'health ok': (r) => r.status === 200,
|
||||
})
|
||||
|
||||
sleep(0.5 + Math.random())
|
||||
}
|
||||
|
||||
export function handleSummary(data) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
total_requests: data.metrics.http_reqs.values.count,
|
||||
avg_duration_ms: Math.round(data.metrics.http_req_duration.values.avg),
|
||||
p95_duration_ms: Math.round(data.metrics.http_req_duration.values['p(95)']),
|
||||
error_rate: data.metrics.errors ? data.metrics.errors.values.rate : 0,
|
||||
avg_prompt_ms: data.metrics.prompt_duration ? Math.round(data.metrics.prompt_duration.values.avg) : 0,
|
||||
}, null, 2),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user