From b339b6bd99f02066e82dcc4ebd20b1961bb09b27 Mon Sep 17 00:00:00 2001 From: Pan YANG Date: Sat, 18 Jul 2026 07:26:42 +0800 Subject: [PATCH] feat(models): unify comparison flow --- docs/PROJECT-REVIEW-2026-07-18.md | 8 +- .../models/compare/[models]/page.client.tsx | 40 +- .../compare/[models]/page.client.tsx.bak2 | 495 ------------------ .../models/comparison/page.client.tsx | 283 ---------- src/app/[locale]/models/comparison/page.tsx | 16 +- src/app/[locale]/models/page.client.tsx | 99 ++-- src/app/sitemap.ts | 1 - .../controls/ModelCompareSelector.tsx | 7 +- src/components/controls/useModelComparison.ts | 65 +++ src/lib/metadata/i18n-validation.ts | 2 +- src/lib/metadata/registry.ts | 2 +- src/lib/model-comparison.ts | 21 + tests/model-comparison.test.ts | 21 + 13 files changed, 210 insertions(+), 850 deletions(-) delete mode 100644 src/app/[locale]/models/compare/[models]/page.client.tsx.bak2 delete mode 100644 src/app/[locale]/models/comparison/page.client.tsx create mode 100644 src/components/controls/useModelComparison.ts create mode 100644 src/lib/model-comparison.ts create mode 100644 tests/model-comparison.test.ts diff --git a/docs/PROJECT-REVIEW-2026-07-18.md b/docs/PROJECT-REVIEW-2026-07-18.md index 854b2d94..91abd233 100644 --- a/docs/PROJECT-REVIEW-2026-07-18.md +++ b/docs/PROJECT-REVIEW-2026-07-18.md @@ -79,9 +79,9 @@ Baseline captured on 2026-07-18: ## P1 — Product Experience - [x] Stop statically generating every possible two-model comparison pair. -- [ ] Unify `/models/comparison` and `/models/compare/...` into one understandable comparison +- [x] Unify `/models/comparison` and `/models/compare/...` into one understandable comparison journey. -- [ ] Add “compare” actions to cards and detail pages with persistent selected items. +- [x] Add “compare” actions to cards and detail pages with persistent selected items. - [x] Search localized names/descriptions, capabilities, modalities, platforms, vendors, and types. - [ ] Add explicit use-case metadata and include it in search when the schema supports it. - [ ] Add guided selection filters: interface, budget, model freedom, privacy/local execution, @@ -167,3 +167,7 @@ evidence; completed items are reflected in the checklists and implementation log providers and representative GPT-5.2, Claude Haiku 4.5, and Gemini 3 Flash model records. - Corrected GPT-5.2 from `latest` to `maintained` after its official model page identified it as a previous frontier model that remains available. +- Made `/models/compare` the canonical model comparison journey, retained the old all-model URL as + a permanent redirect, and removed its duplicate comparison implementation. +- Added a persistent two-model selection shared by model cards, detail-page comparison actions, + canonical pair URLs, and the comparison selector; adding a third model replaces the oldest pick. diff --git a/src/app/[locale]/models/compare/[models]/page.client.tsx b/src/app/[locale]/models/compare/[models]/page.client.tsx index 1467bd63..5b83eaba 100644 --- a/src/app/[locale]/models/compare/[models]/page.client.tsx +++ b/src/app/[locale]/models/compare/[models]/page.client.tsx @@ -2,10 +2,12 @@ import { ExternalLink } from 'lucide-react' import { useTranslations } from 'next-intl' -import { Fragment, useEffect, useState } from 'react' +import { Fragment, useEffect, useRef } from 'react' +import { useModelComparison } from '@/components/controls/useModelComparison' import type { Locale } from '@/i18n/config' import { Link, useRouter } from '@/i18n/navigation' import { providersData } from '@/lib/generated' +import { buildModelComparisonPath } from '@/lib/model-comparison' import type { ManifestBenchmarks, ManifestModel, @@ -180,10 +182,17 @@ export default function ComparePageClient({ const router = useRouter() const tPage = useTranslations('pages.modelCompare') const tShared = useTranslations('shared') - const [selectedModel1, setSelectedModel1] = useState(initialModels[0]?.id ?? '') - const [selectedModel2, setSelectedModel2] = useState(initialModels[1]?.id ?? '') - const [prevModel1, setPrevModel1] = useState(initialModels[0]?.id ?? '') - const [prevModel2, setPrevModel2] = useState(initialModels[1]?.id ?? '') + const initialModelIds = initialModels.map(model => model.id) + const { selectedIds, isHydrated, setSelection } = useModelComparison(initialModelIds) + const selectedModel1 = selectedIds[0] ?? '' + const selectedModel2 = selectedIds[1] ?? '' + const lastComparisonPath = useRef(buildModelComparisonPath(initialModelIds)) + + const updateModelSlot = (slotIndex: number, value: string) => { + const nextSelection = [selectedModel1, selectedModel2] + nextSelection[slotIndex] = value + setSelection(nextSelection.filter(Boolean)) + } const findProviderId = (vendor: string): string | null => { const normalizedVendor = vendor.toLocaleLowerCase() @@ -471,17 +480,14 @@ export default function ComparePageClient({ ) } - // Update URL when both models are selected and at least one has changed + // Keep the shareable URL aligned with the persisted selection. useEffect(() => { - if (selectedModel1 && selectedModel2 && selectedModel1 !== selectedModel2) { - if (selectedModel1 !== prevModel1 || selectedModel2 !== prevModel2) { - const url = `/models/compare/${selectedModel1}-vs-${selectedModel2}` - router.push(url) - setPrevModel1(selectedModel1) - setPrevModel2(selectedModel2) - } - } - }, [selectedModel1, selectedModel2, prevModel1, prevModel2, router]) + if (!isHydrated) return + const nextPath = buildModelComparisonPath(selectedIds) + if (nextPath === lastComparisonPath.current) return + lastComparisonPath.current = nextPath + router.replace(nextPath) + }, [isHydrated, router, selectedIds]) const model1 = allModels.find(m => m.id === selectedModel1) const model2 = allModels.find(m => m.id === selectedModel2) @@ -498,7 +504,7 @@ export default function ComparePageClient({
updateModelSlot(0, value)} availableModels={getAvailableModelsForSlot(0)} />
@@ -507,7 +513,7 @@ export default function ComparePageClient({
updateModelSlot(1, value)} availableModels={getAvailableModelsForSlot(1)} />
diff --git a/src/app/[locale]/models/compare/[models]/page.client.tsx.bak2 b/src/app/[locale]/models/compare/[models]/page.client.tsx.bak2 deleted file mode 100644 index 1be27103..00000000 --- a/src/app/[locale]/models/compare/[models]/page.client.tsx.bak2 +++ /dev/null @@ -1,495 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { VerifiedBadge } from '@/components/controls/VerifiedBadge' -import type { Locale } from '@/i18n/config' -import { Link, useRouter } from '@/i18n/navigation' -import type { - ManifestBenchmarks, - ManifestModel, - ManifestPlatformUrls, - ManifestTokenPricing, -} from '@/types/manifests' -import type { ModelCapability, ModelInputModality } from '@/types/model-enums' - -const EMPTY_BENCHMARKS: ManifestBenchmarks = { - sweBench: null, - terminalBench: null, - mmmu: null, - mmmuPro: null, - webDevArena: null, - sciCode: null, - liveCodeBench: null, -} - -const EMPTY_PLATFORM_URLS: ManifestPlatformUrls = { - huggingface: null, - artificialAnalysis: null, - openrouter: null, -} - -const EMPTY_TOKEN_PRICING: ManifestTokenPricing = { - input: 0, - output: 0, - cache: null, -} - -const EMPTY_MODALITIES: ModelInputModality[] = [] -const EMPTY_CAPABILITIES: ModelCapability[] = [] - -const FALLBACK_MODEL: ManifestModel = { - id: '', - name: '', - vendor: '', - description: '', - translations: {}, - verified: false, - websiteUrl: '', - docsUrl: null, - size: '', - contextWindow: 0, - maxOutput: 0, - tokenPricing: EMPTY_TOKEN_PRICING, - releaseDate: null, - lifecycle: 'latest', - knowledgeCutoff: null, - inputModalities: EMPTY_MODALITIES, - capabilities: EMPTY_CAPABILITIES, - benchmarks: EMPTY_BENCHMARKS, - platformUrls: EMPTY_PLATFORM_URLS, -} - -interface ModelOption { - id: string - name: string -} - -interface Row { - group: string - groupLabel: string - key: string - label: string - render: (model: ManifestModel) => React.ReactNode -} - -interface ComparePageClientProps { - initialModels: ManifestModel[] - allModels: ModelOption[] - groups: string[] - locale: Locale -} - -export default function ComparePageClient({ - initialModels, - allModels, - groups, - locale, -}: ComparePageClientProps) { - const router = useRouter() - const [selectedModel1, setSelectedModel1] = useState(initialModels[0]?.id ?? '') - const [selectedModel2, setSelectedModel2] = useState(initialModels[1]?.id ?? '') - const [prevModel1, setPrevModel1] = useState(initialModels[0]?.id ?? '') - const [prevModel2, setPrevModel2] = useState(initialModels[1]?.id ?? '') - - const getRows = (): Row[] => { - return [ - { - group: 'basicInfo', - groupLabel: 'basicInfo', - key: 'vendor', - label: 'vendor', - render: model => ( - - {model.vendor} - - ), - }, - { - group: 'basicInfo', - groupLabel: 'basicInfo', - key: 'size', - label: 'modelSize', - render: model => model.size ?? '-', - }, - { - group: 'basicInfo', - groupLabel: 'basicInfo', - key: 'description', - label: 'description', - render: model => ( -

- {model.translations?.[locale]?.description ?? model.description} -

- ), - }, - { - group: 'basicInfo', - groupLabel: 'basicInfo', - key: 'verified', - label: 'verified', - render: model => (model.verified ? : '-'), - }, - { - group: 'basicInfo', - groupLabel: 'basicInfo', - key: 'lifecycle', - label: 'lifecycle', - render: model => model.lifecycle ?? '-', - }, - { - group: 'basicInfo', - groupLabel: 'basicInfo', - key: 'releaseDate', - label: 'releaseDate', - render: model => model.releaseDate ?? '-', - }, - { - group: 'basicInfo', - groupLabel: 'basicInfo', - key: 'knowledgeCutoff', - label: 'knowledgeCutoff', - render: model => model.knowledgeCutoff ?? '-', - }, - { - group: 'capabilities', - groupLabel: 'capabilities', - key: 'inputModalities', - label: 'inputModalities', - render: model => - model.inputModalities?.map(m => ( - - {m} - - )) ?? '-', - }, - { - group: 'capabilities', - groupLabel: 'capabilities', - key: 'capabilities', - label: 'capabilities', - render: model => - model.capabilities?.map(c => ( - - {c} - - )) ?? '-', - }, - { - group: 'performance', - groupLabel: 'performance', - key: 'contextWindow', - label: 'contextWindow', - render: model => - model.contextWindow ? `${(model.contextWindow / 1000).toFixed(0)}K` : '-', - }, - { - group: 'performance', - groupLabel: 'performance', - key: 'maxOutput', - label: 'maxOutput', - render: model => (model.maxOutput ? `${(model.maxOutput / 1000).toFixed(0)}K` : '-'), - }, - { - group: 'performance', - groupLabel: 'performance', - key: 'sweBench', - label: 'sweBench', - render: model => (model.benchmarks?.sweBench ? `${model.benchmarks.sweBench}%` : '-'), - }, - { - group: 'performance', - groupLabel: 'performance', - key: 'terminalBench', - label: 'terminalBench', - render: model => - model.benchmarks?.terminalBench ? `${model.benchmarks.terminalBench}%` : '-', - }, - { - group: 'performance', - groupLabel: 'performance', - key: 'sciCode', - label: 'sciCode', - render: model => (model.benchmarks?.sciCode ? `${model.benchmarks.sciCode}%` : '-'), - }, - { - group: 'performance', - groupLabel: 'performance', - key: 'liveCodeBench', - label: 'liveCodeBench', - render: model => - model.benchmarks?.liveCodeBench ? `${model.benchmarks.liveCodeBench}%` : '-', - }, - { - group: 'performance', - groupLabel: 'performance', - key: 'mmmu', - label: 'mmmu', - render: model => (model.benchmarks?.mmmu ? `${model.benchmarks.mmmu}%` : '-'), - }, - { - group: 'performance', - groupLabel: 'performance', - key: 'mmmuPro', - label: 'mmmuPro', - render: model => (model.benchmarks?.mmmuPro ? `${model.benchmarks.mmmuPro}%` : '-'), - }, - { - group: 'performance', - groupLabel: 'performance', - key: 'webDevArena', - label: 'webDevArena', - render: model => (model.benchmarks?.webDevArena ? `${model.benchmarks.webDevArena}%` : '-'), - }, - { - group: 'pricing', - groupLabel: 'pricing', - key: 'inputPrice', - label: 'inputPrice', - render: model => - model.tokenPricing?.input - ? `$${(model.tokenPricing.input / 1000000).toFixed(2)}/1M tokens` - : '-', - }, - { - group: 'pricing', - groupLabel: 'pricing', - key: 'outputPrice', - label: 'outputPrice', - render: model => - model.tokenPricing?.output - ? `$${(model.tokenPricing.output / 1000000).toFixed(2)}/1M tokens` - : '-', - }, - { - group: 'pricing', - groupLabel: 'pricing', - key: 'cachePrice', - label: 'cachePrice', - render: model => - model.tokenPricing?.cache - ? `$${(model.tokenPricing.cache / 1000000).toFixed(2)}/1M tokens` - : '-', - }, - { - group: 'platforms', - groupLabel: 'platforms', - key: 'huggingface', - label: 'huggingface', - render: model => - model.platformUrls?.huggingface ? ( - - Link - - ) : ( - '-' - ), - }, - { - group: 'platforms', - groupLabel: 'platforms', - key: 'artificialAnalysis', - label: 'artificialAnalysis', - render: model => - model.platformUrls?.artificialAnalysis ? ( - - Link - - ) : ( - '-' - ), - }, - { - group: 'platforms', - groupLabel: 'platforms', - key: 'openrouter', - label: 'openrouter', - render: model => - model.platformUrls?.openrouter ? ( - - Link - - ) : ( - '-' - ), - }, - ] - } - - const rowsByGroup = getRows().reduce( - (acc, row) => { - if (!acc[row.group]) { - acc[row.group] = [] - } - acc[row.group]!.push(row) - return acc - }, - {} as Record - ) - - const getAvailableModelsForSlot = (slotIndex: number): ModelOption[] => { - if (slotIndex === 0) { - return allModels.filter(m => m.id !== selectedModel2) - } - return allModels.filter(m => m.id !== selectedModel1) - } - - // Update URL when both models are selected and at least one has changed - useEffect(() => { - if (selectedModel1 && selectedModel2 && selectedModel1 !== selectedModel2) { - if (selectedModel1 !== prevModel1 || selectedModel2 !== prevModel2) { - const url = `/models/compare/${selectedModel1}-vs-${selectedModel2}` - router.push(url) - setPrevModel1(selectedModel1) - setPrevModel2(selectedModel2) - } - } - }, [selectedModel1, selectedModel2, prevModel1, prevModel2, router]) - - const model1 = allModels.find(m => m.id === selectedModel1) - const model2 = allModels.find(m => m.id === selectedModel2) - - return ( -
- {/* Model selection header */} -
-
-
- Model 1 - -
-
- Model 2 - -
-
-
- - {/* Comparison table */} - {model1 && model2 && ( -
- - - - - - - - - - {groups.map(group => { - const groupRows = rowsByGroup[group] - if (!groupRows) return null - - return ( - - - - ) - })} - {getRows().map(row => ( - - - - - - ))} - -
-
- {/* biome-ignore lint/performance/noImgElement: External CDN brand icon */} - m.id === selectedModel1)?.vendor?.toLowerCase() || ''}.png`} - alt={initialModels.find(m => m.id === selectedModel1)?.vendor || ''} - className="w-6 h-6" - onError={e => { - e.currentTarget.style.display = 'none' - }} - /> - {model1.name} -
-
- Attribute - -
- m.id === selectedModel2)?.vendor?.toLowerCase() || ''}.png`} - alt={initialModels.find(m => m.id === selectedModel2)?.vendor || ''} - className="w-6 h-6" - onError={e => { - e.currentTarget.style.display = 'none' - }} - /> - {model2.name} -
-
- {group} -
- {row.render(initialModels.find(m => m.id === selectedModel1) ?? FALLBACK_MODEL)} - - {row.label} - - {row.render(initialModels.find(m => m.id === selectedModel2) ?? FALLBACK_MODEL)} -
-
- )} - - {(!model1 || !model2) && ( -
-

Select 2 models to compare

-
- )} -
- ) -} diff --git a/src/app/[locale]/models/comparison/page.client.tsx b/src/app/[locale]/models/comparison/page.client.tsx deleted file mode 100644 index bfd6e678..00000000 --- a/src/app/[locale]/models/comparison/page.client.tsx +++ /dev/null @@ -1,283 +0,0 @@ -'use client' - -import { Home } from 'lucide-react' - -import { useTranslations } from 'next-intl' - -import Footer from '@/components/Footer' -import Header from '@/components/Header' -import { Breadcrumb } from '@/components/navigation/Breadcrumb' -import ComparisonTable, { type ComparisonColumn } from '@/components/product/ComparisonTable' - -import { Link } from '@/i18n/navigation' - -import { BENCHMARK_KEYS, formatBenchmarkValue } from '@/lib/benchmarks' -import { modelsData as models } from '@/lib/generated' -import { MODEL_CAPABILITIES, MODEL_INPUT_MODALITIES } from '@/types/model-enums' - -type Props = { - locale: string -} - -// Helper to wrap content in a span with alignment -function wrapWithAlign( - content: string | React.ReactNode, - align: 'left' | 'center' | 'right' = 'left' -): React.ReactNode { - const alignClass = - align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left' - return {content} -} - -// Format token count to K units -function formatTokenCount(value: unknown): React.ReactNode { - if (typeof value !== 'number') return wrapWithAlign('-', 'right') - const kValue = (value / 1000).toFixed(0) - return wrapWithAlign(`${Number(kValue).toLocaleString()}K`, 'right') -} - -// Create a token pricing column renderer -function createTokenPricingRenderer(field: 'input' | 'output' | 'cache') { - return (_: unknown, item: Record) => { - const tokenPricing = item.tokenPricing as Record | undefined - const value = tokenPricing?.[field] - return wrapWithAlign( - value !== null && value !== undefined ? `$${value.toFixed(2)}` : '-', - 'right' - ) - } -} - -// Create abbreviation renderer with all possible values -function createAbbreviationsRenderer(allValues: readonly string[]) { - return (value: unknown): React.ReactNode => { - const actualValues = Array.isArray(value) ? (value as string[]) : [] - const actualSet = new Set(actualValues) - - return ( - - {allValues.map(v => { - const initial = v.charAt(0).toUpperCase() - const tooltip = v - .split('-') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') - const isActive = actualSet.has(v) - - return ( - - {initial} - - ) - })} - - ) - } -} - -// Platform link configuration -const PLATFORM_LINK_CONFIG: readonly { - key: string - initial: string - urlKey: 'huggingface' | 'artificialAnalysis' | 'openrouter' -}[] = [ - { key: 'huggingface', initial: 'H', urlKey: 'huggingface' }, - { key: 'artificialAnalysis', initial: 'A', urlKey: 'artificialAnalysis' }, - { key: 'openrouter', initial: 'O', urlKey: 'openrouter' }, -] as const - -// Create platform link element -function createPlatformLink( - link: (typeof PLATFORM_LINK_CONFIG)[number], - url: string | null | undefined, - label: string -) { - const baseClasses = - 'inline-flex items-center justify-center px-1 py-[1px] text-xs leading-none border border-[var(--color-border)] rounded' - if (url) { - return ( - - {link.initial} - - ) - } - return ( - - {link.initial} - - ) -} - -// Pricing column configuration -const PRICING_CONFIG: readonly { field: 'input' | 'output' | 'cache'; labelKey: string }[] = [ - { field: 'input', labelKey: 'pricingInput' }, - { field: 'output', labelKey: 'pricingOutput' }, - { field: 'cache', labelKey: 'pricingCache' }, -] as const - -export default function ModelComparisonPageClient({ locale: _locale }: Props) { - const tPage = useTranslations('pages.comparison') - const tShared = useTranslations('shared') - - const columns: ComparisonColumn[] = [ - { - key: 'vendor', - label: tShared('categories.singular.vendor'), - }, - { - key: 'links', - label: tPage('columns.links'), - render: (_: unknown, item: Record) => { - const websiteUrl = item.websiteUrl as string | null | undefined - const platformUrls = item.platformUrls as - | { - huggingface?: string | null - artificialAnalysis?: string | null - openrouter?: string | null - } - | undefined - - return ( - - {websiteUrl ? ( - - - - ) : ( - - - - )} - {PLATFORM_LINK_CONFIG.map(link => - createPlatformLink( - link, - platformUrls?.[link.urlKey], - tShared(`platforms.${link.key}`) - ) - )} - - ) - }, - }, - { - key: 'size', - label: tShared('terms.modelSize'), - render: (value: unknown) => { - if (!value) return wrapWithAlign('-', 'right') - return wrapWithAlign(value as string, 'right') - }, - }, - { - key: 'contextWindow', - label: tPage('columns.contextLength'), - render: formatTokenCount, - }, - { - key: 'maxOutput', - label: tShared('terms.maxOutput'), - render: formatTokenCount, - }, - ...PRICING_CONFIG.map(({ field, labelKey }) => ({ - key: `tokenPricing${field.charAt(0).toUpperCase() + field.slice(1)}`, - label: tPage(`columns.${labelKey}`), - title: `${tPage(`columns.${labelKey}`)} (/M)`, - render: createTokenPricingRenderer(field), - })), - { - key: 'inputModalities', - label: tShared('capabilities.inputModalities'), - render: createAbbreviationsRenderer(MODEL_INPUT_MODALITIES), - }, - { - key: 'capabilities', - label: tShared('capabilities.capabilities'), - render: createAbbreviationsRenderer(MODEL_CAPABILITIES), - }, - ...BENCHMARK_KEYS.map(key => ({ - key, - label: tShared(`benchmarks.${key}`), - render: (_: unknown, item: Record) => { - const benchmarks = item.benchmarks as Record | undefined - const value = benchmarks?.[key] - if (value === null || value === undefined) return wrapWithAlign('-', 'center') - return wrapWithAlign(formatBenchmarkValue(key, value), 'center') - }, - })), - ] - - return ( - <> -
- - - - {/* Page Header */} -
-
-

- {tPage('models.title')} -

-

- {tPage('models.subtitle')} -

-
-
- - {/* Comparison Table */} -
-
- []} - columns={columns} - itemLinkPrefix={`/models`} - nameColumnLabel={tShared('labels.name')} - /> -
-
- - {/* Back Navigation */} -
-
- - ← {tPage('models.backTo')} - -
-
- -