feat(models): Download-all per segment (DRY) + Featured community models (Qwen/Llama/Gemma/Mistral/DeepSeek, verified-public HF GGUF)

This commit is contained in:
2026-06-21 20:44:46 +00:00
parent ea9bf48619
commit 828bafb33c
2 changed files with 232 additions and 46 deletions
@@ -1,6 +1,7 @@
import {
Models,
type ZenModelEntry,
featuredCatalog,
zenCatalog,
} from '@hanzo_network/hanzo-node-state/lib/utils/models';
import { useAddLLMProvider } from '@hanzo_network/hanzo-node-state/v2/mutations/addLLMProvider/useAddLLMProvider';
@@ -15,8 +16,8 @@ import {
} from '@hanzo_network/hanzo-ui';
import { cn } from '@hanzo_network/hanzo-ui/utils';
import { useQuery } from '@tanstack/react-query';
import { Check, Cpu, Download, Loader2 } from 'lucide-react';
import { useMemo } from 'react';
import { Check, Cpu, Download, DownloadCloud, Loader2 } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { getEmbeddingEngineUrl, getEngineUrl } from '../../lib/hanzo-engine';
@@ -84,6 +85,51 @@ const useServedRepos = () => {
}, [data]);
};
/**
* THE single download action for a catalog model — registers the model as a
* local provider so the on-device engine pulls its weights from Hugging Face on
* first use. Returns an imperative `download(model)` (async, resolves when the
* node accepts the provider) plus the per-call `isPending` flag, so both the
* per-row button and the per-section "Download all" reuse the exact same path
* (no parallel download mechanism).
*/
const useModelDownload = () => {
const auth = useAuth((state) => state.auth);
const { mutateAsync } = useAddLLMProvider();
const download = useCallback(
async (model: ZenModelEntry): Promise<void> => {
if (!auth) return;
const id = `zen_${model.value.replace(/[^a-zA-Z0-9_]/g, '_')}`;
// The native engine speaks the OpenAI API, so the node registers it as an
// `OpenAILegacy` provider pointed at the brand engine URL — the SAME wire
// shape the node provisions its default `zen_engine` agent with.
// `model_type` is the HF repo id, so the on-device engine pulls those
// weights from Hugging Face on first request. The engine is always
// reachable and fetches lazily, so we skip the connectivity probe
// (`enableTest: false`). Embedding models target the embed engine.
await mutateAsync({
nodeAddress: auth.node_address ?? '',
token: auth.api_v2_key ?? '',
enableTest: false,
agent: {
id,
name: model.name,
full_identity_name: `${auth.hanzo_identity}/${auth.profile}/agent/${id}`,
external_url: model.embedding
? getEmbeddingEngineUrl()
: getEngineUrl(),
model: { OpenAILegacy: { model_type: model.value } },
},
});
},
[auth, mutateAsync],
);
return { download, enabled: Boolean(auth) };
};
/** Engine availability pill, polled every 10s. */
const EngineCard = ({ provider }: { provider: LocalProvider }) => {
const { data: running, isPending } = useQuery({
@@ -137,50 +183,35 @@ const EngineCard = ({ provider }: { provider: LocalProvider }) => {
const ModelRow = ({
model,
served,
download,
bulkPending,
}: {
model: ZenModelEntry;
served: boolean;
download: (model: ZenModelEntry) => Promise<void>;
bulkPending: boolean;
}) => {
const auth = useAuth((state) => state.auth);
const [isPending, setIsPending] = useState(false);
const { mutate: addProvider, isPending } = useAddLLMProvider({
onSuccess: () => {
const onDownload = async () => {
setIsPending(true);
try {
await download(model);
toast.success(`${model.name} added`, {
description:
'The native engine will download it from Hugging Face on first use.',
});
},
onError: (error) => {
} catch (error) {
const err = error as {
response?: { data?: { message?: string } };
message?: string;
};
toast.error(`Could not add ${model.name}`, {
description: error.response?.data?.message ?? error.message,
description: err.response?.data?.message ?? err.message,
});
},
});
const onDownload = () => {
if (!auth) return;
const id = `zen_${model.value.replace(/[^a-zA-Z0-9_]/g, '_')}`;
// Reuse the canonical add-provider flow. The native engine speaks the OpenAI
// API, so the node registers it as an `OpenAILegacy` provider pointed at the
// brand engine URL — the SAME wire shape the node provisions its default
// `zen_engine` agent with (the only model variant the node accepts for a
// local engine; there is no `hanzo` variant on the wire). `model_type` is the
// HF repo id, so the on-device engine pulls those weights from Hugging Face on
// first request. The engine is always reachable and fetches lazily, so we skip
// the connectivity probe (`enableTest: false`). Embedding models target the
// embed engine (enginePort + 1).
addProvider({
nodeAddress: auth.node_address ?? '',
token: auth.api_v2_key ?? '',
enableTest: false,
agent: {
id,
name: model.name,
full_identity_name: `${auth.hanzo_identity}/${auth.profile}/agent/${id}`,
external_url: model.embedding ? getEmbeddingEngineUrl() : getEngineUrl(),
model: { OpenAILegacy: { model_type: model.value } },
},
});
} finally {
setIsPending(false);
}
};
return (
@@ -201,7 +232,7 @@ const ModelRow = ({
) : (
<Button
className="shrink-0"
disabled={isPending || !auth}
disabled={isPending || bulkPending}
onClick={onDownload}
size="xs"
variant="outline"
@@ -223,22 +254,85 @@ const ModelSection = ({
description,
models,
isServed,
download,
enabled,
}: {
title: string;
description: string;
models: ZenModelEntry[];
isServed: (repo: string) => boolean;
download: (model: ZenModelEntry) => Promise<void>;
enabled: boolean;
}) => {
const [bulkPending, setBulkPending] = useState(false);
if (models.length === 0) return null;
// Only the not-yet-served rows are downloadable; "Download all" targets them.
const pending = models.filter((m) => !isServed(m.value));
const onDownloadAll = async () => {
setBulkPending(true);
try {
// Reuse the SAME per-model download path, in sequence — the engine queues
// the HF pulls. Failures are surfaced per model but don't abort the rest.
let added = 0;
for (const model of pending) {
try {
await download(model);
added += 1;
} catch (error) {
const err = error as {
response?: { data?: { message?: string } };
message?: string;
};
toast.error(`Could not add ${model.name}`, {
description: err.response?.data?.message ?? err.message,
});
}
}
if (added > 0) {
toast.success(`Added ${added} ${title} model${added > 1 ? 's' : ''}`, {
description:
'The native engine will download them from Hugging Face on first use.',
});
}
} finally {
setBulkPending(false);
}
};
return (
<Card>
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
<CardHeader className="flex flex-row items-start justify-between gap-3 p-4 pb-2">
<div className="flex min-w-0 flex-col gap-1">
<CardTitle className="text-sm">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
</div>
<Button
className="shrink-0"
disabled={!enabled || bulkPending || pending.length === 0}
onClick={onDownloadAll}
size="xs"
variant="outline"
>
{bulkPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<DownloadCloud className="mr-1 h-3.5 w-3.5" />
)}
Download all{pending.length > 0 ? ` (${pending.length})` : ''}
</Button>
</CardHeader>
<CardContent className="px-4 pt-0 pb-3">
{models.map((model) => (
<ModelRow key={model.value} model={model} served={isServed(model.value)} />
<ModelRow
bulkPending={bulkPending}
download={download}
key={model.value}
model={model}
served={isServed(model.value)}
/>
))}
</CardContent>
</Card>
@@ -249,15 +343,18 @@ const ModelSection = ({
* Local Models — the ONE surface for first-party Zen models on this device.
*
* Pick the local engine (native Hanzo / Ollama / LM Studio, live-detected), then
* browse the Zen catalog segmented Chat / Embeddings / Reranker. Each row shows
* "Served" when the engine is already serving that model (probed live from the
* brand engine's `/v1/models`), otherwise "Download" — which registers a `hanzo`
* browse the catalog segmented Chat / Embeddings / Reranker / Featured. Each row
* shows "Served" when the engine is already serving that model (probed live from
* the brand engine's `/v1/models`), otherwise "Download" — which registers a
* provider pointed at the model's Hugging Face repo so the engine fetches the
* weights natively on first use (the same add-provider path used everywhere — no
* parallel download flow, no separate served-models list).
* parallel download flow, no separate served-models list). Each section also has
* a "Download all" that runs that same per-model path over every not-yet-served
* row in the segment.
*/
export const LocalModelBrowser = ({ className }: { className?: string }) => {
const isServed = useServedRepos();
const { download, enabled } = useModelDownload();
const chatModels = zenCatalog.filter((m) => !m.embedding && !m.reranker);
const embedModels = zenCatalog.filter((m) => m.embedding);
@@ -283,29 +380,54 @@ export const LocalModelBrowser = ({ className }: { className?: string }) => {
<h2 className="text-base font-medium">Zen Models</h2>
<p className="text-text-secondary text-xs">
First-party models from huggingface.co/zenlm. Rows the engine is
already serving show Served; click Download to add another the
on-device engine pulls it from Hugging Face on first use.
already serving show Served; click Download to add one or Download
all for the whole segment and the on-device engine pulls it from
Hugging Face on first use.
</p>
</div>
<ModelSection
description="Conversational models served by the native engine."
download={download}
enabled={enabled}
isServed={isServed}
models={chatModels}
title="Chat"
/>
<ModelSection
description="Text embedding models for retrieval and vector search (served by the embed engine)."
download={download}
enabled={enabled}
isServed={isServed}
models={embedModels}
title="Embeddings"
/>
<ModelSection
description="Cross-encoder rerankers for retrieval result ordering."
download={download}
enabled={enabled}
isServed={isServed}
models={rerankModels}
title="Reranker"
/>
<div className="mt-2 flex flex-col gap-1">
<h2 className="text-base font-medium">Featured</h2>
<p className="text-text-secondary text-xs">
Popular community models from Hugging Face. The native engine
downloads and runs these public GGUF builds locally, just like the Zen
catalog.
</p>
</div>
<ModelSection
description="Curated open GGUF models the native engine can download and run."
download={download}
enabled={enabled}
isServed={isServed}
models={featuredCatalog}
title="Featured"
/>
</div>
);
};
+64
View File
@@ -91,6 +91,70 @@ export const zenCatalog: ZenModelEntry[] = [
},
];
/**
* Featured community models — a curated, LM-Studio-style list of popular public
* GGUF models the native Hanzo engine can pull from Hugging Face and run.
*
* Same `ZenModelEntry[]` shape and same download path as `zenCatalog`; rendered
* as a "Featured" segment in the Local Models gallery. Every `value` is a
* verified-PUBLIC HF repo (no gated meta-llama/google/mistralai originals — only
* open GGUF mirrors), and every `file` is a real Q4_K_M artifact that exists in
* the repo (the first shard when the quant is split). These are chat models, so
* no `embedding`/`reranker` flags.
*/
export const featuredCatalog: ZenModelEntry[] = [
{
name: 'Qwen2.5 3B Instruct',
value: 'Qwen/Qwen2.5-3B-Instruct-GGUF',
file: 'qwen2.5-3b-instruct-q4_k_m.gguf',
format: 'gguf',
size: '~2 GB',
},
{
name: 'Qwen2.5 7B Instruct',
value: 'Qwen/Qwen2.5-7B-Instruct-GGUF',
// Q4_K_M is sharded; the engine resolves the rest from the first shard.
file: 'qwen2.5-7b-instruct-q4_k_m-00001-of-00002.gguf',
format: 'gguf',
size: '~4.7 GB',
},
{
name: 'Llama 3.2 3B Instruct',
value: 'bartowski/Llama-3.2-3B-Instruct-GGUF',
file: 'Llama-3.2-3B-Instruct-Q4_K_M.gguf',
format: 'gguf',
size: '~2 GB',
},
{
name: 'Llama 3.1 8B Instruct',
value: 'bartowski/Meta-Llama-3.1-8B-Instruct-GGUF',
file: 'Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf',
format: 'gguf',
size: '~4.9 GB',
},
{
name: 'Gemma 2 2B Instruct',
value: 'bartowski/gemma-2-2b-it-GGUF',
file: 'gemma-2-2b-it-Q4_K_M.gguf',
format: 'gguf',
size: '~1.7 GB',
},
{
name: 'Mistral 7B Instruct v0.3',
value: 'bartowski/Mistral-7B-Instruct-v0.3-GGUF',
file: 'Mistral-7B-Instruct-v0.3-Q4_K_M.gguf',
format: 'gguf',
size: '~4.4 GB',
},
{
name: 'DeepSeek R1 Distill Qwen 7B',
value: 'bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF',
file: 'DeepSeek-R1-Distill-Qwen-7B-Q4_K_M.gguf',
format: 'gguf',
size: '~4.7 GB',
},
];
export const modelsConfig = {
// Native Hanzo engine — Zen catalog (zenCatalog). Apps point apiUrl at their
// own engine port (hanzo 36900 / zoo 36910 / lux 36920) via the local-node