261 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|