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:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user