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
This commit is contained in:
bboxwtf
2026-03-21 19:41:15 +00:00
parent c57d694236
commit e228e7a655
4 changed files with 747 additions and 249 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
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";
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
import { Loader2, Plus, RefreshCw } from "lucide-react";
import { Loader2, Plus, Wand2, Sparkles } from "lucide-react";
interface AgentCreateModalProps {
open: boolean;
@@ -22,27 +22,40 @@ interface AgentCreateModalProps {
}
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: "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" },
];
// Providers are loaded dynamically from server config — no hardcoded list
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({
name: "",
description: "",
role: "developer",
provider: "Ollama",
model: "deepseek-v3.2",
temperature: 0.7,
maxTokens: 2048,
systemPrompt: "",
});
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,
@@ -50,22 +63,46 @@ export function AgentCreateModal({ open, onOpenChange, onSuccess }: AgentCreateM
const { data: configData } = trpc.config.providers.useQuery(undefined, {
staleTime: 300_000,
});
// Only providers configured on server
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({
name: "",
description: "",
role: "developer",
provider: "Ollama",
model: "deepseek-v3.2",
temperature: 0.7,
maxTokens: 2048,
systemPrompt: "",
});
setFormData({ ...DEFAULT_FORM });
onOpenChange(false);
onSuccess?.();
},
@@ -74,19 +111,46 @@ export function AgentCreateModal({ open, onOpenChange, onSuccess }: AgentCreateM
},
});
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,
provider: formData.provider || "Ollama",
model: formData.model,
temperature: formData.temperature,
maxTokens: formData.maxTokens,
@@ -98,167 +162,247 @@ export function AgentCreateModal({ open, onOpenChange, onSuccess }: AgentCreateM
}
};
// All models from API — no provider filtering (API returns only what's connected)
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">
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-card border-border">
<DialogHeader>
<DialogTitle>Deploy New Agent</DialogTitle>
<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">
{/* Basic Info */}
<div className="space-y-2">
<Label htmlFor="name">Agent Name *</Label>
<Input
id="name"
placeholder="e.g., Code Reviewer Agent"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="font-mono"
/>
{/* ── 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>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
{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
id="description"
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"
className="font-mono text-sm resize-none"
/>
</div>
{/* Role & Provider */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role *</Label>
<Select value={formData.role} onValueChange={(value) => setFormData({ ...formData, role: value })}>
<SelectTrigger id="role" className="font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGENT_ROLES.map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="provider">Provider *</Label>
<Select value={formData.provider} onValueChange={(value) => setFormData({ ...formData, provider: value, model: "" })}>
<SelectTrigger id="provider" className="font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
{connectedProviders.length > 0
? connectedProviders.map((p) => (
<SelectItem key={p.id} value={p.name}>
<span className="flex items-center gap-2">
{p.name}
<span className="text-[10px] text-neon-green font-mono"> connected</span>
</span>
</SelectItem>
))
: <SelectItem value={formData.provider}>{formData.provider}</SelectItem>
}
</SelectContent>
</Select>
</div>
</div>
{/* Model Selection */}
<div className="space-y-2">
<Label htmlFor="model" className="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-mono font-normal">{availableModels.length} available</span>
)}
{!modelsLoading && availableModels.length === 0 && (
<span className="text-[10px] text-neon-amber font-mono font-normal">API unavailable</span>
)}
</Label>
<Select
value={formData.model}
onValueChange={(value) => setFormData({ ...formData, model: value })}
disabled={modelsLoading}
>
<SelectTrigger id="model" className="font-mono">
<SelectValue placeholder={modelsLoading ? "Loading models..." : "Select model..."} />
{/* ── 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>
{availableModels.length > 0 ? (
availableModels.map((model: string) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))
) : (
<SelectItem value={formData.model || "_placeholder"} disabled>
{modelsLoading ? "Loading..." : "No models available from API"}
{AGENT_ROLES.map((role) => (
<SelectItem key={role.value} value={role.value} className="font-mono text-sm">
{role.label}
</SelectItem>
)}
))}
</SelectContent>
</Select>
</div>
{/* LLM Parameters */}
{/* ── 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">
<Label htmlFor="temperature">
Temperature: {formData.temperature.toFixed(2)}
</Label>
<Input
id="temperature"
<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="cursor-pointer"
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-2">
<Label htmlFor="maxTokens">Max Tokens</Label>
<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
id="maxTokens"
type="number"
value={formData.maxTokens}
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) })}
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-2">
<Label htmlFor="systemPrompt">System Prompt</Label>
{/* ── System Prompt ───────────────────────────────────────────────── */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">SYSTEM PROMPT</Label>
<Textarea
id="systemPrompt"
placeholder="Define the agent's behavior and instructions..."
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"
className="font-mono text-sm resize-none"
/>
</div>
</div>
<DialogFooter>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isLoading || createMutation.isPending || !formData.name.trim()}>
<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" />

