Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning

This commit is contained in:
2026-06-02 02:21:11 +00:00
parent 809e0061ca
commit 15e939ad7e
116 changed files with 14991 additions and 5326 deletions
+194 -112
View File
@@ -11,12 +11,83 @@ const PROVIDER_MODELS: Record<Exclude<AIProvider, "custom">, string[]> = {
xai: ["grok-2", "grok-2-mini", "grok-1.5"],
};
const PROVIDERS: { id: AIProvider; name: string; icon: string; description: string; website: string }[] = [
{ id: "openai", name: "OpenAI", icon: "🤖", description: "GPT-4o, GPT-4o-mini, GPT-4 Turbo. Industry standard for general AI tasks.", website: "openai.com" },
{ id: "anthropic", name: "Anthropic Claude", icon: "🧠", description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for long-form reasoning and analysis.", website: "anthropic.com" },
{ id: "google", name: "Google Gemini", icon: "🔶", description: "Gemini 2.0 Flash, Gemini 1.5 Pro. Fast, cost-effective, strong multimodal.", website: "ai.google" },
{ id: "xai", name: "xAI Grok", icon: "🌪️", description: "Grok-2, Grok-1.5. Real-time data, humor, unconventional reasoning.", website: "x.ai" },
{ id: "custom", name: "Custom / Other", icon: "🔧", description: "Connect any OpenAI-compatible API or custom LLM endpoint.", website: "" },
// Icons
const SparkleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/>
</svg>
);
const RobotIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="10" rx="2"/>
<circle cx="12" cy="5" r="2"/>
<path d="M12 7v4"/>
<line x1="8" y1="16" x2="8" y2="16"/>
<line x1="16" y1="16" x2="16" y2="16"/>
</svg>
);
const BrainIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.5 2A2.5 2.5 0 0112 4.5v15a2.5 2.5 0 01-2.5 2.5M4.5 8a2.5 2.5 0 015 0v8a2.5 2.5 0 01-5 0M14.5 8a2.5 2.5 0 015 0v8a2.5 2.5 0 01-5 0M9.5 16a2.5 2.5 0 015 0"/>
<path d="M12 4.5v15"/>
</svg>
);
const CircleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="4"/>
</svg>
);
const TornadoIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 4H3M21 4v16M3 8l3 3 3-3 3 3 3-3 3 3M3 16l3 3 3-3 3 3 3-3 3 3"/>
</svg>
);
const WrenchIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>
</svg>
);
const EyeIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
);
const EyeOffIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
);
const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const AlertIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
const PROVIDERS: { id: AIProvider; name: string; icon: React.ReactNode; description: string; website: string; color: string }[] = [
{ id: "openai", name: "OpenAI", icon: <RobotIcon className="h-5 w-5" />, description: "GPT-4o, GPT-4o-mini, GPT-4 Turbo. Industry standard.", website: "openai.com", color: "bg-emerald-500" },
{ id: "anthropic", name: "Anthropic", icon: <BrainIcon className="h-5 w-5" />, description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for reasoning.", website: "anthropic.com", color: "bg-amber-500" },
{ id: "google", name: "Google", icon: <CircleIcon className="h-5 w-5" />, description: "Gemini 2.0 Flash, 1.5 Pro. Fast, cost-effective.", website: "ai.google", color: "bg-blue-500" },
{ id: "xai", name: "xAI", icon: <TornadoIcon className="h-5 w-5" />, description: "Grok-2, Grok-1.5. Real-time data, humor.", website: "x.ai", color: "bg-orange-500" },
{ id: "custom", name: "Custom", icon: <WrenchIcon className="h-5 w-5" />, description: "Any OpenAI-compatible API endpoint.", website: "", color: "bg-stone-500" },
];
type Props = {
@@ -92,37 +163,33 @@ export default function AIProviderPanel({ brandId }: Props) {
if (loading) {
return (
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700 animate-pulse">
<div className="h-6 bg-slate-200 rounded w-1/3 mb-4" />
<div className="h-4 bg-slate-200 rounded w-2/3 mb-6" />
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6 animate-pulse">
<div className="h-6 bg-stone-200 rounded w-1/3 mb-4" />
<div className="h-4 bg-stone-200 rounded w-2/3 mb-6" />
<div className="grid grid-cols-5 gap-3">
{[1,2,3,4,5].map(i => <div key={i} className="h-16 bg-zinc-950 rounded-xl" />)}
{[1,2,3,4,5].map(i => <div key={i} className="h-16 bg-stone-100 rounded-xl" />)}
</div>
</div>
);
}
return (
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-violet-200 overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-violet-600 to-indigo-600 px-6 py-5 flex items-center gap-4">
<span className="text-2xl">🤖</span>
<div>
<h2 className="text-base font-bold text-white">AI Provider</h2>
<p className="text-xs text-violet-200 mt-0.5">Choose your AI model provider for all AI tools</p>
<div className="space-y-5">
{/* Provider Selection Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500">
<SparkleIcon className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">AI Provider</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Choose your AI model provider for all AI tools</p>
</div>
</div>
</div>
<span className="ml-auto text-xs text-violet-200 bg-zinc-900/20 rounded-full px-3 py-1">
{PROVIDERS.find(p => p.id === provider)?.name}
</span>
</div>
<div className="p-6 space-y-6">
{/* Provider selector */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-3">
Select Provider
</label>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div className="p-4 sm:p-6">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 sm:gap-3">
{PROVIDERS.map((p) => (
<button
key={p.id}
@@ -132,26 +199,33 @@ export default function AIProviderPanel({ brandId }: Props) {
}}
className={`rounded-xl p-3 text-center transition-all border-2 ${
provider === p.id
? "border-violet-500 bg-violet-50 shadow-black/20"
: "border-zinc-800 hover:border-violet-200 hover:bg-zinc-800"
? "border-violet-500 bg-violet-50"
: "border-[var(--admin-border)] hover:border-violet-200 hover:bg-stone-50"
}`}
>
<span className="text-2xl block mb-1">{p.icon}</span>
<p className="text-xs font-semibold text-slate-800">{p.name}</p>
<div className={`flex h-10 w-10 mx-auto items-center justify-center rounded-xl mb-2 ${p.color}`}>
<span className="text-white">{p.icon}</span>
</div>
<p className="text-xs font-semibold text-[var(--admin-text-primary)]">{p.name}</p>
{p.website && (
<p className="text-[10px] text-slate-400 mt-0.5">{p.website}</p>
<p className="text-[10px] text-[var(--admin-text-muted)] mt-0.5">{p.website}</p>
)}
</button>
))}
</div>
</div>
</div>
{/* Credentials — only for non-custom */}
{provider !== "custom" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Credentials Card */}
{provider !== "custom" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Credentials</h3>
</div>
<div className="p-4 sm:p-6 space-y-4">
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">
API Key {provider === "anthropic" ? "(Anthropic)" : provider === "google" ? "(Google AI)" : provider === "xai" ? "(xAI)" : "(OpenAI)"}
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
API Key
</label>
<div className="relative">
<input
@@ -159,77 +233,83 @@ export default function AIProviderPanel({ brandId }: Props) {
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === "anthropic" ? "sk-ant-..." : provider === "google" ? "AIza..." : provider === "xai" ? "xai-..." : "sk-..."}
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm pr-20 outline-none focus:border-violet-500"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400"
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
>
{showKey ? "Hide" : "Show"}
{showKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
{provider === "openai" && (
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">Organization ID (optional)</label>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
Organization ID (optional)
</label>
<input
type="text"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
placeholder="org-..."
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm outline-none focus:border-violet-500"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
)}
</div>
)}
</div>
)}
{/* Custom endpoint fields */}
{provider === "custom" && (
<div className="space-y-4 rounded-xl bg-slate-50 border border-zinc-800 p-4">
<div className="flex items-start gap-3">
<span className="text-lg mt-0.5">🔧</span>
<div className="flex-1">
<p className="text-sm font-semibold text-zinc-300">Custom API Endpoint</p>
<p className="text-xs text-zinc-500 mt-0.5">Connect any OpenAI-compatible API (Ollama, LM Studio, custom proxy, etc.)</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Custom Endpoint Card */}
{provider === "custom" && (
<div className="rounded-xl border border-violet-200 bg-violet-50 overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-violet-200">
<div className="flex items-center gap-3">
<WrenchIcon className="h-5 w-5 text-violet-600" />
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">API Key</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm pr-20 outline-none focus:border-violet-500"
/>
<button type="button" onClick={() => setShowKey(!showKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400">
{showKey ? "Hide" : "Show"}
</button>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">Base URL</label>
<input
type="text"
value={customEndpoint}
onChange={(e) => setCustomEndpoint(e.target.value)}
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm outline-none focus:border-violet-500"
/>
<p className="text-sm font-semibold text-violet-800">Custom API Endpoint</p>
<p className="text-xs text-violet-600 mt-0.5">Connect any OpenAI-compatible API</p>
</div>
</div>
</div>
)}
<div className="p-4 sm:p-6 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
/>
<button type="button" onClick={() => setShowKey(!showKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600">
{showKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Base URL</label>
<input
type="text"
value={customEndpoint}
onChange={(e) => setCustomEndpoint(e.target.value)}
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
/>
</div>
</div>
</div>
)}
{/* Model selector */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-2">
Model
</label>
{/* Model Selection Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Model</h3>
</div>
<div className="p-4 sm:p-6">
<div className="flex flex-wrap gap-2">
{models.map((m) => (
<button
@@ -238,7 +318,7 @@ export default function AIProviderPanel({ brandId }: Props) {
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
model === m
? "bg-violet-600 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
: "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"
}`}
>
{m}
@@ -246,34 +326,36 @@ export default function AIProviderPanel({ brandId }: Props) {
))}
</div>
</div>
</div>
{/* Test result */}
{testResult && (
<div className={`rounded-xl px-4 py-3 text-sm flex items-center gap-2 ${
testResult.ok ? "bg-green-900/30 text-green-400 border border-green-200" : "bg-red-900/30 text-red-400 border border-red-200"
}`}>
<span>{testResult.ok ? "✓" : "✗"}</span>
<span>{testResult.message}</span>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2 border-t border-slate-100">
<button
onClick={handleTest}
disabled={saving}
className="rounded-xl border border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-400 hover:bg-zinc-800 disabled:opacity-50"
>
Test Connection
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-xl bg-violet-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-violet-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Provider Settings"}
</button>
{/* Test Result */}
{testResult && (
<div className={`rounded-xl px-4 py-3 text-sm flex items-center gap-2 ${
testResult.ok
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200"
}`}>
{testResult.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
{testResult.message}
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleTest}
disabled={saving}
className="rounded-lg border border-[var(--admin-border)] px-5 py-2.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-lg bg-emerald-600 px-5 py-2.5 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Provider Settings"}
</button>
</div>
</div>
);