wulf-pulse/app/admin/workflow/templates/page.tsx

261 lines
10 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, Brain, Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { AiPromptTemplate, PromptPurpose } from '@/lib/types/workflow';
const PURPOSES: { value: PromptPurpose; label: string }[] = [
{ value: 'title_cleanup', label: 'Title Cleanup' },
{ value: 'description_rewrite', label: 'Description Rewrite' },
{ value: 'ambiguous_classification', label: 'Ambiguous Classification' },
{ value: 'troubleshooting_steps', label: 'Troubleshooting Steps' },
{ value: 'noc_format', label: 'NOC Format' },
{ value: 'soc_analysis', label: 'SOC Analysis' },
];
export default function TemplatesPage() {
const [templates, setTemplates] = useState<AiPromptTemplate[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editing, setEditing] = useState<Partial<AiPromptTemplate> | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => { loadTemplates(); }, []);
const loadTemplates = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/templates');
if (res.ok) setTemplates(await res.json());
} catch { toast.error('Failed to load templates'); }
finally { setIsLoading(false); }
};
const handleCreate = () => {
setEditing({
name: '',
purpose: 'title_cleanup',
system_prompt: '',
user_prompt_template: '',
provider: 'openai',
model: 'gpt-4o',
temperature: 0.3,
max_tokens: 4000,
is_active: true,
});
};
const handleSave = async () => {
if (!editing) return;
setIsSaving(true);
try {
const isUpdate = editing.id;
const url = isUpdate ? `/api/workflow/templates/${editing.id}` : '/api/workflow/templates';
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editing),
});
if (res.ok) {
toast.success(isUpdate ? 'Template updated' : 'Template created');
setEditing(null);
loadTemplates();
}
} catch { toast.error('Failed to save template'); }
finally { setIsSaving(false); }
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this template?')) return;
try {
const res = await fetch(`/api/workflow/templates/${id}`, { method: 'DELETE' });
if (res.ok) { toast.success('Template deleted'); loadTemplates(); }
} catch { toast.error('Failed to delete'); }
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Brain className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">AI Prompt Templates</h1>
<p className="text-sm text-muted-foreground">Configure prompts for AI-assisted triage</p>
</div>
</div>
<Button onClick={handleCreate} size="sm">
<Plus className="w-4 h-4 mr-2" />
New Template
</Button>
</div>
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No templates configured. Default prompts will be used.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Purpose</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Model</TableHead>
<TableHead className="w-20">Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell><Badge variant="outline">{t.purpose}</Badge></TableCell>
<TableCell>{t.provider}</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.model}</TableCell>
<TableCell>
<Badge variant={t.is_active ? 'default' : 'secondary'}>
{t.is_active ? 'Yes' : 'No'}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditing({ ...t })}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(t.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing?.id ? 'Edit' : 'New'} AI Template</DialogTitle>
<DialogDescription>
Templates support {'{{field}}'} interpolation. Available variables: title, description, ticket_category, failed_fields, issueTypes, subIssueTypes, priorities.
</DialogDescription>
</DialogHeader>
{editing && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={editing.name || ''} onChange={(e) => setEditing({ ...editing, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Purpose</Label>
<Select value={editing.purpose} onValueChange={(v) => setEditing({ ...editing, purpose: v as PromptPurpose })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PURPOSES.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select value={editing.provider || 'openai'} onValueChange={(v) => setEditing({ ...editing, provider: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input value={editing.model || ''} onChange={(e) => setEditing({ ...editing, model: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Temperature</Label>
<Input type="number" step="0.1" min="0" max="2" value={editing.temperature ?? 0.3} onChange={(e) => setEditing({ ...editing, temperature: parseFloat(e.target.value) })} />
</div>
</div>
<div className="space-y-2">
<Label>System Prompt</Label>
<Textarea
value={editing.system_prompt || ''}
onChange={(e) => setEditing({ ...editing, system_prompt: e.target.value })}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>User Prompt Template</Label>
<Textarea
value={editing.user_prompt_template || ''}
onChange={(e) => setEditing({ ...editing, user_prompt_template: e.target.value })}
rows={8}
className="font-mono text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={editing.is_active ?? true}
onCheckedChange={(v) => setEditing({ ...editing, is_active: v })}
/>
<Label>Active</Label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditing(null)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || !editing.name}>
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{editing.id ? 'Update' : 'Create'}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}