View File

@@ -4,7 +4,7 @@ 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Switch } from "@/components/ui/switch";
@@ -48,7 +48,10 @@ interface AgentDetailModalProps {
onSave?: () => void;
}
const TOOL_OPTIONS = ["http_get", "http_post", "shell_exec", "file_read", "file_write", "docker_list", "docker_exec", "docker_logs", "browser_navigate"];
const TOOL_OPTIONS = [
"http_get", "http_post", "shell_exec", "file_read", "file_write",
"docker_list", "docker_exec", "docker_logs", "browser_navigate",
];
function toNum(v: string | number | null | undefined, fallback = 0): number {
if (v === null || v === undefined) return fallback;
@@ -56,33 +59,90 @@ function toNum(v: string | number | null | undefined, fallback = 0): number {
return isNaN(n) ? fallback : n;
}
/** Case-insensitive provider match: returns the provider.name from the list
* that best matches the agent's stored provider string, or returns the stored
* value as-is (so the Select always has a valid value). */
function resolveProviderValue(stored: string, providers: Array<{ id: string; name: string }>): string {
if (!stored) return "";
const exact = providers.find((p) => p.name === stored);
if (exact) return exact.name;
// case-insensitive fallback
const lower = stored.toLowerCase();
const ci = providers.find((p) => p.name.toLowerCase() === lower);
if (ci) return ci.name;
// partial match (e.g. stored="ollama" → provider.name="Ollama Cloud")
const partial = providers.find(
(p) => p.name.toLowerCase().includes(lower) || lower.includes(p.name.toLowerCase())
);
if (partial) return partial.name;
// Return stored value verbatim — the Select will show it via fallback item
return stored;
}
export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDetailModalProps) {
const [form, setForm] = useState<any>({});
const [newTool, setNewTool] = useState("");
const [newDomain, setNewDomain] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [maxTokensHint, setMaxTokensHint] = useState<number | null>(null);
useEffect(() => {
if (agent) setForm({ ...agent });
}, [agent]);
// ─── 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,
});
// Only show providers that are actually configured on the server
const connectedProviders = configData?.providers ?? [];
const { data: history = [] } = trpc.agents.history.useQuery(
{ id: agent?.id ?? 0, limit: 20 },
{ enabled: !!agent && open }
);
const { data: stats } = trpc.agents.stats.useQuery(
{ id: agent?.id ?? 0, hoursBack: 24 },
{ enabled: !!agent && open }
// Model info (context_length) — enabled only when a model is selected
const { data: modelInfoData, isFetching: modelInfoFetching } = trpc.ollama.modelInfo.useQuery(
{ modelId: form.model ?? "" },
{
enabled: !!form.model && open,
staleTime: 300_000,
}
);
// ─── Init form from agent ─────────────────────────────────────────────────
useEffect(() => {
if (agent) {
const resolvedProvider = resolveProviderValue(agent.provider ?? "", connectedProviders);
setForm({
...agent,
provider: resolvedProvider || agent.provider,
});
setMaxTokensHint(null);
}
}, [agent]); // eslint-disable-line react-hooks/exhaustive-deps — intentionally excludes connectedProviders
// Re-resolve provider once providers load (they may arrive after agent)
useEffect(() => {
if (agent && connectedProviders.length > 0 && form.provider !== undefined) {
const resolved = resolveProviderValue(agent.provider ?? "", connectedProviders);
if (resolved && resolved !== form.provider) {
setForm((prev: any) => ({ ...prev, provider: resolved }));
}
}
}, [connectedProviders]); // eslint-disable-line react-hooks/exhaustive-deps
// When model info arrives → update maxTokens with context_length
useEffect(() => {
if (modelInfoData?.contextLength && modelInfoData.contextLength > 0) {
setMaxTokensHint(modelInfoData.contextLength);
// Only auto-update if user hasn't manually changed it
setForm((prev: any) => {
const currentMax = toNum(prev.maxTokens, 2048);
// Update if it's still at the default value or lower than the actual context window
if (currentMax <= 2048 || currentMax < modelInfoData.contextLength) {
return { ...prev, maxTokens: modelInfoData.contextLength };
}
return prev;
});
}
}, [modelInfoData]);
// ─── Mutations ───────────────────────────────────────────────────────────
const updateMutation = trpc.agents.update.useMutation({
onSuccess: () => {
toast.success("Agent configuration saved");
@@ -92,6 +152,16 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
onError: (err) => toast.error(`Save failed: ${err.message}`),
});
const { data: history = [] } = trpc.agents.history.useQuery(
{ id: agent?.id ?? 0, limit: 20 },
{ enabled: !!agent && open }
);
const { data: stats } = trpc.agents.stats.useQuery(
{ id: agent?.id ?? 0, hoursBack: 24 },
{ enabled: !!agent && open }
);
// ─── Handlers ────────────────────────────────────────────────────────────
const handleSave = async () => {
if (!agent) return;
setIsSaving(true);
@@ -144,11 +214,15 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
setForm({ ...form, allowedDomains: (form.allowedDomains || []).filter((x: string) => x !== d) });
};
// Build available models list — always include current agent model as fallback
const handleModelChange = (model: string) => {
setForm({ ...form, model });
setMaxTokensHint(null); // reset hint; modelInfo query will re-fire
};
// ─── Derived ─────────────────────────────────────────────────────────────
const fetchedModels: string[] = modelsData?.models?.map((m: any) => m.id || m) ?? [];
const availableModels: string[] = fetchedModels.length > 0
? fetchedModels
: (form.model ? [form.model] : []);
const availableModels: string[] =
fetchedModels.length > 0 ? fetchedModels : form.model ? [form.model] : [];
if (!agent) return null;
@@ -159,7 +233,14 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<DialogTitle className="font-mono text-primary flex items-center gap-2">
<span className="text-muted-foreground text-xs">AGENT</span>
{agent.name}
<Badge variant="outline" className={`text-[10px] ml-2 ${form.isActive ? "border-neon-green/40 text-neon-green" : "border-muted text-muted-foreground"}`}>
<Badge
variant="outline"
className={`text-[10px] ml-2 ${
form.isActive
? "border-neon-green/40 text-neon-green"
: "border-muted text-muted-foreground"
}`}
>
{form.isActive ? "ACTIVE" : "INACTIVE"}
</Badge>
</DialogTitle>
@@ -174,64 +255,108 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<TabsTrigger value="stats">Stats</TabsTrigger>
</TabsList>
{/* ── GENERAL ─────────────────────────────────── */}
{/* ── GENERAL ──────────────────────────────────────────────────────── */}
<TabsContent value="general" className="space-y-4 pt-2">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">NAME</Label>
<Input value={form.name ?? ""} onChange={(e) => setForm({ ...form, name: e.target.value })} className="font-mono" />
<Input
value={form.name ?? ""}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="font-mono"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">ROLE</Label>
<Input value={form.role ?? ""} disabled className="font-mono bg-secondary/30 text-muted-foreground" />
<Input
value={form.role ?? ""}
disabled
className="font-mono bg-secondary/30 text-muted-foreground"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">DESCRIPTION</Label>
<Textarea value={form.description ?? ""} onChange={(e) => setForm({ ...form, description: e.target.value })} rows={2} className="font-mono text-sm resize-none" />
<Textarea
value={form.description ?? ""}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={2}
className="font-mono text-sm resize-none"
/>
</div>
{/* Provider + Model */}
<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={form.provider ?? ""} onValueChange={(v) => setForm({ ...form, provider: v, model: "" })}>
<SelectTrigger className="font-mono"><SelectValue /></SelectTrigger>
<Select
value={form.provider ?? ""}
onValueChange={(v) => setForm({ ...form, provider: v, model: "" })}
>
<SelectTrigger className="font-mono w-full">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent>
{connectedProviders.length > 0
? connectedProviders.map((p) => (
<SelectItem key={p.id} value={p.name}>
<span className="flex items-center gap-2">
{p.name}
<span className="text-[10px] text-neon-green font-mono"> connected</span>
</span>
</SelectItem>
))
: <SelectItem value={form.provider ?? "ollama"}>{form.provider ?? "Ollama"}</SelectItem>
}
{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>
))
) : (
/* Fallback: show current stored value so Select isn't empty */
<SelectItem value={form.provider ?? "ollama"} className="font-mono text-sm">
{form.provider ?? "Ollama"}
</SelectItem>
)}
{/* Always include stored value as safety net if not in list */}
{connectedProviders.length > 0 &&
form.provider &&
!connectedProviders.find((p) => p.name === form.provider) && (
<SelectItem
value={form.provider}
className="font-mono text-sm text-neon-amber"
>
{form.provider}{" "}
<span className="text-[10px] opacity-60">(not in list)</span>
</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 && fetchedModels.length > 0 && (
<span className="text-[10px] text-neon-green font-normal">{fetchedModels.length} available</span>
<span className="text-[10px] text-neon-green font-normal">
{fetchedModels.length} available
</span>
)}
{!modelsLoading && fetchedModels.length === 0 && (
<span className="text-[10px] text-neon-amber font-normal">API unavailable using current</span>
<span className="text-[10px] text-neon-amber font-normal">
API unavailable using current
</span>
)}
</Label>
<Select value={form.model ?? ""} onValueChange={(v) => setForm({ ...form, model: v })}>
<Select value={form.model ?? ""} onValueChange={handleModelChange}>
<SelectTrigger className="font-mono">
<SelectValue placeholder={modelsLoading ? "Loading models..." : "Select model..."} />
<SelectValue
placeholder={modelsLoading ? "Loading models..." : "Select model..."}
/>
</SelectTrigger>
<SelectContent>
{availableModels.map((m: string) => (
<SelectItem key={m} value={m}>
<SelectItem key={m} value={m} className="font-mono text-sm">
{m}
{m === agent.model && <span className="ml-2 text-[10px] text-muted-foreground">(current)</span>}
{m === agent.model && (
<span className="ml-2 text-[10px] text-muted-foreground">(current)</span>
)}
</SelectItem>
))}
</SelectContent>
@@ -255,75 +380,149 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<div className="text-sm font-medium">Agent Active</div>
<div className="text-xs text-muted-foreground">Enable/disable this agent</div>
</div>
<Switch checked={form.isActive ?? true} onCheckedChange={(v) => setForm({ ...form, isActive: v })} />
<Switch
checked={form.isActive ?? true}
onCheckedChange={(v) => setForm({ ...form, isActive: v })}
/>
</div>
</TabsContent>
{/* ── LLM PARAMS ──────────────────────────────── */}
{/* ── LLM PARAMS ───────────────────────────────────────────────────── */}
<TabsContent value="llm" className="space-y-5 pt-2">
<div className="grid grid-cols-2 gap-5">
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">TEMPERATURE</Label>
<span className="text-xs font-mono text-primary">{toNum(form.temperature, 0.7).toFixed(2)}</span>
<span className="text-xs font-mono text-primary">
{toNum(form.temperature, 0.7).toFixed(2)}
</span>
</div>
<input type="range" min="0" max="2" step="0.01"
<input
type="range"
min="0"
max="2"
step="0.01"
value={toNum(form.temperature, 0.7)}
onChange={(e) => setForm({ ...form, temperature: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
onChange={(e) =>
setForm({ ...form, 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>
<span>0.0 (precise)</span>
<span>2.0 (creative)</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">TOP P</Label>
<span className="text-xs font-mono text-primary">{toNum(form.topP, 1.0).toFixed(2)}</span>
<span className="text-xs font-mono text-primary">
{toNum(form.topP, 1.0).toFixed(2)}
</span>
</div>
<input type="range" min="0" max="1" step="0.01"
<input
type="range"
min="0"
max="1"
step="0.01"
value={toNum(form.topP, 1.0)}
onChange={(e) => setForm({ ...form, topP: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
className="w-full accent-cyan-400 cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
<span>0.0</span><span>1.0</span>
<span>0.0</span>
<span>1.0</span>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">MAX TOKENS</Label>
<Input type="number" value={form.maxTokens ?? 2048}
onChange={(e) => setForm({ ...form, maxTokens: parseInt(e.target.value) || 2048 })}
className="font-mono" min={1} max={128000} />
<Label className="text-xs text-muted-foreground font-mono flex items-center gap-2">
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">
context window: {maxTokensHint.toLocaleString()}
</span>
)}
</Label>
<Input
type="number"
value={form.maxTokens ?? 2048}
onChange={(e) =>
setForm({ ...form, 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">
Set to{" "}
<button
className="text-primary hover:underline"
onClick={() => setForm({ ...form, maxTokens: maxTokensHint })}
>
{maxTokensHint.toLocaleString()}
</button>{" "}
to use full context window
</p>
)}
</div>
<div className="grid grid-cols-2 gap-5">
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">FREQUENCY PENALTY</Label>
<span className="text-xs font-mono text-primary">{toNum(form.frequencyPenalty, 0).toFixed(2)}</span>
<Label className="text-xs text-muted-foreground font-mono">
FREQUENCY PENALTY
</Label>
<span className="text-xs font-mono text-primary">
{toNum(form.frequencyPenalty, 0).toFixed(2)}
</span>
</div>
<input type="range" min="-2" max="2" step="0.01"
<input
type="range"
min="-2"
max="2"
step="0.01"
value={toNum(form.frequencyPenalty, 0)}
onChange={(e) => setForm({ ...form, frequencyPenalty: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
onChange={(e) =>
setForm({ ...form, frequencyPenalty: 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>-2.0</span><span>+2.0</span>
<span>-2.0</span>
<span>+2.0</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">PRESENCE PENALTY</Label>
<span className="text-xs font-mono text-primary">{toNum(form.presencePenalty, 0).toFixed(2)}</span>
<Label className="text-xs text-muted-foreground font-mono">
PRESENCE PENALTY
</Label>
<span className="text-xs font-mono text-primary">
{toNum(form.presencePenalty, 0).toFixed(2)}
</span>
</div>
<input type="range" min="-2" max="2" step="0.01"
<input
type="range"
min="-2"
max="2"
step="0.01"
value={toNum(form.presencePenalty, 0)}
onChange={(e) => setForm({ ...form, presencePenalty: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
onChange={(e) =>
setForm({ ...form, presencePenalty: 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>-2.0</span><span>+2.0</span>
<span>-2.0</span>
<span>+2.0</span>
</div>
</div>
</div>
@@ -332,24 +531,42 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<CardContent className="p-3">
<div className="text-xs text-muted-foreground font-mono mb-2">PARAMETER GUIDE</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div><span className="text-primary font-mono">temperature</span> randomness (0=deterministic, 2=very random)</div>
<div><span className="text-primary font-mono">top_p</span> nucleus sampling (0.9=top 90% tokens)</div>
<div><span className="text-primary font-mono">freq_penalty</span> reduce word repetition</div>
<div><span className="text-primary font-mono">pres_penalty</span> encourage topic diversity</div>
<div>
<span className="text-primary font-mono">temperature</span> randomness
(0=deterministic, 2=very random)
</div>
<div>
<span className="text-primary font-mono">top_p</span> nucleus sampling
(0.9=top 90% tokens)
</div>
<div>
<span className="text-primary font-mono">freq_penalty</span> reduce word
repetition
</div>
<div>
<span className="text-primary font-mono">pres_penalty</span> encourage topic
diversity
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ── TOOLS ───────────────────────────────────── */}
{/* ── TOOLS ────────────────────────────────────────────────────────── */}
<TabsContent value="tools" className="space-y-4 pt-2">
<div className="space-y-2">
<Label className="text-xs text-muted-foreground font-mono">ALLOWED TOOLS</Label>
<div className="flex gap-2">
<Select value={newTool} onValueChange={setNewTool}>
<SelectTrigger className="font-mono flex-1"><SelectValue placeholder="Select tool..." /></SelectTrigger>
<SelectTrigger className="font-mono flex-1">
<SelectValue placeholder="Select tool..." />
</SelectTrigger>
<SelectContent>
{TOOL_OPTIONS.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
{TOOL_OPTIONS.map((t) => (
<SelectItem key={t} value={t} className="font-mono text-sm">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={addTool} disabled={!newTool}>
@@ -357,22 +574,34 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-md bg-secondary/20 border border-border/30">
{(form.allowedTools || []).length === 0
? <span className="text-xs text-muted-foreground">No tools assigned all tools blocked</span>
: (form.allowedTools || []).map((tool: string) => (
<Badge key={tool} variant="outline" className="font-mono text-[11px] gap-1 border-primary/30 text-primary">
{(form.allowedTools || []).length === 0 ? (
<span className="text-xs text-muted-foreground">
No tools assigned all tools blocked
</span>
) : (
(form.allowedTools || []).map((tool: string) => (
<Badge
key={tool}
variant="outline"
className="font-mono text-[11px] gap-1 border-primary/30 text-primary"
>
{tool}
<button onClick={() => removeTool(tool)} className="hover:text-neon-red transition-colors">
<button
onClick={() => removeTool(tool)}
className="hover:text-neon-red transition-colors"
>
<X className="w-3 h-3" />
</button>
</Badge>
))
}
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground font-mono">ALLOWED DOMAINS (HTTP whitelist)</Label>
<Label className="text-xs text-muted-foreground font-mono">
ALLOWED DOMAINS (HTTP whitelist)
</Label>
<div className="flex gap-2">
<Input
placeholder="e.g. api.github.com"
@@ -381,27 +610,42 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
onKeyDown={(e) => e.key === "Enter" && addDomain()}
className="font-mono flex-1"
/>
<Button size="sm" variant="outline" onClick={addDomain} disabled={!newDomain.trim()}>
<Button
size="sm"
variant="outline"
onClick={addDomain}
disabled={!newDomain.trim()}
>
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-md bg-secondary/20 border border-border/30">
{(form.allowedDomains || []).length === 0
? <span className="text-xs text-muted-foreground">No domain restrictions all domains allowed</span>
: (form.allowedDomains || []).map((d: string) => (
<Badge key={d} variant="outline" className="font-mono text-[11px] gap-1 border-neon-amber/30 text-neon-amber">
{(form.allowedDomains || []).length === 0 ? (
<span className="text-xs text-muted-foreground">
No domain restrictions all domains allowed
</span>
) : (
(form.allowedDomains || []).map((d: string) => (
<Badge
key={d}
variant="outline"
className="font-mono text-[11px] gap-1 border-neon-amber/30 text-neon-amber"
>
{d}
<button onClick={() => removeDomain(d)} className="hover:text-neon-red transition-colors">
<button
onClick={() => removeDomain(d)}
className="hover:text-neon-red transition-colors"
>
<X className="w-3 h-3" />
</button>
</Badge>
))
}
)}
</div>
</div>
</TabsContent>
{/* ── HISTORY ─────────────────────────────────── */}
{/* ── HISTORY ──────────────────────────────────────────────────────── */}
<TabsContent value="history" className="pt-2">
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1">
{(history as any[]).length === 0 ? (
@@ -414,10 +658,11 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{item.status === "success"
? <CheckCircle className="w-3.5 h-3.5 text-neon-green" />
: <XCircle className="w-3.5 h-3.5 text-neon-red" />
}
{item.status === "success" ? (
<CheckCircle className="w-3.5 h-3.5 text-neon-green" />
) : (
<XCircle className="w-3.5 h-3.5 text-neon-red" />
)}
<span className="text-[10px] font-mono text-muted-foreground">
{new Date(item.createdAt).toLocaleString()}
</span>
@@ -449,22 +694,44 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
</div>
</TabsContent>
{/* ── STATS ───────────────────────────────────── */}
{/* ── STATS ────────────────────────────────────────────────────────── */}
<TabsContent value="stats" className="pt-2">
{stats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{[
{ label: "Total Requests (24h)", value: stats.totalRequests, icon: Zap, color: "text-primary" },
{ label: "Success Rate", value: `${stats.successRate.toFixed(1)}%`, icon: CheckCircle, color: "text-neon-green" },
{ label: "Avg Processing", value: `${stats.avgProcessingTime}ms`, icon: Clock, color: "text-neon-amber" },
{ label: "Total Tokens", value: stats.totalTokens.toLocaleString(), icon: Zap, color: "text-primary" },
{
label: "Total Requests (24h)",
value: stats.totalRequests,
icon: Zap,
color: "text-primary",
},
{
label: "Success Rate",
value: `${stats.successRate.toFixed(1)}%`,
icon: CheckCircle,
color: "text-neon-green",
},
{
label: "Avg Processing",
value: `${stats.avgProcessingTime}ms`,
icon: Clock,
color: "text-neon-amber",
},
{
label: "Total Tokens",
value: stats.totalTokens.toLocaleString(),
icon: Zap,
color: "text-primary",
},
].map(({ label, value, icon: Icon, color }) => (
<Card key={label} className="bg-secondary/20 border-border/30">
<CardContent className="p-3">
<div className="flex items-center gap-2 mb-1">
<Icon className={`w-3.5 h-3.5 ${color}`} />
<span className="text-[10px] text-muted-foreground font-mono">{label.toUpperCase()}</span>
<span className="text-[10px] text-muted-foreground font-mono">
{label.toUpperCase()}
</span>
</div>
<div className={`text-xl font-bold font-mono ${color}`}>{value}</div>
</CardContent>
@@ -474,7 +741,9 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<Card className="bg-secondary/20 border-border/30">
<CardContent className="p-3">
<div className="text-[10px] text-muted-foreground font-mono mb-2">REQUEST BREAKDOWN</div>
<div className="text-[10px] text-muted-foreground font-mono mb-2">
REQUEST BREAKDOWN
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-neon-green font-mono">Success</span>
@@ -483,7 +752,13 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<div className="w-full h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-neon-green rounded-full transition-all"
style={{ width: `${stats.totalRequests > 0 ? (stats.successRequests / stats.totalRequests) * 100 : 0}%` }}
style={{
width: `${
stats.totalRequests > 0
? (stats.successRequests / stats.totalRequests) * 100
: 0
}%`,
}}
/>
</div>
<div className="flex items-center justify-between text-xs">
@@ -493,7 +768,13 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<div className="w-full h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-neon-red rounded-full transition-all"
style={{ width: `${stats.totalRequests > 0 ? (stats.errorRequests / stats.totalRequests) * 100 : 0}%` }}
style={{
width: `${
stats.totalRequests > 0
? (stats.errorRequests / stats.totalRequests) * 100
: 0
}%`,
}}
/>
</div>
</div>
@@ -501,7 +782,9 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
</Card>
<div className="text-[10px] text-muted-foreground font-mono text-center">
Agent ID: {agent.id} · Created: {new Date(agent.createdAt).toLocaleDateString()} · Updated: {new Date(agent.updatedAt).toLocaleDateString()}
Agent ID: {agent.id} · Created:{" "}
{new Date(agent.createdAt).toLocaleDateString()} · Updated:{" "}
{new Date(agent.updatedAt).toLocaleDateString()}
</div>
</div>
) : (
@@ -513,13 +796,25 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
</Tabs>
<div className="flex justify-end gap-2 pt-2 border-t border-border/30">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || updateMutation.isPending}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25">
{isSaving || updateMutation.isPending
? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: <><Save className="w-4 h-4 mr-2" />Save Changes</>
}
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isSaving || updateMutation.isPending}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
>
{isSaving || updateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</DialogContent>

View File

@@ -121,6 +121,56 @@ export async function isGatewayAvailable(): Promise<boolean> {
}
}
// ─── Model Info ───────────────────────────────────────────────────────────────
export interface OllamaModelInfo {
contextLength: number;
parameterSize?: string;
family?: string;
quantization?: string;
capabilities?: string[];
}
/**
* Fetch model details from Ollama /api/show (context_length, parameters, etc.)
* Uses the base URL and API key from environment.
*/
export async function getOllamaModelInfo(modelId: string): Promise<OllamaModelInfo | null> {
const baseUrl = (process.env.OLLAMA_BASE_URL ?? "https://ollama.com/v1").replace(/\/v1\/?$/, "");
const apiKey = process.env.OLLAMA_API_KEY ?? "";
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${baseUrl}/api/show`, {
method: "POST",
headers,
body: JSON.stringify({ model: modelId }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
const data = await res.json();
// context_length is under model_info with key "{arch}.context_length"
let contextLength = 0;
if (data.model_info) {
for (const [k, v] of Object.entries(data.model_info)) {
if (k.endsWith(".context_length") && typeof v === "number") {
contextLength = v;
break;
}
}
}
return {
contextLength,
parameterSize: data.details?.parameter_size,
family: data.details?.family,
quantization: data.details?.quantization_level,
capabilities: data.capabilities,
};
} catch {
return null;
}
}
// ─── Orchestrator ─────────────────────────────────────────────────────────────
/**

View File

@@ -29,6 +29,7 @@ import {
addSwarmNodeLabel,
setNodeAvailability,
createAgentService,
getOllamaModelInfo,
} from "./gateway-proxy";
// Shared system user id for non-authenticated agent management
@@ -201,6 +202,14 @@ export const appRouter = router({
}
}),
/** Fetch model details (context_length, family, quantization…) from Ollama /api/show */
modelInfo: publicProcedure
.input(z.object({ modelId: z.string() }))
.query(async ({ input }) => {
const info = await getOllamaModelInfo(input.modelId);
return info ?? { contextLength: 0 };
}),
chat: publicProcedure
.input(
z.object({