diff --git a/i18n/en/code.json b/i18n/en/code.json
index 31e8f83..d9d184d 100644
--- a/i18n/en/code.json
+++ b/i18n/en/code.json
@@ -1,4 +1,10 @@
{
+ "catalog.recipeNameAsc": {
+ "message": "Recipe name A–Z"
+ },
+ "catalog.recipeNameDesc": {
+ "message": "Recipe name Z–A"
+ },
"catalog.loadingDetails": {
"message": "Loading details…",
"description": "正在加载详情…"
@@ -1676,8 +1682,8 @@
"description": "按模型名称 Z–A"
},
"site.6592019eaa05": {
- "message": "Recipe release date",
- "description": "按配方发布日期"
+ "message": "Newest first",
+ "description": "按最新发布排序"
},
"site.7904ff565aaa": {
"message": "Card view",
diff --git a/src/features/catalog/CatalogPage.tsx b/src/features/catalog/CatalogPage.tsx
index 1c7c261..62bcf7d 100644
--- a/src/features/catalog/CatalogPage.tsx
+++ b/src/features/catalog/CatalogPage.tsx
@@ -36,6 +36,7 @@ import {
reportHref,
} from './routes';
import CatalogSelect from './CatalogSelect';
+import CatalogSort from './CatalogSort';
import ModelCard, { ModelRecipeList, ModelTuning } from './ModelCard';
import styles from './styles.module.css';
@@ -316,49 +317,10 @@ export default function CatalogPage() {
})}
- update({ sort: value })}
- options={[
- {
- value: 'improvement-desc',
- label:
- q.view === 'models'
- ? translate({
- id: 'site.f7e6d40ff9f5',
- message: '按调优配方最佳提升排序',
- })
- : translate({
- id: 'site.26ba23cf9e57',
- message: '按吞吐量提升排序',
- }),
- },
- {
- value: 'name-asc',
- label: translate({
- id: 'site.e273b1d2eca6',
- message: '按模型名称 A–Z',
- }),
- },
- {
- value: 'name-desc',
- label: translate({
- id: 'site.1322e7738e4a',
- message: '按模型名称 Z–A',
- }),
- },
- {
- value: 'updated-desc',
- label: translate({
- id: 'site.6592019eaa05',
- message: '按配方发布日期',
- }),
- },
- ]}
+ context={q.view === 'models' ? 'models' : 'recipes'}
/>
diff --git a/src/features/catalog/CatalogSort.tsx b/src/features/catalog/CatalogSort.tsx
new file mode 100644
index 0000000..446bae6
--- /dev/null
+++ b/src/features/catalog/CatalogSort.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { translate } from '@docusaurus/Translate';
+import CatalogSelect from './CatalogSelect';
+
+export default function CatalogSort({
+ value,
+ onValueChange,
+ context = 'recipes',
+}: {
+ value: string;
+ onValueChange: (value: string) => void;
+ context?: 'models' | 'recipes' | 'model-recipes';
+}) {
+ return (
+
+ );
+}
diff --git a/src/features/catalog/DetailPage.tsx b/src/features/catalog/DetailPage.tsx
index 9960d50..ad16de1 100644
--- a/src/features/catalog/DetailPage.tsx
+++ b/src/features/catalog/DetailPage.tsx
@@ -35,6 +35,8 @@ import ReportPanel from './ReportPanel';
import { availableReportTunings } from './report';
import CatalogLink from './CatalogLink';
import CatalogSelect from './CatalogSelect';
+import CatalogSort from './CatalogSort';
+import { compare, initialQuery, parseSort } from './query';
import RecipeOverview from './RecipeOverview';
import styles from './styles.module.css';
@@ -81,6 +83,7 @@ function DetailContent({ kind }: { kind: 'model' | 'recipe' }) {
params.get('dataVersion') ?? navigation.catalogDataVersion;
const state = useCatalog(dataVersion),
c = state.catalog;
+ const sort = parseSort(params.get('sort'));
const recipeParams = new URLSearchParams(location.search);
recipeParams.delete('returnTo');
if (recipeParams.get('dataVersion') === state.currentCommit)
@@ -90,7 +93,11 @@ function DetailContent({ kind }: { kind: 'model' | 'recipe' }) {
!browser || !modelId
? null
: kind === 'model'
- ? modelRoute(modelId, params.get('dataVersion') ?? '')
+ ? modelRoute(
+ modelId,
+ params.get('dataVersion') ?? '',
+ sort === initialQuery.sort ? '' : sort,
+ )
: recipeId
? recipeRoute(modelId, recipeId, recipeParams.toString())
: null;
@@ -129,6 +136,10 @@ function DetailContent({ kind }: { kind: 'model' | 'recipe' }) {
[c, modelId],
);
const recipe = recipes.find((r) => r.variant.id === recipeId);
+ const sortedRecipes = useMemo(
+ () => [...recipes].sort((a, b) => compare(a, b, sort)),
+ [recipes, sort],
+ );
const [revisions, setRevisions] = useState(null),
[historyError, setHistoryError] = useState(''),
[text, setText] = useState(''),
@@ -230,6 +241,19 @@ function DetailContent({ kind }: { kind: 'model' | 'recipe' }) {
modelNavigationState(dataVersion, returnTo),
);
}
+ function chooseSort(value: string) {
+ if (value === sort) return;
+ history.push(
+ siteHref(
+ modelRoute(
+ modelId,
+ params.get('dataVersion') ?? '',
+ value === initialQuery.sort ? '' : value,
+ ),
+ ) + location.hash,
+ modelNavigationState(dataVersion, returnTo),
+ );
+ }
function chooseTab(tab: RecipeTab) {
if (tab !== activeTab)
history.push(
@@ -487,9 +511,16 @@ function DetailContent({ kind }: { kind: 'model' | 'recipe' }) {
{recipes.length}
+
+
+
- {recipes.map((r) => (
+ {sortedRecipes.map((r) => (
{
let attempts = 0;
+ let available = false;
+ // Keep the report unavailable until Retry, including any redirect remounts.
await page.route('**/mimo-v2.5-h100-report.html', (route) => {
attempts++;
- return attempts === 1
- ? route.fulfill({ status: 503, body: 'Unavailable' })
- : route.fulfill({ body: report, contentType: 'text/html' });
+ return available
+ ? route.fulfill({ body: report, contentType: 'text/html' })
+ : route.fulfill({ status: 503, body: 'Unavailable' });
});
await page.goto(
`/models/recipe?modelId=mimo-v2.5&recipeId=${variant.id}&tab=performance`,
@@ -1218,12 +1220,14 @@ test('original report retries a failed request without requiring structured JSON
await expect(
page.getByRole('button', { name: '全屏观看', exact: true }),
).toBeDisabled();
+ const attemptsBeforeRetry = attempts;
+ available = true;
await page.getByRole('button', { name: '重试', exact: true }).click();
await expect(reportBody(page)).toContainText('119.9%');
await expect(
page.getByRole('button', { name: '全屏观看', exact: true }),
).toBeEnabled();
- expect(attempts).toBe(2);
+ expect(attempts).toBe(attemptsBeforeRetry + 1);
});
test('history opens the exact configuration and keeps reports bound to their measured version', async ({
diff --git a/tests/e2e/model-recipe-sort.spec.ts b/tests/e2e/model-recipe-sort.spec.ts
new file mode 100644
index 0000000..1322f30
--- /dev/null
+++ b/tests/e2e/model-recipe-sort.spec.ts
@@ -0,0 +1,110 @@
+import { test, expect } from '@playwright/test';
+import baseline from '../../content/catalog/baseline.json';
+import { parseCatalog } from '../../src/features/catalog/data';
+
+for (const width of [390, 1440]) {
+ test(`model recipes sort by gain, recipe name and date at ${width}px`, async ({
+ page,
+ }) => {
+ const source = parseCatalog(structuredClone(baseline));
+ const model = source.models.find((item) => item.name === 'glm5.3')!;
+ const template = model.variants[0];
+ model.variants = [
+ { ...template, id: 'alpha', releaseDate: '2026-11-10' },
+ { ...template, id: 'bravo', releaseDate: '2026-10-01' },
+ { ...template, id: 'charlie', releaseDate: undefined },
+ { ...template, id: 'delta', releaseDate: '2026-11-01' },
+ ].map((variant) => ({
+ ...variant,
+ path: `models/${model.name}/${variant.id}.yaml`,
+ history: `models/${model.name}/${variant.id}.history.json`,
+ }));
+ model.tuning = [
+ {
+ optimized: `alpha@${template.version}`,
+ uplift: 10,
+ workloads: [],
+ baseline: '',
+ },
+ {
+ optimized: `bravo@${template.version}`,
+ uplift: 50,
+ workloads: [],
+ baseline: '',
+ },
+ {
+ optimized: `delta@${template.version}`,
+ uplift: 50,
+ workloads: [],
+ baseline: '',
+ },
+ ];
+ const catalog = parseCatalog(source);
+ await page.route(
+ 'https://modelsphere.github.io/model-catalog/**',
+ (route) =>
+ route.request().url().endsWith('index-v2.json')
+ ? route.fulfill({ json: catalog })
+ : route.abort(),
+ );
+ await page.setViewportSize({ width, height: 1000 });
+ const returnTo = '/models?q=glm5.3&layout=table';
+ await page.goto(
+ `/models/detail?modelId=${model.name}&dataVersion=${source.commit}&returnTo=${encodeURIComponent(returnTo)}`,
+ );
+ const section = page.getByRole('region', { name: '可用部署配方 · 4' });
+ const sort = section.getByRole('combobox', { name: '排序方式' });
+ async function expectOrder(ids: string[]) {
+ await expect
+ .poll(() =>
+ section
+ .locator('[data-recipe-id]')
+ .evaluateAll((cards) =>
+ cards.map((card) => card.getAttribute('data-recipe-id')),
+ ),
+ )
+ .toEqual(ids.map((id) => `${model.name}/${id}`));
+ }
+ async function choose(label: string) {
+ await sort.click();
+ await page.getByRole('option', { name: label, exact: true }).click();
+ }
+
+ await expect(sort).toHaveText('按吞吐量提升排序');
+ await expectOrder(['bravo', 'delta', 'alpha', 'charlie']);
+ await choose('按配方名称 A–Z');
+ await expectOrder(['alpha', 'bravo', 'charlie', 'delta']);
+ await choose('按配方名称 Z–A');
+ await expectOrder(['delta', 'charlie', 'bravo', 'alpha']);
+ await choose('按最新发布排序');
+ await expectOrder(['alpha', 'delta', 'bravo', 'charlie']);
+ await expect(page).toHaveURL(
+ (url) =>
+ url.pathname === '/model' &&
+ url.searchParams.get('sort') === 'updated-desc' &&
+ url.searchParams.get('dataVersion') === source.commit,
+ );
+ await page.reload();
+ await expect(sort).toHaveText('按最新发布排序');
+ await expectOrder(['alpha', 'delta', 'bravo', 'charlie']);
+ await page.goBack();
+ await expect(sort).toHaveText('按配方名称 Z–A');
+ await expectOrder(['delta', 'charlie', 'bravo', 'alpha']);
+ await page.goForward();
+ await expect(sort).toHaveText('按最新发布排序');
+ await choose('按吞吐量提升排序');
+ await expectOrder(['bravo', 'delta', 'alpha', 'charlie']);
+ await expect(page).toHaveURL((url) => !url.searchParams.has('sort'));
+ await expect(
+ page.getByRole('navigation', { name: '面包屑' }).getByRole('link', {
+ name: '模型与调优',
+ exact: true,
+ }),
+ ).toHaveAttribute('href', returnTo);
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth,
+ ),
+ ).toBe(true);
+ });
+}
diff --git a/tests/unit/model-recipe-sort.test.ts b/tests/unit/model-recipe-sort.test.ts
new file mode 100644
index 0000000..2265113
--- /dev/null
+++ b/tests/unit/model-recipe-sort.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { baseline, recipesOf } from '../../src/features/catalog/data';
+import { compare, parseSort } from '../../src/features/catalog/query';
+
+describe('model recipe sorting', () => {
+ const template = recipesOf(baseline)[0];
+ const recipes = [
+ { id: 'charlie', gain: null, date: undefined },
+ { id: 'alpha', gain: 0, date: '2026-11-10' },
+ { id: 'delta', gain: 50, date: '2026-11-01' },
+ { id: 'bravo', gain: 50, date: '2026-10-01' },
+ ].map(({ id, gain, date }) => ({
+ ...template,
+ key: `${template.model.name}/${id}`,
+ variant: { ...template.variant, id, releaseDate: date },
+ quotedGain: gain,
+ }));
+ const order = (sort: string) =>
+ [...recipes].sort((a, b) => compare(a, b, sort)).map((r) => r.variant.id);
+
+ it('reverses recipe names within the same model', () => {
+ expect(order('name-asc')).toEqual(['alpha', 'bravo', 'charlie', 'delta']);
+ expect(order('name-desc')).toEqual(['delta', 'charlie', 'bravo', 'alpha']);
+ });
+
+ it('puts missing gains last, keeps zero gains and resolves ties consistently', () => {
+ expect(order('improvement-desc')).toEqual([
+ 'bravo',
+ 'delta',
+ 'alpha',
+ 'charlie',
+ ]);
+ });
+
+ it('orders newest dates first and puts missing dates last', () => {
+ expect(order('updated-desc')).toEqual([
+ 'alpha',
+ 'delta',
+ 'bravo',
+ 'charlie',
+ ]);
+ });
+
+ it('uses the default for missing or unsupported URL values', () => {
+ expect(parseSort(null)).toBe('improvement-desc');
+ expect(parseSort('unsupported')).toBe('improvement-desc');
+ for (const value of ['name-asc', 'name-desc', 'updated-desc'])
+ expect(parseSort(value)).toBe(value);
+ });
+});