Oliver-ai-bot_2.0/frontend/components/admin/agent-editor.tsx
Vadym Samoilenko f2b5dce63a feat: code interpreter, agent analytics/execute APIs, usage sync, RAG scoping fixes
**Phase 1 — Agent Usage Sync to AgentHub Collector**
- Add agent_usage service: per-agent stats (messages, tokens, conversations, unique users, first/last used)
- Collector sync now includes usage data in payload; sync_agent accepts optional db session
- Celery beat task runs every 6h to sync all active agents with fresh usage stats

**Phase 2 — LibreCodeInterpreter Integration**
- Add code-interpreter, redis, minio services to docker-compose.prod.yml
- CodeInterpreterTool (BaseTool): sandboxed execution via /exec, 13 languages, Python session persistence via conversation_id
- ToolContext extended with conversation_id and agent_slug
- enable_code_interpreter boolean on Agent model (migration 027), tool seeded in tool_definitions (migration 026)
- Code interpreter auto-injected into agent tools when enabled
- Frontend: CodeExecutionResult component with terminal-style stdout/stderr/files rendering

**Phase 3 — Agent API Endpoints**
- GET /api/v1/agents/{slug}/analytics — per-agent usage stats + daily time series
- POST /api/v1/agents/{slug}/execute — synchronous programmatic agent execution (non-SSE)
- Sub-routes registered before /{slug} to avoid FastAPI route conflict

**Phase 4 — Fix Department & Region RAG Scoping**
- Department filter now OR-includes global (null department) docs, matching region filter behaviour
- retriever.search_documents/retrieve_and_prepare/query accept department_ids/region_codes lists
- MatchAny used for multi-value Qdrant filters; chat.py passes full arrays from knowledge_scope
- Admin PATCH /users/{id} now validates region_code against the regions table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:13:27 +01:00

