feat: implement localization for models section

- Added a new copy module to handle translations for models-related components.
- Integrated translation functionality into ProviderPickerDialog, ProvidersSection, RequestContentDialog, UsageBarChart, and UsageHistorySection.
- Updated messages for various UI elements to support multiple languages.
- Refactored usage history functions to accept locale for date formatting.
- Enhanced user experience by providing localized strings for success/error messages and UI labels.
This commit is contained in:
duanshuwen
2026-04-19 21:25:05 +08:00
parent 38bea97197
commit 3a86539537
14 changed files with 559 additions and 103 deletions

View File

@@ -1,3 +1,5 @@
import type { LanguageCode } from '../types/runtime';
export const PROVIDER_TYPES = [
'anthropic',
'openai',
@@ -57,6 +59,8 @@ export interface ProviderTypeInfo {
name: string;
icon: string;
placeholder: string;
placeholderZh?: string;
placeholderJa?: string;
model?: string;
requiresApiKey: boolean;
defaultBaseUrl?: string;
@@ -167,12 +171,14 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
{ id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' },
{ id: 'modelstudio', name: 'Model Studio', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: true, defaultBaseUrl: 'https://coding.dashscope.aliyuncs.com/v1', showBaseUrl: true, defaultModelId: 'qwen3.5-plus', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'qwen3.5-plus', apiKeyUrl: 'https://bailian.console.aliyun.com/', hidden: true },
{ id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/', codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', codePlanPresetModelId: 'ark-code-latest', codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh' },
{ id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required', requiresApiKey: false, defaultBaseUrl: 'http://localhost:11434/v1', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'qwen3:latest' },
{ id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required', placeholderZh: '无需填写', placeholderJa: '不要', requiresApiKey: false, defaultBaseUrl: 'http://localhost:11434/v1', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'qwen3:latest' },
{
id: 'custom',
name: 'Custom',
icon: '⚙️',
placeholder: 'API key...',
placeholderZh: 'API Key...',
placeholderJa: 'API キー...',
requiresApiKey: true,
showBaseUrl: true,
showModelId: true,
@@ -198,7 +204,7 @@ export function getProviderTypeInfo(type: ProviderType): ProviderTypeInfo | unde
export function getProviderDocsUrl(
provider: Pick<ProviderTypeInfo, 'docsUrl' | 'docsUrlZh'> | undefined,
language: string,
language: string | LanguageCode,
): string | undefined {
if (!provider?.docsUrl) {
return undefined;
@@ -209,6 +215,22 @@ export function getProviderDocsUrl(
return provider.docsUrl;
}
export function getProviderPlaceholder(
provider: Pick<ProviderTypeInfo, 'placeholder' | 'placeholderZh' | 'placeholderJa'> | undefined,
language: string | LanguageCode,
): string | undefined {
if (!provider?.placeholder) {
return undefined;
}
if (language.startsWith('zh') && provider.placeholderZh) {
return provider.placeholderZh;
}
if (language.startsWith('ja') && provider.placeholderJa) {
return provider.placeholderJa;
}
return provider.placeholder;
}
export function shouldShowProviderModelId(
provider: Pick<ProviderTypeInfo, 'showModelId' | 'showModelIdInDevModeOnly'> | undefined,
devModeUnlocked: boolean,

View File

@@ -289,12 +289,12 @@ export default function AgentsPage() {
<div className="flex flex-col gap-8 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0">
<h1
className="text-[24px] font-normal leading-none tracking-tight text-[#0D1730] dark:text-[#f3f4f6] md:text-[92px]"
className="text-4xl font-normal leading-none tracking-tight text-[#0D1730] dark:text-[#f3f4f6]"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
>
{pageCopy.title}
</h1>
<p className="mt-8 max-w-240 text-[12px] font-semibold leading-[1.55] text-[#555C69] dark:text-gray-400">
<p className="mt-6 max-w-240 text-[12px] font-semibold leading-[1.55] text-[#555C69] dark:text-gray-400">
{pageCopy.subtitle}
</p>
</div>

View File

@@ -4,6 +4,7 @@ type DialogSurfaceProps = {
open: boolean;
title: string;
subtitle?: string;
closeLabel?: string;
widthClassName?: string;
onClose: () => void;
children: ReactNode;
@@ -13,6 +14,7 @@ export default function DialogSurface({
open,
title,
subtitle,
closeLabel = 'Close dialog',
widthClassName = 'max-w-[560px]',
onClose,
children,
@@ -50,7 +52,7 @@ export default function DialogSurface({
type="button"
className="mt-0.5 rounded-full p-1 text-[#99A0AE] transition-colors hover:text-[#171717] dark:hover:text-[#f3f4f6]"
onClick={onClose}
aria-label="Close dialog"
aria-label={closeLabel}
>
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current" strokeWidth="1.8">
<path d="M6 6L18 18M18 6L6 18" strokeLinecap="round" />

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { modelsStore, useModelsStore } from '../../../stores';
import { useModelsCopy } from '../copy';
const CHIP_CLASS_NAME = [
'rounded-full border px-2.5 py-1 text-[11px] leading-none',
@@ -8,6 +9,7 @@ const CHIP_CLASS_NAME = [
export default function ModelsSection() {
const modelsState = useModelsStore();
const t = useModelsCopy();
useEffect(() => {
void modelsStore.init();
@@ -18,10 +20,10 @@ export default function ModelsSection() {
<div className="flex items-end justify-between gap-4">
<div>
<h3 className="text-[18px] font-semibold leading-[24px] text-[#171717] dark:text-gray-100">
Models Snapshot
{t('models.snapshot.title')}
</h3>
<p className="mt-1 text-[13px] leading-[20px] text-[#99A0AE] dark:text-gray-500">
`/api/models` `mainSessionKey`
{t('models.snapshot.subtitle')}
</p>
</div>
<button
@@ -31,7 +33,7 @@ export default function ModelsSection() {
void modelsStore.load();
}}
>
Models
{t('models.snapshot.refresh')}
</button>
</div>
@@ -56,18 +58,18 @@ export default function ModelsSection() {
</div>
{model.isDefault ? (
<span className="rounded-full bg-[#EFF6FF] px-2.5 py-1 text-[11px] font-medium text-[#2B7FFF] dark:bg-[#1d2633]">
{t('models.snapshot.defaultBadge')}
</span>
) : null}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<span className={CHIP_CLASS_NAME}>Provider: {model.providerAccountId || '--'}</span>
<span className={CHIP_CLASS_NAME}>Model: {model.modelDisplay || '--'}</span>
<span className={CHIP_CLASS_NAME}>{t('models.snapshot.provider')}: {model.providerAccountId || '--'}</span>
<span className={CHIP_CLASS_NAME}>{t('models.snapshot.model')}: {model.modelDisplay || '--'}</span>
</div>
<div className="mt-4 rounded-[12px] border border-dashed border-[#DCE5F1] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
<div className="font-medium text-[#171717] dark:text-gray-100">mainSessionKey</div>
<div className="font-medium text-[#171717] dark:text-gray-100">{t('models.snapshot.mainSessionKey')}</div>
<div className="mt-1 break-all">{model.mainSessionKey}</div>
</div>
</article>
@@ -75,7 +77,7 @@ export default function ModelsSection() {
{!modelsState.loading && modelsState.models.length === 0 ? (
<div className="rounded-[16px] border border-dashed border-[#DCE5F1] bg-[#FAFBFC] px-4 py-6 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-300">
provider model snapshot
{t('models.snapshot.empty')}
</div>
) : null}
</div>

View File

@@ -1,4 +1,7 @@
import { useEffect, useState } from 'react';
import { getProviderDocsUrl, getProviderPlaceholder } from '../../../lib/providers';
import { useLocale } from '../../../i18n';
import { useModelsCopy } from '../copy';
import DialogSurface from './DialogSurface';
import type { ProviderEditorValues, ProviderListItem } from './provider-types';
@@ -27,6 +30,8 @@ export default function ProviderEditorDialog({
onSave,
onSwitchProvider,
}: ProviderEditorDialogProps) {
const locale = useLocale();
const t = useModelsCopy();
const [label, setLabel] = useState('');
const [apiKey, setApiKey] = useState('');
const [baseUrl, setBaseUrl] = useState('');
@@ -47,19 +52,22 @@ export default function ProviderEditorDialog({
const isNew = Boolean(item.isNew);
const showBaseUrl = Boolean(provider?.showBaseUrl);
const showModel = Boolean(provider?.showModelId);
const docsUrl = getProviderDocsUrl(provider, locale);
const apiKeyPlaceholder = getProviderPlaceholder(provider, locale) || t('models.providers.editor.apiKeyPlaceholder');
return (
<DialogSurface
open={open}
onClose={onClose}
title={isNew ? 'Add AI Provider' : 'Edit Provider'}
subtitle="Configure the provider account details stored on this device."
title={isNew ? t('models.providers.editor.addTitle') : t('models.providers.editor.editTitle')}
subtitle={t('models.providers.editor.subtitle')}
closeLabel={t('models.common.closeDialog')}
widthClassName="max-w-[640px]"
>
<div className="space-y-6">
<div className="flex items-center gap-4 rounded-[16px] bg-white p-5 shadow-sm dark:bg-[#1f1f22]">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-[14px] bg-[#F4F3EB] text-[24px] dark:bg-[#222225]">
{provider?.icon || 'AI'}
{provider?.icon || t('models.common.ai')}
</div>
<div className="min-w-0">
@@ -68,11 +76,11 @@ export default function ProviderEditorDialog({
</div>
<div className="flex flex-wrap items-center gap-3 text-[13px] text-[#2B7FFF] dark:text-blue-400">
<button type="button" className="hover:underline" onClick={onSwitchProvider}>
Switch provider
{t('models.providers.editor.switchProvider')}
</button>
{provider?.docsUrl ? (
<a href={provider.docsUrl} target="_blank" rel="noreferrer" className="hover:underline">
View docs
{docsUrl ? (
<a href={docsUrl} target="_blank" rel="noreferrer" className="hover:underline">
{t('models.providers.editor.viewDocs')}
</a>
) : null}
</div>
@@ -88,41 +96,41 @@ export default function ProviderEditorDialog({
<div className="space-y-5">
<label className="block">
<span className="mb-2 block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
Display Name
{t('models.providers.editor.displayName')}
</span>
<input
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder={provider?.name || 'Provider name'}
placeholder={provider?.name || t('models.providers.editor.displayNamePlaceholder')}
className={FIELD_CLASS_NAME}
/>
</label>
<label className="block">
<span className="mb-2 block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
API Key
{t('models.providers.editor.apiKey')}
</span>
<input
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
type="password"
placeholder={provider?.placeholder || 'Enter API Key'}
placeholder={apiKeyPlaceholder}
className={[FIELD_CLASS_NAME, 'font-mono'].join(' ')}
/>
<span className="mt-2 block text-[12px] text-[#99A0AE] dark:text-gray-500">
Your API key is stored locally on this machine.
{t('models.providers.editor.apiKeyHint')}
</span>
</label>
{showBaseUrl ? (
<label className="block">
<span className="mb-2 block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
Base URL (Optional)
{t('models.providers.editor.baseUrl')}
</span>
<input
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder={provider?.defaultBaseUrl || 'https://api.example.com/v1'}
placeholder={provider?.defaultBaseUrl || t('models.providers.editor.baseUrlPlaceholder')}
className={[FIELD_CLASS_NAME, 'font-mono'].join(' ')}
/>
</label>
@@ -131,12 +139,12 @@ export default function ProviderEditorDialog({
{showModel ? (
<label className="block">
<span className="mb-2 block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
Default Model (Optional)
{t('models.providers.editor.defaultModel')}
</span>
<input
value={model}
onChange={(event) => setModel(event.target.value)}
placeholder={provider?.modelIdPlaceholder || provider?.defaultModelId || 'gpt-5.4'}
placeholder={provider?.modelIdPlaceholder || provider?.defaultModelId || t('models.providers.editor.defaultModelPlaceholder')}
className={[FIELD_CLASS_NAME, 'font-mono'].join(' ')}
/>
</label>
@@ -150,7 +158,7 @@ export default function ProviderEditorDialog({
onClick={() => onSave({ label, apiKey, baseUrl, model })}
disabled={saving}
>
{saving ? 'Saving...' : isNew ? 'Add Provider' : 'Save'}
{saving ? t('models.providers.editor.saving') : isNew ? t('models.providers.editor.add') : t('models.providers.editor.save')}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import type { ProviderTypeInfo } from '../../../lib/providers';
import { useModelsCopy } from '../copy';
import DialogSurface from './DialogSurface';
type ProviderPickerDialogProps = {
@@ -14,12 +15,15 @@ export default function ProviderPickerDialog({
onClose,
onSelect,
}: ProviderPickerDialogProps) {
const t = useModelsCopy();
return (
<DialogSurface
open={open}
onClose={onClose}
title="Add AI Provider"
subtitle="Configure a new provider for model access and API routing."
title={t('models.providers.picker.title')}
subtitle={t('models.providers.picker.subtitle')}
closeLabel={t('models.common.closeDialog')}
widthClassName="max-w-[800px]"
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@@ -31,7 +35,7 @@ export default function ProviderPickerDialog({
onClick={() => onSelect(provider)}
>
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-[14px] bg-black/5 text-[24px] dark:bg-[#222225]">
{provider.icon || 'AI'}
{provider.icon || t('models.common.ai')}
</div>
<span className="text-[14px] font-medium text-[#171717] dark:text-[#f3f4f6]">
{provider.name}

View File

@@ -12,6 +12,7 @@ import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../../lib
import ProviderEditorDialog from './ProviderEditorDialog';
import ProviderPickerDialog from './ProviderPickerDialog';
import type { DisplayVendor, ProviderEditorValues, ProviderListItem } from './provider-types';
import { useModelsCopy } from '../copy';
type NoticeState = {
tone: 'success' | 'error';
@@ -111,6 +112,7 @@ function buildProviderAccountId(providerId: ProviderTypeInfo['id']): string {
}
export default function ProvidersSection() {
const t = useModelsCopy();
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<ProviderListItem[]>([]);
const [vendors, setVendors] = useState<DisplayVendor[]>(VISIBLE_PROVIDERS);
@@ -153,7 +155,7 @@ export default function ProvidersSection() {
} catch (error) {
setNotice({
tone: 'error',
message: formatErrorMessage(error, 'Failed to load provider settings.'),
message: formatErrorMessage(error, t('models.providers.notices.loadError')),
});
} finally {
if (showLoading) {
@@ -182,17 +184,17 @@ export default function ProvidersSection() {
});
await loadProviders(false);
setDefaultAccountId(accountId);
setNotice({ tone: 'success', message: 'Default provider set successfully.' });
setNotice({ tone: 'success', message: t('models.providers.notices.defaultSuccess') });
} catch (error) {
setNotice({
tone: 'error',
message: formatErrorMessage(error, 'Failed to set the default provider.'),
message: formatErrorMessage(error, t('models.providers.notices.defaultError')),
});
}
}
async function handleDelete(accountId: string): Promise<void> {
const confirmed = window.confirm('Are you sure you want to delete this provider?');
const confirmed = window.confirm(t('models.providers.confirmDelete'));
if (!confirmed) return;
try {
@@ -200,11 +202,11 @@ export default function ProvidersSection() {
method: 'DELETE',
});
await loadProviders(false);
setNotice({ tone: 'success', message: 'Provider deleted successfully.' });
setNotice({ tone: 'success', message: t('models.providers.notices.deleteSuccess') });
} catch (error) {
setNotice({
tone: 'error',
message: formatErrorMessage(error, 'Failed to delete this provider.'),
message: formatErrorMessage(error, t('models.providers.notices.deleteError')),
});
}
}
@@ -277,7 +279,7 @@ export default function ProvidersSection() {
});
}
setNotice({ tone: 'success', message: 'Provider saved successfully.' });
setNotice({ tone: 'success', message: t('models.providers.notices.saveSuccess') });
} else {
await hostApiFetch<{ success: boolean; error?: string }>(
`/api/provider-accounts/${encodeURIComponent(editorItem.account.id)}`,
@@ -295,13 +297,13 @@ export default function ProvidersSection() {
},
);
setNotice({ tone: 'success', message: 'Provider saved successfully.' });
setNotice({ tone: 'success', message: t('models.providers.notices.saveSuccess') });
}
await loadProviders(false);
setEditorItem(null);
} catch (error) {
setEditorError(formatErrorMessage(error, 'Failed to save this provider.'));
setEditorError(formatErrorMessage(error, t('models.providers.notices.saveError')));
} finally {
setSaving(false);
}
@@ -311,9 +313,9 @@ export default function ProvidersSection() {
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-medium text-[#171717] dark:text-[#f3f4f6]">AI Providers</h3>
<h3 className="text-lg font-medium text-[#171717] dark:text-[#f3f4f6]">{t('models.providers.title')}</h3>
<p className="mt-1 text-sm text-[#99A0AE] dark:text-gray-500">
Manage your AI models and API keys.
{t('models.providers.subtitle')}
</p>
</div>
@@ -325,7 +327,7 @@ export default function ProvidersSection() {
setPickerOpen(true);
}}
>
Add Provider
{t('models.providers.add')}
</button>
</div>
@@ -345,7 +347,7 @@ export default function ProvidersSection() {
{loading ? (
<div className="flex items-center justify-center py-8 text-[#99A0AE] dark:text-gray-500">
<span className="mr-3 inline-flex h-5 w-5 animate-spin rounded-full border-2 border-current border-r-transparent" />
Loading provider settings...
{t('models.providers.loading')}
</div>
) : null}
@@ -363,7 +365,7 @@ export default function ProvidersSection() {
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-4">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full border border-gray-200 bg-gray-100 text-xl dark:border-[#2a2a2d] dark:bg-[#222225]">
{item.vendor?.icon || 'AI'}
{item.vendor?.icon || t('models.common.ai')}
</div>
<div className="min-w-0">
@@ -373,7 +375,7 @@ export default function ProvidersSection() {
</span>
{isDefault ? (
<span className="rounded-full bg-[#e6f4ea] px-2 py-0.5 text-[11px] font-medium text-[#167a3a] dark:bg-[#17361f] dark:text-[#8de0a8]">
Default
{t('models.providers.defaultBadge')}
</span>
) : null}
</div>
@@ -393,7 +395,7 @@ export default function ProvidersSection() {
configured ? 'text-green-600 dark:text-green-400' : 'text-orange-500 dark:text-orange-300',
].join(' ')}
>
{configured ? 'Configured' : 'Not Configured'}
{configured ? t('models.providers.configured') : t('models.providers.notConfigured')}
</span>
</div>
</div>
@@ -408,12 +410,12 @@ export default function ProvidersSection() {
void handleSetDefault(item.account.id);
}}
>
Set Default
{t('models.providers.setDefault')}
</button>
) : null}
<button type="button" className={ACTION_BUTTON_CLASS_NAME} onClick={() => handleEdit(item)}>
Edit
{t('models.providers.edit')}
</button>
<button
@@ -423,7 +425,7 @@ export default function ProvidersSection() {
void handleDelete(item.account.id);
}}
>
Delete
{t('models.providers.delete')}
</button>
</div>
</div>
@@ -433,7 +435,7 @@ export default function ProvidersSection() {
{items.length === 0 ? (
<div className="rounded-[14px] border border-dashed border-gray-300 px-6 py-12 text-center text-sm text-gray-500 dark:border-[#2a2a2d] dark:text-gray-400">
No providers configured yet. Click "Add Provider" to start.
{t('models.providers.empty')}
</div>
) : null}
</div>

View File

@@ -1,3 +1,5 @@
import { useLocale } from '../../../i18n';
import { useModelsCopy } from '../copy';
import DialogSurface from './DialogSurface';
import type { UsageHistoryEntry } from '../usage-history';
@@ -7,13 +9,13 @@ type RequestContentDialogProps = {
onClose: () => void;
};
function formatUsageTimestamp(timestamp?: string): string {
function formatUsageTimestamp(timestamp: string | undefined, locale: string): string {
if (!timestamp) return '';
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
hour: '2-digit',
@@ -26,12 +28,16 @@ export default function RequestContentDialog({
entry,
onClose,
}: RequestContentDialogProps) {
const locale = useLocale();
const t = useModelsCopy();
return (
<DialogSurface
open={open}
onClose={onClose}
title="Request Content"
subtitle={entry ? `${entry.model || 'Unknown Model'}${formatUsageTimestamp(entry.timestamp)}` : undefined}
title={t('models.usage.requestContent.title')}
subtitle={entry ? `${entry.model || t('models.common.unknownModel')}${formatUsageTimestamp(entry.timestamp, locale)}` : undefined}
closeLabel={t('models.common.closeDialog')}
widthClassName="max-w-[800px]"
>
<div className="max-h-[60vh] overflow-y-auto rounded-[16px] border border-black/6 bg-white p-5 shadow-sm dark:border-white/8 dark:bg-[#1f1f22]">

View File

@@ -1,3 +1,4 @@
import { useLocale } from '../../../i18n';
import type { UsageGroup } from '../usage-history';
type UsageBarChartProps = {
@@ -9,8 +10,8 @@ type UsageBarChartProps = {
cacheLabel: string;
};
function formatTokenCount(value: number): string {
return new Intl.NumberFormat().format(value);
function formatTokenCount(value: number, locale: string): string {
return new Intl.NumberFormat(locale).format(value);
}
export default function UsageBarChart({
@@ -21,6 +22,8 @@ export default function UsageBarChart({
outputLabel,
cacheLabel,
}: UsageBarChartProps) {
const locale = useLocale();
if (groups.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 p-8 text-center text-[14px] font-medium text-gray-500 dark:border-gray-700 dark:text-gray-400">
@@ -53,7 +56,7 @@ export default function UsageBarChart({
<div className="flex items-center justify-between gap-3 text-[13.5px]">
<span className="truncate font-semibold text-gray-900 dark:text-gray-100">{group.label}</span>
<span className="font-medium text-gray-500 dark:text-gray-400">
{totalLabel}: {formatTokenCount(group.totalTokens)}
{totalLabel}: {formatTokenCount(group.totalTokens, locale)}
</span>
</div>

View File

@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { hostApiFetch } from '../../../lib/host-api';
import { useLocale } from '../../../i18n';
import { useModelsCopy } from '../copy';
import RequestContentDialog from './RequestContentDialog';
import UsageBarChart from './UsageBarChart';
import {
@@ -32,15 +34,15 @@ function formatErrorMessage(error: unknown, fallback: string): string {
return fallback;
}
function formatTokenCount(value: number): string {
return new Intl.NumberFormat().format(value);
function formatTokenCount(value: number, locale: string): string {
return new Intl.NumberFormat(locale).format(value);
}
function formatUsageTimestamp(timestamp: string): string {
function formatUsageTimestamp(timestamp: string, locale: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
hour: '2-digit',
@@ -71,10 +73,10 @@ function getUsageTotalClass(entry: UsageHistoryEntry): string {
return 'font-bold text-[15px] text-[#171717] dark:text-[#f3f4f6]';
}
function getUsageTotalText(entry: UsageHistoryEntry): string {
if (entry.usageStatus === 'error') return 'Error';
function getUsageTotalText(entry: UsageHistoryEntry, locale: string, errorLabel: string): string {
if (entry.usageStatus === 'error') return errorLabel;
if (entry.usageStatus === 'missing') return '--';
return formatTokenCount(entry.totalTokens);
return formatTokenCount(entry.totalTokens, locale);
}
function normalizeUsageEntry(entry: Partial<UsageHistoryEntry>): UsageHistoryEntry {
@@ -137,6 +139,8 @@ function ToggleGroup<T extends string>({
}
export default function UsageHistorySection() {
const locale = useLocale();
const t = useModelsCopy();
const [groupBy, setGroupBy] = useState<UsageGroupBy>('model');
const [windowValue, setWindowValue] = useState<UsageWindow>('7d');
const [page, setPage] = useState(1);
@@ -175,7 +179,7 @@ export default function UsageHistorySection() {
setFetchState((current) => ({
...current,
status: 'done',
error: formatErrorMessage(error, 'Failed to load usage history.'),
error: formatErrorMessage(error, t('models.usage.loadError')),
}));
} finally {
loadingRef.current = false;
@@ -213,7 +217,10 @@ export default function UsageHistorySection() {
preferStableOnEmpty: fetchState.status === 'loading',
});
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, windowValue);
const usageGroups = groupUsageHistory(filteredUsageHistory, groupBy);
const usageGroups = groupUsageHistory(filteredUsageHistory, groupBy, {
locale,
unknownLabel: t('models.common.unknown'),
});
const usagePageSize = 5;
const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize));
const safeUsagePage = Math.min(page, usageTotalPages);
@@ -228,7 +235,7 @@ export default function UsageHistorySection() {
className="mb-6 text-2xl font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6]"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
>
Token Usage History
{t('models.usage.title')}
</h2>
{fetchState.error ? (
@@ -240,19 +247,19 @@ export default function UsageHistorySection() {
{usageLoading ? (
<div className="flex items-center justify-center rounded-2xl border border-dashed border-transparent bg-gray-50 py-12 text-[#99A0AE] dark:border-[#2a2a2d] dark:bg-[#222225] dark:text-gray-500">
<span className="mr-3 inline-flex h-5 w-5 animate-spin rounded-full border-2 border-current border-r-transparent" />
Loading usage history...
{t('models.usage.loading')}
</div>
) : null}
{!usageLoading && visibleUsageHistory.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-dashed border-transparent bg-gray-50 py-12 text-[#99A0AE] dark:border-[#2a2a2d] dark:bg-[#222225] dark:text-gray-500">
No usage history available.
{t('models.usage.empty')}
</div>
) : null}
{!usageLoading && visibleUsageHistory.length > 0 && filteredUsageHistory.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-dashed border-transparent bg-gray-50 py-12 text-[#99A0AE] dark:border-[#2a2a2d] dark:bg-[#222225] dark:text-gray-500">
No usage history in the selected time window.
{t('models.usage.emptyWindow')}
</div>
) : null}
@@ -264,8 +271,8 @@ export default function UsageHistorySection() {
value={groupBy}
onChange={setGroupBy}
options={[
{ value: 'model', label: 'By Model' },
{ value: 'day', label: 'By Time' },
{ value: 'model', label: t('models.usage.groupByModel') },
{ value: 'day', label: t('models.usage.groupByTime') },
]}
/>
@@ -273,25 +280,25 @@ export default function UsageHistorySection() {
value={windowValue}
onChange={setWindowValue}
options={[
{ value: '7d', label: 'Last 7 Days' },
{ value: '30d', label: 'Last 30 Days' },
{ value: 'all', label: 'All Time' },
{ value: '7d', label: t('models.usage.window7d') },
{ value: '30d', label: t('models.usage.window30d') },
{ value: 'all', label: t('models.usage.windowAll') },
]}
/>
</div>
<p className="text-[13px] font-medium text-[#99A0AE] dark:text-gray-500">
{usageRefreshing ? 'Loading...' : `Showing ${filteredUsageHistory.length} records`}
{usageRefreshing ? t('models.common.loading') : t('models.usage.showingRecords', { count: filteredUsageHistory.length })}
</p>
</div>
<UsageBarChart
groups={usageGroups}
emptyLabel="Empty"
totalLabel="Total"
inputLabel="Input"
outputLabel="Output"
cacheLabel="Cache"
emptyLabel={t('models.common.empty')}
totalLabel={t('models.usage.chart.total')}
inputLabel={t('models.usage.chart.input')}
outputLabel={t('models.usage.chart.output')}
cacheLabel={t('models.usage.chart.cache')}
/>
<div className="space-y-3 pt-2">
@@ -303,7 +310,7 @@ export default function UsageHistorySection() {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
{entry.model || 'Unknown Model'}
{entry.model || t('models.common.unknownModel')}
</p>
<p className="mt-0.5 truncate text-[13px] text-[#99A0AE] dark:text-gray-500">
{[formatUsageSource(entry.provider), formatUsageSource(entry.agentId), entry.sessionId]
@@ -313,17 +320,17 @@ export default function UsageHistorySection() {
</div>
<div className="shrink-0 text-right">
<p className={getUsageTotalClass(entry)}>{getUsageTotalText(entry)}</p>
<p className={getUsageTotalClass(entry)}>{getUsageTotalText(entry, locale, t('models.usage.row.error'))}</p>
{entry.usageStatus === 'missing' ? (
<p className="mt-0.5 text-[12px] text-[#99A0AE] dark:text-gray-500">
No usage info
{t('models.usage.row.noUsageInfo')}
</p>
) : null}
{entry.usageStatus === 'error' ? (
<p className="mt-0.5 text-[12px] text-red-500">Error parsing usage</p>
<p className="mt-0.5 text-[12px] text-red-500">{t('models.usage.row.errorParsingUsage')}</p>
) : null}
<p className="mt-0.5 text-[12px] text-[#99A0AE] dark:text-gray-500">
{formatUsageTimestamp(entry.timestamp)}
{formatUsageTimestamp(entry.timestamp, locale)}
</p>
</div>
</div>
@@ -333,28 +340,28 @@ export default function UsageHistorySection() {
<>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-sky-500" />
{`Input: ${formatTokenCount(entry.inputTokens)}`}
{t('models.usage.row.input', { count: formatTokenCount(entry.inputTokens, locale) })}
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-violet-500" />
{`Output: ${formatTokenCount(entry.outputTokens)}`}
{t('models.usage.row.output', { count: formatTokenCount(entry.outputTokens, locale) })}
</span>
{entry.cacheReadTokens > 0 ? (
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500" />
{`Cache Read: ${formatTokenCount(entry.cacheReadTokens)}`}
{t('models.usage.row.cacheRead', { count: formatTokenCount(entry.cacheReadTokens, locale) })}
</span>
) : null}
{entry.cacheWriteTokens > 0 ? (
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500" />
{`Cache Write: ${formatTokenCount(entry.cacheWriteTokens)}`}
{t('models.usage.row.cacheWrite', { count: formatTokenCount(entry.cacheWriteTokens, locale) })}
</span>
) : null}
</>
) : (
<span className="text-[12px]">
{entry.usageStatus === 'missing' ? 'No usage reported' : 'Parse error'}
{entry.usageStatus === 'missing' ? t('models.usage.row.noUsageReported') : t('models.usage.row.parseError')}
</span>
)}
@@ -370,7 +377,7 @@ export default function UsageHistorySection() {
className="ml-2 h-6 rounded-full border border-[#E5E8EE] px-2.5 text-[11.5px] text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:text-gray-300 dark:hover:border-[#3b82f6] dark:hover:text-white"
onClick={() => setSelectedEntry(entry)}
>
View Content
{t('models.usage.row.viewContent')}
</button>
) : null}
</div>
@@ -380,7 +387,7 @@ export default function UsageHistorySection() {
<div className="flex items-center justify-between gap-3 pt-2">
<p className="text-[13px] font-medium text-[#99A0AE] dark:text-gray-500">
{`Page ${safeUsagePage} of ${usageTotalPages}`}
{t('models.usage.pagination.pageOf', { page: safeUsagePage, total: usageTotalPages })}
</p>
<div className="flex items-center gap-2">
@@ -393,7 +400,7 @@ export default function UsageHistorySection() {
<svg viewBox="0 0 24 24" className="mr-1 h-4 w-4 fill-none stroke-current" strokeWidth="1.8">
<path d="M15 6L9 12L15 18" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Prev
{t('models.usage.pagination.prev')}
</button>
<button
@@ -402,7 +409,7 @@ export default function UsageHistorySection() {
onClick={() => setPage(Math.min(usageTotalPages, safeUsagePage + 1))}
disabled={safeUsagePage >= usageTotalPages}
>
Next
{t('models.usage.pagination.next')}
<svg viewBox="0 0 24 24" className="ml-1 h-4 w-4 fill-none stroke-current" strokeWidth="1.8">
<path d="M9 6L15 12L9 18" strokeLinecap="round" strokeLinejoin="round" />
</svg>

48
src/pages/Models/copy.ts Normal file
View File

@@ -0,0 +1,48 @@
import { useLocale } from '../../i18n';
import type { LanguageCode } from '../../types/runtime';
import { MODELS_MESSAGES, type MessageTree } from './messages';
type Primitive = string | number;
type InterpolationParams = Record<string, Primitive>;
export type ModelsTranslate = (path: string, params?: InterpolationParams, fallback?: string) => string;
function normalizeModelsPath(path: string): string {
return path.startsWith('models.') ? path.slice('models.'.length) : path;
}
function lookupMessage(source: MessageTree, path: string): unknown {
return path.split('.').reduce<unknown>((current, segment) => {
if (!current || typeof current !== 'object') return undefined;
return (current as Record<string, unknown>)[segment];
}, source);
}
function interpolate(template: string, params?: InterpolationParams): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_match, token) => {
const value = params[token];
return typeof value === 'undefined' ? `{${token}}` : String(value);
});
}
function createModelsTranslate(locale: LanguageCode): ModelsTranslate {
return (path, params, fallback) => {
const normalizedPath = normalizeModelsPath(path);
const translated = lookupMessage(MODELS_MESSAGES[locale] ?? MODELS_MESSAGES.en, normalizedPath)
?? lookupMessage(MODELS_MESSAGES.en, normalizedPath);
if (typeof translated === 'string' || typeof translated === 'number') {
return interpolate(String(translated), params);
}
return fallback ?? path;
};
}
export function useModelsCopy(): ModelsTranslate {
const locale = useLocale();
return createModelsTranslate(locale);
}

View File

@@ -1,18 +1,21 @@
import ModelsSection from './components/ModelsSection';
import ProvidersSection from './components/ProvidersSection';
import UsageHistorySection from './components/UsageHistorySection';
import { useModelsCopy } from './copy';
export default function ModelsPage() {
const t = useModelsCopy();
return (
<section className="h-full w-full min-h-0">
<div className="flex h-full w-full min-h-0 flex-col rounded-[16px] bg-white p-[20px] dark:bg-[#1b1b1d]">
<div className="mb-[20px] flex items-end justify-between gap-4 border-b border-[#E5E8EE] pb-[20px] dark:border-[#2a2a2d]">
<div className="flex min-w-0 items-end gap-[8px]">
<span className="text-[24px] font-medium leading-[32px] text-[#171717] dark:text-gray-100">
Models Configuration
{t('models.page.title')}
</span>
<span className="pb-[3px] text-[12px] leading-[16px] text-[#99A0AE] dark:text-gray-500">
Configure your AI providers and view token usage.
{t('models.page.subtitle')}
</span>
</div>
</div>

View File

@@ -0,0 +1,344 @@
import type { LanguageCode } from '../../types/runtime';
type Primitive = string | number;
type MessageTree = {
[key: string]: Primitive | MessageTree;
};
const EN_MODELS_MESSAGES: MessageTree = {
page: {
title: 'Models Configuration',
subtitle: 'Configure your AI providers and view token usage.',
},
common: {
closeDialog: 'Close dialog',
unknownModel: 'Unknown Model',
unknown: 'Unknown',
empty: 'Empty',
loading: 'Loading...',
ai: 'AI',
},
snapshot: {
title: 'Models Snapshot',
subtitle: 'Local `/api/models` snapshot and `mainSessionKey` mapping.',
refresh: 'Refresh Models',
defaultBadge: 'Default',
provider: 'Provider',
model: 'Model',
mainSessionKey: 'mainSessionKey',
empty: 'No models are available yet. Configure a provider account below and routable model snapshots will appear here automatically.',
},
providers: {
title: 'AI Providers',
subtitle: 'Manage your AI models and API keys.',
add: 'Add Provider',
loading: 'Loading provider settings...',
defaultBadge: 'Default',
configured: 'Configured',
notConfigured: 'Not Configured',
setDefault: 'Set Default',
edit: 'Edit',
delete: 'Delete',
empty: 'No providers configured yet. Click "Add Provider" to start.',
confirmDelete: 'Are you sure you want to delete this provider?',
notices: {
loadError: 'Failed to load provider settings.',
defaultSuccess: 'Default provider set successfully.',
defaultError: 'Failed to set the default provider.',
deleteSuccess: 'Provider deleted successfully.',
deleteError: 'Failed to delete this provider.',
saveSuccess: 'Provider saved successfully.',
saveError: 'Failed to save this provider.',
},
picker: {
title: 'Add AI Provider',
subtitle: 'Configure a new provider for model access and API routing.',
},
editor: {
addTitle: 'Add AI Provider',
editTitle: 'Edit Provider',
subtitle: 'Configure the provider account details stored on this device.',
switchProvider: 'Switch provider',
viewDocs: 'View docs',
displayName: 'Display Name',
displayNamePlaceholder: 'Provider name',
apiKey: 'API Key',
apiKeyPlaceholder: 'Enter API Key',
apiKeyHint: 'Your API key is stored locally on this machine.',
baseUrl: 'Base URL (Optional)',
baseUrlPlaceholder: 'https://api.example.com/v1',
defaultModel: 'Default Model (Optional)',
defaultModelPlaceholder: 'gpt-5.4',
saving: 'Saving...',
save: 'Save',
add: 'Add Provider',
},
},
usage: {
title: 'Token Usage History',
loading: 'Loading usage history...',
loadError: 'Failed to load usage history.',
empty: 'No usage history available.',
emptyWindow: 'No usage history in the selected time window.',
groupByModel: 'By Model',
groupByTime: 'By Time',
window7d: 'Last 7 Days',
window30d: 'Last 30 Days',
windowAll: 'All Time',
showingRecords: 'Showing {count} records',
chart: {
total: 'Total',
input: 'Input',
output: 'Output',
cache: 'Cache',
},
row: {
noUsageInfo: 'No usage info',
errorParsingUsage: 'Error parsing usage',
input: 'Input: {count}',
output: 'Output: {count}',
cacheRead: 'Cache Read: {count}',
cacheWrite: 'Cache Write: {count}',
noUsageReported: 'No usage reported',
parseError: 'Parse error',
viewContent: 'View Content',
error: 'Error',
},
pagination: {
pageOf: 'Page {page} of {total}',
prev: 'Prev',
next: 'Next',
},
requestContent: {
title: 'Request Content',
},
},
};
const ZH_MODELS_MESSAGES: MessageTree = {
page: {
title: '模型配置',
subtitle: '统一配置 AI 服务商,并查看模型 Token 使用情况。',
},
common: {
closeDialog: '关闭弹窗',
unknownModel: '未知模型',
unknown: '未知',
empty: '暂无数据',
loading: '加载中...',
ai: 'AI',
},
snapshot: {
title: '模型快照',
subtitle: '本地 `/api/models` 快照与 `mainSessionKey` 映射。',
refresh: '刷新模型',
defaultBadge: '默认',
provider: '服务商',
model: '模型',
mainSessionKey: '主会话 Key',
empty: '当前还没有可用模型。先在下方配置服务商账号后,这里会自动生成可路由的模型快照。',
},
providers: {
title: 'AI 服务商',
subtitle: '管理你的 AI 模型与 API Key。',
add: '添加服务商',
loading: '正在加载服务商配置...',
defaultBadge: '默认',
configured: '已配置',
notConfigured: '未配置',
setDefault: '设为默认',
edit: '编辑',
delete: '删除',
empty: '暂未配置服务商。点击“添加服务商”开始。',
confirmDelete: '确定删除这个服务商吗?',
notices: {
loadError: '加载服务商配置失败。',
defaultSuccess: '默认服务商设置成功。',
defaultError: '设置默认服务商失败。',
deleteSuccess: '服务商删除成功。',
deleteError: '删除服务商失败。',
saveSuccess: '服务商保存成功。',
saveError: '保存服务商失败。',
},
picker: {
title: '添加 AI 服务商',
subtitle: '新增一个服务商,用于模型访问和 API 路由。',
},
editor: {
addTitle: '添加 AI 服务商',
editTitle: '编辑服务商',
subtitle: '配置保存在当前设备上的服务商账号信息。',
switchProvider: '切换服务商',
viewDocs: '查看文档',
displayName: '显示名称',
displayNamePlaceholder: '服务商名称',
apiKey: 'API Key',
apiKeyPlaceholder: '输入 API Key',
apiKeyHint: '你的 API Key 只会保存在当前设备本地。',
baseUrl: 'Base URL可选',
baseUrlPlaceholder: 'https://api.example.com/v1',
defaultModel: '默认模型(可选)',
defaultModelPlaceholder: 'gpt-5.4',
saving: '保存中...',
save: '保存',
add: '添加服务商',
},
},
usage: {
title: 'Token 使用历史',
loading: '正在加载使用历史...',
loadError: '加载使用历史失败。',
empty: '暂无使用历史。',
emptyWindow: '所选时间范围内暂无使用历史。',
groupByModel: '按模型',
groupByTime: '按时间',
window7d: '最近 7 天',
window30d: '最近 30 天',
windowAll: '全部时间',
showingRecords: '共显示 {count} 条记录',
chart: {
total: '总计',
input: '输入',
output: '输出',
cache: '缓存',
},
row: {
noUsageInfo: '无用量信息',
errorParsingUsage: '用量解析错误',
input: '输入:{count}',
output: '输出:{count}',
cacheRead: '缓存读取:{count}',
cacheWrite: '缓存写入:{count}',
noUsageReported: '未上报用量',
parseError: '解析失败',
viewContent: '查看内容',
error: '错误',
},
pagination: {
pageOf: '第 {page} / {total} 页',
prev: '上一页',
next: '下一页',
},
requestContent: {
title: '请求内容',
},
},
};
const JA_MODELS_MESSAGES: MessageTree = {
page: {
title: 'モデル設定',
subtitle: 'AI プロバイダーを設定し、モデルの Token 使用状況を確認できます。',
},
common: {
closeDialog: 'ダイアログを閉じる',
unknownModel: '不明なモデル',
unknown: '不明',
empty: 'データなし',
loading: '読み込み中...',
ai: 'AI',
},
snapshot: {
title: 'モデルスナップショット',
subtitle: 'ローカル `/api/models` スナップショットと `mainSessionKey` の対応です。',
refresh: 'モデルを更新',
defaultBadge: 'デフォルト',
provider: 'プロバイダー',
model: 'モデル',
mainSessionKey: 'メインセッションキー',
empty: '利用可能なモデルがまだありません。下でプロバイダーアカウントを設定すると、ルーティング可能なモデルスナップショットが自動生成されます。',
},
providers: {
title: 'AI プロバイダー',
subtitle: 'AI モデルと API キーを管理します。',
add: 'プロバイダーを追加',
loading: 'プロバイダー設定を読み込み中...',
defaultBadge: 'デフォルト',
configured: '設定済み',
notConfigured: '未設定',
setDefault: 'デフォルトに設定',
edit: '編集',
delete: '削除',
empty: 'プロバイダーはまだ設定されていません。「プロバイダーを追加」をクリックして開始してください。',
confirmDelete: 'このプロバイダーを削除してもよろしいですか?',
notices: {
loadError: 'プロバイダー設定の読み込みに失敗しました。',
defaultSuccess: 'デフォルトのプロバイダーを設定しました。',
defaultError: 'デフォルトのプロバイダー設定に失敗しました。',
deleteSuccess: 'プロバイダーを削除しました。',
deleteError: 'プロバイダーの削除に失敗しました。',
saveSuccess: 'プロバイダーを保存しました。',
saveError: 'プロバイダーの保存に失敗しました。',
},
picker: {
title: 'AI プロバイダーを追加',
subtitle: 'モデルアクセスと API ルーティング用の新しいプロバイダーを設定します。',
},
editor: {
addTitle: 'AI プロバイダーを追加',
editTitle: 'プロバイダーを編集',
subtitle: 'このデバイスに保存されるプロバイダーアカウント情報を設定します。',
switchProvider: 'プロバイダーを切り替え',
viewDocs: 'ドキュメントを見る',
displayName: '表示名',
displayNamePlaceholder: 'プロバイダー名',
apiKey: 'API キー',
apiKeyPlaceholder: 'API キーを入力',
apiKeyHint: 'API キーはこの端末にのみ保存されます。',
baseUrl: 'Base URL任意',
baseUrlPlaceholder: 'https://api.example.com/v1',
defaultModel: 'デフォルトモデル(任意)',
defaultModelPlaceholder: 'gpt-5.4',
saving: '保存中...',
save: '保存',
add: 'プロバイダーを追加',
},
},
usage: {
title: 'Token 使用履歴',
loading: '使用履歴を読み込み中...',
loadError: '使用履歴の読み込みに失敗しました。',
empty: '使用履歴はありません。',
emptyWindow: '選択した期間には使用履歴がありません。',
groupByModel: 'モデル別',
groupByTime: '時間別',
window7d: '過去 7 日',
window30d: '過去 30 日',
windowAll: '全期間',
showingRecords: '{count} 件を表示',
chart: {
total: '合計',
input: '入力',
output: '出力',
cache: 'キャッシュ',
},
row: {
noUsageInfo: '使用量情報なし',
errorParsingUsage: '使用量の解析エラー',
input: '入力: {count}',
output: '出力: {count}',
cacheRead: 'キャッシュ読込: {count}',
cacheWrite: 'キャッシュ書込: {count}',
noUsageReported: '使用量が報告されていません',
parseError: '解析エラー',
viewContent: '内容を見る',
error: 'エラー',
},
pagination: {
pageOf: '{page} / {total} ページ',
prev: '前へ',
next: '次へ',
},
requestContent: {
title: 'リクエスト内容',
},
},
};
export const MODELS_MESSAGES: Record<LanguageCode, MessageTree> = {
en: EN_MODELS_MESSAGES,
zh: ZH_MODELS_MESSAGES,
ja: JA_MODELS_MESSAGES,
};
export type { MessageTree };

View File

@@ -51,10 +51,14 @@ export function resolveVisibleUsageHistory(
}
export function formatUsageDay(timestamp: string): string {
return formatUsageDayWithLocale(timestamp, undefined);
}
export function formatUsageDayWithLocale(timestamp: string, locale?: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
}).format(date);
@@ -71,13 +75,14 @@ export function getUsageDaySortKey(timestamp: string): number {
export function groupUsageHistory(
entries: UsageHistoryEntry[],
groupBy: UsageGroupBy,
options: { locale?: string; unknownLabel?: string } = {},
): UsageGroup[] {
const grouped = new Map<string, UsageGroup>();
for (const entry of entries) {
const label = groupBy === 'model'
? (entry.model || 'Unknown')
: formatUsageDay(entry.timestamp);
? (entry.model || options.unknownLabel || 'Unknown')
: formatUsageDayWithLocale(entry.timestamp, options.locale);
const current = grouped.get(label) ?? {
label,
totalTokens: 0,