mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The product pages from /s3 to /zap, /university and its course data, /trust, /security, /sdks, /support, /tasks, /startups, /sms-opt-in, the buying specialist, Zen, Kai's guide, Studio, the settings pages, and the shared sales, solutions, zap and ui pieces. Client code draws with t(), server components with <T>, copy held as data is marked with phrase() and drawn through t() where it lands. A link or a figure inside a sentence is a value, so a translator reorders the whole sentence. Rebased on main: every page main moved onto Fold or Steps keeps main's layout, with its words wrapped (label, title, lede, points). Steps now draws a step's body through t, as it already drew the title. Settings keeps main's usage panel, refresh, rewards wallet and sign-in wallet rows, each label wrapped. Left English: the skills page's install line (a command and two URLs), and the solution eyebrow and sibling heading built from a kind label. Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
779 lines
29 KiB
TypeScript
779 lines
29 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'
|
|
import { phrase } from '@hanzo/i18n'
|
|
import { useI18n } from '@hanzo/i18n/react'
|
|
|
|
// Derive context window from features string (e.g. "202k context window" -> "202K")
|
|
function extractContext(features: string[], na: string): string {
|
|
const ctx = features.find((f) => f.toLowerCase().includes("context"));
|
|
if (!ctx) return na;
|
|
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, na: string) => {
|
|
if (val == null) return na;
|
|
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: phrase("Zen5 -- Agentic Frontier"),
|
|
description: phrase("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: phrase("Zen4 -- Flagship Language Models"),
|
|
description: phrase("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: phrase("Zen4 Coder -- Code Generation"),
|
|
description: phrase("Specialized code models for generation, review, debugging, and full-precision analysis."),
|
|
icon: Code2,
|
|
models: zen4Coder,
|
|
});
|
|
}
|
|
|
|
if (zen3Core.length > 0) {
|
|
groups.push({
|
|
id: "zen3",
|
|
title: phrase("Zen3 -- Multimodal & Specialized"),
|
|
description: phrase("Multimodal, vision-language, edge, and safety models."),
|
|
icon: Eye,
|
|
models: zen3Core,
|
|
});
|
|
}
|
|
|
|
if (zen3Image.length > 0) {
|
|
groups.push({
|
|
id: "zen3-image",
|
|
title: phrase("Zen3 Image -- Generation"),
|
|
description: phrase("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: phrase("Zen3 Audio -- Speech & Voice"),
|
|
description: phrase("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: phrase("Zen3 Retrieval -- Embedding & Reranking"),
|
|
description: phrase("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 { t } = useI18n();
|
|
const [expanded, setExpanded] = useState(false);
|
|
const na = t("N/A");
|
|
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}>
|
|
{t("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 || t("unit")}
|
|
</Line>
|
|
</Line>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
<Line {...LOUD}>{fmt(model.pricing.input, na)}</Line>
|
|
<Line color={DIM}> / </Line>
|
|
<Line {...LOUD}>{fmt(model.pricing.output, na)}</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"
|
|
>
|
|
{t("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, na)}
|
|
</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}>
|
|
{t("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}>
|
|
{t("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}>
|
|
{t("Pricing")}
|
|
</Line>
|
|
{model.pricing === undefined ? (
|
|
<Line render="p" display="block" color={DIM}>
|
|
{t("The price list could not be read.")}
|
|
</Line>
|
|
) : model.pricing == null ? (
|
|
<Line render="p" display="block" fontStyle="italic" color={DIM}>
|
|
{t("Contact sales for pricing")}
|
|
</Line>
|
|
) : pricingMode === "unit" && model.pricing.perUnit != null ? (
|
|
<YStack {...BOX}>
|
|
<Line {...KEY}>{t("Per {unit}", { unit: model.pricingUnit || t("unit") })}</Line>
|
|
<Line {...LOUD}>${model.pricing.perUnit}</Line>
|
|
</YStack>
|
|
) : (
|
|
<Grid columns={2} gap={8}>
|
|
<YStack {...BOX}>
|
|
<Line {...KEY}>{t("Input")}</Line>
|
|
<Line {...LOUD}>{fmt(model.pricing.input, na)}</Line>
|
|
</YStack>
|
|
<YStack {...BOX}>
|
|
<Line {...KEY}>{t("Output")}</Line>
|
|
<Line {...LOUD}>{fmt(model.pricing.output, na)}</Line>
|
|
</YStack>
|
|
</Grid>
|
|
)}
|
|
</YStack>
|
|
</Grid>
|
|
</Line>
|
|
</View>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const ModelGroupSection = ({ group }: { group: ModelGroup }) => {
|
|
const { t } = useI18n();
|
|
const Icon = group.icon;
|
|
const pricingLabel = group.pricingMode === "unit" ? t("Price") : group.pricingMode === "contact" ? t("Availability") : t("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>{t(group.title)}</Title>
|
|
<Line render="p" display="block" {...SM} color={DIM}>
|
|
{t(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}>{t("Model")}</Line>
|
|
<Line {...HEAD}>{t("Params")}</Line>
|
|
<Spare render="th" px={16} py={12}>
|
|
<Line display="block" text="left" {...CAPS}>
|
|
{t("Context")}
|
|
</Line>
|
|
</Spare>
|
|
<Spare render="th" px={16} py={12}>
|
|
<Line display="block" text="left" {...CAPS}>
|
|
{t("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 { t } = useI18n();
|
|
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} />
|
|
{t("{lineup} available now · {next} in research preview", { lineup: LINEUP, next: NEXT.name })}
|
|
</Chip>
|
|
</Box>
|
|
|
|
<Display
|
|
render={<motion.h1 {...RISE} transition={{ duration: 0.4, delay: 0.05 }} />}
|
|
mb={24}
|
|
>
|
|
{t("Zen Models")}
|
|
<br />
|
|
{t("Complete API Catalog")}
|
|
</Display>
|
|
|
|
<Lede
|
|
render={<motion.p {...RISE} transition={{ duration: 0.4, delay: 0.1 }} />}
|
|
mb={32}
|
|
width="100%"
|
|
maxW={768}
|
|
mx="auto"
|
|
>
|
|
{t(JOBS)}{' '}
|
|
{t("{n} foundation models from {link} across language, code, vision, image, audio, and retrieval.", {
|
|
n: totalModels,
|
|
/* `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. */
|
|
link: (
|
|
<Line href="https://zoo.industries" target="_blank" rel="noopener noreferrer" textDecorationLine="underline" color="var(--foreground)">
|
|
Zoo Labs Foundation
|
|
</Line>
|
|
),
|
|
})}{' '}
|
|
{t("Zen MoDE (Mixture of Diverse Experts) architecture.")} {t("From ${price}/MTok.", { price: minPrice })}
|
|
</Lede>
|
|
|
|
{/* Quick stats */}
|
|
<XStack render={<motion.div {...RISE} transition={{ duration: 0.4, delay: 0.15 }} />} flexWrap="wrap" justify="center" gap={32} mb={32}>
|
|
{[
|
|
{ label: phrase("Models"), value: String(totalModels) },
|
|
{ label: phrase("Max Context"), value: maxCtx },
|
|
{ label: phrase("From"), value: `$${minPrice}/MTok` },
|
|
{ label: phrase("Architectures"), value: "MoE + Dense" },
|
|
{ label: phrase("Modalities"), value: "7" },
|
|
].map((stat) => (
|
|
<YStack key={stat.label}>
|
|
<Line {...STAT}>{stat.value}</Line>
|
|
<Line {...UNIT}>{t(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>
|
|
{t("Explore Models")}
|
|
<ArrowRight size={16} data-i18n-mirror />
|
|
</Action>
|
|
<Action href="/pricing">
|
|
{t("Full Pricing")}
|
|
</Action>
|
|
<Action href={KEYS} target="_blank" rel="noopener noreferrer">
|
|
<Terminal size={16} />
|
|
{t("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)">{t("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}>{t("Quick Start")}</Title>
|
|
<Lede mt="$3.5">
|
|
{t("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}>
|
|
{t("Ready to build with Zen?")}
|
|
</Title>
|
|
<Lede mb={32} width="100%" maxW={672} mx="auto" mt="$3.5">
|
|
{t("All {n} Zen models available via OpenAI-compatible API.", { n: totalModels })} {t("Start building today.")}
|
|
</Lede>
|
|
<XStack flexWrap="wrap" justify="center" gap="$4">
|
|
<Action href="/signup" fill>
|
|
{t("Get Started")}
|
|
<ArrowRight size={16} data-i18n-mirror />
|
|
</Action>
|
|
<Action href={dev()}>
|
|
{t("Try Hanzo Dev")}
|
|
</Action>
|
|
<Action href="/pricing">
|
|
{t("View Pricing")}
|
|
</Action>
|
|
</XStack>
|
|
</Box>
|
|
</Band>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default ZenModels;
|