feat: model dir auto-scan, remote API models, model plaza with HF/ModelScope downloads
This commit is contained in:
+31
-19
@@ -168,9 +168,14 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
const engineRunning = useStore((s) => s.engine?.running ?? false);
|
||||
const selectedIsRemote = useMemo(
|
||||
() => models.find((m) => m.id === selectedModelId)?.kind === "remote",
|
||||
[models, selectedModelId],
|
||||
);
|
||||
const engineLabel = useMemo(() => {
|
||||
if (!models.length) return "无可用模型";
|
||||
return models.find((m) => m.id === selectedModelId)?.file_name ?? "选择模型";
|
||||
const m = models.find((x) => x.id === selectedModelId);
|
||||
return m ? `${m.file_name}${m.kind === "remote" ? " · 在线API" : " · 本地"}` : "选择模型";
|
||||
}, [models, selectedModelId]);
|
||||
|
||||
return (
|
||||
@@ -211,27 +216,34 @@ export default function ChatPage() {
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.file_name}
|
||||
{m.kind === "remote" ? "(API)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
|
||||
onClick={() =>
|
||||
api.engineStart(selectedModelId, params).catch((e) => alert(String(e)))
|
||||
}
|
||||
disabled={engineRunning}
|
||||
>
|
||||
启动引擎
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
|
||||
onClick={() => api.engineStop().catch((e) => alert(String(e)))}
|
||||
disabled={!engineRunning}
|
||||
>
|
||||
停止引擎
|
||||
</button>
|
||||
</div>
|
||||
{selectedIsRemote ? (
|
||||
<div className="mt-2 rounded-lg border border-sky-500/30 bg-sky-500/10 px-3 py-1.5 text-xs text-sky-300">
|
||||
在线 API 模型,无需本地引擎
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
|
||||
onClick={() =>
|
||||
api.engineStart(selectedModelId, params).catch((e) => alert(String(e)))
|
||||
}
|
||||
disabled={engineRunning}
|
||||
>
|
||||
启动引擎
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
|
||||
onClick={() => api.engineStop().catch((e) => alert(String(e)))}
|
||||
disabled={!engineRunning}
|
||||
>
|
||||
停止引擎
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, DownloadProgressEvent, onEvent } from "../api";
|
||||
|
||||
export default function DownloadsPage() {
|
||||
const [items, setItems] = useState<Record<string, DownloadProgressEvent>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const un1 = onEvent<DownloadProgressEvent>("download://progress", (e) => {
|
||||
setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "downloading" } }));
|
||||
});
|
||||
const un2 = onEvent<DownloadProgressEvent>("download://done", (e) => {
|
||||
setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "done" } }));
|
||||
});
|
||||
const un3 = onEvent<DownloadProgressEvent>("download://error", (e) => {
|
||||
setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "error" } }));
|
||||
});
|
||||
return () => {
|
||||
un1.then((f) => f());
|
||||
un2.then((f) => f());
|
||||
un3.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleAdd() {
|
||||
const url = prompt("请输入 GGUF 文件直链(支持 hf-mirror / huggingface / modelscope):");
|
||||
if (!url) return;
|
||||
const name = url.split("/").pop() || "model.gguf";
|
||||
const fileName = prompt("保存为(文件名):", name);
|
||||
if (!fileName) return;
|
||||
const sha256 = prompt("SHA256(可选,留空跳过校验):", "") || undefined;
|
||||
const id = await api.downloadEnqueue(url, fileName, sha256);
|
||||
setItems((prev) => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
id,
|
||||
url,
|
||||
file_name: fileName,
|
||||
downloaded: 0,
|
||||
total: null,
|
||||
percent: null,
|
||||
speed_bps: 0,
|
||||
status: "downloading",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const list = Object.values(items).sort((a, b) => b.id.localeCompare(a.id));
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">下载</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
分片断点续传,支持校验。完成后的模型会自动进入模型库
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={handleAdd}>
|
||||
+ 添加下载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{list.length === 0 ? (
|
||||
<div className="mt-16 text-center text-sm text-slate-500">
|
||||
暂无下载任务。点击右上角添加 GGUF 直链。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{list.map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-border bg-panel p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{item.file_name}</div>
|
||||
<div className="truncate text-xs text-slate-400">{item.url}</div>
|
||||
</div>
|
||||
<div className="ml-3 text-right text-xs text-slate-400">
|
||||
{item.status === "done"
|
||||
? "已完成 ✓"
|
||||
: item.status === "error"
|
||||
? "失败"
|
||||
: `${item.percent?.toFixed(1) ?? 0}%`}
|
||||
{item.status === "downloading" && item.speed_bps > 0
|
||||
? ` · ${fmtSpeed(item.speed_bps)}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-panel-2">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
item.status === "error" ? "bg-red-500" : "bg-gradient-to-r from-accent to-accent-2"
|
||||
}`}
|
||||
style={{ width: `${item.percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{item.status === "downloading" && item.total ? (
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{fmtSize(item.downloaded)} / {fmtSize(item.total)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number) {
|
||||
const mb = bytes / 1024 / 1024;
|
||||
return mb >= 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function fmtSpeed(bps: number) {
|
||||
const mbps = bps / 1024 / 1024;
|
||||
return mbps >= 1 ? `${mbps.toFixed(1)} MB/s` : `${(bps / 1024).toFixed(0)} KB/s`;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, DownloadProgressEvent, ModelFile, onEvent, RepoSummary } from "../api";
|
||||
|
||||
function fmtSize(bytes: number) {
|
||||
if (!bytes) return "-";
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
return gb >= 1 ? `${gb.toFixed(2)} GB` : `${(bytes / 1024 / 1024).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function fmtCount(n: number | null) {
|
||||
if (n == null) return "-";
|
||||
return n >= 10000 ? `${(n / 10000).toFixed(1)}w` : n.toLocaleString();
|
||||
}
|
||||
|
||||
export default function ModelPlazaPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [source, setSource] = useState<"hf" | "modelscope">("hf");
|
||||
const [endpoint, setEndpoint] = useState("https://hf-mirror.com");
|
||||
const [repos, setRepos] = useState<RepoSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<Record<string, ModelFile[]>>({});
|
||||
const [loadingRepo, setLoadingRepo] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [manualUrl, setManualUrl] = useState("");
|
||||
const [downloads, setDownloads] = useState<Record<string, DownloadProgressEvent>>({});
|
||||
|
||||
useEffect(() => {
|
||||
api.settingsGet().then((s) => {
|
||||
if (s.hf_endpoint) setEndpoint(s.hf_endpoint);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const un1 = onEvent<DownloadProgressEvent>("download://progress", (e) => {
|
||||
setDownloads((prev) => ({ ...prev, [e.id]: { ...e, status: "downloading" } }));
|
||||
});
|
||||
const un2 = onEvent<DownloadProgressEvent>("download://done", (e) => {
|
||||
setDownloads((prev) => ({ ...prev, [e.id]: { ...e, status: "done" } }));
|
||||
});
|
||||
const un3 = onEvent<DownloadProgressEvent>("download://error", (e) => {
|
||||
setDownloads((prev) => ({ ...prev, [e.id]: { ...e, status: "error" } }));
|
||||
});
|
||||
return () => {
|
||||
un1.then((f) => f());
|
||||
un2.then((f) => f());
|
||||
un3.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setRepos([]);
|
||||
try {
|
||||
setRepos(await api.searchModels(query.trim(), source));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExpand(repoId: string) {
|
||||
if (expanded === repoId) {
|
||||
setExpanded(null);
|
||||
return;
|
||||
}
|
||||
setExpanded(repoId);
|
||||
if (!files[repoId]) {
|
||||
setLoadingRepo(repoId);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await api.listModelFiles(repoId, source);
|
||||
setFiles((prev) => ({ ...prev, [repoId]: list }));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoadingRepo(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUrl(repoId: string, path: string) {
|
||||
if (source === "modelscope") {
|
||||
return `https://modelscope.cn/models/${repoId}/resolve/master/${path}`;
|
||||
}
|
||||
return `${endpoint}/${repoId}/resolve/main/${path}`;
|
||||
}
|
||||
|
||||
async function handleDownload(repoId: string, file: ModelFile) {
|
||||
const url = resolveUrl(repoId, file.path);
|
||||
const fileName = file.path.split("/").pop() || "model.gguf";
|
||||
try {
|
||||
await api.downloadEnqueue(url, fileName, file.sha256 ?? undefined, repoId, source);
|
||||
} catch (e) {
|
||||
alert(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManualDownload() {
|
||||
const url = manualUrl.trim();
|
||||
if (!url) return;
|
||||
const fileName = url.split("/").pop() || "model.gguf";
|
||||
try {
|
||||
await api.downloadEnqueue(url, fileName);
|
||||
setManualUrl("");
|
||||
} catch (e) {
|
||||
alert(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
const downloadList = Object.values(downloads).sort((a, b) => b.id.localeCompare(a.id));
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-semibold">模型广场</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
搜索 Hugging Face / ModelScope 上的 GGUF 模型,选择量化版本直接下载
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-72"
|
||||
placeholder="搜索模型,例如 qwen / llama / deepseek"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<select
|
||||
className="input w-36"
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value as "hf" | "modelscope");
|
||||
setRepos([]);
|
||||
setFiles({});
|
||||
setExpanded(null);
|
||||
}}
|
||||
>
|
||||
<option value="hf">Hugging Face</option>
|
||||
<option value="modelscope">ModelScope</option>
|
||||
</select>
|
||||
<button className="btn-primary" onClick={handleSearch} disabled={loading || !query.trim()}>
|
||||
{loading ? "搜索中…" : "搜索"}
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<input
|
||||
className="input w-80"
|
||||
placeholder="或直接粘贴 GGUF 直链下载(可选)"
|
||||
value={manualUrl}
|
||||
onChange={(e) => setManualUrl(e.target.value)}
|
||||
/>
|
||||
<button className="btn-secondary" onClick={handleManualDownload}>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mb-4 rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{repos.length === 0 && !loading ? (
|
||||
<div className="mt-16 text-center text-sm text-slate-500">
|
||||
输入关键词搜索模型仓库,点击仓库可查看 GGUF 量化版本
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{repos.map((repo) => (
|
||||
<div key={repo.repo_id} className="rounded-xl border border-border bg-panel">
|
||||
<button
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left"
|
||||
onClick={() => toggleExpand(repo.repo_id)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{repo.author}/{repo.name}
|
||||
</div>
|
||||
<div className="mt-0.5 flex gap-3 text-xs text-slate-400">
|
||||
<span>⬇ {fmtCount(repo.downloads)}</span>
|
||||
<span>♥ {fmtCount(repo.likes)}</span>
|
||||
{repo.tags.slice(0, 4).map((t) => (
|
||||
<span key={t} className="rounded bg-panel-2 px-1.5 py-0.5">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
{loadingRepo === repo.repo_id ? "加载中…" : expanded === repo.repo_id ? "收起 ▲" : "展开 ▼"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded === repo.repo_id ? (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{(files[repo.repo_id] ?? []).length === 0 ? (
|
||||
<div className="text-xs text-slate-500">
|
||||
该仓库暂无 GGUF 文件(或已按量化筛选)
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{files[repo.repo_id].map((f) => (
|
||||
<div
|
||||
key={f.path}
|
||||
className="flex items-center gap-3 rounded-lg bg-panel-2/60 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-xs">{f.path}</div>
|
||||
<div className="text-xs text-slate-400">{fmtSize(f.size)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary !px-3 !py-1 text-xs"
|
||||
onClick={() => handleDownload(repo.repo_id, f)}
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloadList.length > 0 ? (
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 text-xs uppercase text-slate-400">下载队列</div>
|
||||
<div className="space-y-2">
|
||||
{downloadList.map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-border bg-panel p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="truncate">{item.file_name}</span>
|
||||
<span className="ml-3 text-xs text-slate-400">
|
||||
{item.status === "done"
|
||||
? "已完成 ✓"
|
||||
: item.status === "error"
|
||||
? "失败"
|
||||
: `${item.percent?.toFixed(1) ?? 0}%`}
|
||||
{item.status === "downloading" && item.speed_bps > 0
|
||||
? ` · ${fmtSpeed(item.speed_bps)}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-panel-2">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
item.status === "error"
|
||||
? "bg-red-500"
|
||||
: "bg-gradient-to-r from-accent to-accent-2"
|
||||
}`}
|
||||
style={{ width: `${item.percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtSpeed(bps: number) {
|
||||
const mbps = bps / 1024 / 1024;
|
||||
return mbps >= 1 ? `${mbps.toFixed(1)} MB/s` : `${(bps / 1024).toFixed(0)} KB/s`;
|
||||
}
|
||||
+129
-10
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useStore } from "../store";
|
||||
|
||||
@@ -11,11 +11,31 @@ function fmtSize(bytes: number) {
|
||||
export default function ModelsPage() {
|
||||
const models = useStore((s) => s.models);
|
||||
const refreshModels = useStore((s) => s.refreshModels);
|
||||
const [showRemoteForm, setShowRemoteForm] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [remote, setRemote] = useState({
|
||||
name: "",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
api_model: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
refreshModels();
|
||||
}, [refreshModels]);
|
||||
|
||||
async function handleScan() {
|
||||
setScanning(true);
|
||||
try {
|
||||
await api.scanModels();
|
||||
await refreshModels();
|
||||
} catch (e) {
|
||||
alert(String(e));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
const path = prompt("请输入本地 GGUF 文件路径:");
|
||||
if (!path) return;
|
||||
@@ -27,8 +47,28 @@ export default function ModelsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddRemote() {
|
||||
if (!remote.name.trim() || !remote.base_url.trim() || !remote.api_model.trim()) {
|
||||
alert("名称、Base URL、模型 ID 均不能为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.addRemoteModel(
|
||||
remote.name.trim(),
|
||||
remote.base_url.trim(),
|
||||
remote.api_key.trim(),
|
||||
remote.api_model.trim(),
|
||||
);
|
||||
setShowRemoteForm(false);
|
||||
setRemote({ name: "", base_url: "", api_key: "", api_model: "" });
|
||||
await refreshModels();
|
||||
} catch (e) {
|
||||
alert(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(id: string, name: string) {
|
||||
if (!confirm(`确定从列表移除「${name}」吗?(不会删除文件)`)) return;
|
||||
if (!confirm(`确定从列表移除「${name}」吗?(不会删除本地文件)`)) return;
|
||||
try {
|
||||
await api.removeModel(id);
|
||||
await refreshModels();
|
||||
@@ -48,22 +88,81 @@ export default function ModelsPage() {
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">模型库</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
管理本地 GGUF 模型,可导入文件或从下载页拉取新模型
|
||||
启动时自动扫描模型目录,也可手动扫描;支持添加本地 GGUF 与在线 API 模型
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-secondary" onClick={handleOpenDir}>
|
||||
打开模型目录
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleImport}>
|
||||
<button className="btn-secondary" onClick={handleScan} disabled={scanning}>
|
||||
{scanning ? "扫描中…" : "扫描模型目录"}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={handleImport}>
|
||||
+ 导入本地模型
|
||||
</button>
|
||||
<button className="btn-primary" onClick={() => setShowRemoteForm((v) => !v)}>
|
||||
+ 添加在线模型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRemoteForm ? (
|
||||
<div className="mb-4 rounded-xl border border-border bg-panel p-5">
|
||||
<div className="mb-3 text-sm font-medium">添加 OpenAI 兼容的在线 API 模型</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-slate-400">显示名称</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
placeholder="例如:GPT-4o(OpenAI)"
|
||||
value={remote.name}
|
||||
onChange={(e) => setRemote({ ...remote, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-slate-400">模型 ID(上游模型名)</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
placeholder="例如:gpt-4o"
|
||||
value={remote.api_model}
|
||||
onChange={(e) => setRemote({ ...remote, api_model: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-slate-400">Base URL(可含 /v1)</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
placeholder="例如:https://api.openai.com/v1"
|
||||
value={remote.base_url}
|
||||
onChange={(e) => setRemote({ ...remote, base_url: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-slate-400">API Key(可选)</span>
|
||||
<input
|
||||
className="input mt-1"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={remote.api_key}
|
||||
onChange={(e) => setRemote({ ...remote, api_key: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="btn-primary" onClick={handleAddRemote}>
|
||||
保存
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => setShowRemoteForm(false)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{models.length === 0 ? (
|
||||
<div className="mt-16 text-center text-sm text-slate-500">
|
||||
暂无模型。点击右上角导入本地 GGUF 文件,或前往「下载」页拉取模型。
|
||||
暂无模型。可导入本地 GGUF、添加在线 API 模型,或到「模型广场」下载模型。
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-panel">
|
||||
@@ -71,9 +170,9 @@ export default function ModelsPage() {
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs uppercase text-slate-400">
|
||||
<th className="px-4 py-3">模型</th>
|
||||
<th className="px-4 py-3">类型</th>
|
||||
<th className="px-4 py-3">量化</th>
|
||||
<th className="px-4 py-3">大小</th>
|
||||
<th className="px-4 py-3">来源</th>
|
||||
<th className="px-4 py-3">状态</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
@@ -83,17 +182,37 @@ export default function ModelsPage() {
|
||||
<tr key={m.id} className="border-b border-border/60 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{m.file_name}</div>
|
||||
<div className="text-xs text-slate-400">{m.file_path}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{m.kind === "remote"
|
||||
? `${m.base_url ?? ""} · ${m.api_model ?? ""}`
|
||||
: m.file_path}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${
|
||||
m.kind === "remote"
|
||||
? "bg-sky-500/20 text-sky-300"
|
||||
: "bg-emerald-500/20 text-emerald-300"
|
||||
}`}
|
||||
>
|
||||
{m.kind === "remote" ? "在线 API" : "本地"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded bg-panel-2 px-2 py-0.5 font-mono text-xs">
|
||||
{m.quant ?? "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-300">{fmtSize(m.file_size)}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{m.source}</td>
|
||||
<td className="px-4 py-3 text-slate-300">
|
||||
{m.kind === "remote" ? "-" : fmtSize(m.file_size)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-emerald-400">{m.status}</span>
|
||||
{m.status === "ready" ? (
|
||||
<span className="text-emerald-400">{m.status}</span>
|
||||
) : (
|
||||
<span className="text-amber-400">{m.status}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user