mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-23 04:09:49 +00:00
Feat : embedding dev
This commit is contained in:
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user