mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
Pages from /machines to /risk, the model pages, and the kits they are made of
(page-kit, ProductLanding, LayerPage, Stack, the models kit, band's Head) mark
authored copy where it is written and draw it in the reader's language. A server
page marks a literal phrase('…') or wraps it in <T>; a client page calls t. The
kits translate a string prop through useSay (components/marketing/say), so a
page passes phrase('…') and markup passes as written. An arrow that points
turns over right to left (data-i18n-mirror).
Rebased on main: the fold-based heroes (Fold, PageHero, MachinesHero, the
product and layer pages) keep main's structure and copy, with their label,
title and lede marked phrase. /platform is main's CloudLanding; the deleted
/platform/layers and the old referral components stay deleted.
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
501 lines
20 KiB
TypeScript
501 lines
20 KiB
TypeScript
/**
|
||
* /models/<lab>/<model> — one model, as a card.
|
||
*
|
||
* Read top to bottom: the hero in the family pages' frame (components/family/
|
||
* Hero: a chip, the name, one line, two actions), the links row (the model's
|
||
* public repositories and this site's boards for it, lib/models/links), the
|
||
* facts as a grid of short labelled cells, the call in four languages
|
||
* (lib/models/examples, by the endpoint the model answers at), the price in
|
||
* one line, and for Kai its limits. No paragraph restates a cell.
|
||
*
|
||
* A Kai page sets what ships today beside the Kai-1.7 target, each in its own
|
||
* column under its own label, so no target reads as measured. The rate is
|
||
* read when the page opens (components/models/Cost); the export states none.
|
||
*/
|
||
|
||
import type { Metadata } from 'next'
|
||
import type { ReactNode } from 'react'
|
||
import { DOCS, KEYS } from '@/components/home/nav-data'
|
||
import { Hero } from '@/components/family/Hero'
|
||
import { Target } from '@/components/kai/figures'
|
||
import { Crumbs, Tile, DIM, SM } from '@/components/models/kit'
|
||
import { Cost } from '@/components/models/Cost'
|
||
import { Links } from '@/components/models/Links'
|
||
import { Samples } from '@/components/models/Samples'
|
||
import {
|
||
canonicalOrg,
|
||
context,
|
||
getOrgAndSlug,
|
||
orgDisplayName,
|
||
formatContext,
|
||
modelPagePath,
|
||
type ModelData,
|
||
} from '@/lib/models'
|
||
import { examples } from '@/lib/models/examples'
|
||
import { links } from '@/lib/models/links'
|
||
import { fetchModels } from '@/lib/models/served'
|
||
import { Box, XStack, YStack } from '@/components/ui'
|
||
import { Action, Lede, Line, More, Title } from '@hanzo/ui/marketing'
|
||
import { Grid } from '@hanzo/ui/grid'
|
||
import { Band } from '@/components/marketing/band'
|
||
import { Code } from '@/components/code'
|
||
import { Answer } from '@/components/preview/answer'
|
||
import { ProviderMark } from '@hanzo/app/ProviderMark'
|
||
import { phrase } from '@hanzo/i18n'
|
||
import { T } from '@hanzo/i18n/react'
|
||
|
||
export const revalidate = 3600
|
||
|
||
interface Props {
|
||
params: Promise<{ org: string; model: string }>
|
||
}
|
||
|
||
export async function generateStaticParams() {
|
||
const data = await fetchModels()
|
||
return data.models
|
||
.map((m) => {
|
||
const { org, slug } = getOrgAndSlug(m.id)
|
||
return { org: canonicalOrg(org), model: slug }
|
||
})
|
||
}
|
||
|
||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||
// Next hands a segment over percent-encoded: `claude-opus-4.8:batch` arrives as
|
||
// `claude-opus-4.8%3Abatch`, and compared raw it matched no model, so every
|
||
// `:batch` page rendered "Model not found".
|
||
const { org, model: segment } = await params
|
||
const modelSlug = decodeURIComponent(segment)
|
||
const data = await fetchModels()
|
||
const model = data.models.find((m) => {
|
||
const { org: mOrg, slug: mSlug } = getOrgAndSlug(m.id)
|
||
return (canonicalOrg(mOrg) === org || mOrg === org) && mSlug === modelSlug
|
||
})
|
||
|
||
if (!model) {
|
||
return { title: 'Model Not Found — Hanzo AI' }
|
||
}
|
||
|
||
const desc = summary(model)
|
||
|
||
return {
|
||
title: `${model.name} — Use via Hanzo AI API`,
|
||
description: desc,
|
||
openGraph: {
|
||
title: `${model.name} — Hanzo AI`,
|
||
description: desc,
|
||
url: `https://hanzo.ai${modelPagePath(model.id)}`,
|
||
siteName: 'Hanzo AI',
|
||
type: 'website',
|
||
},
|
||
twitter: {
|
||
card: 'summary_large_image',
|
||
title: `${model.name} — Hanzo AI`,
|
||
description: desc,
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The one sentence this page says about itself.
|
||
*
|
||
* It was computed inside generateMetadata and nowhere else, so the JSON-LD had
|
||
* to reach for `model.description` — a field that is often absent, because the
|
||
* catalog only writes one where it has something to say. A SoftwareApplication
|
||
* with no description is what a search engine got.
|
||
*
|
||
* The catalog's own words win when there are any; otherwise the model is
|
||
* described by what a reader came here to learn — that it is callable through
|
||
* one API with one key.
|
||
*/
|
||
function summary(model: ModelData): string {
|
||
const ctx = context(model)
|
||
// A catalog description is markdown, and a link clipped mid-URL is a broken
|
||
// link in the meta tag, the JSON-LD and llms.txt: the words, not the links.
|
||
const d = model.description?.replace(/\[([^\]]*)\]\(<?[^)>]*>?\)/g, '$1').trim()
|
||
if (d) {
|
||
const maxLen = 75
|
||
const clipped = d.length > maxLen ? `${d.slice(0, maxLen).trimEnd()}...` : d
|
||
return `${clipped} ${call(model)}${ctx ? ` Context: ${formatContext(ctx)}.` : ''}`
|
||
}
|
||
return `Use ${model.name} through one Hanzo API key. ${call(model)}`
|
||
}
|
||
|
||
/** A model id that names its free variant: `google/gemma-4-31b-it:free`, `zen-free`. */
|
||
const FREE = /[:-]free$/
|
||
|
||
function buildJsonLd(model: ModelData) {
|
||
const ctx = context(model)
|
||
return {
|
||
'@context': 'https://schema.org',
|
||
'@type': 'SoftwareApplication',
|
||
name: model.name,
|
||
description: summary(model),
|
||
url: `https://hanzo.ai${modelPagePath(model.id)}`,
|
||
applicationCategory: 'AIApplication',
|
||
provider: {
|
||
'@type': 'Organization',
|
||
name: 'Hanzo AI',
|
||
url: 'https://hanzo.ai',
|
||
},
|
||
// A price only where the build knows it: a free variant costs nothing. Every
|
||
// other rate is read from GET /v1/pricing when the page opens, so the
|
||
// structured data states none rather than a $0 the bill would contradict.
|
||
...(FREE.test(model.id)
|
||
? { offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD', description: 'Free through the Hanzo AI API' } }
|
||
: {}),
|
||
...(ctx ? { featureList: [`${formatContext(ctx)} context window`] } : {}),
|
||
}
|
||
}
|
||
|
||
/** Where the page says a model is called. */
|
||
const call = (m: ModelData) => (m.endpoint === '/v1/decisions' ? 'Call it at POST /v1/decisions.' : "Call via Hanzo's OpenAI-compatible API.")
|
||
|
||
/** The ground the related band lifts to. */
|
||
const LIFT = 'color-mix(in oklab, var(--secondary) 10%, transparent)'
|
||
|
||
/** A section's heading on this page, one rung under the hero's. */
|
||
const COLUMN = {
|
||
render: 'h2',
|
||
display: 'block',
|
||
fontSize: '$4',
|
||
lineHeight: 'calc(1.75 / 1.125)',
|
||
fontWeight: '600',
|
||
color: 'var(--text-primary)',
|
||
} as const
|
||
|
||
/** One labelled fact. `mono` sets an id or a call in the code face. The label is
|
||
* authored English, marked `phrase` and drawn through `<T>`; a value that is
|
||
* this page's own words is a `<T>` where it is written, and a value read from
|
||
* the catalogue is drawn as it is. */
|
||
interface Fact {
|
||
label: string
|
||
value: ReactNode
|
||
mono?: boolean
|
||
}
|
||
|
||
/**
|
||
* Facts as a grid of cells on a hairline: the label small and quiet, the value
|
||
* in ink. Two across from 480px, so a value has room for a sentence's worth.
|
||
*/
|
||
function Facts({ title, tag, rows }: { title: string; tag?: ReactNode; rows: readonly Fact[] }) {
|
||
return (
|
||
<YStack gap="$3" data-facts={title} minW={0}>
|
||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||
<Line {...COLUMN}>
|
||
<T>{title}</T>
|
||
</Line>
|
||
{tag}
|
||
</XStack>
|
||
<Grid columns={{ min: 200, max: 2 }} gap={1} bg="var(--border)" borderWidth={1} borderColor="var(--border)" rounded="var(--radius-xl)" overflow="hidden">
|
||
{rows.map((r, i) => (
|
||
// An odd last cell takes the row, so no empty cell shows the hairline's ground.
|
||
<YStack key={r.label} gap={4} px="$4" py="$3.5" bg="$background" {...(i === rows.length - 1 && rows.length % 2 ? { '$platform-web': { gridColumn: '1 / -1' } } : {})}>
|
||
<Line fontSize="$1" lineHeight="$1" fontWeight="500" color={DIM} $platform-web={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||
<T>{r.label}</T>
|
||
</Line>
|
||
<Line {...SM} fontWeight="600" color="$ink" {...(r.mono ? { fontFamily: '$mono' } : {})} $platform-web={{ overflowWrap: 'anywhere' }}>
|
||
{r.value}
|
||
</Line>
|
||
</YStack>
|
||
))}
|
||
</Grid>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* What each Kai id answers from, and its pin (universe
|
||
* charts/app/values/hanzo/{kai,kai-1-4,kai-1-5}.yaml). `hanzo/kai` is `kai`.
|
||
*/
|
||
const CHECKPOINT: Record<string, string> = {
|
||
kai: 'kai-1.3 (pin @551f1990…)',
|
||
'kai-1.4': 'kai-1.4 (pin @4a5fdb8c…)',
|
||
'kai-1.5': 'kai-1.5 (pin @b8827bb0…)',
|
||
}
|
||
|
||
/** What ships today on a Kai id. The latency is production's, measured on the default id. */
|
||
function shipping(model: ModelData): Fact[] {
|
||
const id = model.id.replace(/^hanzo\//, '')
|
||
return [
|
||
{ label: phrase('Model ID'), value: model.id, mono: true },
|
||
{ label: phrase('Checkpoint'), value: CHECKPOINT[id] ?? id, mono: true },
|
||
{ label: phrase('Parameters'), value: <T>{'~850M (multimodal decision architecture)'}</T> },
|
||
...(id === 'kai' ? [{ label: phrase('Latency (GPU)'), value: <T>{'<1 ms p50 (one question, CUDA bf16)'}</T> }] : []),
|
||
{ label: phrase('Context reach'), value: <T>{'32,768 tokens (YaRN; trained: 1,024)'}</T> },
|
||
{ label: phrase('Modality'), value: <T>Text and structured state</T> },
|
||
{ label: phrase('Output tokens'), value: <T>{'0 (typed probability distribution)'}</T> },
|
||
{ label: phrase('Endpoint'), value: 'POST /v1/decisions', mono: true },
|
||
{ label: phrase('Architecture'), value: <T>{'Proprietary Decision Transformer + factorized cross-attention'}</T> },
|
||
{ label: phrase('Safety qualification'), value: <T>{'Not yet qualified: a sealed evaluation is being authored'}</T> },
|
||
{ label: phrase('Price'), value: <Cost id={model.id} lab={false} /> },
|
||
]
|
||
}
|
||
|
||
/** The Kai-1.7 multimodal design's figures: targets, not measurements. */
|
||
const TARGETS: readonly Fact[] = [
|
||
{ label: phrase('Parameters'), value: <T>{'~850M (text, vision, audio, video)'}</T> },
|
||
{ label: phrase('Resident memory'), value: <T>{'Under 2.0 GiB'}</T> },
|
||
{ label: phrase('Text latency'), value: <T>{'<1 ms p50 (GPU), multi-pass adaptive refinement'}</T> },
|
||
{ label: phrase('Image latency'), value: <T>{'360–400 ms (image + text state)'}</T> },
|
||
{ label: phrase('Encoders'), value: <T>{'Vision · Audio · Video'}</T> },
|
||
{ label: phrase('Reasoning'), value: 'reasoning: "auto" | "fast" | "deep"', mono: true },
|
||
]
|
||
|
||
/** What Kai does not do today. */
|
||
const LIMITS: readonly string[] = [
|
||
phrase('Reads text and structured state. Images, audio and video are the Kai-1.7 target.'),
|
||
phrase('Reaches 32,768 tokens by position; accuracy at that length is qualified for kai-1.4 and kai-1.5 only.'),
|
||
phrase('At most 100 questions a request. More questions, or a state past 32,768 tokens, is refused with 422.'),
|
||
phrase('The reasoning field is the Kai-1.7 target: the API refuses it today (400).'),
|
||
]
|
||
|
||
/** Any other model's facts, from its catalogue record. */
|
||
function specs(model: ModelData): Fact[] {
|
||
const ctx = context(model)
|
||
const out: Fact[] = []
|
||
if (ctx) out.push({ label: phrase('Context Window'), value: formatContext(ctx) })
|
||
if (model.spec?.params) out.push({ label: phrase('Parameters'), value: model.spec.params })
|
||
if (model.spec?.activeParams) out.push({ label: phrase('Active Params'), value: model.spec.activeParams })
|
||
if (model.spec?.arch) out.push({ label: phrase('Architecture'), value: model.spec.arch })
|
||
if (model.modalities?.length) out.push({ label: phrase('Modalities'), value: model.modalities.join(', ') })
|
||
out.push({ label: phrase('Price'), value: <Cost id={model.id} lab={model.category === 'third-party'} /> })
|
||
out.push({ label: phrase('Model ID'), value: model.id, mono: true })
|
||
return out
|
||
}
|
||
|
||
/** The hero's one line: Kai's own, else the catalogue's first sentence (the
|
||
* catalogue's words, drawn as they are). */
|
||
function line(model: ModelData, kai: boolean): ReactNode {
|
||
if (kai) return <T>Decisions, not completions.</T>
|
||
if (model.endpoint === '/v1/decisions') return <T>Typed decisions at POST /v1/decisions.</T>
|
||
const first = model.description?.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? model.description
|
||
return first && first.length <= 160 ? first : <T values={{ name: model.name }}>{'{name}, through one API key.'}</T>
|
||
}
|
||
|
||
export default async function ModelPage({ params }: Props) {
|
||
// Next hands a segment over percent-encoded: `claude-opus-4.8:batch` arrives as
|
||
// `claude-opus-4.8%3Abatch`, and compared raw it matched no model, so every
|
||
// `:batch` page rendered "Model not found".
|
||
const { org, model: segment } = await params
|
||
const modelSlug = decodeURIComponent(segment)
|
||
const data = await fetchModels()
|
||
const model = data.models.find((m) => {
|
||
const { org: mOrg, slug: mSlug } = getOrgAndSlug(m.id)
|
||
return (canonicalOrg(mOrg) === org || mOrg === org) && mSlug === modelSlug
|
||
})
|
||
|
||
if (!model) {
|
||
return (
|
||
<YStack
|
||
minH="100vh"
|
||
items="center"
|
||
justify="center"
|
||
bg="$background"
|
||
$platform-web={{ color: 'var(--foreground)', textAlign: 'center' }}
|
||
>
|
||
<YStack>
|
||
<Title render="h1" quiet mb={8}>
|
||
<T>Model not found</T>
|
||
</Title>
|
||
<More href="/models" self="center">
|
||
<T>{'← Back to all models'}</T>
|
||
</More>
|
||
</YStack>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
// THE LAB'S PAGE IS AT ITS CANONICAL NAME, and this page's own segment is not
|
||
// it: `[org]/page.tsx` generates its params from `canonicalOrg(org)`, so the
|
||
// raw segment (/models/~anthropic, /models/meta-llama) names a route the
|
||
// export never writes. The trail and "View all" link the canonical one.
|
||
const lab = canonicalOrg(org)
|
||
const providerName = orgDisplayName(lab)
|
||
const kai = model.family === 'kai'
|
||
const decides = model.endpoint === '/v1/decisions'
|
||
const { ts, py, curl, go } = examples(model)
|
||
const jsonLd = buildJsonLd(model)
|
||
|
||
// Related models: same org, excluding current. A Zen model's are Zen: the
|
||
// bare ids filed under the same org are mostly other labs' short names.
|
||
const zen = model.id.startsWith('zen')
|
||
const related = data.models
|
||
.filter((m) => {
|
||
const { org: mOrg } = getOrgAndSlug(m.id)
|
||
return (canonicalOrg(mOrg) === lab || mOrg === org) && m.id !== model.id && (!zen || m.id.startsWith('zen'))
|
||
})
|
||
.slice(0, 6)
|
||
|
||
const primary = decides ? { label: phrase('Get decisions'), href: '#quickstart' } : { label: phrase('Get API key'), href: KEYS }
|
||
const action = kai
|
||
? { label: phrase('Explore Kai'), href: '/kai' }
|
||
: decides
|
||
? { label: phrase('Read the docs'), href: `${DOCS}/docs/decisions` }
|
||
: { label: phrase('Try in Chat'), href: '/chat' }
|
||
|
||
return (
|
||
<>
|
||
{/* JSON-LD structured data */}
|
||
<script
|
||
type="application/ld+json"
|
||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||
/>
|
||
|
||
<Box minH="100vh" $platform-web={{ color: 'var(--foreground)' }}>
|
||
<Hero
|
||
trail={
|
||
<Crumbs
|
||
trail={[
|
||
{ label: phrase('Models'), href: '/models' },
|
||
{ label: providerName, href: `/models/${lab}` },
|
||
{ label: modelSlug },
|
||
]}
|
||
/>
|
||
}
|
||
chip={model.tier ? `${providerName} · ${model.tier}` : providerName}
|
||
title={model.name}
|
||
lede={line(model, kai)}
|
||
primary={primary}
|
||
action={action}
|
||
below={<Links links={links(model)} center />}
|
||
figure={
|
||
!decides && model.description ? (
|
||
<Answer
|
||
models={[
|
||
{
|
||
id: model.id,
|
||
name: model.name,
|
||
says: model.description,
|
||
mark: <ProviderMark org={lab} model={model.id} size={16} />,
|
||
facts: [...(context(model) ? [formatContext(context(model)!)] : []), ...(model.modalities ?? []).slice(0, 2)],
|
||
},
|
||
]}
|
||
/>
|
||
) : undefined
|
||
}
|
||
/>
|
||
|
||
<Band id="specs" pad="$10" measure={1024}>
|
||
{kai ? (
|
||
<Grid columns={{ min: 320, max: 2 }} gap={24}>
|
||
<Facts title={phrase('Shipping today')} rows={shipping(model)} />
|
||
<Facts title={phrase('In development')} tag={<Target />} rows={TARGETS} />
|
||
</Grid>
|
||
) : (
|
||
<YStack gap="$5">
|
||
<Facts title={phrase('Specifications')} rows={specs(model)} />
|
||
{model.features && model.features.length > 0 ? (
|
||
<Line {...SM} color={DIM}>
|
||
{model.features.join(' · ')}
|
||
</Line>
|
||
) : null}
|
||
</YStack>
|
||
)}
|
||
</Band>
|
||
|
||
<Band id="quickstart" pad="$10" measure={896}>
|
||
<YStack gap="$4">
|
||
<XStack items="baseline" justify="space-between" gap="$3" flexWrap="wrap">
|
||
<Line {...COLUMN}>
|
||
<T>Quick start</T>
|
||
</Line>
|
||
<More href={KEYS}>
|
||
<T>{'Get an API key →'}</T>
|
||
</More>
|
||
</XStack>
|
||
<Samples
|
||
samples={[
|
||
{ lang: 'TypeScript', code: ts, sample: <Code lang="typescript" code={ts} /> },
|
||
{ lang: 'Python', code: py, sample: <Code lang="python" code={py} /> },
|
||
{ lang: 'cURL', code: curl, sample: <Code lang="bash" code={curl} /> },
|
||
{ lang: 'Go', code: go, sample: <Code lang="go" code={go} /> },
|
||
]}
|
||
/>
|
||
<Line {...SM} color={DIM} data-price="">
|
||
{kai ? (
|
||
<T values={{ cost: <Cost id={model.id} lab={model.category === 'third-party'} /> }}>
|
||
{'{cost}. A native decision writes 0 output tokens; a decision Jev or a language model answers bills at that model’s rates, output included.'}
|
||
</T>
|
||
) : (
|
||
<Cost id={model.id} lab={model.category === 'third-party'} />
|
||
)}
|
||
</Line>
|
||
</YStack>
|
||
</Band>
|
||
|
||
{kai ? (
|
||
<Band id="limits" pad="$10" measure={896}>
|
||
<YStack gap="$3">
|
||
<Line {...COLUMN}>
|
||
<T>Limits</T>
|
||
</Line>
|
||
<YStack render="ul" gap="$2">
|
||
{LIMITS.map((l) => (
|
||
<XStack render="li" key={l} items="baseline" gap="$3">
|
||
<Box render="span" width={4} height={4} mt={8} shrink={0} rounded="var(--radius-full)" bg="var(--muted-foreground)" />
|
||
<Line {...SM} color={DIM}>
|
||
<T>{l}</T>
|
||
</Line>
|
||
</XStack>
|
||
))}
|
||
</YStack>
|
||
</YStack>
|
||
</Band>
|
||
) : null}
|
||
|
||
{/* Related models */}
|
||
{related.length > 0 && (
|
||
<Band pad="$10" measure={1024} ground={LIFT}>
|
||
<Line {...COLUMN} mb={24}>
|
||
<T values={{ name: providerName }}>{'More from {name}'}</T>
|
||
</Line>
|
||
<Grid columns={{ min: 240, max: 3 }} gap={12}>
|
||
{related.map((m) => {
|
||
const mCtx = context(m)
|
||
return (
|
||
<Tile
|
||
key={m.id}
|
||
href={modelPagePath(m.id)}
|
||
name={m.name}
|
||
ctx={mCtx ? formatContext(mCtx) : undefined}
|
||
blurb={m.description}
|
||
/>
|
||
)
|
||
})}
|
||
</Grid>
|
||
<Box mt="$5">
|
||
<More href={`/models/${lab}`}>
|
||
<T values={{ name: providerName }}>{'View all {name} models →'}</T>
|
||
</More>
|
||
</Box>
|
||
</Band>
|
||
)}
|
||
|
||
{/* CTA */}
|
||
<Band pad="$10" measure={672}>
|
||
<YStack $platform-web={{ textAlign: 'center' }}>
|
||
<Title quiet mb={12}>
|
||
<T values={{ name: model.name }}>{'Use {name} via Hanzo AI'}</T>
|
||
</Title>
|
||
<Lede mb={24} mt="$3.5">
|
||
{decides ? (
|
||
<T>One API key. One call to POST /v1/decisions.</T>
|
||
) : (
|
||
<T>One API key. Every major model. OpenAI-compatible. Start free.</T>
|
||
)}
|
||
</Lede>
|
||
<XStack flexWrap="wrap" justify="center" gap="$3">
|
||
<Action href={KEYS} fill>
|
||
<T>Get an API key</T>
|
||
</Action>
|
||
<Action href="/models">
|
||
<T>Browse All Models</T>
|
||
</Action>
|
||
</XStack>
|
||
</YStack>
|
||
</Band>
|
||
</Box>
|
||
</>
|
||
)
|
||
}
|