| "use client"; |
|
|
| import { useState, useCallback, useEffect } from "react"; |
| import PropTypes from "prop-types"; |
| import { Card, Button, Modal } from "@/shared/components"; |
| import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; |
| import { getProviderAlias } from "@/shared/constants/providers"; |
| import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; |
|
|
| |
| 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> |
| <div className="flex flex-col gap-1"> |
| <code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code> |
| {model.name && <span className="text-[9px] text-text-muted/70 italic pl-1">{model.name}</span>} |
| </div> |
| {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, |
| }; |
|
|
| |
| 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, |
| }; |
|
|
| |
| |
| |
| export default function ModelsCard({ providerId, kindFilter, providerAliasOverride }) { |
| const { copied, copy } = useCopyToClipboard(); |
| const [modelAliases, setModelAliases] = useState({}); |
| const [customModels, setCustomModels] = 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 = providerAliasOverride || getProviderAlias(providerId); |
| const effectiveType = kindFilter || "llm"; |
|
|
| const fetchData = useCallback(async () => { |
| try { |
| const [aliasRes, connRes, customRes] = await Promise.all([ |
| fetch("/api/models/alias"), |
| fetch("/api/providers", { cache: "no-store" }), |
| fetch("/api/models/custom", { cache: "no-store" }), |
| ]); |
| const aliasData = await aliasRes.json(); |
| const connData = await connRes.json(); |
| const customData = await customRes.json(); |
| if (aliasRes.ok) setModelAliases(aliasData.aliases || {}); |
| if (connRes.ok) setConnections((connData.connections || []).filter((c) => c.provider === providerId)); |
| if (customRes.ok) setCustomModels(customData.models || []); |
| } 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 handleAddCustomModel = async (modelId) => { |
| try { |
| const res = await fetch("/api/models/custom", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ providerAlias, id: modelId, type: effectiveType }), |
| }); |
| if (res.ok) { |
| await fetchData(); |
| window.dispatchEvent(new CustomEvent("customModelChanged")); |
| } |
| } catch (e) { console.log("add custom model error:", e); } |
| }; |
|
|
| const handleDeleteCustomModel = async (modelId) => { |
| try { |
| const params = new URLSearchParams({ providerAlias, id: modelId, type: effectiveType }); |
| const res = await fetch(`/api/models/custom?${params}`, { method: "DELETE" }); |
| if (res.ok) { |
| await fetchData(); |
| window.dispatchEvent(new CustomEvent("customModelChanged")); |
| } |
| } catch (e) { console.log("delete custom model 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}`, kind: kindFilter }), |
| }); |
| 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); } |
| }; |
|
|
| |
| const allBuiltIn = getModelsByProviderId(providerId); |
| const builtInModels = kindFilter |
| ? allBuiltIn.filter((m) => { |
| if (m.kinds) return m.kinds.includes(kindFilter); |
| return getModelKind(m, "llm") === kindFilter; |
| }) |
| : allBuiltIn; |
|
|
| |
| const myCustomModels = customModels.filter( |
| (m) => m.providerAlias === providerAlias |
| && getModelKind(m, "llm") === effectiveType |
| && !builtInModels.some((b) => b.id === m.id) |
| ); |
|
|
| const displayModels = builtInModels; |
|
|
| 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} |
| /> |
| ); |
| })} |
| |
| {myCustomModels.map((model) => ( |
| <ModelRow |
| key={`${model.id}-${model.type}`} |
| model={{ id: model.id, name: model.name }} |
| fullModel={`${providerAlias}/${model.id}`} |
| copied={copied} |
| onCopy={copy} |
| onSetAlias={() => {}} |
| onDeleteAlias={() => handleDeleteCustomModel(model.id)} |
| 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 handleAddCustomModel(modelId); |
| setShowAddCustomModel(false); |
| }} |
| onClose={() => setShowAddCustomModel(false)} |
| /> |
| </> |
| ); |
| } |
|
|
| ModelsCard.propTypes = { |
| providerId: PropTypes.string.isRequired, |
| kindFilter: PropTypes.string, |
| providerAliasOverride: PropTypes.string, |
| }; |
|
|