返回 CodeWhale
models-table.tsx
根目录 / web / components / models-table.tsx
1 "use client";
2
3 import { useMemo, useState } from "react";
4 import { Icon } from "./icon";
5 import type { ModelFact } from "@/lib/facts";
6 import { MODELS_COPY } from "@/lib/content/models";
7 import { getChrome, pickText } from "@/lib/i18n/dictionaries";
8
9 type SortKey = "added" | "id" | "provider" | "context";
10
11 function formatTokens(n: number | null): string {
12 if (n === null) return "—";
13 if (n >= 1_000_000) {
14 const m = n / 1_000_000;
15 return `${Number.isInteger(m) ? m : m.toFixed(2)}M`;
16 }
17 if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
18 return String(n);
19 }
20
21 function formatDate(iso: string | null, dateLocale: string): string {
22 if (!iso) return "—";
23 const d = new Date(`${iso}T00:00:00Z`);
24 if (!Number.isFinite(d.getTime())) return "—";
25 return new Intl.DateTimeFormat(dateLocale, {
26 dateStyle: "medium",
27 timeZone: "UTC",
28 }).format(d);
29 }
30
31 // Missing values sort last in BOTH directions — an undated model is never
32 // "newest" or "oldest", it is simply unknown.
33 function compare(a: ModelFact, b: ModelFact, key: SortKey, desc: boolean): number {
34 const tie = a.id.localeCompare(b.id);
35 switch (key) {
36 case "added": {
37 if (!a.addedAt || !b.addedAt) {
38 if (a.addedAt === b.addedAt) return tie;
39 return a.addedAt ? -1 : 1;
40 }
41 if (a.addedAt !== b.addedAt) {
42 return desc
43 ? b.addedAt.localeCompare(a.addedAt)
44 : a.addedAt.localeCompare(b.addedAt);
45 }
46 return tie;
47 }
48 case "id":
49 return desc ? -tie : tie;
50 case "provider": {
51 if (!a.provider || !b.provider) {
52 if (a.provider === b.provider) return tie;
53 return a.provider ? -1 : 1;
54 }
55 const c = a.provider.localeCompare(b.provider);
56 return c !== 0 ? (desc ? -c : c) : tie;
57 }
58 case "context": {
59 if (a.contextWindow === null || b.contextWindow === null) {
60 if (a.contextWindow === b.contextWindow) return tie;
61 return a.contextWindow === null ? 1 : -1;
62 }
63 return a.contextWindow !== b.contextWindow
64 ? desc
65 ? b.contextWindow - a.contextWindow
66 : a.contextWindow - b.contextWindow
67 : tie;
68 }
69 }
70 }
71
72 export function ModelsTable({
73 models,
74 locale,
75 }: {
76 models: ModelFact[];
77 locale: string;
78 }) {
79 const [key, setKey] = useState<SortKey>("added");
80 const [desc, setDesc] = useState(true);
81 const t = (copy: { en: string; zh: string }) => pickText(copy, locale);
82 const dateLocale = getChrome(locale).dateLocale;
83
84 const rows = useMemo(
85 () => [...models].sort((a, b) => compare(a, b, key, desc)),
86 [models, key, desc],
87 );
88
89 const setSort = (next: SortKey) => {
90 if (next === key) {
91 setDesc(!desc);
92 return;
93 }
94 setKey(next);
95 setDesc(next === "added" || next === "context");
96 };
97
98 const headers: { key: SortKey; label: string; className?: string }[] = [
99 { key: "id", label: t(MODELS_COPY.colModel) },
100 { key: "provider", label: t(MODELS_COPY.colProvider) },
101 { key: "context", label: t(MODELS_COPY.colContext) },
102 { key: "added", label: t(MODELS_COPY.colAdded) },
103 ];
104
105 return (
106 <div className="data-table-wrap">
107 <table className="data-table">
108 <caption className="sr-only">{t(MODELS_COPY.modelsTitle)}</caption>
109 <thead>
110 <tr>
111 {headers.map((h) => {
112 const active = key === h.key;
113 const ascending = active && !desc;
114 return (
115 <th
116 key={h.key}
117 scope="col"
118 aria-sort={active ? (ascending ? "ascending" : "descending") : "none"}
119 >
120 <button
121 type="button"
122 className="data-table-sort"
123 data-dir={active ? (ascending ? "asc" : "desc") : "none"}
124 onClick={() => setSort(h.key)}
125 title={t(ascending ? MODELS_COPY.sortDesc : MODELS_COPY.sortAsc)}
126 >
127 {h.label}
128 <Icon name="chevron-down" />
129 </button>
130 </th>
131 );
132 })}
133 </tr>
134 </thead>
135 <tbody>
136 {rows.map((model) => (
137 <tr key={model.id}>
138 <th scope="row">
139 <code>{model.id}</code>
140 {model.reasoning ? (
141 <span className="models-reasoning">{t(MODELS_COPY.reasoning)}</span>
142 ) : null}
143 </th>
144 <td>{model.provider ?? "—"}</td>
145 <td className="tabular">{formatTokens(model.contextWindow)}</td>
146 <td className="tabular">{formatDate(model.addedAt, dateLocale)}</td>
147 </tr>
148 ))}
149 </tbody>
150 </table>
151 </div>
152 );
153 }
154
154 lines Plain Text