feat: model dir auto-scan, remote API models, model plaza with HF/ModelScope downloads

This commit is contained in:
Xianren Studio
2026-08-13 15:37:28 +08:00
parent 0cf86c5c39
commit 451cc75cd9
17 changed files with 1132 additions and 239 deletions
+31 -19
View File
@@ -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>
-116
View File
@@ -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`;
}
+274
View File
@@ -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
View File
@@ -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-4oOpenAI"
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