From 0bf23b490fb63da5b4bb9eaddd842a83012f89d9 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Wed, 21 Oct 2026 07:59:40 +0800 Subject: [PATCH 1/2] feat(ui): translate the viewer with vue-i18n and let readers pick the language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer was English only, while evals already label scores, metrics, and artifacts in several languages (`{ 'en': …, 'zh-Hans': … }`). - vue-i18n 11 with typed TypeScript messages per area (`client/src/i18n/locales/{en,zh-Hans}/.ts`); a zh-Hans module that misses a key fails vue-tsc - `useLocale` and the `vieval-viewer:locale` preference (`auto` or a code); `scheme.js` sets `` before the first paint - the Colors popover becomes Preferences, with a language dropdown (Auto, English, 简体中文) built on `option-trigger`, `AnimatedContent`, and `DropdownMenuRadioGroup`; phones keep three header buttons - every page, panel, menu, tooltip, and empty state translated to zh-Hans, with real plurals in English - `localize()`, numbers, dates, and relative times follow the picked language Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/public/scheme.js | 20 ++ packages/ui/client/src/App.vue | 25 +- packages/ui/client/src/caseMetrics.test.ts | 29 +- packages/ui/client/src/caseMetrics.ts | 21 +- .../src/components/ArtifactViewer.test.ts | 14 + .../client/src/components/ArtifactViewer.vue | 48 ++-- .../client/src/components/ArtifactsPanel.vue | 10 +- .../ui/client/src/components/AxisPicker.vue | 10 +- .../client/src/components/AxisPickerList.vue | 36 ++- .../ui/client/src/components/BarChart.vue | 6 +- .../ui/client/src/components/Breadcrumbs.vue | 10 +- .../ui/client/src/components/CaseCard.vue | 20 +- .../client/src/components/CaseCartesian.vue | 15 +- .../src/components/CaseMetricsPanel.vue | 60 ++-- .../ui/client/src/components/CaseTimeline.vue | 32 ++- .../ui/client/src/components/CompareMenu.vue | 14 +- .../ui/client/src/components/CountsBar.vue | 24 +- .../src/components/CrumbSwitcher.test.ts | 16 ++ .../client/src/components/CrumbSwitcher.vue | 14 +- .../src/components/ErrorGroupsPanel.test.ts | 16 ++ .../src/components/ErrorGroupsPanel.vue | 11 +- .../client/src/components/EvidencePanel.vue | 56 ++-- .../client/src/components/ExecutionTree.vue | 42 +-- .../ui/client/src/components/GroupBars.vue | 10 +- .../src/components/HeadlineScore.test.ts | 17 ++ .../client/src/components/HeadlineScore.vue | 12 +- .../client/src/components/HuePicker.test.ts | 35 --- .../ui/client/src/components/HuePicker.vue | 79 ----- packages/ui/client/src/components/IdMenu.vue | 16 +- .../client/src/components/ImageSequence.vue | 18 +- .../src/components/InferencePanel.test.ts | 18 ++ .../client/src/components/InferencePanel.vue | 113 ++++---- .../ui/client/src/components/JsonlTable.vue | 13 +- .../ui/client/src/components/LineChart.vue | 4 +- packages/ui/client/src/components/LogView.vue | 13 +- .../ui/client/src/components/MatrixView.vue | 52 ++-- .../client/src/components/MetricSummary.vue | 31 +- .../client/src/components/ObjectTree.test.ts | 15 + .../client/src/components/ObjectTreeNode.vue | 8 +- .../ui/client/src/components/OtelPanel.vue | 39 ++- .../ui/client/src/components/PageHeader.vue | 6 +- .../ui/client/src/components/PairsHeatmap.vue | 15 +- .../src/components/ParallelCoordinates.vue | 17 +- .../ui/client/src/components/ParallelSets.vue | 15 +- .../src/components/PreferencesMenu.test.ts | 64 +++++ .../client/src/components/PreferencesMenu.vue | 127 +++++++++ .../client/src/components/ProgressChart.vue | 16 +- .../client/src/components/ProgressPanel.vue | 70 +++-- .../client/src/components/RunDetails.test.ts | 16 ++ .../ui/client/src/components/RunDetails.vue | 24 +- packages/ui/client/src/components/RunEta.vue | 16 +- .../client/src/components/ScatterMatrix.vue | 7 +- .../ui/client/src/components/ScoreBadge.vue | 4 +- .../ui/client/src/components/ScoreBar.vue | 8 +- .../ui/client/src/components/ScoreDetail.vue | 22 +- .../ui/client/src/components/ScoresPanel.vue | 70 ++--- packages/ui/client/src/components/Sidebar.vue | 13 +- .../ui/client/src/components/StateBadge.vue | 11 +- .../ui/client/src/components/StateView.vue | 15 +- .../ui/client/src/composables/useLocale.ts | 35 +++ packages/ui/client/src/errorGroups.test.ts | 13 + packages/ui/client/src/errorGroups.ts | 7 +- packages/ui/client/src/format.test.ts | 22 ++ packages/ui/client/src/format.ts | 39 ++- packages/ui/client/src/hierarchy.ts | 28 +- packages/ui/client/src/i18n/index.test.ts | 40 +++ packages/ui/client/src/i18n/index.ts | 157 ++++++++++ .../ui/client/src/i18n/locales/en/cases.ts | 178 ++++++++++++ .../ui/client/src/i18n/locales/en/common.ts | 11 + .../ui/client/src/i18n/locales/en/compare.ts | 46 +++ .../ui/client/src/i18n/locales/en/evidence.ts | 49 ++++ .../ui/client/src/i18n/locales/en/index.ts | 26 ++ .../ui/client/src/i18n/locales/en/matrix.ts | 50 ++++ .../ui/client/src/i18n/locales/en/metrics.ts | 134 +++++++++ .../client/src/i18n/locales/en/navigation.ts | 9 + .../client/src/i18n/locales/en/preferences.ts | 25 ++ .../ui/client/src/i18n/locales/en/runs.ts | 269 ++++++++++++++++++ .../ui/client/src/i18n/locales/en/scores.ts | 98 +++++++ .../ui/client/src/i18n/locales/en/states.ts | 23 ++ .../client/src/i18n/locales/zh-Hans/cases.ts | 175 ++++++++++++ .../client/src/i18n/locales/zh-Hans/common.ts | 12 + .../src/i18n/locales/zh-Hans/compare.ts | 47 +++ .../src/i18n/locales/zh-Hans/evidence.ts | 46 +++ .../client/src/i18n/locales/zh-Hans/index.ts | 27 ++ .../client/src/i18n/locales/zh-Hans/matrix.ts | 48 ++++ .../src/i18n/locales/zh-Hans/metrics.ts | 118 ++++++++ .../src/i18n/locales/zh-Hans/navigation.ts | 10 + .../src/i18n/locales/zh-Hans/preferences.ts | 25 ++ .../client/src/i18n/locales/zh-Hans/runs.ts | 260 +++++++++++++++++ .../client/src/i18n/locales/zh-Hans/scores.ts | 82 ++++++ .../client/src/i18n/locales/zh-Hans/states.ts | 21 ++ packages/ui/client/src/labels.test.ts | 15 + packages/ui/client/src/labels.ts | 12 +- packages/ui/client/src/localize.test.ts | 14 + packages/ui/client/src/localize.ts | 33 +-- packages/ui/client/src/main.ts | 2 + packages/ui/client/src/pages/CasePage.vue | 62 ++-- packages/ui/client/src/pages/ComparePage.vue | 56 ++-- .../ui/client/src/pages/ExperimentPage.vue | 37 +-- packages/ui/client/src/pages/HomePage.vue | 18 +- packages/ui/client/src/pages/ProjectPage.vue | 29 +- packages/ui/client/src/pages/RunPage.vue | 28 +- packages/ui/client/src/pages/TaskPage.vue | 75 ++--- packages/ui/client/src/scores.test.ts | 17 ++ packages/ui/client/src/scores.ts | 68 +++-- packages/ui/client/src/switcher.ts | 12 +- packages/ui/client/test-setup.ts | 8 +- packages/ui/package.json | 1 + pnpm-lock.yaml | 59 ++++ pnpm-workspace.yaml | 1 + 110 files changed, 3461 insertions(+), 794 deletions(-) delete mode 100644 packages/ui/client/src/components/HuePicker.test.ts delete mode 100644 packages/ui/client/src/components/HuePicker.vue create mode 100644 packages/ui/client/src/components/PreferencesMenu.test.ts create mode 100644 packages/ui/client/src/components/PreferencesMenu.vue create mode 100644 packages/ui/client/src/composables/useLocale.ts create mode 100644 packages/ui/client/src/format.test.ts create mode 100644 packages/ui/client/src/i18n/index.test.ts create mode 100644 packages/ui/client/src/i18n/index.ts create mode 100644 packages/ui/client/src/i18n/locales/en/cases.ts create mode 100644 packages/ui/client/src/i18n/locales/en/common.ts create mode 100644 packages/ui/client/src/i18n/locales/en/compare.ts create mode 100644 packages/ui/client/src/i18n/locales/en/evidence.ts create mode 100644 packages/ui/client/src/i18n/locales/en/index.ts create mode 100644 packages/ui/client/src/i18n/locales/en/matrix.ts create mode 100644 packages/ui/client/src/i18n/locales/en/metrics.ts create mode 100644 packages/ui/client/src/i18n/locales/en/navigation.ts create mode 100644 packages/ui/client/src/i18n/locales/en/preferences.ts create mode 100644 packages/ui/client/src/i18n/locales/en/runs.ts create mode 100644 packages/ui/client/src/i18n/locales/en/scores.ts create mode 100644 packages/ui/client/src/i18n/locales/en/states.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/cases.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/common.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/compare.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/evidence.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/index.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/matrix.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/metrics.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/navigation.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/preferences.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/runs.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/scores.ts create mode 100644 packages/ui/client/src/i18n/locales/zh-Hans/states.ts diff --git a/packages/ui/client/public/scheme.js b/packages/ui/client/public/scheme.js index c4bdd11..6616c82 100644 --- a/packages/ui/client/public/scheme.js +++ b/packages/ui/client/public/scheme.js @@ -13,3 +13,23 @@ document.documentElement.style.setProperty('--chromatic-hue', String(Number.isFi // `src/theme.ts` owns the key (keep in sync); `useColorVision` updates the attribute afterwards. if (localStorage.getItem('vieval-viewer:color-vision') === 'safe') document.documentElement.dataset.colorVision = 'safe' + +// Sets `` to the saved or browser language before the first paint, so the right CJK font and hyphenation +// apply at once. `src/i18n/index.ts` owns the key and the locales, and `resolveLocale` the matching (keep in sync): +// the first browser language with the same language and script as a supported locale (`zh-CN` → `zh-Hans`), else `en`. +// `useLocale` updates the attribute afterwards. +const savedLocale = localStorage.getItem('vieval-viewer:locale') +let lang = savedLocale === 'en' || savedLocale === 'zh-Hans' ? savedLocale : undefined +for (const tag of navigator.languages) { + if (lang != null) + break + try { + const locale = new Intl.Locale(tag).maximize() + if (locale.language === 'en') + lang = 'en' + else if (locale.language === 'zh' && locale.script === 'Hans') + lang = 'zh-Hans' + } + catch {} +} +document.documentElement.lang = lang ?? 'en' diff --git a/packages/ui/client/src/App.vue b/packages/ui/client/src/App.vue index 3a15883..7416203 100644 --- a/packages/ui/client/src/App.vue +++ b/packages/ui/client/src/App.vue @@ -6,10 +6,11 @@ import { useHead } from '@unhead/vue' import { useDark, useToggle } from '@vueuse/core' import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, TooltipProvider } from 'reka-ui' import { computed, shallowRef, useTemplateRef, watch } from 'vue' +import { useI18n } from 'vue-i18n' import CompareMenu from './components/CompareMenu.vue' -import HuePicker from './components/HuePicker.vue' import IconButton from './components/IconButton.vue' +import PreferencesMenu from './components/PreferencesMenu.vue' import Sidebar from './components/Sidebar.vue' import CasePage from './pages/CasePage.vue' import ComparePage from './pages/ComparePage.vue' @@ -20,9 +21,14 @@ import RunPage from './pages/RunPage.vue' import TaskPage from './pages/TaskPage.vue' import { api } from './api' +import { useLocale } from './composables/useLocale' import { FAVICON_KEYS } from './composables/useStatusFavicon' import { currentRoute, href } from './router' +const { t } = useI18n() +// Applies the saved language from the start, before the Preferences popover is opened. +const { locale } = useLocale() + const route = currentRoute const routeRunKey = computed(() => ('runKey' in route.value ? route.value.runKey : undefined)) // Report identity of the run of the route (workspace, project segment, experiment, attempt). It never changes, so one @@ -69,6 +75,7 @@ watch(pageKey, () => main.value?.scrollTo({ top: 0 })) // (`useStatusFavicon`). `vieval-logo.svg` is `docs/content/public/vieval-logo.svg` with its embedded 1200 px gradient // re-encoded as a 300 px WebP (4.3 MB → 7 KB); the PNG is the docs' 96 px favicon for browsers without SVG favicons. useHead({ + htmlAttrs: { lang: locale }, link: [ { href: './vieval-logo.svg', key: FAVICON_KEYS.svg, rel: 'icon', type: 'image/svg+xml' }, { href: './favicon-96x96.png', key: FAVICON_KEYS.png, rel: 'icon', sizes: '96x96', type: 'image/png' }, @@ -87,15 +94,15 @@ watch(route, () => (drawerOpen.value = false))
- + Vieval
- - + +
@@ -105,12 +112,12 @@ watch(route, () => (drawerOpen.value = false))
- Projects and runs + {{ t('navigation.projectsAndRuns') }} - Navigate to a project, an experiment, or a recent run. + {{ t('navigation.drawerDescription') }} - +
@@ -127,8 +134,8 @@ watch(route, () => (drawerOpen.value = false))
- - + +
diff --git a/packages/ui/client/src/caseMetrics.test.ts b/packages/ui/client/src/caseMetrics.test.ts index 0476d33..4424934 100644 --- a/packages/ui/client/src/caseMetrics.test.ts +++ b/packages/ui/client/src/caseMetrics.test.ts @@ -2,7 +2,8 @@ import type { CaseNode, MetricsResponse, ScoreDefinition } from '../../src/share import { describe, expect, it } from 'vitest' -import { buildCaseTable, columnDefinitions, derivedMetricName, formatColumnValue, matchesFilters, metricDefinition, metricLabel, separationOf } from './caseMetrics' +import { buildCaseTable, categoryValueLabel, columnDefinitions, derivedMetricName, formatColumnValue, matchesFilters, metricDefinition, metricLabel, separationOf } from './caseMetrics' +import { i18n } from './i18n' function caseNode(index: number, name: string, state: CaseNode['state']): CaseNode { const caseId = `${index}:${name}` @@ -151,6 +152,21 @@ describe('buildCaseTable list metrics', () => { expect(metricLabel('votes#mean', definitions)).toBe('Listener votes · mean') expect(metricLabel('steps', definitions)).toBe('steps') }) + + /** + * @example + * i18n.global.locale.value = 'zh-Hans' + * expect(metricLabel('votes#mean', definitions)).toBe('Listener votes · 平均值') + */ + it('names the summary of a derived metric and the case states in the language picked for the viewer', () => { + const definitions = new Map([['votes', { label: 'Listener votes', name: 'votes' }]]) + i18n.global.locale.value = 'zh-Hans' + + expect(metricLabel('votes#mean', definitions)).toBe('Listener votes · 平均值') + expect(categoryValueLabel('state', 'passed')).toBe('通过') + expect(categoryValueLabel('group', 'passed')).toBe('passed') + expect(categoryValueLabel('state', 'custom')).toBe('custom') + }) }) describe('buildCaseTable across tasks', () => { @@ -328,4 +344,15 @@ describe('columnDefinitions', () => { expect(formatColumnValue(1234.5, metricDefinition('latency', definitions))).toBe('1.2K ms') expect(formatColumnValue(1.23456, undefined)).toBe('1.23') }) + + /** + * @example + * i18n.global.locale.value = 'zh-Hans' + * expect(formatColumnValue(48_213, { unit: 'tokens' })).toBe('4.8万 tokens') + */ + it('shortens large metric values in the language picked for the viewer', () => { + i18n.global.locale.value = 'zh-Hans' + + expect(formatColumnValue(48_213, { name: 'tokens', unit: 'tokens' })).toBe('4.8万 tokens') + }) }) diff --git a/packages/ui/client/src/caseMetrics.ts b/packages/ui/client/src/caseMetrics.ts index 664f94b..e152536 100644 --- a/packages/ui/client/src/caseMetrics.ts +++ b/packages/ui/client/src/caseMetrics.ts @@ -6,7 +6,8 @@ import type { ColumnDefinition } from './scores' import { scaleLinear, scaleSymlog } from 'd3' import { parseTaskId } from '../../src/shared/reduce' -import { formatScore } from './format' +import { formatCompact, formatScore } from './format' +import { i18n } from './i18n' import { localize } from './localize' import { primaryScoreName, scoreColumn } from './scores' @@ -277,6 +278,18 @@ export function buildCaseTable(cases: readonly CaseNode[], metrics: MetricsRespo return { categories, constants, duplicates, groups, heavyTailed, primary, rows, scores: scores.filter(name => rows.some(row => row.numbers[name] != null)), valueCounts } } +/** + * Readable value of a category axis: a case state named in the viewer's language (`states` messages, as `StateBadge` + * names it), any other value as recorded. + * + * Use when: + * - a chart or legend writes the values of a category axis; filters keep the recorded value + */ +export function categoryValueLabel(name: string, value: string): string { + const { t, te } = i18n.global + return name === 'state' && te(`states.${value}`, 'en') ? t(`states.${value}`) : value +} + /** * Definitions of every column of a case table by name: the metrics' `metric.defined`, and the scores' `score.defined` * read as columns ({@link scoreColumn}); a score wins over a metric of the same name. @@ -303,7 +316,7 @@ export function columnDefinitions(metricDefinitions: readonly MetricDefinition[] export function formatColumnValue(value: number, definition: ColumnDefinition | undefined): string { if (definition?.score != null) return formatScore(value) - const text = Math.abs(value) >= 1000 ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 1, notation: 'compact' }).format(value) : String(Math.round(value * 100) / 100) + const text = Math.abs(value) >= 1000 ? formatCompact(value) : String(Math.round(value * 100) / 100) return definition?.unit == null ? text : `${text} ${definition.unit}` } @@ -411,7 +424,7 @@ export function metricDefinition(name: string, definitions: ReadonlyMap): string { const derived = parseDerivedMetric(name) if (derived != null) - return `${metricLabel(derived.base, definitions)} · ${derived.summary}` + return `${metricLabel(derived.base, definitions)} · ${i18n.global.t(`metrics.derived.${derived.summary}`)}` return localize(definitions.get(name)?.label) ?? name } diff --git a/packages/ui/client/src/components/ArtifactViewer.test.ts b/packages/ui/client/src/components/ArtifactViewer.test.ts index 22269c2..58a99b0 100644 --- a/packages/ui/client/src/components/ArtifactViewer.test.ts +++ b/packages/ui/client/src/components/ArtifactViewer.test.ts @@ -5,6 +5,8 @@ import { defineComponent, h } from 'vue' import ArtifactViewer from './ArtifactViewer.vue' +import { i18n } from '../i18n' + const location = { path: 'task/case/file', runKey: 'run-1-a' } /** Renders the viewer under a `TooltipProvider`, as `App.vue` does for every page (its icon buttons need one). */ @@ -35,6 +37,18 @@ describe('artifactViewer', () => { await expect.element(link).toHaveAttribute('href', expect.stringContaining('download=1')) }) + /** + * @example + * i18n.global.locale.value = 'zh-Hans' + * expect(screen.getByText('无法预览这种文件类型')).toBeVisible() + */ + it('explains a file without a preview in the language picked for the viewer', async () => { + i18n.global.locale.value = 'zh-Hans' + const screen = await renderViewer({ contentType: 'application/octet-stream', location, name: 'dump.bin', previewKind: 'binary', size: 8192 }) + await expect.element(screen.getByText('无法预览这种文件类型')).toBeVisible() + await expect.element(screen.getByRole('link', { name: '下载 dump.bin' })).toBeVisible() + }) + /** * @example * expect(video.hasAttribute('controls')).toBe(true) diff --git a/packages/ui/client/src/components/ArtifactViewer.vue b/packages/ui/client/src/components/ArtifactViewer.vue index f9be500..96e0830 100644 --- a/packages/ui/client/src/components/ArtifactViewer.vue +++ b/packages/ui/client/src/components/ArtifactViewer.vue @@ -7,6 +7,7 @@ import { errorMessageFrom } from '@moeru/std' import { useQuery } from '@pinia/colada' import { parse as parseToml } from 'smol-toml' import { computed, shallowRef } from 'vue' +import { useI18n } from 'vue-i18n' import { parse as parseYaml } from 'yaml' import IconButton from './IconButton.vue' @@ -37,20 +38,25 @@ const props = defineProps<{ size: number }>() +const { t } = useI18n() + /** Text and structured files are read up to this size; larger files show the head and a download link. */ const maxTextBytes = 1024 * 1024 const src = computed(() => rawUrl(props.location)) const download = computed(() => rawUrl(props.location, true)) const structuredView = shallowRef<'source' | 'tree'>('tree') -const structuredViews: Segment<'source' | 'tree'>[] = [{ icon: 'i-mingcute-sitemap-line', label: 'Tree', value: 'tree' }, { icon: 'i-mingcute-code-line', label: 'Source', value: 'source' }] +const structuredViews = computed[]>(() => [{ icon: 'i-mingcute-sitemap-line', label: t('cases.viewer.tree'), value: 'tree' }, { icon: 'i-mingcute-code-line', label: t('cases.viewer.source'), value: 'source' }]) /** What the preview of a text-like or binary file needs, read once per file. */ interface Preview { /** First bytes of a binary file as hex rows. */ hex?: string - /** Parsed value of a structured file, or why it is shown as text. */ - parsed?: { error?: string, value?: unknown } + /** + * Parsed value of a structured file, or why it is shown as text: too large to parse, or the parser's message + * (translated when shown, so a cached preview follows the language). + */ + parsed?: { failure?: { message?: string, reason: 'parse' } | { reason: 'size' }, value?: unknown } text?: { text: string, total: number, truncated: boolean } } @@ -77,17 +83,25 @@ async function loadPreview(location: FileLocation, kind: PreviewKind): Promise

preview.data.value?.text) const parsed = computed(() => preview.data.value?.parsed) const hex = computed(() => preview.data.value?.hex) +const failure = computed(() => { + const value = parsed.value?.failure + if (value == null) + return undefined + return value.reason === 'size' + ? t('cases.viewer.tooLarge', { size: formatBytes(maxTextBytes) }) + : t('cases.viewer.parseFailed', { format: props.previewKind.toUpperCase(), message: value.message ?? t('cases.viewer.unknownError') }) +}) const loadJsonl = computed(() => { const location = props.location @@ -102,8 +116,8 @@ const loadJsonl = computed(() => { {{ props.name }} {{ props.contentType }} · {{ formatBytes(props.size) }}

- - + +
@@ -121,7 +135,7 @@ const loadJsonl = computed(() => {