commit 65ec6ad1eced998fa6433933c39a1d1989e4c33a Author: Azat Date: Mon Feb 2 23:57:37 2026 +0100 Initial dashboard skill - Vite+React+Tailwind web UI diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..aa028fb --- /dev/null +++ b/SKILL.md @@ -0,0 +1,130 @@ +--- +name: dashboard +description: Web dashboard for managing skills, memory, and system status +metadata: + version: "1.0.0" + vibestack: + main: false + requires: + - caddy + - supervisor +--- + +# Dashboard Skill + +Web-based control panel for the VibeStack agent machine. + +## Features + +- **Skills Overview**: View all installed skills, their status, and capabilities +- **Memory Browser**: Search and browse agent memory (conversations, findings, etc.) +- **Backup Management**: View backup status, trigger manual backups, restore +- **Logs Viewer**: Real-time log tailing with filtering +- **System Stats**: Disk, memory, CPU usage +- **Skill Discovery**: Browse and install new skills (via skill-downloader) + +## Awareness + +This dashboard is aware of: + +### Caddy Configuration +- Reads `/skills/caddy/Caddyfile` and `snippets.d/*.caddy` +- Shows which domains/routes are configured +- Displays TLS certificate status + +### Installed Skills +- Scans `/skills/*/SKILL.md` for all installed skills +- Parses YAML frontmatter for metadata (name, version, requires, etc.) +- Shows skill dependencies and relationships +- Reads skill documentation for capabilities + +### Extensibility +- **skill-downloader** (planned): Install new skills from git repositories +- **skill-creator** (planned): Create new skills from templates or AI generation + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DASHBOARD_PORT` | `3000` | Dashboard port | +| `DASHBOARD_DOMAIN` | (none) | Domain for Caddy auto-config | +| `SUPERVISOR_URL` | `http://localhost:9001` | Supervisor XML-RPC URL | +| `MEMORY_URL` | `http://localhost:8081` | Memory API URL | + +## Tech Stack + +- **Vite** - Build tool +- **React 18** - UI framework +- **TypeScript** - Type safety +- **Tailwind CSS** - Styling +- **Lucide React** - Icons +- **React Query** - Data fetching + +## API Endpoints + +The dashboard backend provides: + +``` +GET /api/skills # List all skills with metadata +GET /api/skills/:name # Get skill details +GET /api/supervisor/status # Process status from supervisor +POST /api/supervisor/:name/restart # Restart a process +GET /api/caddy/config # Caddy configuration +GET /api/system/stats # System resource usage +GET /api/logs/:skill # Tail logs for a skill +``` + +## Pages + +| Route | Description | +|-------|-------------| +| `/` | Overview dashboard with quick stats | +| `/skills` | All skills with status and controls | +| `/skills/:name` | Skill detail with docs and logs | +| `/memory` | Memory browser with search | +| `/backups` | Backup status and controls | +| `/logs` | Multi-skill log viewer | +| `/settings` | Caddy config, system settings | + +## Caddy Integration + +If `DASHBOARD_DOMAIN` is set, auto-registers with Caddy: + +```caddyfile +dashboard.example.com { + reverse_proxy localhost:3000 +} +``` + +## Development + +```bash +# Start in dev mode +cd /skills/dashboard/ui +npm run dev + +# Build for production +npm run build +``` + +## Screenshots + +``` +┌─────────────────────────────────────────────────────────┐ +│ VibeStack Dashboard [user] [⚙] │ +├─────────┬───────────────────────────────────────────────┤ +│ │ │ +│ Overview│ Skills: 8 running, 1 stopped │ +│ Skills │ Memory: 1,234 entries │ +│ Memory │ Last Backup: 2h ago ✓ │ +│ Backups │ Disk: 45% used │ +│ Logs │ │ +│ Settings│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ │ postgres│ │ redis │ │ caddy │ │ +│ │ │ ● run │ │ ● run │ │ ● run │ │ +│ │ └─────────┘ └─────────┘ └─────────┘ │ +│ │ │ +└─────────┴───────────────────────────────────────────────┘ +``` diff --git a/scripts/autorun.sh b/scripts/autorun.sh new file mode 100644 index 0000000..9a839d3 --- /dev/null +++ b/scripts/autorun.sh @@ -0,0 +1,101 @@ +#!/bin/bash +set -e + +NODE_VERSION="${NODE_VERSION:-20}" +SKILLS_DIR="${SKILLS_DIR:-/skills}" +SKILL_DIR="$(dirname "$(dirname "$0")")" + +# Idempotent Node.js installation +install_node() { + if command -v node &>/dev/null; then + echo "Node.js already installed: $(node --version)" + return 0 + fi + + echo "Installing Node.js ${NODE_VERSION}..." + + # Install via NodeSource + apt-get update + apt-get install -y curl ca-certificates gnupg + + mkdir -p /etc/apt/keyrings + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg + + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list + + apt-get update + apt-get install -y nodejs + + echo "Node.js installed: $(node --version)" + echo "npm installed: $(npm --version)" +} + +# Install UI dependencies +install_deps() { + local ui_dir="$SKILL_DIR/ui" + + if [ -d "$ui_dir/node_modules" ]; then + echo "Dependencies already installed" + return 0 + fi + + echo "Installing UI dependencies..." + cd "$ui_dir" + npm install + + echo "Dependencies installed" +} + +# Build UI for production +build_ui() { + local ui_dir="$SKILL_DIR/ui" + + if [ -d "$ui_dir/dist" ]; then + echo "UI already built" + return 0 + fi + + echo "Building UI..." + cd "$ui_dir" + npm run build + + echo "UI built" +} + +# Configure Caddy if domain set +configure_caddy() { + local caddy_dir="$SKILLS_DIR/caddy" + local dashboard_domain="${DASHBOARD_DOMAIN:-}" + local dashboard_port="${DASHBOARD_PORT:-3000}" + + if [ ! -d "$caddy_dir" ]; then + echo "Caddy not found - dashboard will run standalone" + return 0 + fi + + if [ -z "$dashboard_domain" ]; then + echo "DASHBOARD_DOMAIN not set - skipping Caddy config" + return 0 + fi + + echo "Configuring Caddy for $dashboard_domain..." + + local snippets_dir="$caddy_dir/snippets.d" + mkdir -p "$snippets_dir" + + cat > "$snippets_dir/dashboard.caddy" << EOF +# Auto-generated by dashboard skill +$dashboard_domain { + reverse_proxy localhost:$dashboard_port +} +EOF + + echo "Caddy config: $dashboard_domain -> localhost:$dashboard_port" +} + +install_node +install_deps +build_ui +configure_caddy + +echo "Dashboard setup complete" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 0000000..e167e06 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -e + +DASHBOARD_PORT="${DASHBOARD_PORT:-3000}" +SKILL_DIR="$(dirname "$(dirname "$0")")" +UI_DIR="$SKILL_DIR/ui" + +# Export environment for the app +export DASHBOARD_PORT +export SUPERVISOR_URL="${SUPERVISOR_URL:-http://localhost:9001}" +export MEMORY_URL="${MEMORY_URL:-http://localhost:8081}" +export SKILLS_DIR="${SKILLS_DIR:-/skills}" + +echo "Starting Dashboard on port $DASHBOARD_PORT..." +echo "Supervisor: $SUPERVISOR_URL" +echo "Memory API: $MEMORY_URL" + +cd "$UI_DIR" + +# In production, serve built files +# In development, run vite dev server +if [ "${NODE_ENV:-production}" = "development" ]; then + echo "Running in development mode..." + exec npm run dev -- --port "$DASHBOARD_PORT" --host +else + echo "Running in production mode..." + exec npm run preview -- --port "$DASHBOARD_PORT" --host +fi diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..885c94f --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + VibeStack Dashboard + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..c1c01c6 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,29 @@ +{ + "name": "vibestack-dashboard", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src --ext ts,tsx" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0", + "@tanstack/react-query": "^5.17.0", + "lucide-react": "^0.303.0", + "clsx": "^2.1.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..16c9ef2 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,25 @@ +import { Routes, Route } from 'react-router-dom' +import { Layout } from './components/Layout' +import { Overview } from './pages/Overview' +import { Skills } from './pages/Skills' +import { SkillDetail } from './pages/SkillDetail' +import { Memory } from './pages/Memory' +import { Backups } from './pages/Backups' +import { Logs } from './pages/Logs' +import { Settings } from './pages/Settings' + +export default function App() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/ui/src/components/Card.tsx b/ui/src/components/Card.tsx new file mode 100644 index 0000000..9194115 --- /dev/null +++ b/ui/src/components/Card.tsx @@ -0,0 +1,29 @@ +import { clsx } from 'clsx' +import { ReactNode } from 'react' + +interface CardProps { + children: ReactNode + className?: string +} + +export function Card({ children, className }: CardProps) { + return ( +
+ {children} +
+ ) +} + +interface CardHeaderProps { + title: string + action?: ReactNode +} + +export function CardHeader({ title, action }: CardHeaderProps) { + return ( +
+

{title}

+ {action} +
+ ) +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx new file mode 100644 index 0000000..bb9c362 --- /dev/null +++ b/ui/src/components/Layout.tsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router-dom' +import { Sidebar } from './Sidebar' + +export function Layout() { + return ( +
+ +
+ +
+
+ ) +} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx new file mode 100644 index 0000000..25e888f --- /dev/null +++ b/ui/src/components/Sidebar.tsx @@ -0,0 +1,61 @@ +import { NavLink } from 'react-router-dom' +import { + LayoutDashboard, + Blocks, + Brain, + Archive, + ScrollText, + Settings, + Plus +} from 'lucide-react' +import { clsx } from 'clsx' + +const navItems = [ + { to: '/', icon: LayoutDashboard, label: 'Overview' }, + { to: '/skills', icon: Blocks, label: 'Skills' }, + { to: '/memory', icon: Brain, label: 'Memory' }, + { to: '/backups', icon: Archive, label: 'Backups' }, + { to: '/logs', icon: ScrollText, label: 'Logs' }, + { to: '/settings', icon: Settings, label: 'Settings' }, +] + +export function Sidebar() { + return ( + + ) +} diff --git a/ui/src/components/StatusBadge.tsx b/ui/src/components/StatusBadge.tsx new file mode 100644 index 0000000..db95e23 --- /dev/null +++ b/ui/src/components/StatusBadge.tsx @@ -0,0 +1,35 @@ +import { clsx } from 'clsx' + +type Status = 'running' | 'stopped' | 'error' | 'pending' + +const statusStyles: Record = { + running: 'bg-green-500/20 text-green-400 border-green-500/30', + stopped: 'bg-gray-500/20 text-gray-400 border-gray-500/30', + error: 'bg-red-500/20 text-red-400 border-red-500/30', + pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', +} + +interface StatusBadgeProps { + status: Status + label?: string +} + +export function StatusBadge({ status, label }: StatusBadgeProps) { + return ( + + + {label || status} + + ) +} diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts new file mode 100644 index 0000000..02cdab1 --- /dev/null +++ b/ui/src/components/index.ts @@ -0,0 +1,4 @@ +export { Layout } from './Layout' +export { Sidebar } from './Sidebar' +export { Card, CardHeader } from './Card' +export { StatusBadge } from './StatusBadge' diff --git a/ui/src/index.css b/ui/src/index.css new file mode 100644 index 0000000..adf9dfe --- /dev/null +++ b/ui/src/index.css @@ -0,0 +1,11 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + font-family: Inter, system-ui, sans-serif; +} + +body { + @apply bg-gray-950 text-gray-100; +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts new file mode 100644 index 0000000..55b0728 --- /dev/null +++ b/ui/src/lib/api.ts @@ -0,0 +1,162 @@ +const API_BASE = '/api' + +export interface Skill { + name: string + description: string + version: string + status: 'running' | 'stopped' | 'error' + requires: string[] + hasRunScript: boolean + metricsPort?: number +} + +export interface SkillDetail extends Skill { + readme: string + config: Record + logs: string[] +} + +export interface ProcessStatus { + name: string + state: string + pid: number + uptime: number + exitcode: number | null +} + +export interface Memory { + id: string + type: string + content: string + metadata: Record + similarity?: number + created_at: string +} + +export interface BackupStatus { + status: string + schedule: string + target: string + last_backup: string | null + last_status: string | null + snapshot_id?: string +} + +export interface SystemStats { + cpu_percent: number + memory_used: number + memory_total: number + disk_used: number + disk_total: number +} + +export interface CaddyConfig { + domains: Array<{ + domain: string + backend: string + tls: boolean + }> + snippets: string[] +} + +// Skills API +export async function fetchSkills(): Promise { + const res = await fetch(`${API_BASE}/skills`) + if (!res.ok) throw new Error('Failed to fetch skills') + return res.json() +} + +export async function fetchSkill(name: string): Promise { + const res = await fetch(`${API_BASE}/skills/${name}`) + if (!res.ok) throw new Error('Failed to fetch skill') + return res.json() +} + +// Supervisor API +export async function fetchSupervisorStatus(): Promise { + const res = await fetch(`${API_BASE}/supervisor/status`) + if (!res.ok) throw new Error('Failed to fetch supervisor status') + return res.json() +} + +export async function restartProcess(name: string): Promise { + const res = await fetch(`${API_BASE}/supervisor/${name}/restart`, { method: 'POST' }) + if (!res.ok) throw new Error('Failed to restart process') +} + +export async function stopProcess(name: string): Promise { + const res = await fetch(`${API_BASE}/supervisor/${name}/stop`, { method: 'POST' }) + if (!res.ok) throw new Error('Failed to stop process') +} + +export async function startProcess(name: string): Promise { + const res = await fetch(`${API_BASE}/supervisor/${name}/start`, { method: 'POST' }) + if (!res.ok) throw new Error('Failed to start process') +} + +// Memory API +export async function searchMemory(query: string, type?: string): Promise { + const params = new URLSearchParams({ q: query }) + if (type) params.set('type', type) + const res = await fetch(`${API_BASE}/memory/search?${params}`) + if (!res.ok) throw new Error('Failed to search memory') + const data = await res.json() + return data.results +} + +export async function fetchMemories(type?: string, limit = 50): Promise { + const params = new URLSearchParams({ limit: String(limit) }) + if (type) params.set('type', type) + const res = await fetch(`${API_BASE}/memory?${params}`) + if (!res.ok) throw new Error('Failed to fetch memories') + const data = await res.json() + return data.results +} + +export async function createMemory(memory: { type: string; content: string; metadata?: Record }): Promise { + const res = await fetch(`${API_BASE}/memory`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(memory), + }) + if (!res.ok) throw new Error('Failed to create memory') + return res.json() +} + +export async function deleteMemory(id: string): Promise { + const res = await fetch(`${API_BASE}/memory/${id}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('Failed to delete memory') +} + +// Backup API +export async function fetchBackupStatus(): Promise { + const res = await fetch(`${API_BASE}/backup/status`) + if (!res.ok) throw new Error('Failed to fetch backup status') + return res.json() +} + +export async function triggerBackup(): Promise { + const res = await fetch(`${API_BASE}/backup/trigger`, { method: 'POST' }) + if (!res.ok) throw new Error('Failed to trigger backup') +} + +// System API +export async function fetchSystemStats(): Promise { + const res = await fetch(`${API_BASE}/system/stats`) + if (!res.ok) throw new Error('Failed to fetch system stats') + return res.json() +} + +// Caddy API +export async function fetchCaddyConfig(): Promise { + const res = await fetch(`${API_BASE}/caddy/config`) + if (!res.ok) throw new Error('Failed to fetch caddy config') + return res.json() +} + +// Logs API +export async function fetchLogs(skill: string, lines = 100): Promise { + const res = await fetch(`${API_BASE}/logs/${skill}?lines=${lines}`) + if (!res.ok) throw new Error('Failed to fetch logs') + return res.json() +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..9035005 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + staleTime: 10000, + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/ui/src/pages/Backups.tsx b/ui/src/pages/Backups.tsx new file mode 100644 index 0000000..b10253d --- /dev/null +++ b/ui/src/pages/Backups.tsx @@ -0,0 +1,216 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Archive, Play, Clock, CheckCircle, AlertCircle, HardDrive } from 'lucide-react' +import { Card, CardHeader } from '../components/Card' +import { StatusBadge } from '../components/StatusBadge' +import { fetchBackupStatus, triggerBackup } from '../lib/api' + +export function Backups() { + const queryClient = useQueryClient() + + const { data: status, isLoading } = useQuery({ + queryKey: ['backup'], + queryFn: fetchBackupStatus, + refetchInterval: 10000, + }) + + const triggerMutation = useMutation({ + mutationFn: triggerBackup, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backup'] }), + }) + + if (isLoading) { + return ( +
+
+
+ ) + } + + return ( +
+
+

Backups

+ +
+ + {/* Status Overview */} +
+ +
+
+ {status?.last_status === 'success' ? ( + + ) : ( + + )} +
+
+

Last Backup

+

+ {status?.last_backup ? formatTimeAgo(status.last_backup) : 'Never'} +

+
+
+
+ + +
+
+ +
+
+

Schedule

+

+ {status?.schedule || 'Not configured'} +

+
+
+
+ + +
+
+ +
+
+

Target

+

+ {status?.target || '/backups'} +

+
+
+
+
+ + {/* Current Status */} + {status?.status === 'running' && ( + +
+
+
+

Backup in progress...

+

This may take a few minutes

+
+
+ + )} + + {/* What gets backed up */} + + +
+ {[ + { path: '/data/postgres', desc: 'PostgreSQL database (via pg_dump)' }, + { path: '/data/redis', desc: 'Redis persistence files' }, + { path: '/data/duckdb', desc: 'DuckDB databases' }, + { path: '/data/loki', desc: 'Log data' }, + { path: '/data/caddy', desc: 'TLS certificates' }, + { path: '/personalities', desc: 'Agent personality configs' }, + { path: '/workspaces', desc: 'Agent workspaces' }, + ].map((item) => ( +
+ +
+

{item.path}

+

{item.desc}

+
+
+ ))} +
+
+ + {/* Recent Backups */} + + +
+ + + + + + + + + + + + {status?.last_backup ? ( + + + + + + + + ) : ( + + + + )} + +
TimeStatusSnapshot IDDurationSize Added
+ {formatDate(status.last_backup)} + + + + {status.snapshot_id || '-'} + --
+ No backups yet +
+
+
+ + {/* Restore Section */} + + +

+ To restore from a backup, use the CLI: +

+
+{`# List available snapshots
+/skills/backup/scripts/restore.sh --list
+
+# Restore latest snapshot
+/skills/backup/scripts/restore.sh --latest
+
+# Restore specific path
+/skills/backup/scripts/restore.sh --latest --path /data/postgres`}
+        
+
+
+ ) +} + +function formatTimeAgo(dateString: string): string { + const date = new Date(dateString) + const now = new Date() + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000) + + if (seconds < 60) return 'Just now' + if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago` + if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago` + return `${Math.floor(seconds / 86400)} days ago` +} + +function formatDate(dateString: string): string { + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/ui/src/pages/Logs.tsx b/ui/src/pages/Logs.tsx new file mode 100644 index 0000000..1d83657 --- /dev/null +++ b/ui/src/pages/Logs.tsx @@ -0,0 +1,184 @@ +import { useState, useEffect, useRef } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Terminal, RefreshCw, Download, Pause, Play } from 'lucide-react' +import { Card, CardHeader } from '../components/Card' +import { fetchSkills, fetchLogs } from '../lib/api' + +export function Logs() { + const [selectedSkill, setSelectedSkill] = useState('all') + const [isPaused, setIsPaused] = useState(false) + const [filter, setFilter] = useState('') + const logsEndRef = useRef(null) + + const { data: skills } = useQuery({ + queryKey: ['skills'], + queryFn: fetchSkills, + }) + + const { data: logs, refetch } = useQuery({ + queryKey: ['logs', selectedSkill], + queryFn: () => fetchLogs(selectedSkill, 200), + refetchInterval: isPaused ? false : 2000, + }) + + useEffect(() => { + if (!isPaused) { + logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + }, [logs, isPaused]) + + const filteredLogs = logs?.filter(line => + filter ? line.toLowerCase().includes(filter.toLowerCase()) : true + ) + + const downloadLogs = () => { + if (!logs) return + const blob = new Blob([logs.join('\n')], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${selectedSkill}-logs-${new Date().toISOString()}.txt` + a.click() + URL.revokeObjectURL(url) + } + + return ( +
+
+

Logs

+
+ + + +
+
+ + {/* Filters */} + +
+
+ + +
+
+ + setFilter(e.target.value)} + placeholder="Filter logs..." + className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500" + /> +
+
+
+ + {/* Log Output */} + + + + Output + {!isPaused && ( + + + Live + + )} +
+ } + /> +
+ {filteredLogs?.length === 0 ? ( +

No logs available

+ ) : ( + <> + {filteredLogs?.map((line, i) => ( + + ))} +
+ + )} +
+ +
+ ) +} + +function LogLine({ line, filter }: { line: string; filter: string }) { + // Detect log level + const isError = /error|exception|fail/i.test(line) + const isWarning = /warn/i.test(line) + const isInfo = /info/i.test(line) + const isDebug = /debug/i.test(line) + + let colorClass = 'text-gray-400' + if (isError) colorClass = 'text-red-400' + else if (isWarning) colorClass = 'text-yellow-400' + else if (isInfo) colorClass = 'text-blue-400' + else if (isDebug) colorClass = 'text-gray-500' + + // Highlight filter matches + if (filter) { + const regex = new RegExp(`(${filter})`, 'gi') + const parts = line.split(regex) + return ( +
+ {parts.map((part, i) => + part.toLowerCase() === filter.toLowerCase() ? ( + + {part} + + ) : ( + part + ) + )} +
+ ) + } + + return
{line}
+} diff --git a/ui/src/pages/Memory.tsx b/ui/src/pages/Memory.tsx new file mode 100644 index 0000000..b38f1b9 --- /dev/null +++ b/ui/src/pages/Memory.tsx @@ -0,0 +1,271 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Search, Plus, Trash2, MessageSquare, Lightbulb, History, Brain } from 'lucide-react' +import { Card, CardHeader } from '../components/Card' +import { searchMemory, fetchMemories, createMemory, deleteMemory } from '../lib/api' +import type { Memory as MemoryType } from '../lib/api' + +const typeIcons: Record = { + conversation: MessageSquare, + finding: Lightbulb, + execution: History, + memory: Brain, +} + +const typeColors: Record = { + conversation: 'text-blue-400 bg-blue-500/20', + finding: 'text-yellow-400 bg-yellow-500/20', + execution: 'text-purple-400 bg-purple-500/20', + memory: 'text-green-400 bg-green-500/20', +} + +export function Memory() { + const queryClient = useQueryClient() + const [searchQuery, setSearchQuery] = useState('') + const [selectedType, setSelectedType] = useState() + const [isSearching, setIsSearching] = useState(false) + const [showAddModal, setShowAddModal] = useState(false) + + const { data: memories, isLoading } = useQuery({ + queryKey: ['memories', selectedType, searchQuery, isSearching], + queryFn: () => + isSearching && searchQuery + ? searchMemory(searchQuery, selectedType) + : fetchMemories(selectedType), + }) + + const deleteMutation = useMutation({ + mutationFn: deleteMemory, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['memories'] }), + }) + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault() + setIsSearching(true) + } + + const clearSearch = () => { + setSearchQuery('') + setIsSearching(false) + } + + return ( +
+
+

Memory

+ +
+ + {/* Search & Filters */} + +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search memories semantically..." + className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500" + /> +
+ + + {isSearching && ( + + )} +
+
+ + {/* Results */} + {isLoading ? ( +
+
+
+ ) : ( +
+ {memories?.length === 0 ? ( + +

+ {isSearching ? 'No memories found matching your search' : 'No memories yet'} +

+
+ ) : ( + memories?.map((memory) => ( + deleteMutation.mutate(memory.id)} + /> + )) + )} +
+ )} + + {/* Add Memory Modal */} + {showAddModal && ( + setShowAddModal(false)} /> + )} +
+ ) +} + +function MemoryCard({ + memory, + onDelete, +}: { + memory: MemoryType + onDelete: () => void +}) { + const Icon = typeIcons[memory.type] || Brain + const colorClass = typeColors[memory.type] || 'text-gray-400 bg-gray-500/20' + + return ( + +
+
+ +
+
+
+ + {memory.type} + + {memory.similarity !== undefined && ( + + {Math.round(memory.similarity * 100)}% match + + )} + + {formatDate(memory.created_at)} + +
+

{memory.content}

+ {Object.keys(memory.metadata).length > 0 && ( +
+ {Object.entries(memory.metadata).map(([key, value]) => ( + + {key}: {String(value)} + + ))} +
+ )} +
+ +
+
+ ) +} + +function AddMemoryModal({ onClose }: { onClose: () => void }) { + const queryClient = useQueryClient() + const [type, setType] = useState('finding') + const [content, setContent] = useState('') + + const mutation = useMutation({ + mutationFn: createMemory, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['memories'] }) + onClose() + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + mutation.mutate({ type, content }) + } + + return ( +
+ + +
+
+ + +
+
+ +