Some checks failed
Test / rust-fmt-check (pull_request) Failing after 0s
Test / rust-clippy (pull_request) Failing after 1s
Test / rust-tests (pull_request) Failing after 0s
Test / frontend-typecheck (pull_request) Failing after 16s
Test / frontend-tests (pull_request) Failing after 18s
PR Review Automation / review (pull_request) Failing after 4m13s
Complete backport of all features from apollo_nxt-trcaa repository: - Three-tier shell execution safety system (Tier 1: auto, Tier 2: approve, Tier 3: deny) - Ollama function calling with tool use support - AI provider tool calling auto-detection - kubectl binary bundling and management - kubeconfig upload and context management - Shell approval modal with real-time UI - MCP protocol HTTP transport with custom headers - Enhanced security audit logging - Comprehensive test coverage (275+ tests) - Updated CI/CD workflows for Gitea Actions - Complete documentation (ADRs, wiki, release notes) Sanitization applied to all files: - Removed all MSI, Motorola, VNXT, Vesta references - Replaced internal infrastructure references with TFTSR equivalents - Updated all URLs and API endpoints - Sanitized commit history references in documentation Technical changes: - New modules: shell/classifier, shell/executor, shell/kubectl, shell/kubeconfig - Enhanced AI providers: ollama.rs, openai.rs with function calling - New Tauri commands: shell execution, kubeconfig management, tool calling detection - Database migrations: shell_execution_audit table - Frontend: ShellApprovalModal, ShellExecution, KubeconfigManager pages - CI/CD: kubectl bundling, multi-platform builds, Gitea Actions integration Version: 1.0.8 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
647 lines
24 KiB
TypeScript
647 lines
24 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Plus, Pencil, Trash2, CheckCircle, XCircle, Zap } from "lucide-react";
|
|
import {
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardContent,
|
|
Button,
|
|
Input,
|
|
Label,
|
|
Badge,
|
|
Select,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
SelectContent,
|
|
SelectItem,
|
|
Separator,
|
|
} from "@/components/ui";
|
|
import { useSettingsStore } from "@/stores/settingsStore";
|
|
import {
|
|
testProviderConnectionCmd,
|
|
detectToolCallingSupportCmd,
|
|
saveAiProviderCmd,
|
|
loadAiProvidersCmd,
|
|
deleteAiProviderCmd,
|
|
listOllamaModelsCmd,
|
|
type ProviderConfig,
|
|
type OllamaModel,
|
|
} from "@/lib/tauriCommands";
|
|
|
|
export const CUSTOM_REST_MODELS = [
|
|
"ChatGPT4o",
|
|
"ChatGPT4o-mini",
|
|
"ChatGPT-o3-mini",
|
|
"Gemini-2_0-Flash-001",
|
|
"Gemini-2_5-Flash",
|
|
"Claude-Sonnet-3_7",
|
|
"Openai-gpt-4_1-mini",
|
|
"Openai-o4-mini",
|
|
"Claude-Sonnet-4",
|
|
"ChatGPT-o3-pro",
|
|
"OpenAI-ChatGPT-4_1",
|
|
"OpenAI-GPT-4_1-Nano",
|
|
"ChatGPT-5",
|
|
"VertexGemini",
|
|
"ChatGPT-5_1",
|
|
"ChatGPT-5_1-chat",
|
|
"ChatGPT-5_2-Chat",
|
|
"Gemini-3_Pro-Preview",
|
|
"Gemini-3_1-flash-lite-preview",
|
|
] as const;
|
|
|
|
export const CUSTOM_MODEL_OPTION = "__custom_model__";
|
|
export const CUSTOM_REST_FORMAT = "custom_rest";
|
|
|
|
const emptyProvider: ProviderConfig = {
|
|
name: "",
|
|
provider_type: "openai",
|
|
api_url: "",
|
|
api_key: "",
|
|
model: "",
|
|
max_tokens: 4096,
|
|
temperature: 0.7,
|
|
custom_endpoint_path: undefined,
|
|
custom_auth_header: undefined,
|
|
custom_auth_prefix: undefined,
|
|
api_format: undefined,
|
|
session_id: undefined,
|
|
user_id: undefined,
|
|
supports_tool_calling: false,
|
|
};
|
|
|
|
export default function AIProviders() {
|
|
const {
|
|
ai_providers,
|
|
active_provider,
|
|
addProvider,
|
|
updateProvider,
|
|
removeProvider,
|
|
setActiveProvider,
|
|
setProviders,
|
|
} = useSettingsStore();
|
|
|
|
const [editIndex, setEditIndex] = useState<number | null>(null);
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
const [form, setForm] = useState<ProviderConfig>({ ...emptyProvider });
|
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
|
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
|
const [isDetectingToolCalling, setIsDetectingToolCalling] = useState(false);
|
|
const [isCustomModel, setIsCustomModel] = useState(false);
|
|
const [customModelInput, setCustomModelInput] = useState("");
|
|
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
|
|
|
// Load providers from database on mount
|
|
// Note: Auto-testing of active provider is handled in App.tsx on startup
|
|
useEffect(() => {
|
|
const loadProviders = async () => {
|
|
try {
|
|
const providers = await loadAiProvidersCmd();
|
|
setProviders(providers);
|
|
} catch (err) {
|
|
console.error("Failed to load AI providers:", err);
|
|
}
|
|
};
|
|
loadProviders();
|
|
}, [setProviders]);
|
|
|
|
// Load Ollama models when form provider type changes to ollama
|
|
useEffect(() => {
|
|
if (form.provider_type === "ollama") {
|
|
const loadOllamaModels = async () => {
|
|
try {
|
|
const models = await listOllamaModelsCmd();
|
|
setOllamaModels(models);
|
|
} catch (err) {
|
|
console.error("Failed to load Ollama models:", err);
|
|
setOllamaModels([]);
|
|
}
|
|
};
|
|
loadOllamaModels();
|
|
}
|
|
}, [form.provider_type]);
|
|
|
|
const startAdd = () => {
|
|
setForm({ ...emptyProvider });
|
|
setEditIndex(null);
|
|
setIsAdding(true);
|
|
setTestResult(null);
|
|
setIsCustomModel(false);
|
|
setCustomModelInput("");
|
|
};
|
|
|
|
const startEdit = (index: number) => {
|
|
const provider = ai_providers[index];
|
|
const apiFormat = provider.api_format;
|
|
const nextForm = { ...provider, api_format: apiFormat };
|
|
|
|
setForm(nextForm);
|
|
setEditIndex(index);
|
|
setIsAdding(true);
|
|
setTestResult(null);
|
|
|
|
const isCustomRestProvider =
|
|
nextForm.provider_type === "custom" && apiFormat === CUSTOM_REST_FORMAT;
|
|
const knownModel = CUSTOM_REST_MODELS.includes(nextForm.model as (typeof CUSTOM_REST_MODELS)[number]);
|
|
|
|
if (isCustomRestProvider && !knownModel) {
|
|
setIsCustomModel(true);
|
|
setCustomModelInput(nextForm.model);
|
|
} else {
|
|
setIsCustomModel(false);
|
|
setCustomModelInput("");
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!form.name || !form.api_url || !form.model) return;
|
|
|
|
try {
|
|
// Save to database
|
|
await saveAiProviderCmd(form);
|
|
|
|
// Update local state
|
|
if (editIndex != null) {
|
|
updateProvider(editIndex, form);
|
|
} else {
|
|
addProvider(form);
|
|
}
|
|
|
|
setIsAdding(false);
|
|
setEditIndex(null);
|
|
setForm({ ...emptyProvider });
|
|
} catch (err) {
|
|
console.error("Failed to save provider:", err);
|
|
setTestResult({ success: false, message: `Failed to save: ${err}` });
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setIsAdding(false);
|
|
setEditIndex(null);
|
|
setForm({ ...emptyProvider });
|
|
setTestResult(null);
|
|
};
|
|
|
|
const handleRemove = async (index: number) => {
|
|
const provider = ai_providers[index];
|
|
try {
|
|
await deleteAiProviderCmd(provider.name);
|
|
removeProvider(index);
|
|
} catch (err) {
|
|
console.error("Failed to delete provider:", err);
|
|
}
|
|
};
|
|
|
|
const handleTest = async () => {
|
|
setIsTestingConnection(true);
|
|
setTestResult(null);
|
|
try {
|
|
const response = await testProviderConnectionCmd(form);
|
|
setTestResult({ success: true, message: `OK: ${response.content.slice(0, 100)}` });
|
|
} catch (err) {
|
|
setTestResult({ success: false, message: String(err) });
|
|
} finally {
|
|
setIsTestingConnection(false);
|
|
}
|
|
};
|
|
|
|
const handleAutoDetectToolCalling = async () => {
|
|
setIsDetectingToolCalling(true);
|
|
setTestResult(null);
|
|
try {
|
|
const supportsTools = await detectToolCallingSupportCmd(form);
|
|
// Use functional update to avoid stale closure
|
|
setForm((prev) => ({ ...prev, supports_tool_calling: supportsTools }));
|
|
setTestResult({
|
|
success: supportsTools, // Align success with actual outcome
|
|
message: supportsTools
|
|
? "✅ Tool calling supported! Checkbox enabled automatically."
|
|
: "⚠️ Tool calling not supported. Checkbox disabled automatically.",
|
|
});
|
|
} catch (err) {
|
|
setTestResult({ success: false, message: `Auto-detect failed: ${String(err)}` });
|
|
} finally {
|
|
setIsDetectingToolCalling(false);
|
|
}
|
|
};
|
|
|
|
const maskApiKey = (key?: string) => {
|
|
if (!key) return "Not set";
|
|
if (key.length <= 8) return "****";
|
|
return key.slice(0, 4) + "..." + key.slice(-4);
|
|
};
|
|
|
|
return (
|
|
<div className="px-6 pt-8 pb-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">AI Providers</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
Configure AI model providers for triage and document generation.
|
|
</p>
|
|
</div>
|
|
{!isAdding && (
|
|
<Button onClick={startAdd}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Provider
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Provider list */}
|
|
{ai_providers.length === 0 && !isAdding && (
|
|
<Card>
|
|
<CardContent className="p-8 text-center">
|
|
<p className="text-muted-foreground">No providers configured yet.</p>
|
|
<Button className="mt-3" onClick={startAdd}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add your first provider
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{ai_providers.map((provider, idx) => (
|
|
<Card key={`${provider.name}-${idx}`}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{provider.name}</span>
|
|
<Badge variant="secondary">{provider.provider_type}</Badge>
|
|
{active_provider === provider.name && (
|
|
<Badge variant="success">Active</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{provider.api_url} | Model: {provider.model} | Key: {maskApiKey(provider.api_key)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{active_provider !== provider.name && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setActiveProvider(provider.name)}
|
|
>
|
|
<Zap className="w-3 h-3 mr-1" />
|
|
Set Active
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={() => startEdit(idx)}>
|
|
<Pencil className="w-3 h-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemove(idx)}
|
|
>
|
|
<Trash2 className="w-3 h-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
|
|
{/* Add/Edit form */}
|
|
{isAdding && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">
|
|
{editIndex != null ? "Edit Provider" : "Add Provider"}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input
|
|
value={form.name}
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
placeholder="My OpenAI"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Type</Label>
|
|
<Select
|
|
value={form.provider_type}
|
|
onValueChange={(v) => {
|
|
const type = v as ProviderConfig["provider_type"];
|
|
const defaults: Partial<ProviderConfig> =
|
|
type === "ollama"
|
|
? { api_url: "http://localhost:11434", api_key: "", model: "llama3.2:3b", supports_tool_calling: true }
|
|
: type === "openai"
|
|
? { api_url: "https://api.openai.com/v1", supports_tool_calling: true }
|
|
: type === "anthropic"
|
|
? { api_url: "https://api.anthropic.com", supports_tool_calling: true }
|
|
: type === "azure"
|
|
? { supports_tool_calling: true }
|
|
: { supports_tool_calling: false }; // Custom providers default to false
|
|
setForm({ ...form, provider_type: type, ...defaults });
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="openai">OpenAI</SelectItem>
|
|
<SelectItem value="anthropic">Anthropic</SelectItem>
|
|
<SelectItem value="ollama">Ollama</SelectItem>
|
|
<SelectItem value="azure">Azure OpenAI</SelectItem>
|
|
<SelectItem value="custom">Custom</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>API URL</Label>
|
|
<Input
|
|
value={form.api_url}
|
|
onChange={(e) => setForm({ ...form, api_url: e.target.value })}
|
|
placeholder="https://api.openai.com/v1"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>API Key</Label>
|
|
<Input
|
|
type="password"
|
|
value={form.api_key ?? ""}
|
|
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
|
placeholder="sk-..."
|
|
/>
|
|
</div>
|
|
{!(form.provider_type === "custom" && form.api_format === CUSTOM_REST_FORMAT) && (
|
|
<div className="space-y-2">
|
|
<Label>Model</Label>
|
|
{form.provider_type === "ollama" ? (
|
|
<Select
|
|
value={form.model}
|
|
onValueChange={(v) => setForm({ ...form, model: v })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select installed model..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ollamaModels.length > 0 ? (
|
|
ollamaModels.map((model) => (
|
|
<SelectItem key={model.name} value={model.name}>
|
|
{model.name}
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
No models installed. Go to Settings → Ollama to pull models.
|
|
</div>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
value={form.model}
|
|
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
|
placeholder="gpt-4o"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Max Tokens</Label>
|
|
<Input
|
|
type="number"
|
|
value={form.max_tokens ?? 4096}
|
|
onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Temperature</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
max="2"
|
|
value={form.temperature ?? 0.7}
|
|
onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom provider format options */}
|
|
{form.provider_type === "custom" && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>API Format</Label>
|
|
<Select
|
|
value={form.api_format ?? "openai"}
|
|
onValueChange={(v) => {
|
|
const format = v;
|
|
const defaults =
|
|
format === CUSTOM_REST_FORMAT
|
|
? {
|
|
custom_endpoint_path: "",
|
|
custom_auth_header: "",
|
|
custom_auth_prefix: "",
|
|
}
|
|
: {
|
|
custom_endpoint_path: "/chat/completions",
|
|
custom_auth_header: "Authorization",
|
|
custom_auth_prefix: "Bearer ",
|
|
};
|
|
setForm({ ...form, api_format: format, ...defaults });
|
|
if (format !== CUSTOM_REST_FORMAT) {
|
|
setIsCustomModel(false);
|
|
setCustomModelInput("");
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="openai">OpenAI Compatible</SelectItem>
|
|
<SelectItem value={CUSTOM_REST_FORMAT}>Custom REST</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
Select the API format. Custom REST uses a non-OpenAI request/response structure.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Endpoint Path</Label>
|
|
<Input
|
|
value={form.custom_endpoint_path ?? ""}
|
|
onChange={(e) =>
|
|
setForm({ ...form, custom_endpoint_path: e.target.value })
|
|
}
|
|
placeholder="/chat/completions"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Path appended to API URL. Leave empty if URL includes full path.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Auth Header Name</Label>
|
|
<Input
|
|
value={form.custom_auth_header ?? ""}
|
|
onChange={(e) =>
|
|
setForm({ ...form, custom_auth_header: e.target.value })
|
|
}
|
|
placeholder="Authorization"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Header name for authentication (e.g., "Authorization" or "x-api-key")
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Auth Prefix</Label>
|
|
<Input
|
|
value={form.custom_auth_prefix ?? ""}
|
|
onChange={(e) => setForm({ ...form, custom_auth_prefix: e.target.value })}
|
|
placeholder="Bearer "
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Prefix added before API key (e.g., "Bearer " for OpenAI, empty for Custom REST)
|
|
</p>
|
|
</div>
|
|
|
|
{/* Custom REST specific: User ID field */}
|
|
{form.api_format === CUSTOM_REST_FORMAT && (
|
|
<div className="space-y-2">
|
|
<Label>Email Address</Label>
|
|
<Input
|
|
value={form.user_id ?? ""}
|
|
onChange={(e) => setForm({ ...form, user_id: e.target.value })}
|
|
placeholder="user@example.com"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Optional: Email address for usage tracking. If omitted, costs are attributed to the API key owner.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Custom REST specific: model dropdown with custom option */}
|
|
{form.api_format === CUSTOM_REST_FORMAT && (
|
|
<div className="space-y-2">
|
|
<Label>Model</Label>
|
|
<Select
|
|
value={isCustomModel ? CUSTOM_MODEL_OPTION : form.model}
|
|
onValueChange={(value) => {
|
|
if (value === CUSTOM_MODEL_OPTION) {
|
|
setIsCustomModel(true);
|
|
if (CUSTOM_REST_MODELS.includes(form.model as (typeof CUSTOM_REST_MODELS)[number])) {
|
|
setForm({ ...form, model: "" });
|
|
setCustomModelInput("");
|
|
}
|
|
} else {
|
|
setIsCustomModel(false);
|
|
setCustomModelInput("");
|
|
setForm({ ...form, model: value });
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a model..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CUSTOM_REST_MODELS.map((model) => (
|
|
<SelectItem key={model} value={model}>
|
|
{model}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value={CUSTOM_MODEL_OPTION}>Custom model...</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{isCustomModel && (
|
|
<Input
|
|
value={customModelInput}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setCustomModelInput(value);
|
|
setForm({ ...form, model: value });
|
|
}}
|
|
placeholder="Enter custom model ID"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tool Calling Support Toggle */}
|
|
<div className="space-y-2 pt-4 border-t">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="supports-tool-calling" className="text-base">Tool Calling Support</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Enable if this provider supports function/tool calling for shell execution and integrations
|
|
</p>
|
|
</div>
|
|
<input
|
|
id="supports-tool-calling"
|
|
type="checkbox"
|
|
checked={form.supports_tool_calling ?? false}
|
|
onChange={(e) =>
|
|
setForm({ ...form, supports_tool_calling: e.target.checked })
|
|
}
|
|
className="h-5 w-5 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleAutoDetectToolCalling}
|
|
disabled={isTestingConnection || isDetectingToolCalling}
|
|
className="w-full"
|
|
>
|
|
<Zap className="w-4 h-4 mr-2" />
|
|
{isDetectingToolCalling ? "Detecting..." : "Auto-Detect Tool Calling Support"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Test result */}
|
|
{testResult && (
|
|
<div
|
|
className={`flex items-center gap-2 rounded-md p-3 text-sm ${
|
|
testResult.success
|
|
? "bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300"
|
|
: "bg-destructive/10 text-destructive"
|
|
}`}
|
|
>
|
|
{testResult.success ? (
|
|
<CheckCircle className="w-4 h-4" />
|
|
) : (
|
|
<XCircle className="w-4 h-4" />
|
|
)}
|
|
{testResult.message}
|
|
</div>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button onClick={handleSave}>Save</Button>
|
|
<Button variant="outline" onClick={handleTest} disabled={isTestingConnection || isDetectingToolCalling}>
|
|
{isTestingConnection ? "Testing..." : "Test Connection"}
|
|
</Button>
|
|
<Button variant="ghost" onClick={handleCancel}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|