Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
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.
2026-10-22 19:07:20 -07:00

371 lines
15 KiB
TypeScript

'use client'
/**
* Every model on the endpoint, with what it costs, narrowable.
*
* THE PROBLEM THIS SOLVES IS NAVIGATION, not listing. Five hundred rows is a
* wall, and a wall answers no question a reader actually has — which is one of
* "does it carry the model I already use", "what does that cost", and "what is
* the cheap one". So the whole set is in the document, and four things make it
* legible: it is grouped by lab, the group heads carry a tally and lead to the
* lab's own page, it lives in its own scroll rather than fifty screens of page,
* and one field narrows it.
*
* ONE FIELD, and that is a decision rather than a shortcut. A provider filter
* beside a search box that already matches provider names is two controls for
* one question, and the reader has to learn which of them the answer is behind.
* The field matches the id, the name and the lab, so `anthropic` narrows to
* Anthropic exactly as a dropdown would, and the group heads left standing are
* the list of labs a dropdown would have offered. The chips beside it are the
* questions the field CANNOT answer, because their answer is not a word in any
* row: free, cheap, long, ours.
*
* READ WHEN THE PAGE OPENS. The rows are the routing table's (GET /v1/models)
* joined to the price list (GET /v1/pricing) by lib/models/catalog, both read by
* lib/api in the browser; the export carries the loading line and no row, so no
* rate in it can be older than the reader's visit. A backend that does not
* answer is said to be unreachable, with nothing in its place.
*
* NO TOTAL IS PRINTED HERE. The page's opening line states how many models the
* price list counts (components/api/count); the routing table holds a slightly
* different number of rows, and printing its length under that line would put
* two counts of one thing on one page. So the tally under the controls appears
* only once something is narrowed, where it is plainly a count of what MATCHED
* and not a claim about the catalogue.
*
* NO TAILWIND, and no colour but the page's. `Card` is the hairline tile the
* rest of the page is made of; the search is @hanzo/composer's `Field` in a
* capsule that wears shell/pill's `RING` on focus. A chip is `--primary` when
* it is on.
*
* A REAL `<table>`, each element a gui frame that states its table display
* (`components/pricing/kit.tsx` records why: a frame's box model is flexbox,
* and a row left to it falls out of the table). A cell is a `Line`, so it
* carries its own type; the column a phone drops is a `Spare` with a `Line` in
* it.
*/
import { useMemo, useState } from 'react'
import { Box, View, XStack } from '@hanzo/ui'
import { Field } from '@hanzo/composer'
import { RING } from '@/components/shell/pill'
import { Card } from '@/components/marketing/band'
import { DIM, SM, XS } from '@/components/models/kit'
import { CELL, ROW, Spare, TABLE } from '@/components/pricing/kit'
import { mix } from '@hanzo/app/lib/mix'
import { formatContext, orgDisplayName, rate } from '@/lib/models'
import { ALIAS, catalog, type Row } from '@/lib/models/catalog'
import { usePricing, useServed } from '@/lib/api/use'
import { Loading, Unreachable } from '@/components/api/state'
import { Line, More } from '@hanzo/ui/marketing'
/** A million tokens of context. Half the priced catalogue clears 262K, so a
* threshold there would narrow nothing; this one splits it. */
const LONG = 1_000_000
/** A dollar per million output tokens — a BUCKET BOUNDARY and not a rate. It
* says nothing about what any model costs; the cells say that, from the
* catalogue. It does not move when the catalogue does, which is exactly why it
* is allowed to be written down. */
const THRIFTY = 1
const free = (r: Row) => r.in === 0 && r.out === 0
const NARROW: { id: string; label: string; hits: (r: Row) => boolean }[] = [
{ id: 'all', label: 'Everything', hits: () => true },
{ id: 'ours', label: 'Ours', hits: (r) => r.lab === 'hanzo' },
{ id: 'free', label: 'Free', hits: free },
{ id: 'thrifty', label: `Under $${THRIFTY} / M out`, hits: (r) => r.out !== null && r.out < THRIFTY && !free(r) },
{ id: 'long', label: `${formatContext(LONG)} context`, hits: (r) => r.ctx >= LONG },
]
/** What a lab's group head says. `hanzo` and the short names are the two the
* display table has no entry for, and both want a word rather than a slug. */
function labName(lab: string): string {
return lab === ALIAS ? 'Aliases' : orgDisplayName(lab)
}
/** A cell's room and its rule, and the type the table is set in. */
const BOX = { px: 12, py: 10, borderBottomWidth: 1, borderColor: 'var(--border)' } as const
const BODY = { render: 'td', display: CELL, ...BOX, ...SM } as const
/** The head stays put while the rows scroll under it. */
const HEAD = {
render: 'th',
display: CELL,
position: 'sticky',
t: 0,
z: 'var(--z-raised)',
px: 12,
py: 10,
bg: 'var(--background)',
borderBottomWidth: 1,
borderColor: 'var(--border)',
...XS,
fontWeight: '500',
text: 'left',
color: DIM,
whiteSpace: 'nowrap',
} as const
/** A figure is read against the decimal point. */
const FIGURES = { fontVariantNumeric: 'tabular-nums' } as const
/** The row's link: an alias is set in the mono of an id, a named model in the
* page's ink; both hold their ink under the pointer. */
const SHORT = { fontFamily: 'var(--font-mono)', ...XS, color: DIM } as const
const NAMED = { fontWeight: '400', color: 'var(--foreground)' } as const
/** A lab's line across the table: who trained what follows, how many of them,
* and the way to that lab's own page. */
const GROUP = {
pt: 18,
px: 12,
pb: 8,
borderBottomWidth: 1,
borderColor: 'var(--border)',
bg: mix('var(--secondary)', 30),
} as const
/** The two reads the table is joined from; the table once both are here. */
export default function Catalog() {
const served = useServed()
const pricing = usePricing()
const rows = useMemo(
() => (served.state === 'ready' && pricing.state === 'ready' ? catalog(served.data, pricing.data) : null),
[served, pricing],
)
if (served.state === 'unreachable' || pricing.state === 'unreachable') return <Unreachable what="every model and its rate" />
if (!rows) return <Loading what="every model and its rate" />
return <Table rows={rows} />
}
function Table({ rows }: { rows: Row[] }) {
const [query, setQuery] = useState('')
const [only, setOnly] = useState('all')
// What a row can be found BY, built once. Rebuilding fifty thousand lowercase
// strings on every keystroke is the difference between a field that keeps up
// with typing and one that does not.
const found = useMemo(
() => new Map(rows.map((r) => [r.id, `${r.id} ${r.name} ${labName(r.lab)}`.toLowerCase()])),
[rows],
)
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
const hits = NARROW.find((n) => n.id === only)?.hits ?? NARROW[0].hits
const shown = rows.filter((r) => hits(r) && terms.every((t) => found.get(r.id)!.includes(t)))
const narrowed = terms.length > 0 || only !== 'all'
// The rows arrive in reading order, so a group is a RUN of them and never a
// second pass over the set.
const groups: { lab: string; rows: Row[] }[] = []
for (const row of shown) {
const last = groups[groups.length - 1]
if (last && last.lab === row.lab) last.rows.push(row)
else groups.push({ lab: row.lab, rows: [row] })
}
return (
<Card p={0} overflow="hidden">
<Box p="$4" $md={{ p: '$5' }} borderBottomWidth={1} borderColor="var(--border)">
{/* The field has no edge of its own, so the capsule around it is the
control and wears the focus ring. */}
<XStack
items="center"
mb="$3"
px="$3"
minH={44}
rounded="var(--radius-full)"
borderWidth={1}
borderColor="var(--border)"
bg="var(--glass)"
{...RING}
>
{/* A searchbox by role and by keyboard, not by `type="search"`: gui
has no `appearance` prop, and the native search widget is the
one field chrome that ignores the reset under it. */}
<Field
role="searchbox"
enterKeyHint="search"
value={query}
onChangeText={setQuery}
placeholder="Search by model, lab or id"
aria-label="Search the catalogue"
data-search="models"
/>
</XStack>
<XStack flexWrap="wrap" gap="$2">
{NARROW.map((n) => {
const on = n.id === only
return (
<Line
key={n.id}
render="button"
type="button"
onClick={() => setOnly(n.id)}
data-narrow={n.id}
aria-pressed={on}
minH={32}
px={12}
rounded="var(--radius-full)"
borderWidth={on ? 0 : 1}
borderStyle="solid"
borderColor="var(--border)"
bg={on ? 'var(--primary)' : 'transparent'}
color={on ? 'var(--primary-foreground)' : DIM}
{...XS}
cursor="pointer"
$platform-web={{ transition: 'color 150ms, background-color 150ms, border-color 150ms' }}
>
{n.label}
</Line>
)
})}
</XStack>
{narrowed ? (
<Line render="p" display="block" data-matched={shown.length} mt={12} {...XS} color={DIM}>
{shown.length === 0 ? 'No model answers to that.' : shown.length === 1 ? 'One model.' : `${shown.length} models.`}
</Line>
) : null}
</Box>
{/* A column of scroll and a row of none, each axis stated. */}
<Box maxH="70vh" minH={420} overflowY="auto" overflowX="hidden">
<View render="table" display={TABLE} width="100%" $platform-web={{ tableLayout: 'fixed', borderCollapse: 'collapse' }}>
<thead>
{/* CONTEXT LAST, and the order is structural rather than editorial.
`Spare` drops that column under 48rem, so it is the only one
whose cell may be absent — and a group head spanning a column
that is not there leaves the table one PHANTOM column wide,
which on a phone drew the whole table into 280 of 360 available
pixels with dead air beside it. Last is the one position where a
column can vanish without any row above it having to know. It is
also the number a reader consults least, so the money now sits
against the name. */}
<View render="tr" display={ROW}>
<Line {...HEAD}>Model</Line>
<Line {...HEAD} width={84} text="right" $platform-web={FIGURES}>
In $/M
</Line>
<Line {...HEAD} width={84} text="right" $platform-web={FIGURES}>
Out $/M
</Line>
<Spare
render="th"
position="sticky"
t={0}
z="var(--z-raised)"
width={96}
px={12}
py={10}
bg="var(--background)"
borderBottomWidth={1}
borderColor="var(--border)"
>
<Line display="block" {...XS} fontWeight="500" color={DIM} text="right" whiteSpace="nowrap" $platform-web={FIGURES}>
Context
</Line>
</Spare>
</View>
</thead>
<tbody>
{groups.map((group) => (
<Rows key={group.lab} lab={group.lab} rows={group.rows} />
))}
{shown.length === 0 ? (
<View render="tr" display={ROW}>
<Line render="td" display={CELL} {...{ colSpan: 3 }} p={32} {...SM} text="center" color={DIM}>
Nothing in the catalogue answers to that.
</Line>
</View>
) : null}
</tbody>
</View>
</Box>
</Card>
)
}
function Rows({ lab, rows }: { lab: string; rows: Row[] }) {
return (
<>
<View render="tr" display={ROW} data-lab={lab}>
<Line render="th" display={CELL} {...{ colSpan: 3, scope: 'colgroup' }} {...GROUP} {...SM} fontWeight="500" text="left">
<More href={`/models/${lab === ALIAS ? 'hanzo' : lab}`}>
{labName(lab)}
</More>
<Line ml={8} fontSize="$1" fontWeight="400" color={DIM}>
{lab === ALIAS
? `${rows.length} short names — each answers under its lab's full name too`
: `${rows.length} model${rows.length === 1 ? '' : 's'}`}
</Line>
</Line>
<Spare render="th" {...GROUP} />
</View>
{rows.map((row) => (
<View key={row.id} render="tr" display={ROW} data-model={row.id} data-lab={row.lab}>
{/* Two lines where the model has a name and an id, one where those
are the same string — an alias is an id and printing it twice, once
in prose and once in mono, says nothing the second time. */}
<Line {...BODY}>
<More
href={row.href}
display="block"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
{...(row.name === row.id ? SHORT : NAMED)}
>
{row.name}
</More>
{row.name === row.id ? null : (
<Line display="block" numberOfLines={1} fontFamily="$mono" {...XS} color={DIM}>
{row.id}
</Line>
)}
</Line>
<Rate row={row} />
{/* The figure takes the cell's own leading, which is the frame's. */}
<Spare render="td" {...BOX}>
<Line display="block" fontSize="$2" color={DIM} text="right" $platform-web={FIGURES}>
{row.ctx ? formatContext(row.ctx) : '—'}
</Line>
</Spare>
</View>
))}
</>
)
}
/** What it costs. Three shapes, because the catalogue prices in three ways and
* flattening them into one column would have to invent the two it does not
* hold: per token, per thing made, or a conversation. */
function Rate({ row }: { row: Row }) {
if (row.sales) {
return (
<Line {...BODY} {...{ colSpan: 2 }} text="right" color={DIM} $platform-web={FIGURES}>
Contact sales
</Line>
)
}
if (row.each !== null) {
return (
<Line {...BODY} {...{ colSpan: 2 }} text="right" color="var(--foreground)" whiteSpace="nowrap" $platform-web={FIGURES}>
{rate(row.each)} <Line color={DIM}>/ {row.unit}</Line>
</Line>
)
}
const ink = (v: number | null) => (v === null ? DIM : 'var(--foreground)')
return (
<>
<Line {...BODY} text="right" color={ink(row.in)} $platform-web={FIGURES}>
{rate(row.in) ?? '—'}
</Line>
<Line {...BODY} text="right" color={ink(row.out)} $platform-web={FIGURES}>
{rate(row.out) ?? '—'}
</Line>
</>
)
}