feat(models): Download-all per segment (DRY) + Featured community models (Qwen/Llama/Gemma/Mistral/DeepSeek, verified-public HF GGUF)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user