mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
Every import, the catalog pin and the docs name @hanzo/app and github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and components/rooms.tsx is components/app.tsx.
770 lines
28 KiB
TypeScript
770 lines
28 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* /zen/models — the full Zen catalog, on gui.
|
|
*
|
|
* The four sections are `components/marketing/band`'s `Band` (the rule above,
|
|
* the rhythm around, the gutter beside and the measure it centres on); the code
|
|
* frame is `components/models/kit`'s `Sample`, shared with /models and the
|
|
* model page; the type and the controls are @hanzo/ui's marketing components —
|
|
* `Display` for the page heading, `Title`/`Lede` for a section's, `Line` for
|
|
* every other run of words, `Chip` for a chip and `Action` for a control. No
|
|
* element carries a style object: a table is gui frames stating their table
|
|
* display, a motion host is the `render` of the frame it moves.
|
|
*
|
|
* Six things the page used to decide for itself and now takes from the kit,
|
|
* each a VISIBLE change rather than a translation:
|
|
*
|
|
* - THE HAIRLINE and THE GUTTER. `border-t border-border` at two sections and
|
|
* a `px-4 md:px-8 lg:px-12` ladder become `Band`'s single `--neutral-900`
|
|
* rule and the site's 16 → 24 → 32.
|
|
* - THE PAGE HEADING. `text-3xl sm:4xl lg:5xl xl:6xl` — four rungs, one of
|
|
* which no other heading on this site stands on — becomes `display`, which
|
|
* is the one display ladder here: 32 / 40 / 52.
|
|
* - THE CONTROLS. Six links drew two button shapes by hand. They are `action`
|
|
* at its two weights, and the quick-nav chips are `pill`, whose hover is the
|
|
* one thing they were each restating.
|
|
* - THE WIDE COLUMNS. `hidden md:table-cell` and `hidden lg:table-cell` are
|
|
* one question — a column a narrow screen has no room for — asked at two
|
|
* breakpoints. `Spare` (components/pricing/kit) already answers it, so Arch
|
|
* joins Context at 48rem rather than waiting for 64.
|
|
* - THE CELL PADDING. `px-4 md:px-6` on every one of twelve cells becomes a
|
|
* flat 16. The table already scrolls inside its own box, so the wider step
|
|
* was buying nothing a reader could not already reach.
|
|
* - THE WINDOW DOTS. The sample's three dots were drawn in ONE grey here and
|
|
* as a red/yellow/green traffic light on the other two pages that draw the
|
|
* same frame. `Sample` draws the traffic light.
|
|
*
|
|
* NO TAILWIND. The last two — a hover and a breakpoint, the two things a style
|
|
* attribute cannot carry — are a role and a gui prop this site already had, and
|
|
* neither needed a name of its own:
|
|
*
|
|
* - the ground of a model row on approach is `Cell`. It reads as a grid
|
|
* word and is not one: `Cell` adds no geometry, only a ground and its
|
|
* change, which is the whole of what a row wants. `Leaf` would have been
|
|
* wrong — block, full height, padding and a radius on a `<tr>` — and that
|
|
* is what made this look like it needed a role of its own.
|
|
* - the description under a model's name asks the same question the
|
|
* Context and Arch columns ask, at the same breakpoint, through gui's
|
|
* `$max-md`. The text is in the document either way — what the breakpoint
|
|
* decides is whether a phone's row is two lines or five.
|
|
*/
|
|
|
|
import { dev } from '@hanzo/app/lib/host'
|
|
import { KEYS } from '@/components/home/nav-data'
|
|
import { JOBS, LINEUP, NEXT } from '@/lib/zen'
|
|
|
|
import React, { useState, useEffect, type ReactNode } from "react";
|
|
import { motion } from "@/components/motion";
|
|
import { Spinner } from "@hanzo/gui";
|
|
import zen from "@/lib/data/zen.json";
|
|
import type { ZenModel } from "@zenlm/models";
|
|
|
|
/** The price list, on the gateway of the host this page is open on. */
|
|
const pricing = () => `${api()}/v1/pricing`;
|
|
import {
|
|
ArrowRight,
|
|
Code2,
|
|
Zap,
|
|
Brain,
|
|
Terminal,
|
|
Eye,
|
|
Search,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Image,
|
|
Volume2,
|
|
Sparkles,
|
|
} from "lucide-react";
|
|
import { Box, View, XStack, YStack } from '@hanzo/ui'
|
|
import { Grid } from '@hanzo/ui/grid'
|
|
import { Band } from '@/components/marketing/band'
|
|
import { Sample, DIM, SM, XS } from '@/components/models/kit'
|
|
import { CELL, ROW, Spare, TABLE } from '@/components/pricing/kit'
|
|
import { mix } from '@hanzo/app/lib/mix'
|
|
import { api } from '@hanzo/app/lib/api'
|
|
import { Action, Chip, Display, Lede, Line, Title, Cell } from '@hanzo/ui/marketing'
|
|
|
|
// Derive context window from features string (e.g. "202k context window" -> "202K")
|
|
function extractContext(features: string[]): string {
|
|
const ctx = features.find((f) => f.toLowerCase().includes("context"));
|
|
if (!ctx) return "N/A";
|
|
const match = ctx.match(/(\d+[kKmM]+)/);
|
|
return match ? match[1].toUpperCase() : ctx;
|
|
}
|
|
|
|
/** A tier, as the three values that tell it from the tier above. */
|
|
const TIERS: Record<string, { bg: string; color: string; borderColor: string }> = {
|
|
"ultra max": { bg: mix('var(--primary)', 15), color: 'var(--foreground)', borderColor: 'var(--white-30)' },
|
|
ultra: { bg: mix('var(--primary)', 10), color: mix('var(--foreground)', 60), borderColor: 'var(--border)' },
|
|
"pro max": { bg: mix('var(--primary)', 10), color: mix('var(--foreground)', 70), borderColor: 'var(--border)' },
|
|
pro: { bg: mix('var(--primary)', 10), color: mix('var(--foreground)', 70), borderColor: 'var(--border)' },
|
|
starter: { bg: 'var(--white-05)', color: 'var(--white-60)', borderColor: 'var(--white-10)' },
|
|
};
|
|
|
|
/** The badge's own shape, which every tier shares. */
|
|
const BADGE = {
|
|
display: 'inline-flex',
|
|
items: 'center',
|
|
px: 8,
|
|
py: 2,
|
|
borderWidth: 1,
|
|
borderStyle: 'solid',
|
|
rounded: 'var(--radius-full)',
|
|
fontSize: 'var(--text-floor)',
|
|
lineHeight: '1.6',
|
|
fontWeight: '700',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em',
|
|
} as const
|
|
|
|
function TierBadge({ tier }: { tier: string }) {
|
|
return (
|
|
<Line {...BADGE} {...(TIERS[tier] || TIERS.pro)}>
|
|
{tier}
|
|
</Line>
|
|
);
|
|
}
|
|
|
|
const fmt = (val: number | null | undefined) => {
|
|
if (val == null) return "N/A";
|
|
return `$${val}`;
|
|
};
|
|
|
|
/* ── the values the table is set in ─────────────────────────────────────────── */
|
|
|
|
/** The row of the flagship, on a ground of the accent; it holds under the pointer. */
|
|
const ULTRA = 'color-mix(in oklab, var(--primary) 5%, transparent)'
|
|
|
|
/** A cell: its room, and the table display a frame has to be told. A cell is
|
|
* a `Line`, so it takes the row's type the way a plain cell would. */
|
|
const BODY = { render: 'td', display: CELL, p: 16 } as const
|
|
/** The words a head cell is set in, and the room around them. */
|
|
const CAPS = { ...XS, fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em', color: DIM } as const
|
|
const HEAD = { render: 'th', display: CELL, px: 16, py: 12, text: 'left', ...CAPS } as const
|
|
/** A fact about the model, at the small rung. */
|
|
const INK = mix('var(--foreground)', 80)
|
|
const FACT = { ...SM, color: INK } as const
|
|
const LOUD = { color: 'var(--foreground)', fontWeight: '500' } as const
|
|
/** The label over one third of an opened row. */
|
|
const KEY = {
|
|
fontSize: 'var(--text-floor)',
|
|
lineHeight: '1.6',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em',
|
|
color: 'var(--muted-foreground)',
|
|
} as const
|
|
/** A price, boxed. */
|
|
const BOX = { p: 8, rounded: 'var(--radius-md)', borderWidth: 1, borderColor: 'var(--border)', bg: 'var(--secondary)' } as const
|
|
|
|
// Group models by generation + purpose
|
|
interface HanzoModel {
|
|
name: string;
|
|
fullName: string;
|
|
description: string;
|
|
features: string[];
|
|
tier: string;
|
|
specs: { params: string; arch: string };
|
|
/** The price list's rates; null when it states none (contact sales), absent when the price list was not read. */
|
|
pricing?: { input: number | null; output: number | null; cacheRead: number | null; cacheWrite: number | null; perUnit?: number } | null;
|
|
pricingUnit?: string;
|
|
endpoint?: string;
|
|
/**
|
|
* Served via the API only — no public weights. Distinct from "no retail
|
|
* price", which is `pricing == null`; `contactSales` used to braid the two
|
|
* into one flag derived from a `contact-sales` status @zenlm/models does not
|
|
* have, so it was always false and the badge never rendered.
|
|
*/
|
|
cloudOnly?: boolean;
|
|
}
|
|
|
|
interface ModelGroup {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
icon: React.ElementType;
|
|
models: HanzoModel[];
|
|
pricingMode?: "token" | "unit" | "contact";
|
|
}
|
|
|
|
function groupModels(models: HanzoModel[]): ModelGroup[] {
|
|
const zen5 = models.filter((m) => m.name.startsWith("zen5"));
|
|
const zen4General = models.filter(
|
|
(m) =>
|
|
(m.name.startsWith("zen4") || m.name.startsWith("zen4.")) &&
|
|
!m.name.includes("coder") &&
|
|
!m.name.includes("thinking")
|
|
);
|
|
const zen4Thinking = models.filter((m) => m.name === "zen4-thinking");
|
|
const zen4Coder = models.filter((m) => m.name.startsWith("zen4-coder"));
|
|
const zen3Core = models.filter(
|
|
(m) =>
|
|
m.name.startsWith("zen3") &&
|
|
!m.name.includes("image") &&
|
|
!m.name.includes("audio") &&
|
|
!m.name.includes("asr") &&
|
|
!m.name.includes("tts") &&
|
|
!m.name.includes("embedding") &&
|
|
!m.name.includes("reranker")
|
|
);
|
|
const zen3Image = models.filter((m) => m.name.startsWith("zen3-image"));
|
|
const zen3Audio = models.filter(
|
|
(m) =>
|
|
m.name.startsWith("zen3-audio") ||
|
|
m.name.startsWith("zen3-asr") ||
|
|
m.name.startsWith("zen3-tts")
|
|
);
|
|
const zen3Retrieval = models.filter(
|
|
(m) =>
|
|
m.name.startsWith("zen3-embedding") ||
|
|
m.name.startsWith("zen3-reranker")
|
|
);
|
|
|
|
const groups: ModelGroup[] = [];
|
|
|
|
if (zen5.length > 0) {
|
|
groups.push({
|
|
id: "zen5",
|
|
title: "Zen5 -- Agentic Frontier",
|
|
description: "Agentic frontier models trained on 10B+ tokens of real-world tool use and multi-step reasoning.",
|
|
icon: Sparkles,
|
|
models: zen5,
|
|
pricingMode: "contact",
|
|
});
|
|
}
|
|
|
|
if (zen4General.length > 0 || zen4Thinking.length > 0) {
|
|
groups.push({
|
|
id: "zen4",
|
|
title: "Zen4 -- Flagship Language Models",
|
|
description: "Flagship and high-capability language models for general reasoning, analysis, and generation.",
|
|
icon: Brain,
|
|
models: [...zen4General, ...zen4Thinking],
|
|
});
|
|
}
|
|
|
|
if (zen4Coder.length > 0) {
|
|
groups.push({
|
|
id: "zen4-coder",
|
|
title: "Zen4 Coder -- Code Generation",
|
|
description: "Specialized code models for generation, review, debugging, and full-precision analysis.",
|
|
icon: Code2,
|
|
models: zen4Coder,
|
|
});
|
|
}
|
|
|
|
if (zen3Core.length > 0) {
|
|
groups.push({
|
|
id: "zen3",
|
|
title: "Zen3 -- Multimodal & Specialized",
|
|
description: "Multimodal, vision-language, edge, and safety models.",
|
|
icon: Eye,
|
|
models: zen3Core,
|
|
});
|
|
}
|
|
|
|
if (zen3Image.length > 0) {
|
|
groups.push({
|
|
id: "zen3-image",
|
|
title: "Zen3 Image -- Generation",
|
|
description: "Text-to-image generation from fast prototyping to broadcast quality.",
|
|
icon: Image,
|
|
models: zen3Image,
|
|
pricingMode: "unit",
|
|
});
|
|
}
|
|
|
|
if (zen3Audio.length > 0) {
|
|
groups.push({
|
|
id: "zen3-audio",
|
|
title: "Zen3 Audio -- Speech & Voice",
|
|
description: "Speech-to-text, text-to-speech, and real-time streaming ASR.",
|
|
icon: Volume2,
|
|
models: zen3Audio,
|
|
pricingMode: "unit",
|
|
});
|
|
}
|
|
|
|
if (zen3Retrieval.length > 0) {
|
|
groups.push({
|
|
id: "zen3-retrieval",
|
|
title: "Zen3 Retrieval -- Embedding & Reranking",
|
|
description: "High-quality embeddings and rerankers for RAG, search, and classification.",
|
|
icon: Search,
|
|
models: zen3Retrieval,
|
|
});
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
const ModelRow = ({ model, pricingMode }: { model: HanzoModel; pricingMode?: string }) => {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const isUltraMax = model.tier === "ultra max";
|
|
|
|
const renderPricing = () => {
|
|
if (model.pricing === undefined) {
|
|
return <Line color={DIM}>—</Line>;
|
|
}
|
|
if (model.pricing == null) {
|
|
return (
|
|
<Line fontSize="$1" fontStyle="italic" color={DIM}>
|
|
Contact Sales
|
|
</Line>
|
|
);
|
|
}
|
|
if (pricingMode === "unit" && model.pricing.perUnit != null) {
|
|
return (
|
|
<Line {...LOUD}>
|
|
${model.pricing.perUnit}
|
|
<Line fontSize="$1" color={DIM} fontWeight="400">
|
|
/{model.pricingUnit || "unit"}
|
|
</Line>
|
|
</Line>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
<Line {...LOUD}>{fmt(model.pricing.input)}</Line>
|
|
<Line color={DIM}> / </Line>
|
|
<Line {...LOUD}>{fmt(model.pricing.output)}</Line>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* `Cell` is the site's surface whose ground answers a pointer. It adds
|
|
no geometry at all, so a table row wears it exactly as a grid tile
|
|
does, and both grounds are its props. See the file header. */}
|
|
<Cell
|
|
render="tr"
|
|
bg={isUltraMax ? ULTRA : 'transparent'}
|
|
borderWidth={0}
|
|
borderTopWidth={1}
|
|
borderStyle="solid"
|
|
borderTopColor="var(--border)"
|
|
cursor="pointer"
|
|
onClick={() => setExpanded(!expanded)}
|
|
hoverStyle={{ bg: isUltraMax ? ULTRA : 'color-mix(in oklab, var(--secondary) 50%, transparent)' }}
|
|
>
|
|
<Line {...BODY}>
|
|
<XStack flexWrap="wrap" items="center" gap="$2">
|
|
<Line fontWeight="600" color="var(--foreground)" fontSize="$2">
|
|
{model.fullName?.split(" — ")[0] || model.fullName?.split(" -- ")[0] || model.name}
|
|
</Line>
|
|
<TierBadge tier={model.tier} />
|
|
{model.cloudOnly && (
|
|
<Line
|
|
px={8}
|
|
py={2}
|
|
rounded="var(--radius-full)"
|
|
bg={mix('var(--primary)', 20)}
|
|
color="var(--primary)"
|
|
fontSize="var(--text-floor)"
|
|
lineHeight="1.6"
|
|
fontWeight="500"
|
|
>
|
|
Cloud API
|
|
</Line>
|
|
)}
|
|
</XStack>
|
|
{/* The same question the Context and Arch columns ask, at the same
|
|
breakpoint, on a `<p>` instead of a `<td>`. */}
|
|
<Line render="p" display="block" mt={4} {...XS} color={DIM} $max-md={{ display: 'none' }}>
|
|
{model.description}
|
|
</Line>
|
|
</Line>
|
|
<Line {...BODY} {...FACT}>
|
|
{model.specs.params}
|
|
</Line>
|
|
<Spare render="td" p={16}>
|
|
<Line display="block" {...FACT}>
|
|
{extractContext(model.features)}
|
|
</Line>
|
|
</Spare>
|
|
<Spare render="td" p={16}>
|
|
<Line display="block" {...FACT}>
|
|
{model.specs.arch}
|
|
</Line>
|
|
</Spare>
|
|
<Line {...BODY} {...FACT} text="right">
|
|
{renderPricing()}
|
|
</Line>
|
|
<Line {...BODY} px={8} color="var(--muted-foreground)">
|
|
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
</Line>
|
|
</Cell>
|
|
{expanded && (
|
|
<View render="tr" display={ROW} borderTopWidth={1} borderColor={mix('var(--border)', 50)}>
|
|
<Line {...BODY} {...{ colSpan: 6 }} bg={mix('var(--background)', 80)} fontSize="$2">
|
|
<Grid columns={{ min: 200, max: 3 }} gap={24}>
|
|
<YStack>
|
|
<Line render="h4" mb={8} {...KEY}>
|
|
Architecture
|
|
</Line>
|
|
<Line render="p" display="block" color={INK}>
|
|
<Line {...LOUD}>{model.specs.params}</Line>
|
|
</Line>
|
|
<Line render="p" display="block" color={DIM}>
|
|
{model.specs.arch}
|
|
</Line>
|
|
{model.endpoint && (
|
|
<Line render="p" display="block" mt={4} fontFamily="$mono" fontSize="$1" color={DIM}>
|
|
{model.endpoint}
|
|
</Line>
|
|
)}
|
|
</YStack>
|
|
<YStack>
|
|
<Line render="h4" mb={8} {...KEY}>
|
|
Features
|
|
</Line>
|
|
<YStack gap="$1">
|
|
{model.features.map((f, i) => (
|
|
<XStack key={i} items="center" gap="$2">
|
|
<View render="span" width={6} height={6} rounded="var(--radius-full)" bg="var(--primary)" />
|
|
<Line color={INK}>{f}</Line>
|
|
</XStack>
|
|
))}
|
|
</YStack>
|
|
</YStack>
|
|
<YStack>
|
|
<Line render="h4" mb={8} {...KEY}>
|
|
Pricing
|
|
</Line>
|
|
{model.pricing === undefined ? (
|
|
<Line render="p" display="block" color={DIM}>
|
|
The price list could not be read.
|
|
</Line>
|
|
) : model.pricing == null ? (
|
|
<Line render="p" display="block" fontStyle="italic" color={DIM}>
|
|
Contact sales for pricing
|
|
</Line>
|
|
) : pricingMode === "unit" && model.pricing.perUnit != null ? (
|
|
<YStack {...BOX}>
|
|
<Line {...KEY}>Per {model.pricingUnit || "unit"}</Line>
|
|
<Line {...LOUD}>${model.pricing.perUnit}</Line>
|
|
</YStack>
|
|
) : (
|
|
<Grid columns={2} gap={8}>
|
|
<YStack {...BOX}>
|
|
<Line {...KEY}>Input</Line>
|
|
<Line {...LOUD}>{fmt(model.pricing.input)}</Line>
|
|
</YStack>
|
|
<YStack {...BOX}>
|
|
<Line {...KEY}>Output</Line>
|
|
<Line {...LOUD}>{fmt(model.pricing.output)}</Line>
|
|
</YStack>
|
|
</Grid>
|
|
)}
|
|
</YStack>
|
|
</Grid>
|
|
</Line>
|
|
</View>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const ModelGroupSection = ({ group }: { group: ModelGroup }) => {
|
|
const Icon = group.icon;
|
|
const pricingLabel = group.pricingMode === "unit" ? "Price" : group.pricingMode === "contact" ? "Availability" : "Input / Output";
|
|
|
|
return (
|
|
<Box render="section" id={group.id} mb="$11">
|
|
<XStack items="center" gap="$4" mb="$5">
|
|
<XStack
|
|
shrink={0}
|
|
width={48}
|
|
height={48}
|
|
items="center"
|
|
justify="center"
|
|
rounded="var(--radius-xl)"
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="$raised"
|
|
$platform-web={{ color: 'var(--foreground)' }}
|
|
>
|
|
<Icon size={24} />
|
|
</XStack>
|
|
<YStack shrink={1}>
|
|
<Title quiet>{group.title}</Title>
|
|
<Line render="p" display="block" {...SM} color={DIM}>
|
|
{group.description}
|
|
</Line>
|
|
</YStack>
|
|
</XStack>
|
|
|
|
<Box overflowX="auto">
|
|
<View
|
|
render="table"
|
|
display={TABLE}
|
|
width="100%"
|
|
overflow="hidden"
|
|
rounded="var(--radius-xl)"
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
bg="var(--background)"
|
|
$platform-web={{ borderCollapse: 'collapse' }}
|
|
>
|
|
<thead>
|
|
<View render="tr" display={ROW} bg={mix('var(--secondary)', 80)}>
|
|
<Line {...HEAD}>Model</Line>
|
|
<Line {...HEAD}>Params</Line>
|
|
<Spare render="th" px={16} py={12}>
|
|
<Line display="block" text="left" {...CAPS}>
|
|
Context
|
|
</Line>
|
|
</Spare>
|
|
<Spare render="th" px={16} py={12}>
|
|
<Line display="block" text="left" {...CAPS}>
|
|
Arch
|
|
</Line>
|
|
</Spare>
|
|
<Line {...HEAD} text="right">
|
|
{pricingLabel}
|
|
</Line>
|
|
<Line {...HEAD} width={32} px={8} />
|
|
</View>
|
|
</thead>
|
|
<tbody>
|
|
{group.models.map((model) => (
|
|
<ModelRow key={model.name} model={model} pricingMode={group.pricingMode} />
|
|
))}
|
|
</tbody>
|
|
</View>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
// Static fallback data — matches API schema exactly
|
|
// Adapter: convert a Zen catalogue row (lib/data/zen.json, scripts/zen.mjs) → HanzoModel (no duplicate data)
|
|
function zenToHanzo(m: ZenModel): HanzoModel {
|
|
const params = m.spec.params && !['N/A', 'TBA', 'TBD'].includes(m.spec.params)
|
|
? m.spec.activeParams ? `${m.spec.params} (${m.spec.activeParams} active)` : m.spec.params
|
|
: (m.spec.params || 'TBA');
|
|
return {
|
|
name: m.id,
|
|
fullName: m.fullName,
|
|
description: m.description,
|
|
features: m.features,
|
|
tier: m.tier,
|
|
specs: { params, arch: m.spec.arch || '' },
|
|
cloudOnly: m.status === 'cloud-only',
|
|
endpoint: m.id.includes('embedding') ? '/v1/embeddings'
|
|
: m.id.includes('reranker') ? '/v1/rerank'
|
|
: m.id.includes('image') ? '/v1/images/generations'
|
|
: m.id.includes('audio') || m.id.includes('asr') || m.id.includes('tts') ? '/v1/audio/transcriptions'
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
const FALLBACK_MODELS: HanzoModel[] = (zen as ZenModel[]).map(zenToHanzo);
|
|
|
|
/** One of the five figures under the lede, and what it counts. */
|
|
const STAT = { fontSize: '$6', lineHeight: '1.4', fontWeight: '700', color: 'var(--foreground)' } as const
|
|
const UNIT = { ...XS, textTransform: 'uppercase', letterSpacing: '0.05em', color: DIM } as const
|
|
|
|
const RISE = { initial: { opacity: 0, y: 16 }, animate: { opacity: 1, y: 0 } } as const
|
|
|
|
const ZenModels = ({ quickstart }: { quickstart: ReactNode }) => {
|
|
const [hanzoModels, setHanzoModels] = useState<HanzoModel[]>(FALLBACK_MODELS);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetch(pricing())
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
const models = data.hanzoModels || [];
|
|
if (models.length > 0) setHanzoModels(models);
|
|
setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, []);
|
|
|
|
const groups = groupModels(hanzoModels);
|
|
const totalModels = hanzoModels.length;
|
|
const tokenModels = hanzoModels.filter((m) => m.pricing?.input != null && m.pricing.input > 0);
|
|
const minPrice = tokenModels.length > 0 ? Math.min(...tokenModels.map((m) => m.pricing!.input!)) : 0.15;
|
|
const maxCtx = "2M";
|
|
|
|
return (
|
|
<Box minH="100vh" $platform-web={{ color: 'var(--foreground)' }}>
|
|
<Box render="main">
|
|
{/* Hero. Its own shape rather than a Band: the rhythm above it and the
|
|
rhythm below it are different numbers, and the light behind it wants
|
|
a box of its own to be clipped by. */}
|
|
<Box
|
|
render="section"
|
|
position="relative"
|
|
overflow="hidden"
|
|
pt="$12"
|
|
pb="$10"
|
|
px="$4"
|
|
$sm={{ px: '$5' }}
|
|
$lg={{ px: '$6' }}
|
|
>
|
|
|
|
<Box position="relative" z="var(--z-raised)" width="100%" maxW={1280} mx="auto" $platform-web={{ textAlign: 'center' }}>
|
|
<Box render={<motion.div {...RISE} transition={{ duration: 0.4 }} />} mb={24}>
|
|
<Chip px={12} py={4} fontSize="$1" lineHeight="calc(1 / 0.75)" fontWeight="500" color="var(--foreground)">
|
|
<Zap size={12} />
|
|
{LINEUP} available now · {NEXT.name} in research preview
|
|
</Chip>
|
|
</Box>
|
|
|
|
<Display
|
|
render={<motion.h1 {...RISE} transition={{ duration: 0.4, delay: 0.05 }} />}
|
|
mb={24}
|
|
>
|
|
Zen Models
|
|
<br />
|
|
Complete API Catalog
|
|
</Display>
|
|
|
|
<Lede
|
|
render={<motion.p {...RISE} transition={{ duration: 0.4, delay: 0.1 }} />}
|
|
mb={32}
|
|
width="100%"
|
|
maxW={768}
|
|
mx="auto"
|
|
>
|
|
{JOBS} {totalModels} foundation models from{' '}
|
|
{/* `underline` is inline and the hover is DROPPED: @hanzo/design
|
|
underlines any link inside a paragraph on approach, so the
|
|
utility that used to sit here asked for the opposite of what
|
|
shipped. */}
|
|
<Line href="https://zoo.industries" target="_blank" rel="noopener noreferrer" textDecorationLine="underline" color="var(--foreground)">
|
|
Zoo Labs Foundation
|
|
</Line>{' '}
|
|
across language, code, vision, image, audio, and retrieval.
|
|
Zen MoDE (Mixture of Diverse Experts) architecture. From ${minPrice}/MTok.
|
|
</Lede>
|
|
|
|
{/* Quick stats */}
|
|
<XStack render={<motion.div {...RISE} transition={{ duration: 0.4, delay: 0.15 }} />} flexWrap="wrap" justify="center" gap={32} mb={32}>
|
|
{[
|
|
{ label: "Models", value: String(totalModels) },
|
|
{ label: "Max Context", value: maxCtx },
|
|
{ label: "From", value: `$${minPrice}/MTok` },
|
|
{ label: "Architectures", value: "MoE + Dense" },
|
|
{ label: "Modalities", value: "7" },
|
|
].map((stat) => (
|
|
<YStack key={stat.label}>
|
|
<Line {...STAT}>{stat.value}</Line>
|
|
<Line {...UNIT}>{stat.label}</Line>
|
|
</YStack>
|
|
))}
|
|
</XStack>
|
|
|
|
{/* Tier legend */}
|
|
<XStack render={<motion.div {...RISE} transition={{ duration: 0.4, delay: 0.2 }} />} flexWrap="wrap" justify="center" gap={12} mb={32}>
|
|
{Object.keys(TIERS).map((tier) => (
|
|
<TierBadge key={tier} tier={tier} />
|
|
))}
|
|
</XStack>
|
|
|
|
{/* CTAs */}
|
|
<XStack render={<motion.div {...RISE} transition={{ duration: 0.4, delay: 0.25 }} />} flexWrap="wrap" justify="center" items="center" gap={16}>
|
|
<Action href="#zen5" fill>
|
|
Explore Models
|
|
<ArrowRight size={16} />
|
|
</Action>
|
|
<Action href="/pricing">
|
|
Full Pricing
|
|
</Action>
|
|
<Action href={KEYS} target="_blank" rel="noopener noreferrer">
|
|
<Terminal size={16} />
|
|
Get API key
|
|
</Action>
|
|
</XStack>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Model Groups */}
|
|
<Band pad="$10" measure={1280} rule={false}>
|
|
{loading ? (
|
|
<XStack items="center" justify="center" py="$12">
|
|
<Spinner size="small" color="$soft" mr={8} />
|
|
<Line color="var(--muted-foreground)">Loading live pricing...</Line>
|
|
</XStack>
|
|
) : (
|
|
<>
|
|
{/* Quick nav */}
|
|
<XStack mb="$11" flexWrap="wrap" justify="center" gap="$3">
|
|
{groups.map((group) => {
|
|
const Icon = group.icon;
|
|
return (
|
|
<Chip
|
|
render="a"
|
|
key={group.id}
|
|
href={`#${group.id}`}
|
|
px={16}
|
|
py={8}
|
|
color="var(--muted-foreground)"
|
|
>
|
|
<Icon size={16} />
|
|
{group.title.split(" -- ")[0]}
|
|
</Chip>
|
|
);
|
|
})}
|
|
</XStack>
|
|
|
|
{groups.map((group) => (
|
|
<ModelGroupSection key={group.id} group={group} />
|
|
))}
|
|
</>
|
|
)}
|
|
</Band>
|
|
|
|
{/* API Usage Example */}
|
|
<Band pad="$10" measure={896} ground="color-mix(in oklab, var(--background) 50%, transparent)">
|
|
<Box
|
|
render={<motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} />}
|
|
mb={32}
|
|
$platform-web={{ textAlign: 'center' }}
|
|
>
|
|
<Title quiet mb={12}>Quick Start</Title>
|
|
<Lede mt="$3.5">
|
|
Drop-in OpenAI-compatible API. Switch models with one line.
|
|
</Lede>
|
|
</Box>
|
|
|
|
<Sample lang="python">{quickstart}</Sample>
|
|
</Band>
|
|
|
|
{/* CTA Section */}
|
|
<Band pad="$11" measure={1280}>
|
|
<Box
|
|
render={<motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} />}
|
|
$platform-web={{ textAlign: 'center' }}
|
|
>
|
|
<Title quiet mb={16}>
|
|
Ready to build with Zen?
|
|
</Title>
|
|
<Lede mb={32} width="100%" maxW={672} mx="auto" mt="$3.5">
|
|
All {totalModels} Zen models available via OpenAI-compatible
|
|
API. Start building today.
|
|
</Lede>
|
|
<XStack flexWrap="wrap" justify="center" gap="$4">
|
|
<Action href="/signup" fill>
|
|
Get Started
|
|
<ArrowRight size={16} />
|
|
</Action>
|
|
<Action href={dev()}>
|
|
Try Hanzo Dev
|
|
</Action>
|
|
<Action href="/pricing">
|
|
View Pricing
|
|
</Action>
|
|
</XStack>
|
|
</Box>
|
|
</Band>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default ZenModels;
|