Files
GoClaw/client/src/components/AgentCreateModal.tsx
bboxwtf e228e7a655 fix(agents): provider pre-selection, magic-wand auto-fill, maxTokens from Ollama API
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
2026-03-21 19:41:15 +00:00

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>
);
}