SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
15.6 KB · 282 lines tsx
Raw Blame History
1import { Search, X } from 'lucide-react';2import type { Metadata } from 'next';3import Link from 'next/link';4import { Chip, EntityBadge, OpennessBadge } from '@/components/ui/badges';5import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';6import { cheapestPrice, EntityLink, EntityRow, QualityMark } from '@/components/ui/entity';7import { Hint } from '@/components/ui/hint';8import { Pagination, withParams } from '@/components/ui/pagination';9import { Container, Note, PageHeader } from '@/components/ui/section';10import { EmptyState, Unavailable } from '@/components/ui/unavailable';11import { apiD3, safe } from '@/lib/api';12import { fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format';13import { EXAMPLE_QUERIES, exploreNav, PALETTE_PREFIXES, routes, typeLabel } from '@/lib/site';14import type { EntitySummary } from '@/lib/types';15import { compiledType, impliedColumns, toModelsParams, withoutSpan } from './compiled';1617type SP = { q?: string; type?: string; offset?: string };18const LIMIT = 30;1920export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {21  const { q } = await searchParams;22  return { title: q ? `“${q}” — search` : 'Search', description: 'Plain-English search over the AI Atlas graph: the compiler turns your words into filters (organization, parameters, context, openness, licence, dates, price) and shows them back as removable chips.', robots: { index: false, follow: true }, alternates: { canonical: '/search' } };23}2425export default async function SearchPage({ searchParams }: { searchParams: Promise<SP> }) {26  const sp = await searchParams;27  const q = (sp.q ?? '').trim();28  const type = sp.type ?? '';29  const offset = Math.max(0, Number(sp.offset) || 0);30  const res = q ? await safe(apiD3.search(q, { type: type || undefined, limit: LIMIT, offset })) : null;31  const query = res?.query;32  const compiled = query?.compiled ?? [];33  const cType = compiledType(query);34  const isModelTable = (type || cType) === 'model' && compiled.length > 0;35  const mapped = isModelTable ? toModelsParams(compiled, query?.residual) : null;36  const cols = impliedColumns(compiled, query?.sort);37  const current = { q, type };38  const unrec = query?.unrecognised ?? [];39  const exploreHref = mapped ? `/explore?type=model&${mapped.params.toString()}` : `/explore${cType ? `?type=${encodeURIComponent(cType)}` : ''}`;4041  return (42    <Container wide>43      <PageHeader eyebrow="Search 3.0" title={q ? <>Results for <span className="text-ink-2">“{q}”</span></> : 'Search the atlas'} lede={!q ? 'Names, ids, providers, benchmarks — or a plain-English question. The compiler turns your words into filters and shows them back as chips you can remove.' : undefined}>44        <form action="/search" method="get" role="search" className="mt-5 flex max-w-3xl items-stretch border border-rule-strong bg-surface focus-within:border-accent">45          <span className="flex items-center pl-3 text-ink-3">46            <Search className="size-5" aria-hidden />47          </span>48          <input name="q" type="search" defaultValue={q} placeholder="open reasoning models over 30B released in 2026…" className="h-12 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" aria-label="Search query" />49          {type && <input type="hidden" name="type" value={type} />}50          <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">51            Search52          </button>53        </form>5455        {q && res && (56          <section className="mt-4 border-y border-rule py-3" aria-label="Compiled query" data-compiled>57            <div className="flex flex-wrap items-start gap-x-6 gap-y-2">58              <div className="min-w-0 flex-1">59                <p className="eyebrow mb-1.5 flex items-center gap-1">60                  Compiled as61                  <Hint align="right" text="The search compiler (v2) recognises organizations, parameter and context bounds, openness, licences, dates, prices, modalities and sort hints. Each chip shows the words it came from; removing a chip re-runs the search without those words." />62                  {query?.semantic && <span className="ml-2 normal-case tracking-normal text-ink-3">· semantic ranking on</span>}63                </p>64                {compiled.length === 0 ? (65                  <p className="text-sm text-ink-2">66                    No structured filter recognised — full-text search for “{q}”{cType ? ` among ${typeLabel(cType, true).toLowerCase()}` : ''}.67                  </p>68                ) : (69                  <ul className="flex flex-wrap gap-1.5" data-compiled-chips>70                    {compiled.map((f, i) => {71                      const nextText = withoutSpan(q, f.source_span);72                      const removable = !!f.source_span && nextText !== q;73                      return (74                        <li key={`${f.filter}-${i}`} className="inline-flex h-8 items-stretch border border-rule bg-surface text-xs" data-compiled-chip={f.filter}>75                          <span className="flex items-center gap-1.5 px-2 text-ink" title={f.source_span ? `from “${f.source_span}”` : undefined}>76                            <span className="mono text-[10px] uppercase text-ink-3">{f.filter}</span>77                            {f.label}78                          </span>79                          {removable ? (80                            <Link href={nextText ? withParams('/search', current, { q: nextText, offset: undefined }) : routes.search('')} className="flex items-center border-l border-rule px-1.5 text-ink-3 hover:bg-surface-2 hover:text-danger" aria-label={`Remove ${f.label}`} title="Remove this filter (re-runs the search without its words)">81                              <X className="size-3" aria-hidden />82                            </Link>83                          ) : (84                            <span className="flex items-center border-l border-rule px-1.5 text-ink-3" title="Implicit — not tied to words in your query">85                              ·86                            </span>87                          )}88                        </li>89                      );90                    })}91                  </ul>92                )}93                <p className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-3">94                  {query?.sort && (95                    <span>96                      Sort: <span className="text-ink-2">{query.sort}</span>97                    </span>98                  )}99                  {query?.residual?.trim() && (100                    <span>101                      Free text: <span className="text-ink-2">“{query.residual.trim()}”</span>102                    </span>103                  )}104                  {unrec.length > 0 && (105                    <span data-unrecognised>106                      Not understood: <span className="text-warning">{unrec.join(' · ')}</span>107                      <Hint align="right" text="Words the compiler could not turn into a filter (an organization name it does not know, a benchmark alias…) stay as free text and are matched against names and descriptions." />108                    </span>109                  )}110                  {query?.note && <span className="text-warning">{query.note}</span>}111                </p>112              </div>113              <div className="flex flex-wrap items-center gap-2 text-xs">114                {mapped && (115                  <Link href={`/models?${mapped.params.toString()}`} className="inline-flex h-9 items-center border border-rule px-2.5 text-ink-2 hover:border-rule-strong hover:text-ink">116                    Open in Models →117                  </Link>118                )}119                <Link href={exploreHref} className="inline-flex h-9 items-center border border-rule px-2.5 text-ink-2 hover:border-rule-strong hover:text-ink" data-open-builder>120                  Open in Explore builder →121                </Link>122                {mapped?.links.map((l) => (123                  <Link key={l.href} href={l.href} className="link">124                    {l.label}125                  </Link>126                ))}127              </div>128            </div>129            {mapped && mapped.unmapped.length > 0 && (130              <Note className="mt-2">131                Not expressible in /models: {mapped.unmapped.map((u, i) => (132                  <span key={i}>133                    {i > 0 && ' · '}134                    <span className="text-ink-2">{u.filter.label}</span> ({u.why})135                  </span>136                ))}137                .138              </Note>139            )}140          </section>141        )}142143        {q && (144          <ul className="no-scrollbar -mx-4 mt-4 flex gap-1 overflow-x-auto px-4 md:mx-0 md:px-0" aria-label="Filter by type">145            {[{ label: 'All', type: '' }, ...exploreNav.map((n) => ({ label: n.label.replace(' & Pricing', ''), type: n.type }))].map((t) => {146              const on = t.type === type;147              return (148                <li key={t.type} className="shrink-0">149                  <Link href={withParams('/search', current, { type: t.type || undefined, offset: undefined })} className={`inline-flex h-9 items-center border px-3 text-sm ${on ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink'}`} aria-current={on ? 'true' : undefined}>150                    {t.label}151                  </Link>152                </li>153              );154            })}155          </ul>156        )}157      </PageHeader>158159      {!q ? (160        <div className="grid gap-10 pb-16 md:grid-cols-2">161          <div>162            <p className="eyebrow mb-2">Try</p>163            <ul className="flex flex-wrap gap-2">164              {[...EXAMPLE_QUERIES, 'open reasoning models over 30B released in 2026', 'cheapest models under $1/M output with 1M context', 'papers by DeepSeek'].map((ex) => (165                <li key={ex}>166                  <Link href={routes.search(ex)} className="inline-block border border-rule px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">167                    {ex}168                  </Link>169                </li>170              ))}171            </ul>172          </div>173          <div>174            <p className="eyebrow mb-2">Prefixes (also in the ⌘K palette)</p>175            <ul className="space-y-1 text-sm">176              {Object.entries(PALETTE_PREFIXES).map(([k, v]) => (177                <li key={k} className="flex items-center gap-3">178                  <span className="mono w-8 text-ink">{k}:</span>179                  <Link href={withParams('/search', {}, { type: v.types[0] })} className="text-ink-2 hover:text-ink">180                    {v.label}181                  </Link>182                </li>183              ))}184            </ul>185            <p className="mt-3 text-xs text-ink-3">186              Prefer a form? <Link href={routes.explore()} className="link">Open the Explore builder →</Link>187            </p>188          </div>189        </div>190      ) : !res ? (191        <Unavailable what="Search" className="mb-16" reason="The search service did not answer (rate limit or outage)." />192      ) : res.items.length === 0 ? (193        <EmptyState title={`Nothing matched “${q}”`} className="mb-16">194          Try fewer words, a provider's API id, or <Link href={routes.explore()} className="link">the builder</Link>.195          {compiled.length > 0 && (196            <>197              {' '}198              Remove a chip above to widen the search.199            </>200          )}201        </EmptyState>202      ) : (203        <div className="pb-16">204          <p className="tnum mb-2 text-xs text-ink-3">205            {fmtInt(res.total)} result{res.total === 1 ? '' : 's'}206            {isModelTable ? ' · dense model table (columns implied by the query)' : ''}207          </p>208          {isModelTable ? <ModelResults items={res.items} cols={cols} /> : (209            <ul className="border-t border-rule">210              {res.items.map((e) => (211                <EntityRow key={e.id} e={e} trailing={<EntityBadge type={e.entity_type} small className="md:hidden" />} />212              ))}213            </ul>214          )}215          <Pagination total={res.total} limit={LIMIT} offset={offset} makeHref={(o) => withParams('/search', current, { offset: o || undefined })} className="mt-4" />216        </div>217      )}218    </Container>219  );220}221222function ModelResults({ items, cols }: { items: (EntitySummary & { rank: number })[]; cols: Set<string> }) {223  return (224    <DataTable caption="Models matching the compiled query" compact scroll>225      <thead>226        <tr>227          <Th>Model</Th>228          <Th>Organization</Th>229          {cols.has('params') && <Th num>Params</Th>}230          {cols.has('context') && <Th num>Context</Th>}231          {cols.has('price') && <Th num>Cheapest input</Th>}232          {cols.has('openness') && <Th>Openness · licence</Th>}233          {cols.has('reasoning') && <Th>Reasoning</Th>}234          {cols.has('modalities') && <Th>Modalities</Th>}235          {cols.has('released') && <Th>Released</Th>}236          <Th num>Quality</Th>237        </tr>238      </thead>239      <tbody>240        {items.length === 0 && <EmptyRow cols={10}>No rows.</EmptyRow>}241        {items.map((e) => {242          const a = e.attributes ?? {};243          const ap = num(a.active_parameter_count);244          const p = num(a.parameter_count);245          return (246            <tr key={e.id}>247              <Td primary>248                <EntityLink e={e} />249                {e.entity_type !== 'model' && <EntityBadge type={e.entity_type} small className="ml-2" />}250              </Td>251              <Td label="Organization" className="text-ink-2">252                {e.organization ? <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">{e.organization.name}</Link> : <span className="text-ink-3">—</span>}253              </Td>254              {cols.has('params') && (255                <Td num label="Params" className="tnum">256                  {p === null ? <span className="text-ink-3">—</span> : ap !== null && ap !== p ? `${fmtParams(p)} · ${fmtParams(ap)} active` : fmtParams(p)}257                </Td>258              )}259              {cols.has('context') && <Td num label="Context" className="tnum">{num(a.context_length) === null ? <span className="text-ink-3">—</span> : fmtTokens(a.context_length)}</Td>}260              {cols.has('price') && <Td num label="Cheapest input" className="tnum text-accent-2">{cheapestPrice(e) ?? <span className="text-ink-3">—</span>}</Td>}261              {cols.has('openness') && (262                <Td label="Openness">263                  <span className="inline-flex flex-wrap items-center gap-1">264                    {typeof a.openness === 'string' ? <OpennessBadge openness={a.openness} /> : <span className="text-ink-3">—</span>}265                    {typeof a.license === 'string' && <Chip>{a.license}</Chip>}266                  </span>267                </Td>268              )}269              {cols.has('reasoning') && <Td label="Reasoning" className="text-ink-2">{a.reasoning === true ? 'yes' : a.reasoning === false ? 'no' : <span className="text-ink-3">—</span>}</Td>}270              {cols.has('modalities') && <Td label="Modalities" className="text-xs text-ink-2">{Array.isArray(a.modalities) && a.modalities.length ? (a.modalities as unknown[]).map(String).join(', ') : <span className="text-ink-3">—</span>}</Td>}271              {cols.has('released') && <Td label="Released" className="tnum text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : <span className="text-ink-3">—</span>}</Td>}272              <Td num label="Quality">273                <QualityMark q={e.quality?.score} />274              </Td>275            </tr>276          );277        })}278      </tbody>279    </DataTable>280  );281}282