mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +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.
371 lines
15 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|