mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
- Introduced OpenCode Go provider with relevant configurations. - Enhanced model management by allowing users to add and delete custom models. - Updated UI components to support model selection for image types. - Adjusted sidebar visibility to include image media kinds.
288 lines
12 KiB
JavaScript
288 lines
12 KiB
JavaScript
"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>
|
|
<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,
|
|
};
|
|
|
|
// ── 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 [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 = 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();
|
|
} 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();
|
|
} 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); }
|
|
};
|
|
|
|
// Built-in models — filter by kindFilter if provided
|
|
const allBuiltIn = getModelsByProviderId(providerId);
|
|
const builtInModels = kindFilter
|
|
? allBuiltIn.filter((m) => {
|
|
if (m.kinds) return m.kinds.includes(kindFilter);
|
|
return (m.type || "llm") === kindFilter;
|
|
})
|
|
: allBuiltIn;
|
|
|
|
// Custom models for this provider + kind, dedupe vs built-in
|
|
const myCustomModels = customModels.filter(
|
|
(m) => m.providerAlias === providerAlias
|
|
&& (m.type || "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, // e.g. "tts", "embedding" — filters models shown
|
|
};
|