Next-level improvements: 15 items across backend, frontend, and testing
Backend: - SQLiteStateBackend: persistent task/trace storage with SQLite - InMemoryStateBackend: in-memory impl of StateBackend interface - Redis cache backend (CacheBackend ABC + MemoryCacheBackend + RedisCacheBackend) - OpenAI adapter: async acomplete() with retry logic - Per-tenant + per-IP rate limiting in middleware Frontend: - State management: useStore + useAppState (zero-dep, context + reducer) - React Router integration: URL-based navigation (usePageNavigation) - WebSocket streaming: sendPrompt + StreamCallbacks for token-by-token updates - File preview: inline image/text/binary preview with expand/collapse - Sparkline charts + MetricCard + BarChart for dashboard visualization - Push notifications hook (useNotifications) with browser Notification API - i18n system: 6 locales (en, es, fr, de, ja, zh) with interpolation - 6 new Storybook stories (ChatMessage, Skeleton, Markdown, SearchFilter, Toast, FilePreview) Testing: - Playwright E2E config + 6 browser specs (desktop + mobile) - 18 new Python tests (SQLiteStateBackend, InMemoryStateBackend, cache backends) 570 Python tests + 45 frontend tests = 615 total, 0 ruff errors. Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
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,
|
||||
},
|
||||
})
|
||||
@@ -837,3 +837,33 @@ 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; }
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
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(),
|
||||
},
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
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.',
|
||||
},
|
||||
}
|
||||
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 },
|
||||
}
|
||||
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 },
|
||||
}
|
||||
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: () => {} },
|
||||
}
|
||||
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 }
|
||||
@@ -6,13 +6,22 @@ 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
|
||||
@@ -31,6 +40,7 @@ export function useWebSocket(sessionId: string | null) {
|
||||
|
||||
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++
|
||||
@@ -38,12 +48,30 @@ export function useWebSocket(sessionId: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => setStatus('error')
|
||||
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 */ }
|
||||
}
|
||||
}, [])
|
||||
@@ -54,12 +82,19 @@ 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
|
||||
}, [])
|
||||
|
||||
@@ -73,5 +108,5 @@ export function useWebSocket(sessionId: string | null) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { status, events, connect, send, disconnect, clearEvents }
|
||||
return { status, events, streaming, connect, send, sendPrompt, 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()
|
||||
@@ -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]],
|
||||
|
||||
@@ -93,23 +93,39 @@ 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 sliding window rate limiter (advisory mode).
|
||||
|
||||
Logs rate limit exceedances but allows the request through.
|
||||
Consistent with the advisory governance philosophy.
|
||||
Tracks both IP-level and tenant-level 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},
|
||||
)
|
||||
|
||||
_buckets[ip_key].append(now)
|
||||
_buckets[tenant_key].append(now)
|
||||
return await call_next(request) # type: ignore[no-any-return]
|
||||
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
|
||||
@@ -1,20 +1,176 @@
|
||||
"""In-memory response cache with TTL for the FusionAGI API."""
|
||||
"""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:
|
||||
"""LRU-like response cache with configurable TTL.
|
||||
"""High-level response cache with pluggable backend.
|
||||
|
||||
For production, replace with Redis-backed cache.
|
||||
Uses MemoryCacheBackend by default. Pass a RedisCacheBackend for
|
||||
production multi-worker deployments.
|
||||
"""
|
||||
|
||||
def __init__(self, max_size: int = 1000, ttl_seconds: float = 300.0) -> None:
|
||||
self._cache: dict[str, tuple[float, Any]] = {}
|
||||
self._max_size = max_size
|
||||
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
|
||||
@@ -26,36 +182,22 @@ class ResponseCache:
|
||||
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)
|
||||
entry = self._cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
ts, value = entry
|
||||
if time.time() - ts > self._ttl:
|
||||
del self._cache[key]
|
||||
return None
|
||||
return value
|
||||
return self._backend.get(key)
|
||||
|
||||
def set(self, prompt: str, session_id: str, value: Any, tenant_id: str = "default") -> None:
|
||||
"""Cache a response."""
|
||||
if len(self._cache) >= self._max_size:
|
||||
oldest_key = min(self._cache, key=lambda k: self._cache[k][0])
|
||||
del self._cache[oldest_key]
|
||||
key = self._make_key(prompt, session_id, tenant_id)
|
||||
self._cache[key] = (time.time(), value)
|
||||
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._cache.pop(key, None) is not None
|
||||
return self._backend.delete(key)
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Clear all cache entries. Returns count of cleared entries."""
|
||||
count = len(self._cache)
|
||||
self._cache.clear()
|
||||
return count
|
||||
"""Clear all cache entries."""
|
||||
return self._backend.clear()
|
||||
|
||||
def stats(self) -> dict[str, int]:
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Return cache statistics."""
|
||||
now = time.time()
|
||||
active = sum(1 for ts, _ in self._cache.values() if now - ts <= self._ttl)
|
||||
return {"total": len(self._cache), "active": active, "max_size": self._max_size}
|
||||
return self._backend.stats()
|
||||
|
||||
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)
|
||||
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()
|
||||
48
tests/test_cache_backends.py
Normal file
48
tests/test_cache_backends.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Tests for ResponseCache with pluggable backends."""
|
||||
|
||||
from fusionagi.api.cache import MemoryCacheBackend, ResponseCache
|
||||
|
||||
|
||||
def test_memory_backend_basic():
|
||||
backend = MemoryCacheBackend(max_size=10, default_ttl=60.0)
|
||||
backend.set("k1", {"data": "value"})
|
||||
assert backend.get("k1") == {"data": "value"}
|
||||
|
||||
|
||||
def test_memory_backend_delete():
|
||||
backend = MemoryCacheBackend()
|
||||
backend.set("k2", "val")
|
||||
assert backend.delete("k2") is True
|
||||
assert backend.get("k2") is None
|
||||
|
||||
|
||||
def test_memory_backend_clear():
|
||||
backend = MemoryCacheBackend()
|
||||
backend.set("a", 1)
|
||||
backend.set("b", 2)
|
||||
assert backend.clear() == 2
|
||||
assert backend.get("a") is None
|
||||
|
||||
|
||||
def test_memory_backend_stats():
|
||||
backend = MemoryCacheBackend(max_size=100)
|
||||
backend.set("s1", "v1")
|
||||
stats = backend.stats()
|
||||
assert stats["backend"] == "memory"
|
||||
assert stats["total"] == 1
|
||||
|
||||
|
||||
def test_response_cache_with_backend():
|
||||
backend = MemoryCacheBackend(max_size=50, default_ttl=120.0)
|
||||
cache = ResponseCache(backend=backend)
|
||||
cache.set("hello", "session-1", {"answer": "world"})
|
||||
assert cache.get("hello", "session-1") == {"answer": "world"}
|
||||
assert cache.get("hello", "session-2") is None # different session
|
||||
|
||||
|
||||
def test_response_cache_tenant_isolation():
|
||||
cache = ResponseCache()
|
||||
cache.set("prompt", "s1", "result-a", tenant_id="tenant-1")
|
||||
cache.set("prompt", "s1", "result-b", tenant_id="tenant-2")
|
||||
assert cache.get("prompt", "s1", "tenant-1") == "result-a"
|
||||
assert cache.get("prompt", "s1", "tenant-2") == "result-b"
|
||||
42
tests/test_memory_backend.py
Normal file
42
tests/test_memory_backend.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Tests for InMemoryStateBackend."""
|
||||
|
||||
from fusionagi.core.memory_backend import InMemoryStateBackend
|
||||
from fusionagi.schemas.task import Task, TaskState
|
||||
|
||||
|
||||
def test_set_and_get():
|
||||
backend = InMemoryStateBackend()
|
||||
task = Task(task_id="m1", goal="memory test")
|
||||
backend.set_task(task)
|
||||
assert backend.get_task("m1") is not None
|
||||
assert backend.get_task("m1").goal == "memory test"
|
||||
|
||||
|
||||
def test_state_management():
|
||||
backend = InMemoryStateBackend()
|
||||
backend.set_task(Task(task_id="m2", goal="state"))
|
||||
backend.set_task_state("m2", TaskState.ACTIVE)
|
||||
assert backend.get_task_state("m2") == TaskState.ACTIVE
|
||||
|
||||
|
||||
def test_traces():
|
||||
backend = InMemoryStateBackend()
|
||||
backend.set_task(Task(task_id="m3", goal="traces"))
|
||||
backend.append_trace("m3", {"a": 1})
|
||||
backend.append_trace("m3", {"b": 2})
|
||||
assert len(backend.get_trace("m3")) == 2
|
||||
|
||||
|
||||
def test_delete():
|
||||
backend = InMemoryStateBackend()
|
||||
backend.set_task(Task(task_id="m4", goal="del"))
|
||||
assert backend.delete_task("m4") is True
|
||||
assert backend.delete_task("m4") is False
|
||||
|
||||
|
||||
def test_list_and_count():
|
||||
backend = InMemoryStateBackend()
|
||||
for i in range(3):
|
||||
backend.set_task(Task(task_id=f"l{i}", goal=f"g{i}"))
|
||||
assert backend.count_tasks() == 3
|
||||
assert len(backend.list_tasks()) == 3
|
||||
79
tests/test_sqlite_backend.py
Normal file
79
tests/test_sqlite_backend.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Tests for SQLiteStateBackend."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from fusionagi.core.sqlite_backend import SQLiteStateBackend
|
||||
from fusionagi.schemas.task import Task, TaskState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path():
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
yield path
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(db_path):
|
||||
return SQLiteStateBackend(db_path=db_path)
|
||||
|
||||
|
||||
def test_set_and_get_task(backend):
|
||||
task = Task(task_id="t1", goal="test goal")
|
||||
backend.set_task(task)
|
||||
loaded = backend.get_task("t1")
|
||||
assert loaded is not None
|
||||
assert loaded.task_id == "t1"
|
||||
assert loaded.goal == "test goal"
|
||||
|
||||
|
||||
def test_get_missing_task(backend):
|
||||
assert backend.get_task("nonexistent") is None
|
||||
|
||||
|
||||
def test_task_state(backend):
|
||||
task = Task(task_id="t2", goal="state test")
|
||||
backend.set_task(task)
|
||||
assert backend.get_task_state("t2") == TaskState.PENDING
|
||||
backend.set_task_state("t2", TaskState.ACTIVE)
|
||||
assert backend.get_task_state("t2") == TaskState.ACTIVE
|
||||
|
||||
|
||||
def test_traces(backend):
|
||||
backend.set_task(Task(task_id="t3", goal="trace test"))
|
||||
backend.append_trace("t3", {"step": 1, "action": "start"})
|
||||
backend.append_trace("t3", {"step": 2, "action": "complete"})
|
||||
traces = backend.get_trace("t3")
|
||||
assert len(traces) == 2
|
||||
assert traces[0]["step"] == 1
|
||||
assert traces[1]["action"] == "complete"
|
||||
|
||||
|
||||
def test_list_tasks(backend):
|
||||
for i in range(5):
|
||||
t = Task(task_id=f"list-{i}", goal=f"goal {i}")
|
||||
if i >= 3:
|
||||
t = t.model_copy(update={"state": TaskState.ACTIVE})
|
||||
backend.set_task(t)
|
||||
all_tasks = backend.list_tasks()
|
||||
assert len(all_tasks) == 5
|
||||
active = backend.list_tasks(state=TaskState.ACTIVE)
|
||||
assert len(active) == 2
|
||||
|
||||
|
||||
def test_delete_task(backend):
|
||||
backend.set_task(Task(task_id="del-1", goal="delete me"))
|
||||
backend.append_trace("del-1", {"action": "trace"})
|
||||
assert backend.delete_task("del-1") is True
|
||||
assert backend.get_task("del-1") is None
|
||||
assert backend.get_trace("del-1") == []
|
||||
|
||||
|
||||
def test_count_tasks(backend):
|
||||
assert backend.count_tasks() == 0
|
||||
backend.set_task(Task(task_id="c1", goal="count"))
|
||||
assert backend.count_tasks() == 1
|
||||
Reference in New Issue
Block a user