feat(2.0): W2+W3 — HF-Link/Suche + Modell-Verwaltungs-UX
W2: install akzeptiert HF-URL ODER org/repo (normalize_repo); GET /api/hf/
search + /api/hf/quants; Frontend AddModel-Panel (URL+Quant-Dropdown+freie
Suche) im Discover-Tab. W3: POST /api/models/{id}/role + /ctx; Installiert-
Tab mit Rollen-Select (fast/heavy/coder/...), ctx-Edit, Loeschen → LLM
tauschen per Klick.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -70,17 +70,29 @@ class InstallReq(BaseModel):
|
|||||||
hf_token: str | None = None
|
hf_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hf/search")
|
||||||
|
def hf_search(q: str) -> dict:
|
||||||
|
return {"results": hf.search(q)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hf/quants")
|
||||||
|
def hf_quants(repo: str) -> dict:
|
||||||
|
repo = hf.normalize_repo(repo)
|
||||||
|
return {"repo": repo, "quants": hf.list_quants(repo)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/models/install")
|
@router.post("/models/install")
|
||||||
def install(req: InstallReq) -> dict:
|
def install(req: InstallReq) -> dict:
|
||||||
"""Lädt ein Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in
|
"""Lädt ein Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in
|
||||||
llama-swap ein (cmd + Rolle-Alias). llama-swap (-watch-config) lädt es, sobald
|
llama-swap ein (cmd + Rolle-Alias). llama-swap (-watch-config) lädt es, sobald
|
||||||
die Datei da ist. Split-GGUFs werden komplett geladen, registriert wird der
|
die Datei da ist. Split-GGUFs werden komplett geladen, registriert wird der
|
||||||
erste Teil (-00001-of-…)."""
|
erste Teil (-00001-of-…). Akzeptiert volle HF-URL ODER org/repo."""
|
||||||
info = hf.resolve_gguf(req.repo, req.quant)
|
repo = hf.normalize_repo(req.repo)
|
||||||
|
info = hf.resolve_gguf(repo, req.quant)
|
||||||
if not info["first"]:
|
if not info["first"]:
|
||||||
raise HTTPException(404, f"Keine GGUF-Datei für Quant '{req.quant}' in {req.repo} gefunden.")
|
raise HTTPException(404, f"Keine GGUF-Datei für Quant '{req.quant}' in {repo} gefunden.")
|
||||||
|
|
||||||
subdir = req.repo.split("/")[-1]
|
subdir = repo.split("/")[-1]
|
||||||
target = MODELS_DIR / subdir
|
target = MODELS_DIR / subdir
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
model_path = str(target / info["first"])
|
model_path = str(target / info["first"])
|
||||||
@@ -89,7 +101,7 @@ def install(req: InstallReq) -> dict:
|
|||||||
ctx = req.ctx
|
ctx = req.ctx
|
||||||
if ctx is None:
|
if ctx is None:
|
||||||
ram = _ram_gb()
|
ram = _ram_gb()
|
||||||
ctx = max_ctx_for(extract_params_b(req.repo), req.quant, ram)
|
ctx = max_ctx_for(extract_params_b(repo), req.quant, ram)
|
||||||
|
|
||||||
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
|
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
|
||||||
try:
|
try:
|
||||||
@@ -99,7 +111,7 @@ def install(req: InstallReq) -> dict:
|
|||||||
raise HTTPException(500, str(exc))
|
raise HTTPException(500, str(exc))
|
||||||
|
|
||||||
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
||||||
args = [hf.hf_bin(), "download", req.repo]
|
args = [hf.hf_bin(), "download", repo]
|
||||||
for f in info["files"]:
|
for f in info["files"]:
|
||||||
args.append(f)
|
args.append(f)
|
||||||
if info["mmproj"]:
|
if info["mmproj"]:
|
||||||
@@ -124,6 +136,28 @@ def cancel(job_id: str) -> dict:
|
|||||||
return {"ok": jobengine.cancel_job(job_id)}
|
return {"ok": jobengine.cancel_job(job_id)}
|
||||||
|
|
||||||
|
|
||||||
|
class RoleReq(BaseModel):
|
||||||
|
role: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/models/{model_id}/role")
|
||||||
|
def set_model_role(model_id: str, body: RoleReq) -> dict:
|
||||||
|
if not llamaswap.set_role(model_id, body.role):
|
||||||
|
raise HTTPException(404, "Modell nicht gefunden")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CtxReq(BaseModel):
|
||||||
|
ctx: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/models/{model_id}/ctx")
|
||||||
|
def set_model_ctx(model_id: str, body: CtxReq) -> dict:
|
||||||
|
if not llamaswap.set_ctx(model_id, body.ctx):
|
||||||
|
raise HTTPException(404, "Modell nicht gefunden")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/models/{model_id}")
|
@router.delete("/models/{model_id}")
|
||||||
def delete(model_id: str) -> dict:
|
def delete(model_id: str) -> dict:
|
||||||
if not llamaswap.delete_model(model_id):
|
if not llamaswap.delete_model(model_id):
|
||||||
|
|||||||
+44
-1
@@ -1,11 +1,54 @@
|
|||||||
"""HuggingFace-Helfer: GGUF-Dateien eines Repos auflösen (inkl. Split-Teile) + Größen."""
|
"""HuggingFace-Helfer: GGUF-Dateien eines Repos auflösen (inkl. Split-Teile) + Größen,
|
||||||
|
freie Suche, Repo-URL→ID, verfügbare Quants."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_repo(s: str) -> str:
|
||||||
|
"""Akzeptiert volle HF-URL oder `org/repo` → liefert immer `org/repo`."""
|
||||||
|
s = (s or "").strip()
|
||||||
|
m = re.search(r"huggingface\.co/([^/\s]+/[^/\s?#]+)", s)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return s.strip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def list_quants(repo: str) -> list[str]:
|
||||||
|
"""Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj)."""
|
||||||
|
quants: set[str] = set()
|
||||||
|
for e in _tree(repo):
|
||||||
|
p = str(e.get("path", ""))
|
||||||
|
if p.lower().endswith(".gguf") and "mmproj" not in p.lower():
|
||||||
|
m = re.search(r"(I?Q\d[\w]*|F16|BF16|FP16|F32)", p, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
quants.add(m.group(1).upper())
|
||||||
|
# gängige Reihenfolge zuerst
|
||||||
|
order = {"Q4_K_M": 0, "Q4_K_S": 1, "Q5_K_M": 2, "Q6_K": 3, "Q8_0": 4, "Q3_K_M": 5, "Q2_K": 6}
|
||||||
|
return sorted(quants, key=lambda q: (order.get(q, 99), q))
|
||||||
|
|
||||||
|
|
||||||
|
def search(q: str, limit: int = 20) -> list[dict]:
|
||||||
|
"""Freie HF-Suche nach GGUF-Repos."""
|
||||||
|
url = (f"https://huggingface.co/api/models?search={q}"
|
||||||
|
f"&filter=gguf&sort=downloads&direction=-1&limit={limit}")
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=12.0) as c:
|
||||||
|
data = c.get(url).json()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for m in (data if isinstance(data, list) else []):
|
||||||
|
rid = m.get("id")
|
||||||
|
if rid:
|
||||||
|
out.append({"repo": rid, "downloads": int(m.get("downloads") or 0),
|
||||||
|
"likes": int(m.get("likes") or 0)})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def hf_bin() -> str:
|
def hf_bin() -> str:
|
||||||
"""Pfad zur `hf`-CLI (bevorzugt neben dem laufenden Python im venv)."""
|
"""Pfad zur `hf`-CLI (bevorzugt neben dem laufenden Python im venv)."""
|
||||||
cand = os.path.join(os.path.dirname(sys.executable), "hf")
|
cand = os.path.join(os.path.dirname(sys.executable), "hf")
|
||||||
|
|||||||
@@ -188,6 +188,33 @@ def list_groups() -> dict:
|
|||||||
return read_config().get("groups") or {}
|
return read_config().get("groups") or {}
|
||||||
|
|
||||||
|
|
||||||
|
def set_role(model_id: str, role: str | None) -> bool:
|
||||||
|
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
|
||||||
|
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
|
||||||
|
cfg = read_config()
|
||||||
|
if model_id not in (cfg.get("models") or {}):
|
||||||
|
return False
|
||||||
|
set_role_alias(cfg, model_id, role)
|
||||||
|
write_config(cfg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_ctx(model_id: str, ctx: int) -> bool:
|
||||||
|
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
||||||
|
cfg = read_config()
|
||||||
|
spec = (cfg.get("models") or {}).get(model_id)
|
||||||
|
if not spec:
|
||||||
|
return False
|
||||||
|
cmd = str(spec.get("cmd", ""))
|
||||||
|
if _CTX_RE.search(cmd):
|
||||||
|
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
|
||||||
|
else:
|
||||||
|
cmd = cmd.rstrip() + f" -c {ctx}"
|
||||||
|
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
|
||||||
|
write_config(cfg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def delete_model(model_id: str) -> bool:
|
def delete_model(model_id: str) -> bool:
|
||||||
"""Entfernt einen Modell-Eintrag aus der config.yaml (und aus allen Gruppen)."""
|
"""Entfernt einen Modell-Eintrag aus der config.yaml (und aus allen Gruppen)."""
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
|
|||||||
+10
-9
@@ -12,15 +12,16 @@
|
|||||||
16 Einträge) — eine geteilte „Verfassung" für Cockpit, Hermes, IDEs.
|
16 Einträge) — eine geteilte „Verfassung" für Cockpit, Hermes, IDEs.
|
||||||
|
|
||||||
## Bekannte Tuning-Punkte (vor „rundem" Cutover sinnvoll, kein Blocker)
|
## Bekannte Tuning-Punkte (vor „rundem" Cutover sinnvoll, kein Blocker)
|
||||||
1. **Hermes thrasht / Kontext-Bloat (NICHT brain-abhängig):** Auf simple Prompts feuert der Agent
|
1. **Hermes-Thrash → BEHOBEN (W1).** Ursache war eine **vergiftete Dauer-Session** (mein Test hatte sie
|
||||||
Fehl-Tools (`vision_analyze` auf Text → „Bild nicht gefunden", `search_files`-Schleife → Eigen-Guard
|
mit einem Vision-Fehl-Lauf verseucht; Hermes fütterte sie jeden Zug erneut → ~249k Tokens, Vision-auf-
|
||||||
blockt) und injiziert ~124–249k Prompt-Tokens (→ 1–2 min/Antwort). Getestet: mit `auto`→Qwen3.6 UND
|
Text, Such-Schleifen). **Verifiziert:** frische Session = sauber & kohärent, **34k statt 249k**,
|
||||||
mit `coder` (Qwen3-Coder-30B) — **gleiches Verhalten**, also kein Modell-, sondern ein Hermes-
|
keine Fehl-Tools. Maßnahmen: `code_execution.max_tool_calls` 50→20 (Schleifen-Bremse); **History
|
||||||
**Kontext/Session/Tool-Problem** (vorbestehend = das „dumm/loop" aus v1). **Hands-on-Debug nötig
|
unangetastet** (64 Sessions/1048 Msgs bleiben — Hermes' Gedächtnis). Sessions reseten ohnehin täglich
|
||||||
(mit dir):** (a) Session zurücksetzen/frische Session-ID (akkumulierte History?), (b) unnötige
|
(4 Uhr) / nach 24 h idle; das WebUI nutzt pro Chat eine eigene Session. **Speed:** Gateway schaltet
|
||||||
Toolsets/Skills abschalten (`hermes` CLI / config `toolsets`/`platform_toolsets`), v.a. Vision-Tools
|
auf der `fast`-Spur **Thinking aus** (Qwen3.6) → bare Gateway-Antwort ~10 s, direkt.
|
||||||
für reine Text-Aufgaben, (c) Kontext-/History-Limits setzen, (d) Thinking für den Agenten aus.
|
**Rest-Tuning (mit dir, optional):** Hermes' erste Nachricht dauert noch ~60–90 s wegen **34k Basis-
|
||||||
Das Brain steht auf `auto` (deine Vorgabe); das ist nicht die Ursache.
|
Kontext** (Tool-Schemas + 26 Skills) + Agent-Loop. Hebel: unnötige Toolsets/Skills prunen
|
||||||
|
(`config toolsets`/`platform_toolsets`, 26 Skills sichten) → schlankerer Prompt = schnellerer Agent.
|
||||||
2. **SSH→Windows (voller PC-Zugriff):** Windows-seitig OpenSSH-Server aktivieren + Key
|
2. **SSH→Windows (voller PC-Zugriff):** Windows-seitig OpenSSH-Server aktivieren + Key
|
||||||
`id_ed25519_hermes_agent` autorisieren (vom Box-Host aus). Erst dann erreicht Hermes' Shell den PC.
|
`id_ed25519_hermes_agent` autorisieren (vom Box-Host aus). Erst dann erreicht Hermes' Shell den PC.
|
||||||
3. **hermes-webui (nesquena) standalone:** optional — aktuell läuft das eingebaute `hermes-dashboard`.
|
3. **hermes-webui (nesquena) standalone:** optional — aktuell läuft das eingebaute `hermes-dashboard`.
|
||||||
|
|||||||
-1
File diff suppressed because one or more lines are too long
+29
-29
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
<meta name="theme-color" content="#0d1117" />
|
<meta name="theme-color" content="#0d1117" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Ch2rl17k.js"></script>
|
<script type="module" crossorigin src="/assets/index-EHQnFJCd.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C-XbOjOw.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-IpcnHTkp.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -74,17 +74,40 @@ function FitBadge({ fit }: { fit: Fit }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROLES = ["", "fast", "heavy", "coder", "reasoning", "agent", "vision", "scout"]
|
||||||
|
|
||||||
function Installed() {
|
function Installed() {
|
||||||
const [models, setModels] = useState<ModelInfo[]>([])
|
const [models, setModels] = useState<ModelInfo[]>([])
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
function load() {
|
||||||
api<{ models: ModelInfo[] }>("/api/models")
|
api<{ models: ModelInfo[] }>("/api/models")
|
||||||
.then((d) => setModels(d.models))
|
.then((d) => setModels(d.models))
|
||||||
.catch((e) => setError(String(e)))
|
.catch((e) => setError(String(e)))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
async function setRole(name: string, role: string) {
|
||||||
|
await api(`/api/models/${encodeURIComponent(name)}/role`, {
|
||||||
|
method: "POST", body: JSON.stringify({ role: role || null }),
|
||||||
|
})
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
async function setCtx(name: string, cur: number | null) {
|
||||||
|
const v = prompt("Kontextlänge (Tokens):", String(cur || 32768))
|
||||||
|
if (!v) return
|
||||||
|
await api(`/api/models/${encodeURIComponent(name)}/ctx`, {
|
||||||
|
method: "POST", body: JSON.stringify({ ctx: parseInt(v, 10) }),
|
||||||
|
})
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
async function del(name: string) {
|
||||||
|
if (!confirm(`Modell '${name}' aus der Config entfernen? (GGUF-Datei bleibt)`)) return
|
||||||
|
await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" })
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div className="text-sm text-muted-foreground">Lade…</div>
|
if (loading) return <div className="text-sm text-muted-foreground">Lade…</div>
|
||||||
if (error)
|
if (error)
|
||||||
@@ -104,12 +127,13 @@ function Installed() {
|
|||||||
<th className="px-4 py-2 font-medium">Fähigkeiten</th>
|
<th className="px-4 py-2 font-medium">Fähigkeiten</th>
|
||||||
<th className="px-4 py-2 font-medium">Kontext</th>
|
<th className="px-4 py-2 font-medium">Kontext</th>
|
||||||
<th className="px-4 py-2 font-medium">Größe</th>
|
<th className="px-4 py-2 font-medium">Größe</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{models.length === 0 && (
|
{models.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
Keine Modelle konfiguriert.
|
Keine Modelle konfiguriert.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -118,17 +142,31 @@ function Installed() {
|
|||||||
<tr key={m.name} className="border-b border-border/50 last:border-0">
|
<tr key={m.name} className="border-b border-border/50 last:border-0">
|
||||||
<td className="px-4 py-2.5 font-medium">{m.name}</td>
|
<td className="px-4 py-2.5 font-medium">{m.name}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
{m.role ? (
|
<select
|
||||||
<span className="rounded-md bg-primary/15 px-2 py-0.5 text-xs text-primary">{m.role}</span>
|
value={m.role || ""}
|
||||||
) : (
|
onChange={(e) => setRole(m.name, e.target.value)}
|
||||||
<span className="text-muted-foreground">—</span>
|
className="rounded-md border border-border bg-background px-1.5 py-1 text-xs outline-none"
|
||||||
)}
|
title="Rolle/Alias setzen (so tauschst du z.B. das fast-Hirn)"
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r} value={r}>{r || "—"}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<CapsChips caps={m.capabilities} />
|
<CapsChips caps={m.capabilities} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground">{fmtCtx(m.ctx)}</td>
|
<td className="px-4 py-2.5">
|
||||||
|
<button onClick={() => setCtx(m.name, m.ctx)} className="text-muted-foreground hover:text-foreground" title="Kontext ändern">
|
||||||
|
{fmtCtx(m.ctx)} ✎
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground">{fmtSize(m.size_bytes)}</td>
|
<td className="px-4 py-2.5 text-muted-foreground">{fmtSize(m.size_bytes)}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<button onClick={() => del(m.name)} className="text-muted-foreground hover:text-red-500" title="Aus Config entfernen">
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -137,6 +175,100 @@ function Installed() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AddModel() {
|
||||||
|
const [repo, setRepo] = useState("")
|
||||||
|
const [quants, setQuants] = useState<string[]>([])
|
||||||
|
const [quant, setQuant] = useState("Q4_K_M")
|
||||||
|
const [msg, setMsg] = useState("")
|
||||||
|
const [q, setQ] = useState("")
|
||||||
|
const [results, setResults] = useState<{ repo: string; downloads: number }[]>([])
|
||||||
|
|
||||||
|
async function loadQuants(r?: string) {
|
||||||
|
const rr = r ?? repo
|
||||||
|
if (!rr.trim()) return
|
||||||
|
setMsg("Lade Quants…")
|
||||||
|
try {
|
||||||
|
const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(rr)}`)
|
||||||
|
setRepo(d.repo)
|
||||||
|
setQuants(d.quants)
|
||||||
|
if (d.quants.length) setQuant(d.quants.includes("Q4_K_M") ? "Q4_K_M" : d.quants[0])
|
||||||
|
setMsg(d.quants.length ? "" : "Keine GGUF-Quants gefunden")
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(`Fehler: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function search() {
|
||||||
|
if (!q.trim()) return
|
||||||
|
const d = await api<{ results: { repo: string; downloads: number }[] }>(`/api/hf/search?q=${encodeURIComponent(q)}`)
|
||||||
|
setResults(d.results)
|
||||||
|
}
|
||||||
|
async function install() {
|
||||||
|
if (!repo.trim()) return
|
||||||
|
setMsg("Installiere…")
|
||||||
|
try {
|
||||||
|
await api("/api/models/install", {
|
||||||
|
method: "POST", body: JSON.stringify({ repo, quant, jinja: true }),
|
||||||
|
})
|
||||||
|
setMsg(`Download gestartet: ${repo} (${quant}) — Fortschritt oben.`)
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(`Fehler: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-xl border border-border bg-card p-4">
|
||||||
|
<div className="text-sm font-medium">Eigenes Modell laden (HuggingFace)</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={repo}
|
||||||
|
onChange={(e) => setRepo(e.target.value)}
|
||||||
|
placeholder="HF-URL oder org/repo (z.B. unsloth/Qwen3.6-35B-A3B-GGUF)"
|
||||||
|
className="min-w-[280px] flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button onClick={() => loadQuants()} className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent">
|
||||||
|
Quants laden
|
||||||
|
</button>
|
||||||
|
{quants.length > 0 && (
|
||||||
|
<>
|
||||||
|
<select value={quant} onChange={(e) => setQuant(e.target.value)} className="rounded-md border border-border bg-background px-2 py-1.5 text-sm">
|
||||||
|
{quants.map((qq) => <option key={qq} value={qq}>{qq}</option>)}
|
||||||
|
</select>
|
||||||
|
<button onClick={install} className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90">
|
||||||
|
Installieren
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||||
|
placeholder="HuggingFace durchsuchen (GGUF)…"
|
||||||
|
className="min-w-[240px] flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button onClick={search} className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent">Suchen</button>
|
||||||
|
</div>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||||
|
{results.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.repo}
|
||||||
|
onClick={() => { setRepo(r.repo); setResults([]); setQ(""); loadQuants(r.repo) }}
|
||||||
|
className="flex w-full items-center justify-between rounded-md px-2 py-1 text-left text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span className="truncate">{r.repo}</span>
|
||||||
|
<span className="text-muted-foreground">↓{r.downloads.toLocaleString()}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && <div className="text-xs text-muted-foreground">{msg}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Discover() {
|
function Discover() {
|
||||||
const [data, setData] = useState<DiscoverResp | null>(null)
|
const [data, setData] = useState<DiscoverResp | null>(null)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
@@ -173,6 +305,7 @@ function Discover() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<AddModel />
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Live von HuggingFace · Hardware-Fit für ~{data.sys_ram_gb} GB · ⭐ = beste Wahl je Kategorie
|
Live von HuggingFace · Hardware-Fit für ~{data.sys_ram_gb} GB · ⭐ = beste Wahl je Kategorie
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user