diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 112b559..d00f256 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { DispatchPanel } from '@/components/agents/dispatch-panel'; interface Agent { id: string; @@ -164,11 +165,15 @@ export default function AgentsPage() { return (
-
-

Agent Activity

-

- Every agent action, visible and reversible. -

+
+
+

Agent Activity

+

+ Every agent action, visible and reversible. +

+
+ +
{feedback && (

+ {/* Floating AI dispatch button */} +
+ +
); diff --git a/apps/web/app/api/agent-tasks/route.ts b/apps/web/app/api/agent-tasks/route.ts index 46d7902..5c5c7a6 100644 --- a/apps/web/app/api/agent-tasks/route.ts +++ b/apps/web/app/api/agent-tasks/route.ts @@ -4,8 +4,10 @@ // See AGENTS.md for full rules. import { NextRequest, NextResponse } from 'next/server'; -import { withAuth } from '@/lib/auth'; +import { withAuth, createErrorResponse } from '@/lib/auth'; import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createAgentTaskSchema } from '@project-e/shared'; +import { z } from 'zod'; // GET /api/agent-tasks — List agent tasks export const GET = withAuth(async (request: NextRequest) => { @@ -27,3 +29,24 @@ export const GET = withAuth(async (request: NextRequest) => { perPage: result.perPage, }); }); + +// POST /api/agent-tasks — Create a new agent task +export const POST = withAuth(async (request: NextRequest) => { + try { + const body = await request.json(); + const data = createAgentTaskSchema.parse(body); + + const pb = createPocketBaseClient(); + const task = await pb.collection('agent_tasks').create({ + ...data, + status: 'pending', + }); + + return NextResponse.json(task, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/components/agents/dispatch-panel.tsx b/apps/web/components/agents/dispatch-panel.tsx new file mode 100644 index 0000000..1469920 --- /dev/null +++ b/apps/web/components/agents/dispatch-panel.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Bot, Sparkles, X, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet'; + +interface Agent { + id: string; + name: string; + description?: string; + status: 'active' | 'disabled'; + permission_tier: string; +} + +interface DispatchPanelProps { + /** Optional trigger label override */ + triggerLabel?: string; + /** Optional variant for the trigger button */ + triggerVariant?: 'default' | 'outline' | 'ghost'; + /** Optional class name for the trigger button */ + triggerClassName?: string; +} + +export function DispatchPanel({ + triggerLabel = 'Ask AI', + triggerVariant = 'default', + triggerClassName, +}: DispatchPanelProps) { + const [open, setOpen] = useState(false); + const [agents, setAgents] = useState([]); + const [selectedAgentId, setSelectedAgentId] = useState(''); + const [prompt, setPrompt] = useState(''); + const [entityRef, setEntityRef] = useState(''); + const [dispatching, setDispatching] = useState(false); + const [loadingAgents, setLoadingAgents] = useState(false); + + const fetchAgents = useCallback(async () => { + setLoadingAgents(true); + try { + const res = await fetch('/api/agents'); + if (!res.ok) throw new Error('Failed to fetch agents'); + const data = await res.json(); + const activeAgents = (data.items || []).filter( + (a: Agent) => a.status === 'active' + ); + setAgents(activeAgents); + if (activeAgents.length > 0 && !selectedAgentId) { + setSelectedAgentId(activeAgents[0].id); + } + } catch (err) { + console.error('Failed to fetch agents:', err); + } finally { + setLoadingAgents(false); + } + }, [selectedAgentId]); + + useEffect(() => { + if (open) { + fetchAgents(); + } + }, [open, fetchAgents]); + + async function handleDispatch() { + if (!selectedAgentId || !prompt.trim()) { + toast.error('Please select an agent and enter a prompt'); + return; + } + + setDispatching(true); + try { + const body: Record = { + agent_id: selectedAgentId, + task_type: 'custom', + input: { prompt: prompt.trim() }, + }; + + if (entityRef.trim()) { + // Parse entity reference: "type:id" or just a free-form reference + body.entity_type = 'reference'; + body.entity_id = entityRef.trim(); + } + + const res = await fetch('/api/agent-tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + console.error('Failed to dispatch task:', text); + toast.error('Failed to dispatch task'); + return; + } + + toast.success('Task dispatched — see Agent Activity for results'); + setPrompt(''); + setEntityRef(''); + setOpen(false); + } catch (err) { + console.error('Failed to dispatch task:', err); + toast.error('Failed to dispatch task'); + } finally { + setDispatching(false); + } + } + + return ( + + + + + + + + + Dispatch AI Agent + + + Send a task to an AI agent and view results in Agent Activity. + + + +
+ {/* Agent selector */} +
+ + {loadingAgents ? ( +
+ + Loading agents... +
+ ) : agents.length === 0 ? ( +

+ No active agents available +

+ ) : ( + + )} +
+ + {/* Prompt */} +
+ +