diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts new file mode 100644 index 0000000..c8ed8fd --- /dev/null +++ b/frontend/e2e/app.spec.ts @@ -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() + }) +}) diff --git a/frontend/e2e/playwright.config.ts b/frontend/e2e/playwright.config.ts new file mode 100644 index 0000000..6a98f59 --- /dev/null +++ b/frontend/e2e/playwright.config.ts @@ -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, + }, +}) diff --git a/frontend/src/App.css b/frontend/src/App.css index 31a8a9e..a79fc78 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; } diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx new file mode 100644 index 0000000..1276ce1 --- /dev/null +++ b/frontend/src/Router.tsx @@ -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 = { + 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 ( + + {children} + + ) +} + +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 ( + + + } /> + + ) + } + + return ( + + + + + + } /> + } /> + + ) +} diff --git a/frontend/src/components/ChatMessage.stories.tsx b/frontend/src/components/ChatMessage.stories.tsx new file mode 100644 index 0000000..c7b56c3 --- /dev/null +++ b/frontend/src/components/ChatMessage.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { ChatMessage } from './ChatMessage' + +const meta: Meta = { + title: 'Components/ChatMessage', + component: ChatMessage, + tags: ['autodocs'], +} +export default meta +type Story = StoryObj + +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(), + }, +} diff --git a/frontend/src/components/FilePreview.stories.tsx b/frontend/src/components/FilePreview.stories.tsx new file mode 100644 index 0000000..8ea130d --- /dev/null +++ b/frontend/src/components/FilePreview.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { FilePreview } from './FilePreview' + +const meta: Meta = { + title: 'Components/FilePreview', + component: FilePreview, + tags: ['autodocs'], +} +export default meta +type Story = StoryObj + +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, + }, + }, +} diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx new file mode 100644 index 0000000..e4966df --- /dev/null +++ b/frontend/src/components/FilePreview.tsx @@ -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 ( +
+
+ {file.name} + {formatSize(file.size)} + {onRemove && ( + + )} +
+ + {isImageFile(file) && file.url && ( +
+ {file.name} +
+ )} + + {isTextFile(file) && file.content && ( +
+ + {expanded && ( +
+              {file.content.slice(0, 5000)}
+              {file.content.length > 5000 && ... (truncated)}
+            
+ )} +
+ )} + + {!isImageFile(file) && !isTextFile(file) && ( +
+ {file.url ? ( + Download + ) : ( + Binary file ({file.type || 'unknown type'}) + )} +
+ )} +
+ ) +} + +interface FilePreviewListProps { + files: FileAttachment[] + onRemove?: (index: number) => void +} + +export function FilePreviewList({ files, onRemove }: FilePreviewListProps) { + if (files.length === 0) return null + return ( +
+ {files.map((file, i) => ( + onRemove(i) : undefined} + /> + ))} +
+ ) +} diff --git a/frontend/src/components/Markdown.stories.tsx b/frontend/src/components/Markdown.stories.tsx new file mode 100644 index 0000000..26c0bb5 --- /dev/null +++ b/frontend/src/components/Markdown.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Markdown } from './Markdown' + +const meta: Meta = { + title: 'Components/Markdown', + component: Markdown, + tags: ['autodocs'], +} +export default meta +type Story = StoryObj + +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.', + }, +} diff --git a/frontend/src/components/SearchFilter.stories.tsx b/frontend/src/components/SearchFilter.stories.tsx new file mode 100644 index 0000000..7d5b678 --- /dev/null +++ b/frontend/src/components/SearchFilter.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { SearchFilter } from './SearchFilter' + +const meta: Meta = { + title: 'Components/SearchFilter', + component: SearchFilter, + tags: ['autodocs'], +} +export default meta +type Story = StoryObj + +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 }, +} diff --git a/frontend/src/components/Skeleton.stories.tsx b/frontend/src/components/Skeleton.stories.tsx new file mode 100644 index 0000000..5d645e9 --- /dev/null +++ b/frontend/src/components/Skeleton.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Skeleton } from './Skeleton' + +const meta: Meta = { + title: 'Components/Skeleton', + component: Skeleton, + tags: ['autodocs'], +} +export default meta +type Story = StoryObj + +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 }, +} diff --git a/frontend/src/components/SparklineChart.tsx b/frontend/src/components/SparklineChart.tsx new file mode 100644 index 0000000..fd031d8 --- /dev/null +++ b/frontend/src/components/SparklineChart.tsx @@ -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 + } + + 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 ( + + {fillD && ( + + )} + + {showDots && points.map((p, i) => ( + + ))} + + ) +} + +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 ( +
+
+ {title} + {trend && {trendSymbol}} +
+
+ {value} + {unit && {unit}} +
+ {data && data.length > 1 && ( + + )} +
+ ) +} + +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 ( + + {data.map((d, i) => { + const barHeight = (d.value / max) * (height - 4) + const x = i * (barWidth + 4) + 2 + const y = height - barHeight + return ( + + + + {d.label} + + + ) + })} + + ) +} diff --git a/frontend/src/components/Toast.stories.tsx b/frontend/src/components/Toast.stories.tsx new file mode 100644 index 0000000..fcecf53 --- /dev/null +++ b/frontend/src/components/Toast.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Toast } from './Toast' + +const meta: Meta = { + title: 'Components/Toast', + component: Toast, + tags: ['autodocs'], +} +export default meta +type Story = StoryObj + +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: () => {} }, +} diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts new file mode 100644 index 0000000..061e1e4 --- /dev/null +++ b/frontend/src/hooks/useNotifications.ts @@ -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([]) + const [unreadCount, setUnreadCount] = useState(0) + const permissionRef = useRef('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) => { + 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 }) => { + 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, + } +} diff --git a/frontend/src/hooks/useStore.ts b/frontend/src/hooks/useStore.ts new file mode 100644 index 0000000..70972d8 --- /dev/null +++ b/frontend/src/hooks/useStore.ts @@ -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 +} + +export const StoreContext = createContext({ + 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 } diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 37be4fe..e561059 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -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) => void + onError?: (error: string) => void +} + export function useWebSocket(sessionId: string | null) { const [status, setStatus] = useState('disconnected') const [events, setEvents] = useState([]) + const [streaming, setStreaming] = useState(false) const wsRef = useRef(null) const retryCount = useRef(0) const retryTimer = useRef | null>(null) const shouldReconnect = useRef(true) + const callbacksRef = useRef({}) 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 + cb.onHeadUpdate(d.head, d.content) + } else if (event.type === 'complete' && cb.onComplete) { + setStreaming(false) + cb.onComplete(event.data as Record) + } 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 } } diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..c4ba9df --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -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 +type LocaleData = Record + +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 { + 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() diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 72e0f2a..2caebff 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,5 +14,6 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: './src/test-setup.ts', + exclude: ['e2e/**', 'node_modules/**'], }, }) diff --git a/fusionagi/adapters/openai_adapter.py b/fusionagi/adapters/openai_adapter.py index e7b8175..24dc17e 100644 --- a/fusionagi/adapters/openai_adapter.py +++ b/fusionagi/adapters/openai_adapter.py @@ -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]], diff --git a/fusionagi/api/app.py b/fusionagi/api/app.py index 13981af..66f02ca 100644 --- a/fusionagi/api/app.py +++ b/fusionagi/api/app.py @@ -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) diff --git a/fusionagi/api/cache.py b/fusionagi/api/cache.py index 8a9db30..bc190c8 100644 --- a/fusionagi/api/cache.py +++ b/fusionagi/api/cache.py @@ -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() diff --git a/fusionagi/core/memory_backend.py b/fusionagi/core/memory_backend.py new file mode 100644 index 0000000..61b3ca1 --- /dev/null +++ b/fusionagi/core/memory_backend.py @@ -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) diff --git a/fusionagi/core/sqlite_backend.py b/fusionagi/core/sqlite_backend.py new file mode 100644 index 0000000..33be246 --- /dev/null +++ b/fusionagi/core/sqlite_backend.py @@ -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() diff --git a/tests/test_cache_backends.py b/tests/test_cache_backends.py new file mode 100644 index 0000000..54d8127 --- /dev/null +++ b/tests/test_cache_backends.py @@ -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" diff --git a/tests/test_memory_backend.py b/tests/test_memory_backend.py new file mode 100644 index 0000000..ee6232b --- /dev/null +++ b/tests/test_memory_backend.py @@ -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 diff --git a/tests/test_sqlite_backend.py b/tests/test_sqlite_backend.py new file mode 100644 index 0000000..f5aab6c --- /dev/null +++ b/tests/test_sqlite_backend.py @@ -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