mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
Feat : embedding dev
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { Card, Badge } from "@/shared/components";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard";
|
||||
import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard";
|
||||
|
||||
export default function MediaProviderDetailPage() {
|
||||
const { kind, id } = useParams();
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const [headerImgError, setHeaderImgError] = useState(false);
|
||||
|
||||
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
|
||||
if (!kindConfig) return notFound();
|
||||
|
||||
const provider = AI_PROVIDERS[id];
|
||||
if (!provider) return notFound();
|
||||
|
||||
const kinds = provider.serviceKinds ?? ["llm"];
|
||||
if (!kinds.includes(kind)) return notFound();
|
||||
|
||||
const endpointText = `${kindConfig.endpoint.method} ${kindConfig.endpoint.path}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Back */}
|
||||
<div>
|
||||
<Link
|
||||
href={`/dashboard/media-providers/${kind}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">arrow_back</span>
|
||||
{kindConfig.label}
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-lg flex items-center justify-center" style={{ backgroundColor: `${provider.color}15` }}>
|
||||
{headerImgError ? (
|
||||
<span className="size-12 flex items-center justify-center text-sm font-bold rounded-lg" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={`/providers/${provider.id}.png`}
|
||||
alt={provider.name}
|
||||
width={48} height={48}
|
||||
className="object-contain rounded-lg max-w-[48px] max-h-[48px]"
|
||||
sizes="48px"
|
||||
onError={() => setHeaderImgError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{provider.name}</h1>
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
{kinds.map((k) => (
|
||||
<Badge key={k} variant={k === kind ? "primary" : "default"} size="sm">
|
||||
{k.toUpperCase()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endpoint block */}
|
||||
<Card padding="sm">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wider mb-2 font-semibold">{kindConfig.label} Endpoint</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-bold text-primary bg-primary/10 px-2 py-0.5 rounded">
|
||||
{kindConfig.endpoint.method}
|
||||
</span>
|
||||
<code className="text-sm font-mono text-text-main flex-1">{kindConfig.endpoint.path}</code>
|
||||
<button onClick={() => copy(endpointText)} className="text-text-muted hover:text-text-main transition-colors" title="Copy">
|
||||
<span className="material-symbols-outlined text-[18px]">{copied ? "check" : "content_copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Connections — reuse shared component */}
|
||||
<ConnectionsCard providerId={id} isOAuth={false} />
|
||||
|
||||
{/* Models — filtered by current kind */}
|
||||
<ModelsCard providerId={id} kindFilter={kind} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
export default function MediaProviderKindPage() {
|
||||
const { kind } = useParams();
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
|
||||
if (!kindConfig) return notFound();
|
||||
|
||||
const providers = getProvidersByKind(kind);
|
||||
const endpointText = `${kindConfig.endpoint.method} ${kindConfig.endpoint.path}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Endpoint block */}
|
||||
<Card padding="sm">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wider mb-2 font-semibold">Endpoint</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-bold text-primary bg-primary/10 px-2 py-0.5 rounded">
|
||||
{kindConfig.endpoint.method}
|
||||
</span>
|
||||
<code className="text-sm font-mono text-text-main flex-1">{kindConfig.endpoint.path}</code>
|
||||
<button
|
||||
onClick={() => copy(endpointText)}
|
||||
className="text-text-muted hover:text-text-main transition-colors"
|
||||
title="Copy endpoint"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Provider list */}
|
||||
{providers.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-border rounded-xl text-text-muted text-sm">
|
||||
No providers support <strong>{kindConfig.label}</strong> yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{providers.map((provider) => (
|
||||
<Link key={provider.id} href={`/dashboard/media-providers/${kind}/${provider.id}`}>
|
||||
<Card padding="xs" className="h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : (provider.color ?? "#888") + "15"}` }}
|
||||
>
|
||||
<ProviderIcon
|
||||
src={`/providers/${provider.id}.png`}
|
||||
alt={provider.name}
|
||||
size={30}
|
||||
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
|
||||
fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
fallbackColor={provider.color}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">{provider.name}</p>
|
||||
<p className="text-xs text-text-muted">{(provider.serviceKinds ?? ["llm"]).join(", ")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, Badge, Button, Modal, Select, Toggle, EditConnectionModal } from "@/shared/components";
|
||||
|
||||
// ── CooldownTimer ──────────────────────────────────────────────
|
||||
function CooldownTimer({ until }) {
|
||||
const [remaining, setRemaining] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const diff = new Date(until).getTime() - Date.now();
|
||||
if (diff <= 0) { setRemaining(""); return; }
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) setRemaining(`${s}s`);
|
||||
else if (s < 3600) setRemaining(`${Math.floor(s / 60)}m ${s % 60}s`);
|
||||
else setRemaining(`${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`);
|
||||
};
|
||||
update();
|
||||
const t = setInterval(update, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [until]);
|
||||
|
||||
if (!remaining) return null;
|
||||
return <span className="text-xs text-orange-500 font-mono">⏱ {remaining}</span>;
|
||||
}
|
||||
|
||||
CooldownTimer.propTypes = { until: PropTypes.string.isRequired };
|
||||
|
||||
// ── ConnectionRow ──────────────────────────────────────────────
|
||||
function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete }) {
|
||||
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
||||
const [updatingProxy, setUpdatingProxy] = useState(false);
|
||||
const [isCooldown, setIsCooldown] = useState(false);
|
||||
const proxyDropdownRef = useRef(null);
|
||||
|
||||
const proxyPoolMap = new Map((proxyPools || []).map((p) => [p.id, p]));
|
||||
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
|
||||
const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null;
|
||||
const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl;
|
||||
const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy;
|
||||
|
||||
const proxyDisplayText = boundProxyPool
|
||||
? `Pool: ${boundProxyPool.name}`
|
||||
: boundProxyPoolId ? `Pool: ${boundProxyPoolId} (inactive/missing)`
|
||||
: hasLegacyProxy ? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}` : "";
|
||||
|
||||
let maskedProxyUrl = "";
|
||||
const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl;
|
||||
if (rawProxyUrl) {
|
||||
try {
|
||||
const p = new URL(rawProxyUrl);
|
||||
maskedProxyUrl = `${p.protocol}//${p.hostname}${p.port ? `:${p.port}` : ""}`;
|
||||
} catch { maskedProxyUrl = rawProxyUrl; }
|
||||
}
|
||||
|
||||
const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || "";
|
||||
const proxyBadgeVariant = boundProxyPool?.isActive === true ? "success" : (boundProxyPoolId || hasLegacyProxy) ? "error" : "default";
|
||||
|
||||
const modelLockUntil = Object.entries(connection)
|
||||
.filter(([k]) => k.startsWith("modelLock_"))
|
||||
.map(([, v]) => v).filter(Boolean).sort()[0] || null;
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
const until = Object.entries(connection)
|
||||
.filter(([k]) => k.startsWith("modelLock_"))
|
||||
.map(([, v]) => v).filter(v => v && new Date(v).getTime() > Date.now()).sort()[0] || null;
|
||||
setIsCooldown(!!until);
|
||||
};
|
||||
check();
|
||||
const t = modelLockUntil ? setInterval(check, 1000) : null;
|
||||
return () => { if (t) clearInterval(t); };
|
||||
}, [modelLockUntil]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showProxyDropdown) return;
|
||||
const handler = (e) => {
|
||||
if (proxyDropdownRef.current && !proxyDropdownRef.current.contains(e.target))
|
||||
setShowProxyDropdown(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showProxyDropdown]);
|
||||
|
||||
const effectiveStatus = connection.testStatus === "unavailable" && !isCooldown ? "active" : connection.testStatus;
|
||||
|
||||
const getStatusVariant = () => {
|
||||
if (connection.isActive === false) return "default";
|
||||
if (effectiveStatus === "active" || effectiveStatus === "success") return "success";
|
||||
if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error";
|
||||
return "default";
|
||||
};
|
||||
|
||||
const displayName = isOAuth
|
||||
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
||||
: connection.name;
|
||||
|
||||
const handleSelectProxy = async (poolId) => {
|
||||
setUpdatingProxy(true);
|
||||
try { await onUpdateProxy(poolId === "__none__" ? null : poolId); }
|
||||
finally { setUpdatingProxy(false); setShowProxyDropdown(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`group flex items-center justify-between p-3 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${connection.isActive === false ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex flex-col">
|
||||
<button onClick={onMoveUp} disabled={isFirst} className={`p-0.5 rounded ${isFirst ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}>
|
||||
<span className="material-symbols-outlined text-sm">keyboard_arrow_up</span>
|
||||
</button>
|
||||
<button onClick={onMoveDown} disabled={isLast} className={`p-0.5 rounded ${isLast ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}>
|
||||
<span className="material-symbols-outlined text-sm">keyboard_arrow_down</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-base text-text-muted">{isOAuth ? "lock" : "key"}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{displayName}</p>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<Badge variant={getStatusVariant()} size="sm" dot>
|
||||
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")}
|
||||
</Badge>
|
||||
{hasAnyProxy && <Badge variant={proxyBadgeVariant} size="sm">Proxy</Badge>}
|
||||
{isCooldown && connection.isActive !== false && <CooldownTimer until={modelLockUntil} />}
|
||||
{connection.lastError && connection.isActive !== false && (
|
||||
<span className="text-xs text-red-500 truncate max-w-[300px]" title={connection.lastError}>{connection.lastError}</span>
|
||||
)}
|
||||
<span className="text-xs text-text-muted">#{connection.priority}</span>
|
||||
</div>
|
||||
{hasAnyProxy && (
|
||||
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] text-text-muted truncate max-w-[420px]" title={proxyDisplayText}>{proxyDisplayText}</span>
|
||||
{maskedProxyUrl && <code className="text-[10px] font-mono bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded text-text-muted">{maskedProxyUrl}</code>}
|
||||
{noProxyText && <span className="text-[11px] text-text-muted truncate max-w-[320px]" title={noProxyText}>no_proxy: {noProxyText}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{(proxyPools || []).length > 0 && (
|
||||
<div className="relative" ref={proxyDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowProxyDropdown((v) => !v)}
|
||||
className={`flex flex-col items-center px-2 py-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors ${hasAnyProxy ? "text-primary" : "text-text-muted hover:text-primary"}`}
|
||||
disabled={updatingProxy}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{updatingProxy ? "progress_activity" : "lan"}</span>
|
||||
<span className="text-[10px] leading-tight">Proxy</span>
|
||||
</button>
|
||||
{showProxyDropdown && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 bg-bg border border-border rounded-lg shadow-lg py-1 min-w-[160px]">
|
||||
<button onClick={() => handleSelectProxy("__none__")} className={`w-full text-left px-3 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${!boundProxyPoolId ? "text-primary font-medium" : "text-text-main"}`}>None</button>
|
||||
{(proxyPools || []).map((pool) => (
|
||||
<button key={pool.id} onClick={() => handleSelectProxy(pool.id)} className={`w-full text-left px-3 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${boundProxyPoolId === pool.id ? "text-primary font-medium" : "text-text-main"}`}>{pool.name}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={onEdit} className="flex flex-col items-center px-2 py-1 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary">
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
<span className="text-[10px] leading-tight">Edit</span>
|
||||
</button>
|
||||
<button onClick={onDelete} className="flex flex-col items-center px-2 py-1 rounded hover:bg-red-500/10 text-red-500">
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
<span className="text-[10px] leading-tight">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<Toggle size="sm" checked={connection.isActive ?? true} onChange={onToggleActive} title={(connection.isActive ?? true) ? "Disable" : "Enable"} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ConnectionRow.propTypes = {
|
||||
connection: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
testStatus: PropTypes.string,
|
||||
isActive: PropTypes.bool,
|
||||
lastError: PropTypes.string,
|
||||
priority: PropTypes.number,
|
||||
}).isRequired,
|
||||
proxyPools: PropTypes.array,
|
||||
isOAuth: PropTypes.bool.isRequired,
|
||||
isFirst: PropTypes.bool.isRequired,
|
||||
isLast: PropTypes.bool.isRequired,
|
||||
onMoveUp: PropTypes.func.isRequired,
|
||||
onMoveDown: PropTypes.func.isRequired,
|
||||
onToggleActive: PropTypes.func.isRequired,
|
||||
onUpdateProxy: PropTypes.func,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// ── AddApiKeyModal ─────────────────────────────────────────────
|
||||
function AddApiKeyModal({ isOpen, provider, providerName, proxyPools, onSave, onClose }) {
|
||||
const NONE = "__none__";
|
||||
const [formData, setFormData] = useState({ name: "", apiKey: "", priority: 1, proxyPoolId: NONE });
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleValidate = async () => {
|
||||
setValidating(true);
|
||||
try {
|
||||
const res = await fetch("/api/providers/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, apiKey: formData.apiKey }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setValidationResult(data.valid ? "success" : "failed");
|
||||
} catch { setValidationResult("failed"); }
|
||||
finally { setValidating(false); }
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!provider || !formData.apiKey) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
let isValid = false;
|
||||
try {
|
||||
setValidating(true); setValidationResult(null);
|
||||
const res = await fetch("/api/providers/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, apiKey: formData.apiKey }),
|
||||
});
|
||||
const data = await res.json();
|
||||
isValid = !!data.valid;
|
||||
setValidationResult(isValid ? "success" : "failed");
|
||||
} catch { setValidationResult("failed"); }
|
||||
finally { setValidating(false); }
|
||||
await onSave({
|
||||
name: formData.name,
|
||||
apiKey: formData.apiKey,
|
||||
priority: formData.priority,
|
||||
proxyPoolId: formData.proxyPoolId === NONE ? null : formData.proxyPoolId,
|
||||
testStatus: isValid ? "active" : "unknown",
|
||||
});
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (!provider) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={`Add ${providerName || provider} API Key`} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Name</label>
|
||||
<input className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Production Key" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-text-muted mb-1 block">API Key</label>
|
||||
<input type="password" className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" value={formData.apiKey} onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })} />
|
||||
</div>
|
||||
<div className="pt-6">
|
||||
<Button onClick={handleValidate} disabled={!formData.apiKey || validating || saving} variant="secondary">
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Priority</label>
|
||||
<input type="number" className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" value={formData.priority} onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })} />
|
||||
</div>
|
||||
<Select label="Proxy Pool" value={formData.proxyPoolId} onChange={(e) => setFormData({ ...formData, proxyPoolId: e.target.value })}
|
||||
options={[{ value: NONE, label: "None" }, ...(proxyPools || []).map((p) => ({ value: p.id, label: p.name }))]} />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey || saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddApiKeyModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
provider: PropTypes.string,
|
||||
providerName: PropTypes.string,
|
||||
proxyPools: PropTypes.array,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// ── ConnectionsCard ────────────────────────────────────────────
|
||||
// Self-contained card: fetches, displays and manages all connections for a provider.
|
||||
export default function ConnectionsCard({ providerId, isOAuth }) {
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [proxyPools, setProxyPools] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedConnection, setSelectedConnection] = useState(null);
|
||||
const [providerStrategy, setProviderStrategy] = useState(null);
|
||||
const [providerStickyLimit, setProviderStickyLimit] = useState("1");
|
||||
|
||||
const fetch_ = useCallback(async () => {
|
||||
try {
|
||||
const [connRes, proxyRes, settingsRes] = await Promise.all([
|
||||
fetch("/api/providers", { cache: "no-store" }),
|
||||
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }),
|
||||
fetch("/api/settings", { cache: "no-store" }),
|
||||
]);
|
||||
const connData = await connRes.json();
|
||||
const proxyData = await proxyRes.json();
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||
if (connRes.ok) setConnections((connData.connections || []).filter((c) => c.provider === providerId));
|
||||
if (proxyRes.ok) setProxyPools(proxyData.proxyPools || []);
|
||||
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
||||
setProviderStrategy(override.fallbackStrategy || null);
|
||||
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
||||
} catch (e) { console.log("ConnectionsCard fetch error:", e); }
|
||||
finally { setLoading(false); }
|
||||
}, [providerId]);
|
||||
|
||||
useEffect(() => { fetch_(); }, [fetch_]);
|
||||
|
||||
const saveStrategy = async (strategy, stickyLimit) => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", { cache: "no-store" });
|
||||
const data = res.ok ? await res.json() : {};
|
||||
const current = data.providerStrategies || {};
|
||||
const override = {};
|
||||
if (strategy) override.fallbackStrategy = strategy;
|
||||
if (strategy === "round-robin" && stickyLimit !== "") override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
|
||||
const updated = { ...current };
|
||||
if (Object.keys(override).length === 0) delete updated[providerId];
|
||||
else updated[providerId] = override;
|
||||
await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerStrategies: updated }) });
|
||||
} catch (e) { console.log("saveStrategy error:", e); }
|
||||
};
|
||||
|
||||
const handleSwapPriority = async (i1, i2) => {
|
||||
const next = [...connections];
|
||||
[next[i1], next[i2]] = [next[i2], next[i1]];
|
||||
setConnections(next);
|
||||
try {
|
||||
await Promise.all([
|
||||
fetch(`/api/providers/${next[i1].id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ priority: i1 }) }),
|
||||
fetch(`/api/providers/${next[i2].id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ priority: i2 }) }),
|
||||
]);
|
||||
} catch { await fetch_(); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm("Delete this connection?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${id}`, { method: "DELETE" });
|
||||
if (res.ok) setConnections((prev) => prev.filter((c) => c.id !== id));
|
||||
} catch (e) { console.log("delete error:", e); }
|
||||
};
|
||||
|
||||
const handleToggleActive = async (id, isActive) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isActive }) });
|
||||
if (res.ok) setConnections((prev) => prev.map((c) => c.id === id ? { ...c, isActive } : c));
|
||||
} catch (e) { console.log("toggle error:", e); }
|
||||
};
|
||||
|
||||
const handleUpdateProxy = async (connId, proxyPoolId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ proxyPoolId: proxyPoolId || null }) });
|
||||
if (res.ok) setConnections((prev) => prev.map((c) => c.id === connId ? { ...c, providerSpecificData: { ...c.providerSpecificData, proxyPoolId: proxyPoolId || null } } : c));
|
||||
} catch (e) { console.log("proxy error:", e); }
|
||||
};
|
||||
|
||||
const handleSaveApiKey = async (formData) => {
|
||||
try {
|
||||
const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: providerId, ...formData }) });
|
||||
if (res.ok) { await fetch_(); setShowAddModal(false); }
|
||||
} catch (e) { console.log("save apikey error:", e); }
|
||||
};
|
||||
|
||||
const handleUpdateConnection = async (formData) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${selectedConnection.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData) });
|
||||
if (res.ok) { await fetch_(); setShowEditModal(false); }
|
||||
} catch (e) { console.log("update connection error:", e); }
|
||||
};
|
||||
|
||||
if (loading) return <Card><div className="h-20 animate-pulse bg-black/5 rounded-lg" /></Card>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Connections</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Round Robin</span>
|
||||
<Toggle
|
||||
checked={providerStrategy === "round-robin"}
|
||||
onChange={(enabled) => {
|
||||
const strategy = enabled ? "round-robin" : null;
|
||||
setProviderStrategy(strategy);
|
||||
if (enabled && !providerStickyLimit) setProviderStickyLimit("1");
|
||||
saveStrategy(strategy, enabled ? (providerStickyLimit || "1") : providerStickyLimit);
|
||||
}}
|
||||
/>
|
||||
{providerStrategy === "round-robin" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-text-muted">Sticky:</span>
|
||||
<input
|
||||
type="number" min={1} value={providerStickyLimit}
|
||||
onChange={(e) => { setProviderStickyLimit(e.target.value); saveStrategy("round-robin", e.target.value); }}
|
||||
className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-text-muted">No connections yet</p>
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>Add Connection</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{connections.map((conn, idx) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
proxyPools={proxyPools}
|
||||
isOAuth={isOAuth}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === connections.length - 1}
|
||||
onMoveUp={() => handleSwapPriority(idx, idx - 1)}
|
||||
onMoveDown={() => handleSwapPriority(idx, idx + 1)}
|
||||
onToggleActive={(isActive) => handleToggleActive(conn.id, isActive)}
|
||||
onUpdateProxy={(poolId) => handleUpdateProxy(conn.id, poolId)}
|
||||
onEdit={() => { setSelectedConnection(conn); setShowEditModal(true); }}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>Add</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AddApiKeyModal
|
||||
isOpen={showAddModal}
|
||||
provider={providerId}
|
||||
proxyPools={proxyPools}
|
||||
onSave={handleSaveApiKey}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
/>
|
||||
<EditConnectionModal
|
||||
isOpen={showEditModal}
|
||||
connection={selectedConnection}
|
||||
proxyPools={proxyPools}
|
||||
onSave={handleUpdateConnection}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ConnectionsCard.propTypes = {
|
||||
providerId: PropTypes.string.isRequired,
|
||||
isOAuth: PropTypes.bool,
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, Button, Modal } from "@/shared/components";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
// ── ModelRow ───────────────────────────────────────────────────
|
||||
export function ModelRow({ model, fullModel, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting }) {
|
||||
const borderColor = testStatus === "ok" ? "border-green-500/40" : testStatus === "error" ? "border-red-500/40" : "border-border";
|
||||
const iconColor = testStatus === "ok" ? "#22c55e" : testStatus === "error" ? "#ef4444" : undefined;
|
||||
|
||||
return (
|
||||
<div className={`group px-3 py-2 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base" style={iconColor ? { color: iconColor } : undefined}>
|
||||
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
||||
</span>
|
||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||
{onTest && (
|
||||
<div className="relative group/btn">
|
||||
<button onClick={onTest} disabled={isTesting} className={`p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-opacity ${isTesting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>
|
||||
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
{isTesting ? "progress_activity" : "science"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="pointer-events-none absolute mt-1 top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||
{isTesting ? "Testing..." : "Test"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative group/btn">
|
||||
<button onClick={() => onCopy(fullModel, `model-${model.id}`)} className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary">
|
||||
<span className="material-symbols-outlined text-sm">{copied === `model-${model.id}` ? "check" : "content_copy"}</span>
|
||||
</button>
|
||||
<span className="pointer-events-none absolute mt-1 top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||
{copied === `model-${model.id}` ? "Copied!" : "Copy"}
|
||||
</span>
|
||||
</div>
|
||||
{isFree && <span className="text-[10px] font-bold text-green-500 bg-green-500/10 px-1.5 py-0.5 rounded">FREE</span>}
|
||||
{isCustom && (
|
||||
<button onClick={onDeleteAlias} className="p-0.5 hover:bg-red-500/10 rounded text-text-muted hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity ml-auto" title="Remove custom model">
|
||||
<span className="material-symbols-outlined text-sm">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ModelRow.propTypes = {
|
||||
model: PropTypes.shape({ id: PropTypes.string.isRequired }).isRequired,
|
||||
fullModel: PropTypes.string.isRequired,
|
||||
copied: PropTypes.string,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
testStatus: PropTypes.oneOf(["ok", "error"]),
|
||||
isCustom: PropTypes.bool,
|
||||
isFree: PropTypes.bool,
|
||||
onDeleteAlias: PropTypes.func,
|
||||
onTest: PropTypes.func,
|
||||
isTesting: PropTypes.bool,
|
||||
};
|
||||
|
||||
// ── AddCustomModelModal ────────────────────────────────────────
|
||||
function AddCustomModelModal({ isOpen, onSave, onClose }) {
|
||||
const [modelId, setModelId] = useState("");
|
||||
|
||||
const handleSave = () => {
|
||||
if (!modelId.trim()) return;
|
||||
onSave(modelId.trim());
|
||||
setModelId("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title="Add Custom Model" onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Model ID</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
placeholder="e.g. tts-1-hd"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} fullWidth disabled={!modelId.trim()}>Add</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddCustomModelModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// ── ModelsCard ─────────────────────────────────────────────────
|
||||
// Self-contained card: shows models for a provider, filtered by optional `kindFilter`.
|
||||
// kindFilter: if provided, only shows models with matching type/kinds field.
|
||||
export default function ModelsCard({ providerId, kindFilter }) {
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [modelTestResults, setModelTestResults] = useState({});
|
||||
const [testingModelId, setTestingModelId] = useState(null);
|
||||
const [testError, setTestError] = useState("");
|
||||
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
|
||||
const [connections, setConnections] = useState([]);
|
||||
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [aliasRes, connRes] = await Promise.all([
|
||||
fetch("/api/models/alias"),
|
||||
fetch("/api/providers", { cache: "no-store" }),
|
||||
]);
|
||||
const aliasData = await aliasRes.json();
|
||||
const connData = await connRes.json();
|
||||
if (aliasRes.ok) setModelAliases(aliasData.aliases || {});
|
||||
if (connRes.ok) setConnections((connData.connections || []).filter((c) => c.provider === providerId));
|
||||
} catch (e) { console.log("ModelsCard fetch error:", e); }
|
||||
}, [providerId]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleSetAlias = async (modelId, alias) => {
|
||||
const fullModel = `${providerAlias}/${modelId}`;
|
||||
try {
|
||||
const res = await fetch("/api/models/alias", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: fullModel, alias }),
|
||||
});
|
||||
if (res.ok) await fetchData();
|
||||
} catch (e) { console.log("set alias error:", e); }
|
||||
};
|
||||
|
||||
const handleDeleteAlias = async (alias) => {
|
||||
try {
|
||||
const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, { method: "DELETE" });
|
||||
if (res.ok) await fetchData();
|
||||
} catch (e) { console.log("delete alias error:", e); }
|
||||
};
|
||||
|
||||
const handleTestModel = async (modelId) => {
|
||||
if (testingModelId) return;
|
||||
setTestingModelId(modelId);
|
||||
try {
|
||||
const res = await fetch("/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: `${providerAlias}/${modelId}` }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setModelTestResults((prev) => ({ ...prev, [modelId]: data.ok ? "ok" : "error" }));
|
||||
setTestError(data.ok ? "" : (data.error || "Model not reachable"));
|
||||
} catch {
|
||||
setModelTestResults((prev) => ({ ...prev, [modelId]: "error" }));
|
||||
setTestError("Network error");
|
||||
} finally { setTestingModelId(null); }
|
||||
};
|
||||
|
||||
// Get models — filter by kindFilter if provided
|
||||
const allModels = getModelsByProviderId(providerId);
|
||||
const displayModels = kindFilter
|
||||
? allModels.filter((m) => {
|
||||
if (m.kinds) return m.kinds.includes(kindFilter);
|
||||
if (m.type) return m.type === kindFilter;
|
||||
return kindFilter === "llm";
|
||||
})
|
||||
: allModels;
|
||||
|
||||
// Custom models added via alias
|
||||
const customModels = Object.entries(modelAliases)
|
||||
.filter(([alias, fullModel]) => {
|
||||
const prefix = `${providerAlias}/`;
|
||||
if (!fullModel.startsWith(prefix)) return false;
|
||||
const modelId = fullModel.slice(prefix.length);
|
||||
return !displayModels.some((m) => m.id === modelId) && alias === modelId;
|
||||
})
|
||||
.map(([alias, fullModel]) => ({
|
||||
id: fullModel.slice(`${providerAlias}/`.length),
|
||||
alias,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Models{kindFilter ? ` — ${kindFilter.toUpperCase()}` : ""}</h2>
|
||||
</div>
|
||||
{testError && <p className="text-xs text-red-500 mb-3 break-words">{testError}</p>}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{displayModels.map((model) => {
|
||||
const fullModel = `${providerAlias}/${model.id}`;
|
||||
const existingAlias = Object.entries(modelAliases).find(([, m]) => m === fullModel)?.[0];
|
||||
return (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
fullModel={`${providerAlias}/${model.id}`}
|
||||
alias={existingAlias}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={(alias) => handleSetAlias(model.id, alias)}
|
||||
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
|
||||
testStatus={modelTestResults[model.id]}
|
||||
onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined}
|
||||
isTesting={testingModelId === model.id}
|
||||
isFree={model.isFree}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{customModels.map((model) => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={{ id: model.id }}
|
||||
fullModel={`${providerAlias}/${model.id}`}
|
||||
alias={model.alias}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={() => {}}
|
||||
onDeleteAlias={() => handleDeleteAlias(model.alias)}
|
||||
testStatus={modelTestResults[model.id]}
|
||||
onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined}
|
||||
isTesting={testingModelId === model.id}
|
||||
isCustom
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddCustomModel(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-dashed border-black/15 dark:border-white/15 text-xs text-text-muted hover:text-primary hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">add</span>
|
||||
Add Model
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<AddCustomModelModal
|
||||
isOpen={showAddCustomModel}
|
||||
onSave={async (modelId) => {
|
||||
await handleSetAlias(modelId, modelId);
|
||||
setShowAddCustomModel(false);
|
||||
}}
|
||||
onClose={() => setShowAddCustomModel(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ModelsCard.propTypes = {
|
||||
providerId: PropTypes.string.isRequired,
|
||||
kindFilter: PropTypes.string, // e.g. "tts", "embedding" — filters models shown
|
||||
};
|
||||
@@ -362,16 +362,18 @@ export default function ProvidersPage() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
authType="apikey"
|
||||
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
||||
/>
|
||||
))}
|
||||
{Object.entries(APIKEY_PROVIDERS)
|
||||
.filter(([, info]) => (info.serviceKinds ?? ["llm"]).includes("llm"))
|
||||
.map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
authType="apikey"
|
||||
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
|
||||
import Button from "./Button";
|
||||
import { ConfirmModal } from "./Modal";
|
||||
|
||||
@@ -31,6 +32,7 @@ const systemItems = [
|
||||
|
||||
export default function Sidebar({ onClose }) {
|
||||
const pathname = usePathname();
|
||||
const [mediaOpen, setMediaOpen] = useState(false);
|
||||
const [showShutdownModal, setShowShutdownModal] = useState(false);
|
||||
const [isShuttingDown, setIsShuttingDown] = useState(false);
|
||||
const [isDisconnected, setIsDisconnected] = useState(false);
|
||||
@@ -132,6 +134,43 @@ export default function Sidebar({ onClose }) {
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Media Providers accordion */}
|
||||
{/* <button
|
||||
onClick={() => setMediaOpen((v) => !v)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
pathname.startsWith("/dashboard/media-providers")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">perm_media</span>
|
||||
<span className="text-sm font-medium flex-1 text-left">Media Providers</span>
|
||||
<span className="material-symbols-outlined text-[14px] transition-transform" style={{ transform: mediaOpen ? "rotate(180deg)" : "rotate(0deg)" }}>
|
||||
expand_more
|
||||
</span>
|
||||
</button> */}
|
||||
{mediaOpen && (
|
||||
<div className="pl-4">
|
||||
{MEDIA_PROVIDER_KINDS.map((kind) => (
|
||||
<Link
|
||||
key={kind.id}
|
||||
href={`/dashboard/media-providers/${kind.id}`}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all group",
|
||||
pathname.startsWith(`/dashboard/media-providers/${kind.id}`)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">{kind.icon}</span>
|
||||
<span className="text-sm">{kind.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug section */}
|
||||
<div className="pt-4 mt-2">
|
||||
<p className="px-4 text-xs font-semibold text-text-muted/60 uppercase tracking-wider mb-2">
|
||||
|
||||
@@ -40,9 +40,9 @@ export const APIKEY_PROVIDERS = {
|
||||
"minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC", website: "https://www.minimaxi.com" },
|
||||
alicode: { id: "alicode", alias: "alicode", name: "Alibaba", icon: "cloud", color: "#FF6A00", textIcon: "ALi" },
|
||||
"alicode-intl": { id: "alicode-intl", alias: "alicode-intl", name: "Alibaba Intl", icon: "cloud", color: "#FF6A00", textIcon: "ALi" },
|
||||
openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com" },
|
||||
anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com" },
|
||||
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev" },
|
||||
openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts"] },
|
||||
anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", serviceKinds: ["llm"] },
|
||||
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding"] },
|
||||
deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com" },
|
||||
groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com" },
|
||||
xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai" },
|
||||
@@ -55,14 +55,30 @@ export const APIKEY_PROVIDERS = {
|
||||
nebius: { id: "nebius", alias: "nebius", name: "Nebius AI", icon: "cloud", color: "#6C5CE7", textIcon: "NB", website: "https://nebius.com" },
|
||||
siliconflow: { id: "siliconflow", alias: "siliconflow", name: "SiliconFlow", icon: "cloud_queue", color: "#5B6EF5", textIcon: "SF", website: "https://cloud.siliconflow.com" },
|
||||
hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz" },
|
||||
deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com" },
|
||||
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com" },
|
||||
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai" },
|
||||
deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com", serviceKinds: ["stt"] },
|
||||
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com", serviceKinds: ["stt"] },
|
||||
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai", serviceKinds: ["image"] },
|
||||
elevenlabs: { id: "elevenlabs", alias: "el", name: "ElevenLabs", icon: "record_voice_over", color: "#6C47FF", textIcon: "EL", website: "https://elevenlabs.io", serviceKinds: ["tts"] },
|
||||
cartesia: { id: "cartesia", alias: "cartesia", name: "Cartesia", icon: "spatial_audio", color: "#FF4F8B", textIcon: "CA", website: "https://cartesia.ai", serviceKinds: ["tts"] },
|
||||
playht: { id: "playht", alias: "playht", name: "PlayHT", icon: "play_circle", color: "#00B4D8", textIcon: "PH", website: "https://play.ht", serviceKinds: ["tts"] },
|
||||
sdwebui: { id: "sdwebui", alias: "sdwebui", name: "SD WebUI", icon: "brush", color: "#FF7043", textIcon: "SD", website: "https://github.com/AUTOMATIC1111/stable-diffusion-webui", serviceKinds: ["image"] },
|
||||
comfyui: { id: "comfyui", alias: "comfyui", name: "ComfyUI", icon: "account_tree", color: "#4CAF50", textIcon: "CF", website: "https://github.com/comfyanonymous/ComfyUI", serviceKinds: ["image"] },
|
||||
huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", serviceKinds: ["embedding", "image", "tts"] },
|
||||
chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" },
|
||||
"ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
|
||||
"vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models" },
|
||||
};
|
||||
|
||||
// Media provider kinds — each kind maps to a route and endpoint config
|
||||
export const MEDIA_PROVIDER_KINDS = [
|
||||
{ id: "embedding", label: "Embedding", icon: "data_array", endpoint: { method: "POST", path: "/v1/embeddings" } },
|
||||
{ id: "image", label: "Image", icon: "image", endpoint: { method: "POST", path: "/v1/images/generations" } },
|
||||
{ id: "tts", label: "TTS", icon: "record_voice_over", endpoint: { method: "POST", path: "/v1/audio/speech" } },
|
||||
{ id: "stt", label: "STT", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } },
|
||||
{ id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } },
|
||||
{ id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } },
|
||||
];
|
||||
|
||||
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
export const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
|
||||
|
||||
@@ -117,6 +133,15 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Helper: Get providers by service kind (e.g. "tts", "embedding", "image")
|
||||
// Providers without serviceKinds default to ["llm"]
|
||||
export function getProvidersByKind(kind) {
|
||||
return Object.values(AI_PROVIDERS).filter((p) => {
|
||||
const kinds = p.serviceKinds ?? ["llm"];
|
||||
return kinds.includes(kind);
|
||||
});
|
||||
}
|
||||
|
||||
// Providers that support usage/quota API
|
||||
export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"claude",
|
||||
|
||||
Reference in New Issue
Block a user