**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>
624 lines
24 KiB
TypeScript
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 & Publish
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|