github-actions[bot]
deploy: b1de43e — 更新 README.md
e1d8498
import { useState, useEffect } from "react";
import { X, ExternalLink, Info, Eye, EyeOff } from "lucide-react";
import { api } from "../api";
const MODEL_TYPES = [
{ value: "chat", label: "Chat / Instruct" },
{ value: "completion", label: "Text Completion" },
{ value: "embedding", label: "Embedding" },
{ value: "image", label: "Image Generation" },
{ value: "audio", label: "Audio / Speech" },
];
export default function ModelForm({ initial, onSave, onClose }) {
const isEdit = !!initial;
const [form, setForm] = useState({
displayName: initial?.displayName || "",
provider: initial?.provider || "",
litellmModel:initial?.litellmModel|| "",
apiBase: initial?.apiBase || "",
apiKey: "",
description: initial?.description || "",
modelType: initial?.modelType || "chat",
tags: initial?.tags?.join(", ") || "",
});
const [providers, setProviders] = useState([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [showKey, setShowKey] = useState(false);
const [selectedProvider, setSelectedProvider] = useState(null);
useEffect(() => {
api.getProviders().then(r => setProviders(r.data)).catch(() => {});
}, []);
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }));
const handleProviderSelect = (e) => {
const prov = providers.find(p => p.id === e.target.value);
setSelectedProvider(prov || null);
if (prov) {
setForm(f => ({
...f,
provider: prov.id,
apiBase: prov.defaultApiBase || "",
litellmModel: prov.exampleModels?.[0] || "",
modelType: prov.modelTypes?.[0] || "chat",
}));
}
};
const handleSubmit = async () => {
if (!form.displayName.trim() || !form.provider.trim() || !form.litellmModel.trim()) {
setError("Display name, provider, and LiteLLM model are required.");
return;
}
setSaving(true);
setError(null);
try {
await onSave({
...form,
tags: form.tags.split(",").map(t => t.trim()).filter(Boolean),
apiKey: form.apiKey || undefined,
});
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-xl glass rounded-xl border border-white/10
shadow-2xl shadow-black/50 animate-slide-in overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/5">
<div>
<h2 className="font-display font-semibold text-text-primary">
{isEdit ? "Edit Model" : "Register New Model"}
</h2>
<p className="text-text-muted text-xs mt-0.5">
{isEdit ? "Update model configuration." : "Add any AI model to the gateway."}
</p>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
<X size={18} />
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Provider quick-select */}
{!isEdit && providers.length > 0 && (
<div>
<label className="form-label">Quick-select provider</label>
<select className="select" onChange={handleProviderSelect} defaultValue="">
<option value="">— choose a provider to pre-fill —</option>
{providers.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{selectedProvider && (
<a
href={selectedProvider.docs}
target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-accent-cyan text-xs mt-1.5 hover:underline"
>
<ExternalLink size={10} />
LiteLLM docs for {selectedProvider.name}
</a>
)}
</div>
)}
{/* Display name */}
<div>
<label className="form-label">Display Name <Required /></label>
<input
className="input"
placeholder="e.g. My Claude Proxy"
value={form.displayName}
onChange={set("displayName")}
/>
</div>
{/* Provider */}
<div>
<label className="form-label">Provider ID <Required /></label>
<input
className="input"
placeholder="e.g. anthropic, openai, ollama"
value={form.provider}
onChange={set("provider")}
/>
<p className="text-text-muted text-xs mt-1">
Used to build the OpenAI model alias: <span className="font-mono">provider/model</span>
</p>
</div>
{/* LiteLLM model name */}
<div>
<label className="form-label">
LiteLLM Model Name <Required />
<a
href="https://docs.litellm.ai/docs/providers"
target="_blank" rel="noreferrer"
className="ml-2 text-accent-cyan text-xs inline-flex items-center gap-0.5 hover:underline"
>
<ExternalLink size={9} /> LiteLLM docs
</a>
</label>
<input
className="input input-mono"
placeholder="e.g. anthropic/claude-3-5-sonnet-20241022"
value={form.litellmModel}
onChange={set("litellmModel")}
/>
{selectedProvider?.exampleModels?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{selectedProvider.exampleModels.map(m => (
<button
key={m}
type="button"
onClick={() => setForm(f => ({ ...f, litellmModel: m }))}
className="badge badge-gray cursor-pointer hover:badge-green font-mono text-[0.65rem]"
>
{m}
</button>
))}
</div>
)}
</div>
{/* API Base */}
<div>
<label className="form-label">API Base URL <span className="text-text-muted">(optional)</span></label>
<input
className="input input-mono"
placeholder="e.g. https://api.anthropic.com or http://localhost:11434"
value={form.apiBase}
onChange={set("apiBase")}
/>
<p className="text-text-muted text-xs mt-1">
Leave blank to use LiteLLM's default for this provider.
</p>
</div>
{/* API Key */}
<div>
<label className="form-label">
API Key <span className="text-text-muted">(optional)</span>
</label>
<div className="relative">
<input
className="input input-mono pr-9"
placeholder={isEdit ? "Leave blank to keep existing" : "sk-… (leave empty for keyless providers)"}
type={showKey ? "text" : "password"}
value={form.apiKey}
onChange={set("apiKey")}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowKey(s => !s)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<p className="text-text-muted text-xs mt-1 flex items-center gap-1">
<Info size={10} />
Keys are stored server-side and masked in the UI.
For Ollama and local models, no key is needed.
</p>
</div>
{/* Model type */}
<div>
<label className="form-label">Model Type</label>
<select className="select" value={form.modelType} onChange={set("modelType")}>
{MODEL_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
{/* Description */}
<div>
<label className="form-label">Description <span className="text-text-muted">(optional)</span></label>
<textarea
className="input resize-none"
rows={2}
placeholder="Brief description of this model endpoint…"
value={form.description}
onChange={set("description")}
/>
</div>
{/* Tags */}
<div>
<label className="form-label">Tags <span className="text-text-muted">(comma-separated)</span></label>
<input
className="input"
placeholder="e.g. production, fast, vision"
value={form.tags}
onChange={set("tags")}
/>
</div>
{error && (
<div className="p-3 rounded-lg bg-accent-red/10 border border-accent-red/20 text-accent-red text-sm">
{error}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-white/5 bg-surface-1/30">
<button onClick={onClose} className="btn-secondary">Cancel</button>
<button onClick={handleSubmit} disabled={saving} className="btn-primary">
{saving ? "Saving…" : isEdit ? "Save Changes" : "Register Model"}
</button>
</div>
</div>
</div>
);
}
function Required() {
return <span className="text-accent-red text-xs ml-0.5">*</span>;
}
// BUG FIX: Removed module-level document.createElement("style") — it ran on every
// module evaluation and injected duplicate <style> tags on HMR reloads.
// The .form-label class is now defined in index.css @layer components.