624 lines
24 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useAgentStore } from '@/store/useAgentStore';
import { useAuthStore } from '@/store/useAuthStore';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, Plus, X, ChevronDown, ChevronUp } from 'lucide-react';
import type { Agent, AgentCreate, AgentUpdate } from '@/types';
import { cn } from '@/lib/utils';
import apiClient from '@/lib/api-client';
interface ToolDef {
id: string;
name: string;
display_name: string;
description: string;
category: string;
}
interface Department {
id: string;
code: string;
name: string;
}
interface FormState {
name: string;
slug: string;
description: string;
icon: string;
color: string;
category: string;
llm_provider: 'openai' | 'anthropic' | 'google';
llm_model: string;
temperature: string;
max_tokens: string;
system_prompt: string;
welcome_message: string;
visibility: 'public' | 'department' | 'private';
enable_rag: boolean;
enable_file_upload: boolean;
enable_code_interpreter: boolean;
is_template: boolean;
}
const DEFAULT_FORM: FormState = {
name: '',
slug: '',
description: '',
icon: 'bot',
color: 'primary',
category: 'general',
llm_provider: 'anthropic',
llm_model: 'claude-sonnet-4-6',
temperature: '0.7',
max_tokens: '',
system_prompt: '',
welcome_message: '',
visibility: 'private',
enable_rag: false,
enable_file_upload: true,
enable_code_interpreter: false,
is_template: false,
};
const PROVIDER_MODELS: Record<string, string[]> = {
openai: ['gpt-5.2'],
anthropic: ['claude-sonnet-4-6', 'claude-haiku-4-5-20251001'],
google: ['gemini-3.1-pro-preview'],
};
const SELECT_CLS = 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50';
interface AgentEditorModalProps {
open: boolean;
agentSlug: string | null;
onClose: () => void;
}
export function AgentEditorModal({ open, agentSlug, onClose }: AgentEditorModalProps) {
const { createAgent, updateAgent, publishAgent } = useAgentStore();
const { user } = useAuthStore();
const [agent, setAgent] = useState<Agent | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [tools, setTools] = useState<ToolDef[]>([]);
const [selectedToolIds, setSelectedToolIds] = useState<string[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [selectedDeptIds, setSelectedDeptIds] = useState<string[]>([]);
const [suggestedPrompts, setSuggestedPrompts] = useState<string[]>([]);
const [newPrompt, setNewPrompt] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const isSuperAdmin = user?.role?.toLowerCase() === 'super_admin';
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
// Load agent if editing
useEffect(() => {
if (!open) return;
if (agentSlug) {
setLoading(true);
useAgentStore.getState().getAgent(agentSlug).then((a) => {
setAgent(a);
setForm({
name: a.name,
slug: a.slug,
description: a.description || '',
icon: a.icon,
color: a.color,
category: a.category,
llm_provider: a.llm_provider,
llm_model: a.llm_model,
temperature: String(a.temperature),
max_tokens: a.max_tokens ? String(a.max_tokens) : '',
system_prompt: a.system_prompt,
welcome_message: a.welcome_message || '',
visibility: a.visibility,
enable_rag: a.enable_rag,
enable_file_upload: a.enable_file_upload,
enable_code_interpreter: a.enable_code_interpreter ?? false,
is_template: a.is_template,
});
setSelectedToolIds(a.tool_ids || []);
setSelectedDeptIds(a.allowed_department_ids || []);
setSuggestedPrompts(a.suggested_prompts || []);
}).catch(console.error).finally(() => setLoading(false));
} else {
setAgent(null);
setForm(DEFAULT_FORM);
setSelectedToolIds([]);
setSelectedDeptIds([]);
setSuggestedPrompts([]);
setErrors({});
}
}, [open, agentSlug]);
// Load tools & departments once
useEffect(() => {
if (!open) return;
apiClient.get<ToolDef[]>('/admin/tools').then(setTools).catch(() => {});
apiClient.get<{ departments: Department[] }>('/admin/departments').then((d) => {
setDepartments(d.departments || []);
}).catch(() => {});
}, [open]);
const validate = () => {
const errs: Record<string, string> = {};
if (!form.name.trim()) errs.name = 'Name is required';
if (!form.system_prompt.trim()) errs.system_prompt = 'Instructions are required';
setErrors(errs);
return Object.keys(errs).length === 0;
};
const buildPayload = (): AgentCreate => ({
name: form.name.trim(),
slug: form.slug || undefined,
description: form.description || undefined,
icon: form.icon,
color: form.color,
category: form.category,
llm_provider: form.llm_provider,
llm_model: form.llm_model,
temperature: parseFloat(form.temperature) || 0.7,
max_tokens: form.max_tokens ? parseInt(form.max_tokens) : undefined,
system_prompt: form.system_prompt.trim(),
welcome_message: form.welcome_message || undefined,
suggested_prompts: suggestedPrompts,
tool_ids: selectedToolIds,
enable_rag: form.enable_rag,
knowledge_scope: form.enable_rag ? { all: true } : {},
enable_file_upload: form.enable_file_upload,
enable_code_interpreter: form.enable_code_interpreter,
visibility: form.visibility,
allowed_department_ids: selectedDeptIds,
is_template: form.is_template,
capabilities: [],
});
const handleSaveDraft = async () => {
if (!validate()) return;
setSaving(true);
try {
if (agent) {
await updateAgent(agent.id, buildPayload() as AgentUpdate);
} else {
await createAgent(buildPayload());
}
onClose();
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
};
const handlePublish = async () => {
if (!agent) return;
setSaving(true);
try {
await publishAgent(agent.id);
onClose();
} finally {
setSaving(false);
}
};
const handleSaveAndPublish = async () => {
if (!validate()) return;
setSaving(true);
try {
const created = await createAgent(buildPayload());
await publishAgent(created.id);
onClose();
} finally {
setSaving(false);
}
};
const addPrompt = () => {
if (newPrompt.trim()) {
setSuggestedPrompts((p) => [...p, newPrompt.trim()]);
setNewPrompt('');
}
};
const removePrompt = (i: number) =>
setSuggestedPrompts((p) => p.filter((_, idx) => idx !== i));
const toggleTool = (name: string) =>
setSelectedToolIds((ids) => ids.includes(name) ? ids.filter((id) => id !== name) : [...ids, name]);
const toggleDept = (id: string) =>
setSelectedDeptIds((ids) => ids.includes(id) ? ids.filter((d) => d !== id) : [...ids, id]);
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{agent ? `Edit: ${agent.name}` : 'Create New Agent'}</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-5 pt-2">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
<Input
id="name"
placeholder="e.g. HR Onboarding Bot"
value={form.name}
onChange={(e) => set('name', e.target.value)}
className={errors.name ? 'border-destructive' : ''}
/>
{errors.name && <p className="text-xs text-destructive">{errors.name}</p>}
</div>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Input
id="description"
placeholder="What does this agent do?"
value={form.description}
onChange={(e) => set('description', e.target.value)}
/>
</div>
{/* Category */}
<div className="space-y-1.5">
<Label htmlFor="category">Category <span className="text-destructive">*</span></Label>
<select
id="category"
value={form.category}
onChange={(e) => set('category', e.target.value)}
className={SELECT_CLS}
>
{['general', 'hr', 'finance', 'it', 'operations', 'custom'].map((c) => (
<option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>
))}
</select>
</div>
{/* Instructions */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="system_prompt">Instructions <span className="text-destructive">*</span></Label>
<span className="text-[11px] text-muted-foreground">{form.system_prompt.length} chars</span>
</div>
<Textarea
id="system_prompt"
rows={6}
placeholder="The system instructions that the agent uses…"
value={form.system_prompt}
onChange={(e) => set('system_prompt', e.target.value)}
className={cn('resize-none font-mono text-[13px]', errors.system_prompt ? 'border-destructive' : '')}
/>
{errors.system_prompt && <p className="text-xs text-destructive">{errors.system_prompt}</p>}
</div>
{/* Welcome Message */}
<div className="space-y-1.5">
<Label htmlFor="welcome_message">Welcome Message</Label>
<Input
id="welcome_message"
placeholder="Greeting shown on empty chat"
value={form.welcome_message}
onChange={(e) => set('welcome_message', e.target.value)}
/>
</div>
{/* Suggested Prompts */}
<div className="space-y-2">
<Label>Suggested Prompts</Label>
<div className="flex gap-2">
<Input
value={newPrompt}
onChange={(e) => setNewPrompt(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addPrompt())}
placeholder="Add a suggested prompt…"
className="flex-1"
/>
<Button type="button" variant="outline" size="icon" onClick={addPrompt}>
<Plus className="h-4 w-4" />
</Button>
</div>
{suggestedPrompts.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{suggestedPrompts.map((p, i) => (
<Badge key={i} variant="secondary" className="gap-1 pr-1">
{p}
<button type="button" onClick={() => removePrompt(i)} className="ml-0.5 hover:text-destructive">
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* Model */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="llm_provider">Provider <span className="text-destructive">*</span></Label>
<select
id="llm_provider"
value={form.llm_provider}
onChange={(e) => {
const p = e.target.value as FormState['llm_provider'];
set('llm_provider', p);
set('llm_model', PROVIDER_MODELS[p]?.[0] || '');
}}
className={SELECT_CLS}
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="google">Google</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="llm_model">Model <span className="text-destructive">*</span></Label>
<select
id="llm_model"
value={form.llm_model}
onChange={(e) => set('llm_model', e.target.value)}
className={SELECT_CLS}
>
{(PROVIDER_MODELS[form.llm_provider] || []).map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
</div>
{/* Capabilities */}
<div className="space-y-3 rounded-lg border border-border/50 p-4">
<p className="text-sm font-medium">Capabilities</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm">Knowledge Base (RAG)</p>
<p className="text-xs text-muted-foreground">Search corporate documents</p>
</div>
<button
type="button"
onClick={() => set('enable_rag', !form.enable_rag)}
className={cn(
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
form.enable_rag ? 'bg-primary' : 'bg-input'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow-lg ring-0 transition-transform',
form.enable_rag && 'translate-x-[18px]'
)}
/>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm">File Upload</p>
<p className="text-xs text-muted-foreground">Allow users to attach files</p>
</div>
<button
type="button"
onClick={() => set('enable_file_upload', !form.enable_file_upload)}
className={cn(
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
form.enable_file_upload ? 'bg-primary' : 'bg-input'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow-lg ring-0 transition-transform',
form.enable_file_upload && 'translate-x-[18px]'
)}
/>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm">Code Interpreter</p>
<p className="text-xs text-muted-foreground">Allow agent to execute code in a sandbox</p>
</div>
<button
type="button"
onClick={() => set('enable_code_interpreter', !form.enable_code_interpreter)}
className={cn(
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
form.enable_code_interpreter ? 'bg-primary' : 'bg-input'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow-lg ring-0 transition-transform',
form.enable_code_interpreter && 'translate-x-[18px]'
)}
/>
</button>
</div>
</div>
{/* Tools */}
{tools.length > 0 && (
<div className="space-y-2">
<Label>Tools</Label>
<div className="grid grid-cols-2 gap-1.5 max-h-48 overflow-y-auto rounded-lg border border-border/50 p-3">
{tools.map((tool) => (
<label key={tool.name} className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedToolIds.includes(tool.name)}
onChange={() => toggleTool(tool.name)}
className="mt-0.5 accent-primary"
/>
<div>
<p className="text-xs font-medium">{tool.display_name}</p>
<p className="text-[11px] text-muted-foreground">{tool.category}</p>
</div>
</label>
))}
</div>
</div>
)}
{/* Visibility */}
<div className="space-y-1.5">
<Label htmlFor="visibility">Visibility</Label>
<select
id="visibility"
value={form.visibility}
onChange={(e) => set('visibility', e.target.value as FormState['visibility'])}
className={SELECT_CLS}
>
<option value="public">Public visible to all users</option>
<option value="department">Department specific teams only</option>
<option value="private">Private only me</option>
</select>
</div>
{/* Department selector */}
{form.visibility === 'department' && departments.length > 0 && (
<div className="space-y-2">
<Label>Departments</Label>
<div className="grid grid-cols-2 gap-1.5 max-h-40 overflow-y-auto rounded-lg border border-border/50 p-3">
{departments.map((dept) => (
<label key={dept.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedDeptIds.includes(dept.id)}
onChange={() => toggleDept(dept.id)}
className="accent-primary"
/>
<span className="text-xs">{dept.name}</span>
</label>
))}
</div>
</div>
)}
{/* Template toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Make Template</p>
<p className="text-xs text-muted-foreground">Appear in the user agent catalog as a preset</p>
</div>
<button
type="button"
onClick={() => set('is_template', !form.is_template)}
className={cn(
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
form.is_template ? 'bg-primary' : 'bg-input'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow-lg ring-0 transition-transform',
form.is_template && 'translate-x-[18px]'
)}
/>
</button>
</div>
{/* Advanced */}
<div className="rounded-lg border border-border/50">
<button
type="button"
onClick={() => setAdvancedOpen((o) => !o)}
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium"
>
Advanced
{advancedOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
{advancedOpen && (
<div className="px-4 pb-4 space-y-3 border-t border-border/50 pt-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="temperature">Temperature</Label>
<Input
id="temperature"
type="number"
step="0.1"
min="0"
max="2"
value={form.temperature}
onChange={(e) => set('temperature', e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="max_tokens">Max Tokens</Label>
<Input
id="max_tokens"
type="number"
placeholder="Default"
value={form.max_tokens}
onChange={(e) => set('max_tokens', e.target.value)}
/>
</div>
</div>
{isSuperAdmin && (
<div className="space-y-1.5">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
placeholder="auto-generated"
value={form.slug}
onChange={(e) => set('slug', e.target.value)}
/>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border/50">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={saving} onClick={handleSaveDraft}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
Save Draft
</Button>
{agent && agent.status === 'draft' && (
<Button type="button" onClick={handlePublish} disabled={saving}>
Publish
</Button>
)}
{!agent && (
<Button type="button" onClick={handleSaveAndPublish} disabled={saving}>
Save &amp; Publish
</Button>
)}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}