feat: add AI agent dispatch panel with floating button and new task button on agents page
This commit is contained in:
@@ -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 (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Agent Activity</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Every agent action, visible and reversible.
|
||||
</p>
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Agent Activity</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Every agent action, visible and reversible.
|
||||
</p>
|
||||
</div>
|
||||
<DispatchPanel triggerLabel="+ New task" triggerVariant="default" />
|
||||
</div>
|
||||
{feedback && (
|
||||
<p
|
||||
className={`mt-3 text-sm ${
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NetworkErrorBanner } from '@/components/network-error-banner';
|
||||
import { KeyboardShortcutsProvider } from '@/components/keyboard-shortcuts-provider';
|
||||
import { WebVitalsTracker } from '@/components/web-vitals-tracker';
|
||||
import { MobileBottomNav } from '@/components/mobile-bottom-nav';
|
||||
import { DispatchPanel } from '@/components/agents/dispatch-panel';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -23,6 +24,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
</div>
|
||||
</div>
|
||||
<MobileBottomNav />
|
||||
{/* Floating AI dispatch button */}
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<DispatchPanel
|
||||
triggerLabel="Ask AI"
|
||||
triggerVariant="default"
|
||||
triggerClassName="h-12 w-12 rounded-full shadow-lg md:h-auto md:w-auto md:rounded-md md:px-4 md:py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" id="a11y-announcer" />
|
||||
</KeyboardShortcutsProvider>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<Agent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>('');
|
||||
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<string, unknown> = {
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant={triggerVariant}
|
||||
className={triggerClassName}
|
||||
aria-label={triggerLabel}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5" />
|
||||
Dispatch AI Agent
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Send a task to an AI agent and view results in Agent Activity.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* Agent selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Agent</label>
|
||||
{loadingAgents ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading agents...
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No active agents available
|
||||
</p>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedAgentId}
|
||||
onValueChange={setSelectedAgentId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{agent.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({agent.permission_tier})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Prompt</label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="What should the agent do?"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Entity reference (optional) */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Entity reference{' '}
|
||||
<span className="text-xs text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={entityRef}
|
||||
onChange={(e) => setEntityRef(e.target.value)}
|
||||
placeholder="e.g. task:abc123 or project:xyz"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reference a specific entity the agent should work on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dispatch button */}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleDispatch}
|
||||
disabled={dispatching || !selectedAgentId || !prompt.trim()}
|
||||
>
|
||||
{dispatching ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Dispatching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Dispatch
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user