1. AgentDetailModal – fix provider not being pre-selected on edit open:
- Add resolveProviderValue() that does exact → case-insensitive → partial
match between stored provider string and connectedProviders list
- Re-resolve provider in a second useEffect once providers load from API
- Add safety-net SelectItem for stored value not found in providers list
2. AgentCreateModal – refactor Deploy Agent form:
- Fix Provider + Model fields layout (grid-cols-2 with w-full truncate to
prevent overflow/merging)
- Add Wand2 'Auto-fill' button next to Agent Name field that calls
agentCompiler.compile (existing LLM endpoint) with name+description as
spec — fills role, model, temperature, systemPrompt automatically
- Add Sparkles hint text explaining the magic wand functionality
- Auto-select first provider/model when data loads
- All fields use font-mono + proper label spacing
3. Both modals – MaxTokens auto-fill from Ollama API:
- Add getOllamaModelInfo() in gateway-proxy.ts: calls Ollama /api/show,
extracts {arch}.context_length from model_info, returns contextLength +
parameterSize, family, quantization, capabilities
- Add ollama.modelInfo tRPC query endpoint in routers.ts (input: modelId)
- Both modals query trpc.ollama.modelInfo on model selection change
- Auto-set maxTokens to context_length from API (262144 for kimi-k2.5 etc.)
- Show 'max N from API' hint + clickable link to set full context window
- Loading spinner while fetching model info
423 lines
17 KiB
TypeScript
423 lines
17 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { toast } from "sonner";
|
|
import { Loader2, Plus, Wand2, Sparkles } from "lucide-react";
|
|
|
|
interface AgentCreateModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
const AGENT_ROLES = [
|
|
{ value: "developer", label: "Developer — Code generation & testing" },
|
|
{ value: "researcher", label: "Researcher — Data analysis & research" },
|
|
{ value: "executor", label: "Executor — Task automation" },
|
|
{ value: "monitor", label: "Monitor — System monitoring" },
|
|
{ value: "analyst", label: "Analyst — Data processing & reports" },
|
|
{ value: "writer", label: "Writer — Content generation" },
|
|
{ value: "coordinator", label: "Coordinator — Multi-agent orchestration" },
|
|
];
|
|
|
|
const DEFAULT_FORM = {
|
|
name: "",
|
|
description: "",
|
|
role: "developer",
|
|
provider: "",
|
|
model: "",
|
|
temperature: 0.7,
|
|
maxTokens: 2048,
|
|
systemPrompt: "",
|
|
};
|
|
|
|
function toNum(v: string | number | null | undefined, fallback = 0): number {
|
|
if (v === null || v === undefined) return fallback;
|
|
const n = typeof v === "string" ? parseFloat(v) : v;
|
|
return isNaN(n) ? fallback : n;
|
|
}
|
|
|
|
export function AgentCreateModal({ open, onOpenChange, onSuccess }: AgentCreateModalProps) {
|
|
const [formData, setFormData] = useState({ ...DEFAULT_FORM });
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isAutoFilling, setIsAutoFilling] = useState(false);
|
|
const [maxTokensHint, setMaxTokensHint] = useState<number | null>(null);
|
|
const [modelInfoLoading, setModelInfoLoading] = useState(false);
|
|
|
|
// ─── Remote data ───────────────────────────────────────────────────────────
|
|
|
|
const { data: modelsData, isLoading: modelsLoading } = trpc.ollama.models.useQuery(undefined, {
|
|
staleTime: 60_000,
|
|
});
|
|
const { data: configData } = trpc.config.providers.useQuery(undefined, {
|
|
staleTime: 300_000,
|
|
});
|
|
const connectedProviders = configData?.providers ?? [];
|
|
|
|
// ─── Model info (context_length) ───────────────────────────────────────────
|
|
const { data: modelInfoData, isFetching: modelInfoFetching } = trpc.ollama.modelInfo.useQuery(
|
|
{ modelId: formData.model },
|
|
{
|
|
enabled: !!formData.model,
|
|
staleTime: 300_000,
|
|
}
|
|
);
|
|
|
|
// When model info arrives, update maxTokens with context_length
|
|
useEffect(() => {
|
|
if (modelInfoData?.contextLength && modelInfoData.contextLength > 0) {
|
|
setMaxTokensHint(modelInfoData.contextLength);
|
|
setFormData((prev) => ({ ...prev, maxTokens: modelInfoData.contextLength }));
|
|
}
|
|
}, [modelInfoData]);
|
|
|
|
// Auto-select first provider when providers load
|
|
useEffect(() => {
|
|
if (connectedProviders.length > 0 && !formData.provider) {
|
|
setFormData((prev) => ({ ...prev, provider: connectedProviders[0].name }));
|
|
}
|
|
}, [connectedProviders]);
|
|
|
|
// Auto-select first model when models load
|
|
useEffect(() => {
|
|
const availableModels: string[] = modelsData?.models?.map((m: any) => m.id || m) ?? [];
|
|
if (availableModels.length > 0 && !formData.model) {
|
|
setFormData((prev) => ({ ...prev, model: availableModels[0] }));
|
|
}
|
|
}, [modelsData]);
|
|
|
|
// ─── Mutations ─────────────────────────────────────────────────────────────
|
|
|
|
const createMutation = trpc.agents.create.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Agent created successfully");
|
|
setFormData({ ...DEFAULT_FORM });
|
|
onOpenChange(false);
|
|
onSuccess?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Failed to create agent: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
const compileMutation = trpc.agentCompiler.compile.useMutation({
|
|
onSuccess: (result) => {
|
|
if (!result.success || !result.config) {
|
|
toast.error("AI auto-fill failed — fill fields manually");
|
|
setIsAutoFilling(false);
|
|
return;
|
|
}
|
|
const cfg = result.config;
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
role: cfg.role || prev.role,
|
|
provider: cfg.provider || prev.provider,
|
|
model: cfg.model || prev.model,
|
|
temperature: toNum(cfg.temperature, 0.7),
|
|
maxTokens: cfg.maxTokens || prev.maxTokens,
|
|
systemPrompt: cfg.systemPrompt || prev.systemPrompt,
|
|
}));
|
|
toast.success("AI filled the fields — review and save");
|
|
setIsAutoFilling(false);
|
|
},
|
|
onError: (err) => {
|
|
toast.error(`Auto-fill error: ${err.message}`);
|
|
setIsAutoFilling(false);
|
|
},
|
|
});
|
|
|
|
// ─── Handlers ──────────────────────────────────────────────────────────────
|
|
|
|
const handleCreate = async () => {
|
|
if (!formData.name.trim()) {
|
|
toast.error("Agent name is required");
|
|
return;
|
|
}
|
|
setIsLoading(true);
|
|
try {
|
|
await createMutation.mutateAsync({
|
|
name: formData.name,
|
|
description: formData.description,
|
|
role: formData.role,
|
|
provider: formData.provider || "Ollama",
|
|
model: formData.model,
|
|
temperature: formData.temperature,
|
|
maxTokens: formData.maxTokens,
|
|
systemPrompt: formData.systemPrompt,
|
|
allowedTools: [],
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAutoFill = async () => {
|
|
if (!formData.name.trim()) {
|
|
toast.error("Enter Agent Name first — the AI needs it to suggest parameters");
|
|
return;
|
|
}
|
|
setIsAutoFilling(true);
|
|
const spec = `Agent name: ${formData.name}\n${formData.description ? `Description: ${formData.description}` : ""}`.trim();
|
|
await compileMutation.mutateAsync({
|
|
specification: spec,
|
|
name: formData.name,
|
|
preferredProvider: formData.provider || undefined,
|
|
preferredModel: formData.model || undefined,
|
|
});
|
|
};
|
|
|
|
const handleModelChange = (model: string) => {
|
|
setFormData((prev) => ({ ...prev, model }));
|
|
setMaxTokensHint(null);
|
|
};
|
|
|
|
// ─── Derived ───────────────────────────────────────────────────────────────
|
|
const availableModels: string[] = modelsData?.models?.map((m: any) => m.id || m) ?? [];
|
|
const canAutoFill = formData.name.trim().length > 0;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-card border-border">
|
|
<DialogHeader>
|
|
<DialogTitle className="font-mono text-primary flex items-center gap-2">
|
|
<Plus className="w-4 h-4" />
|
|
Deploy New Agent
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* ── Name + Description ─────────────────────────────────────────── */}
|
|
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground font-mono">AGENT NAME *</Label>
|
|
<Input
|
|
placeholder="e.g. Code Reviewer Agent"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
className="font-mono"
|
|
/>
|
|
</div>
|
|
{/* Magic wand — auto-fill remaining fields via AI */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleAutoFill}
|
|
disabled={!canAutoFill || isAutoFilling}
|
|
className={`h-9 gap-1.5 border-primary/40 text-primary hover:bg-primary/10 transition-all ${
|
|
canAutoFill ? "opacity-100" : "opacity-40"
|
|
}`}
|
|
title="Auto-fill fields with AI based on name & description"
|
|
>
|
|
{isAutoFilling ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Wand2 className="w-4 h-4" />
|
|
)}
|
|
<span className="text-xs font-mono hidden sm:inline">
|
|
{isAutoFilling ? "Thinking..." : "Auto-fill"}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{canAutoFill && !isAutoFilling && (
|
|
<p className="text-[11px] text-muted-foreground font-mono -mt-2 flex items-center gap-1">
|
|
<Sparkles className="w-3 h-3 text-primary" />
|
|
Click <span className="text-primary">Auto-fill</span> to let AI suggest temperature, system prompt and other params
|
|
</p>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground font-mono">DESCRIPTION</Label>
|
|
<Textarea
|
|
placeholder="What is this agent responsible for?"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
rows={2}
|
|
className="font-mono text-sm resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* ── Role ───────────────────────────────────────────────────────── */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground font-mono">ROLE *</Label>
|
|
<Select value={formData.role} onValueChange={(v) => setFormData({ ...formData, role: v })}>
|
|
<SelectTrigger className="font-mono">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AGENT_ROLES.map((role) => (
|
|
<SelectItem key={role.value} value={role.value} className="font-mono text-sm">
|
|
{role.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* ── Provider + Model side-by-side ──────────────────────────────── */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground font-mono">PROVIDER *</Label>
|
|
<Select
|
|
value={formData.provider}
|
|
onValueChange={(v) => setFormData({ ...formData, provider: v, model: "" })}
|
|
>
|
|
<SelectTrigger className="font-mono w-full truncate">
|
|
<SelectValue placeholder="Select provider..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{connectedProviders.length > 0 ? (
|
|
connectedProviders.map((p) => (
|
|
<SelectItem key={p.id} value={p.name} className="font-mono text-sm">
|
|
<span className="flex items-center gap-2">
|
|
{p.name}
|
|
<span className="text-[10px] text-neon-green font-mono">● connected</span>
|
|
</span>
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<SelectItem value="Ollama" className="font-mono text-sm">
|
|
Ollama
|
|
</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground font-mono flex items-center gap-2">
|
|
MODEL *
|
|
{modelsLoading && <Loader2 className="w-3 h-3 animate-spin text-primary" />}
|
|
{!modelsLoading && availableModels.length > 0 && (
|
|
<span className="text-[10px] text-neon-green font-normal">{availableModels.length} available</span>
|
|
)}
|
|
{!modelsLoading && availableModels.length === 0 && (
|
|
<span className="text-[10px] text-neon-amber font-normal">API unavailable</span>
|
|
)}
|
|
</Label>
|
|
<Select
|
|
value={formData.model}
|
|
onValueChange={handleModelChange}
|
|
disabled={modelsLoading}
|
|
>
|
|
<SelectTrigger className="font-mono w-full truncate">
|
|
<SelectValue placeholder={modelsLoading ? "Loading models..." : "Select model..."} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableModels.length > 0 ? (
|
|
availableModels.map((model: string) => (
|
|
<SelectItem key={model} value={model} className="font-mono text-sm">
|
|
{model}
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<SelectItem value="_placeholder" disabled className="font-mono text-sm">
|
|
{modelsLoading ? "Loading..." : "No models available"}
|
|
</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Temperature + Max Tokens ────────────────────────────────────── */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<Label className="text-xs text-muted-foreground font-mono">TEMPERATURE</Label>
|
|
<span className="text-xs font-mono text-primary">{formData.temperature.toFixed(2)}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="2"
|
|
step="0.01"
|
|
value={formData.temperature}
|
|
onChange={(e) => setFormData({ ...formData, temperature: parseFloat(e.target.value) })}
|
|
className="w-full accent-cyan-400 cursor-pointer"
|
|
/>
|
|
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
|
|
<span>0.0 precise</span>
|
|
<span>2.0 creative</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground font-mono flex items-center gap-1.5">
|
|
MAX TOKENS
|
|
{(modelInfoFetching) && (
|
|
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
|
)}
|
|
{maxTokensHint && maxTokensHint > 0 && !modelInfoFetching && (
|
|
<span className="text-[10px] text-neon-green font-normal">
|
|
max {maxTokensHint.toLocaleString()} from API
|
|
</span>
|
|
)}
|
|
</Label>
|
|
<Input
|
|
type="number"
|
|
value={formData.maxTokens}
|
|
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) || 2048 })}
|
|
className="font-mono"
|
|
min={1}
|
|
max={maxTokensHint || 1000000}
|
|
/>
|
|
{maxTokensHint && maxTokensHint > 0 && (
|
|
<p className="text-[10px] text-muted-foreground font-mono">
|
|
Context window: {maxTokensHint.toLocaleString()} tokens
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── System Prompt ───────────────────────────────────────────────── */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground font-mono">SYSTEM PROMPT</Label>
|
|
<Textarea
|
|
placeholder="Define the agent's behavior and instructions... (AI auto-fill will suggest this)"
|
|
value={formData.systemPrompt}
|
|
onChange={(e) => setFormData({ ...formData, systemPrompt: e.target.value })}
|
|
rows={4}
|
|
className="font-mono text-sm resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleCreate}
|
|
disabled={isLoading || createMutation.isPending || !formData.name.trim()}
|
|
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
|
|
>
|
|
{isLoading || createMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Create Agent
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|