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