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 = [ export const PROVIDER_TYPES = [
'anthropic', 'anthropic',
'openai', 'openai',
@@ -57,6 +59,8 @@ export interface ProviderTypeInfo {
name: string; name: string;
icon: string; icon: string;
placeholder: string; placeholder: string;
placeholderZh?: string;
placeholderJa?: string;
model?: string; model?: string;
requiresApiKey: boolean; requiresApiKey: boolean;
defaultBaseUrl?: string; 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: '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: '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: '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', id: 'custom',
name: 'Custom', name: 'Custom',
icon: '⚙️', icon: '⚙️',
placeholder: 'API key...', placeholder: 'API key...',
placeholderZh: 'API Key...',
placeholderJa: 'API キー...',
requiresApiKey: true, requiresApiKey: true,
showBaseUrl: true, showBaseUrl: true,
showModelId: true, showModelId: true,
@@ -198,7 +204,7 @@ export function getProviderTypeInfo(type: ProviderType): ProviderTypeInfo | unde
export function getProviderDocsUrl( export function getProviderDocsUrl(
provider: Pick<ProviderTypeInfo, 'docsUrl' | 'docsUrlZh'> | undefined, provider: Pick<ProviderTypeInfo, 'docsUrl' | 'docsUrlZh'> | undefined,
language: string, language: string | LanguageCode,
): string | undefined { ): string | undefined {
if (!provider?.docsUrl) { if (!provider?.docsUrl) {
return undefined; return undefined;
@@ -209,6 +215,22 @@ export function getProviderDocsUrl(
return provider.docsUrl; 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( export function shouldShowProviderModelId(
provider: Pick<ProviderTypeInfo, 'showModelId' | 'showModelIdInDevModeOnly'> | undefined, provider: Pick<ProviderTypeInfo, 'showModelId' | 'showModelIdInDevModeOnly'> | undefined,
devModeUnlocked: boolean, 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="flex flex-col gap-8 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<h1 <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" }} style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
> >
{pageCopy.title} {pageCopy.title}
</h1> </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} {pageCopy.subtitle}
</p> </p>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../../lib
import ProviderEditorDialog from './ProviderEditorDialog'; import ProviderEditorDialog from './ProviderEditorDialog';
import ProviderPickerDialog from './ProviderPickerDialog'; import ProviderPickerDialog from './ProviderPickerDialog';
import type { DisplayVendor, ProviderEditorValues, ProviderListItem } from './provider-types'; import type { DisplayVendor, ProviderEditorValues, ProviderListItem } from './provider-types';
import { useModelsCopy } from '../copy';
type NoticeState = { type NoticeState = {
tone: 'success' | 'error'; tone: 'success' | 'error';
@@ -111,6 +112,7 @@ function buildProviderAccountId(providerId: ProviderTypeInfo['id']): string {
} }
export default function ProvidersSection() { export default function ProvidersSection() {
const t = useModelsCopy();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [items, setItems] = useState<ProviderListItem[]>([]); const [items, setItems] = useState<ProviderListItem[]>([]);
const [vendors, setVendors] = useState<DisplayVendor[]>(VISIBLE_PROVIDERS); const [vendors, setVendors] = useState<DisplayVendor[]>(VISIBLE_PROVIDERS);
@@ -153,7 +155,7 @@ export default function ProvidersSection() {
} catch (error) { } catch (error) {
setNotice({ setNotice({
tone: 'error', tone: 'error',
message: formatErrorMessage(error, 'Failed to load provider settings.'), message: formatErrorMessage(error, t('models.providers.notices.loadError')),
}); });
} finally { } finally {
if (showLoading) { if (showLoading) {
@@ -182,17 +184,17 @@ export default function ProvidersSection() {
}); });
await loadProviders(false); await loadProviders(false);
setDefaultAccountId(accountId); setDefaultAccountId(accountId);
setNotice({ tone: 'success', message: 'Default provider set successfully.' }); setNotice({ tone: 'success', message: t('models.providers.notices.defaultSuccess') });
} catch (error) { } catch (error) {
setNotice({ setNotice({
tone: 'error', 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> { 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; if (!confirmed) return;
try { try {
@@ -200,11 +202,11 @@ export default function ProvidersSection() {
method: 'DELETE', method: 'DELETE',
}); });
await loadProviders(false); await loadProviders(false);
setNotice({ tone: 'success', message: 'Provider deleted successfully.' }); setNotice({ tone: 'success', message: t('models.providers.notices.deleteSuccess') });
} catch (error) { } catch (error) {
setNotice({ setNotice({
tone: 'error', 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 { } else {
await hostApiFetch<{ success: boolean; error?: string }>( await hostApiFetch<{ success: boolean; error?: string }>(
`/api/provider-accounts/${encodeURIComponent(editorItem.account.id)}`, `/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); await loadProviders(false);
setEditorItem(null); setEditorItem(null);
} catch (error) { } catch (error) {
setEditorError(formatErrorMessage(error, 'Failed to save this provider.')); setEditorError(formatErrorMessage(error, t('models.providers.notices.saveError')));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -311,9 +313,9 @@ export default function ProvidersSection() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <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"> <p className="mt-1 text-sm text-[#99A0AE] dark:text-gray-500">
Manage your AI models and API keys. {t('models.providers.subtitle')}
</p> </p>
</div> </div>
@@ -325,7 +327,7 @@ export default function ProvidersSection() {
setPickerOpen(true); setPickerOpen(true);
}} }}
> >
Add Provider {t('models.providers.add')}
</button> </button>
</div> </div>
@@ -345,7 +347,7 @@ export default function ProvidersSection() {
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8 text-[#99A0AE] dark:text-gray-500"> <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" /> <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> </div>
) : null} ) : null}
@@ -363,7 +365,7 @@ export default function ProvidersSection() {
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start 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]"> <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>
<div className="min-w-0"> <div className="min-w-0">
@@ -373,7 +375,7 @@ export default function ProvidersSection() {
</span> </span>
{isDefault ? ( {isDefault ? (
<span className="rounded-full bg-[#e6f4ea] px-2 py-0.5 text-[11px] font-medium text-[#167a3a] dark:bg-[#17361f] dark:text-[#8de0a8]"> <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> </span>
) : null} ) : null}
</div> </div>
@@ -393,7 +395,7 @@ export default function ProvidersSection() {
configured ? 'text-green-600 dark:text-green-400' : 'text-orange-500 dark:text-orange-300', configured ? 'text-green-600 dark:text-green-400' : 'text-orange-500 dark:text-orange-300',
].join(' ')} ].join(' ')}
> >
{configured ? 'Configured' : 'Not Configured'} {configured ? t('models.providers.configured') : t('models.providers.notConfigured')}
</span> </span>
</div> </div>
</div> </div>
@@ -408,12 +410,12 @@ export default function ProvidersSection() {
void handleSetDefault(item.account.id); void handleSetDefault(item.account.id);
}} }}
> >
Set Default {t('models.providers.setDefault')}
</button> </button>
) : null} ) : null}
<button type="button" className={ACTION_BUTTON_CLASS_NAME} onClick={() => handleEdit(item)}> <button type="button" className={ACTION_BUTTON_CLASS_NAME} onClick={() => handleEdit(item)}>
Edit {t('models.providers.edit')}
</button> </button>
<button <button
@@ -423,7 +425,7 @@ export default function ProvidersSection() {
void handleDelete(item.account.id); void handleDelete(item.account.id);
}} }}
> >
Delete {t('models.providers.delete')}
</button> </button>
</div> </div>
</div> </div>
@@ -433,7 +435,7 @@ export default function ProvidersSection() {
{items.length === 0 ? ( {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"> <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> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,3 +1,5 @@
import { useLocale } from '../../../i18n';
import { useModelsCopy } from '../copy';
import DialogSurface from './DialogSurface'; import DialogSurface from './DialogSurface';
import type { UsageHistoryEntry } from '../usage-history'; import type { UsageHistoryEntry } from '../usage-history';
@@ -7,13 +9,13 @@ type RequestContentDialogProps = {
onClose: () => void; onClose: () => void;
}; };
function formatUsageTimestamp(timestamp?: string): string { function formatUsageTimestamp(timestamp: string | undefined, locale: string): string {
if (!timestamp) return ''; if (!timestamp) return '';
const date = new Date(timestamp); const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp; if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, { return new Intl.DateTimeFormat(locale, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
@@ -26,12 +28,16 @@ export default function RequestContentDialog({
entry, entry,
onClose, onClose,
}: RequestContentDialogProps) { }: RequestContentDialogProps) {
const locale = useLocale();
const t = useModelsCopy();
return ( return (
<DialogSurface <DialogSurface
open={open} open={open}
onClose={onClose} onClose={onClose}
title="Request Content" title={t('models.usage.requestContent.title')}
subtitle={entry ? `${entry.model || 'Unknown Model'}${formatUsageTimestamp(entry.timestamp)}` : undefined} subtitle={entry ? `${entry.model || t('models.common.unknownModel')}${formatUsageTimestamp(entry.timestamp, locale)}` : undefined}
closeLabel={t('models.common.closeDialog')}
widthClassName="max-w-[800px]" 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]"> <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'; import type { UsageGroup } from '../usage-history';
type UsageBarChartProps = { type UsageBarChartProps = {
@@ -9,8 +10,8 @@ type UsageBarChartProps = {
cacheLabel: string; cacheLabel: string;
}; };
function formatTokenCount(value: number): string { function formatTokenCount(value: number, locale: string): string {
return new Intl.NumberFormat().format(value); return new Intl.NumberFormat(locale).format(value);
} }
export default function UsageBarChart({ export default function UsageBarChart({
@@ -21,6 +22,8 @@ export default function UsageBarChart({
outputLabel, outputLabel,
cacheLabel, cacheLabel,
}: UsageBarChartProps) { }: UsageBarChartProps) {
const locale = useLocale();
if (groups.length === 0) { if (groups.length === 0) {
return ( 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"> <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]"> <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="truncate font-semibold text-gray-900 dark:text-gray-100">{group.label}</span>
<span className="font-medium text-gray-500 dark:text-gray-400"> <span className="font-medium text-gray-500 dark:text-gray-400">
{totalLabel}: {formatTokenCount(group.totalTokens)} {totalLabel}: {formatTokenCount(group.totalTokens, locale)}
</span> </span>
</div> </div>

View File

@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { hostApiFetch } from '../../../lib/host-api'; import { hostApiFetch } from '../../../lib/host-api';
import { useLocale } from '../../../i18n';
import { useModelsCopy } from '../copy';
import RequestContentDialog from './RequestContentDialog'; import RequestContentDialog from './RequestContentDialog';
import UsageBarChart from './UsageBarChart'; import UsageBarChart from './UsageBarChart';
import { import {
@@ -32,15 +34,15 @@ function formatErrorMessage(error: unknown, fallback: string): string {
return fallback; return fallback;
} }
function formatTokenCount(value: number): string { function formatTokenCount(value: number, locale: string): string {
return new Intl.NumberFormat().format(value); return new Intl.NumberFormat(locale).format(value);
} }
function formatUsageTimestamp(timestamp: string): string { function formatUsageTimestamp(timestamp: string, locale: string): string {
const date = new Date(timestamp); const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp; if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, { return new Intl.DateTimeFormat(locale, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
@@ -71,10 +73,10 @@ function getUsageTotalClass(entry: UsageHistoryEntry): string {
return 'font-bold text-[15px] text-[#171717] dark:text-[#f3f4f6]'; return 'font-bold text-[15px] text-[#171717] dark:text-[#f3f4f6]';
} }
function getUsageTotalText(entry: UsageHistoryEntry): string { function getUsageTotalText(entry: UsageHistoryEntry, locale: string, errorLabel: string): string {
if (entry.usageStatus === 'error') return 'Error'; if (entry.usageStatus === 'error') return errorLabel;
if (entry.usageStatus === 'missing') return '--'; if (entry.usageStatus === 'missing') return '--';
return formatTokenCount(entry.totalTokens); return formatTokenCount(entry.totalTokens, locale);
} }
function normalizeUsageEntry(entry: Partial<UsageHistoryEntry>): UsageHistoryEntry { function normalizeUsageEntry(entry: Partial<UsageHistoryEntry>): UsageHistoryEntry {
@@ -137,6 +139,8 @@ function ToggleGroup<T extends string>({
} }
export default function UsageHistorySection() { export default function UsageHistorySection() {
const locale = useLocale();
const t = useModelsCopy();
const [groupBy, setGroupBy] = useState<UsageGroupBy>('model'); const [groupBy, setGroupBy] = useState<UsageGroupBy>('model');
const [windowValue, setWindowValue] = useState<UsageWindow>('7d'); const [windowValue, setWindowValue] = useState<UsageWindow>('7d');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -175,7 +179,7 @@ export default function UsageHistorySection() {
setFetchState((current) => ({ setFetchState((current) => ({
...current, ...current,
status: 'done', status: 'done',
error: formatErrorMessage(error, 'Failed to load usage history.'), error: formatErrorMessage(error, t('models.usage.loadError')),
})); }));
} finally { } finally {
loadingRef.current = false; loadingRef.current = false;
@@ -213,7 +217,10 @@ export default function UsageHistorySection() {
preferStableOnEmpty: fetchState.status === 'loading', preferStableOnEmpty: fetchState.status === 'loading',
}); });
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, windowValue); 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 usagePageSize = 5;
const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize)); const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize));
const safeUsagePage = Math.min(page, usageTotalPages); 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]" className="mb-6 text-2xl font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6]"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }} style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
> >
Token Usage History {t('models.usage.title')}
</h2> </h2>
{fetchState.error ? ( {fetchState.error ? (
@@ -240,19 +247,19 @@ export default function UsageHistorySection() {
{usageLoading ? ( {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"> <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" /> <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> </div>
) : null} ) : null}
{!usageLoading && visibleUsageHistory.length === 0 ? ( {!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"> <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> </div>
) : null} ) : null}
{!usageLoading && visibleUsageHistory.length > 0 && filteredUsageHistory.length === 0 ? ( {!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"> <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> </div>
) : null} ) : null}
@@ -264,8 +271,8 @@ export default function UsageHistorySection() {
value={groupBy} value={groupBy}
onChange={setGroupBy} onChange={setGroupBy}
options={[ options={[
{ value: 'model', label: 'By Model' }, { value: 'model', label: t('models.usage.groupByModel') },
{ value: 'day', label: 'By Time' }, { value: 'day', label: t('models.usage.groupByTime') },
]} ]}
/> />
@@ -273,25 +280,25 @@ export default function UsageHistorySection() {
value={windowValue} value={windowValue}
onChange={setWindowValue} onChange={setWindowValue}
options={[ options={[
{ value: '7d', label: 'Last 7 Days' }, { value: '7d', label: t('models.usage.window7d') },
{ value: '30d', label: 'Last 30 Days' }, { value: '30d', label: t('models.usage.window30d') },
{ value: 'all', label: 'All Time' }, { value: 'all', label: t('models.usage.windowAll') },
]} ]}
/> />
</div> </div>
<p className="text-[13px] font-medium text-[#99A0AE] dark:text-gray-500"> <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> </p>
</div> </div>
<UsageBarChart <UsageBarChart
groups={usageGroups} groups={usageGroups}
emptyLabel="Empty" emptyLabel={t('models.common.empty')}
totalLabel="Total" totalLabel={t('models.usage.chart.total')}
inputLabel="Input" inputLabel={t('models.usage.chart.input')}
outputLabel="Output" outputLabel={t('models.usage.chart.output')}
cacheLabel="Cache" cacheLabel={t('models.usage.chart.cache')}
/> />
<div className="space-y-3 pt-2"> <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="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]"> <p className="truncate text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
{entry.model || 'Unknown Model'} {entry.model || t('models.common.unknownModel')}
</p> </p>
<p className="mt-0.5 truncate text-[13px] text-[#99A0AE] dark:text-gray-500"> <p className="mt-0.5 truncate text-[13px] text-[#99A0AE] dark:text-gray-500">
{[formatUsageSource(entry.provider), formatUsageSource(entry.agentId), entry.sessionId] {[formatUsageSource(entry.provider), formatUsageSource(entry.agentId), entry.sessionId]
@@ -313,17 +320,17 @@ export default function UsageHistorySection() {
</div> </div>
<div className="shrink-0 text-right"> <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' ? ( {entry.usageStatus === 'missing' ? (
<p className="mt-0.5 text-[12px] text-[#99A0AE] dark:text-gray-500"> <p className="mt-0.5 text-[12px] text-[#99A0AE] dark:text-gray-500">
No usage info {t('models.usage.row.noUsageInfo')}
</p> </p>
) : null} ) : null}
{entry.usageStatus === 'error' ? ( {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} ) : null}
<p className="mt-0.5 text-[12px] text-[#99A0AE] dark:text-gray-500"> <p className="mt-0.5 text-[12px] text-[#99A0AE] dark:text-gray-500">
{formatUsageTimestamp(entry.timestamp)} {formatUsageTimestamp(entry.timestamp, locale)}
</p> </p>
</div> </div>
</div> </div>
@@ -333,28 +340,28 @@ export default function UsageHistorySection() {
<> <>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-sky-500" /> <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>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-violet-500" /> <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> </span>
{entry.cacheReadTokens > 0 ? ( {entry.cacheReadTokens > 0 ? (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500" /> <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> </span>
) : null} ) : null}
{entry.cacheWriteTokens > 0 ? ( {entry.cacheWriteTokens > 0 ? (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500" /> <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> </span>
) : null} ) : null}
</> </>
) : ( ) : (
<span className="text-[12px]"> <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> </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" 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)} onClick={() => setSelectedEntry(entry)}
> >
View Content {t('models.usage.row.viewContent')}
</button> </button>
) : null} ) : null}
</div> </div>
@@ -380,7 +387,7 @@ export default function UsageHistorySection() {
<div className="flex items-center justify-between gap-3 pt-2"> <div className="flex items-center justify-between gap-3 pt-2">
<p className="text-[13px] font-medium text-[#99A0AE] dark:text-gray-500"> <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> </p>
<div className="flex items-center gap-2"> <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"> <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" /> <path d="M15 6L9 12L15 18" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
Prev {t('models.usage.pagination.prev')}
</button> </button>
<button <button
@@ -402,7 +409,7 @@ export default function UsageHistorySection() {
onClick={() => setPage(Math.min(usageTotalPages, safeUsagePage + 1))} onClick={() => setPage(Math.min(usageTotalPages, safeUsagePage + 1))}
disabled={safeUsagePage >= usageTotalPages} 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"> <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" /> <path d="M9 6L15 12L9 18" strokeLinecap="round" strokeLinejoin="round" />
</svg> </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 ModelsSection from './components/ModelsSection';
import ProvidersSection from './components/ProvidersSection'; import ProvidersSection from './components/ProvidersSection';
import UsageHistorySection from './components/UsageHistorySection'; import UsageHistorySection from './components/UsageHistorySection';
import { useModelsCopy } from './copy';
export default function ModelsPage() { export default function ModelsPage() {
const t = useModelsCopy();
return ( return (
<section className="h-full w-full min-h-0"> <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="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="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]"> <div className="flex min-w-0 items-end gap-[8px]">
<span className="text-[24px] font-medium leading-[32px] text-[#171717] dark:text-gray-100"> <span className="text-[24px] font-medium leading-[32px] text-[#171717] dark:text-gray-100">
Models Configuration {t('models.page.title')}
</span> </span>
<span className="pb-[3px] text-[12px] leading-[16px] text-[#99A0AE] dark:text-gray-500"> <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> </span>
</div> </div>
</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 { export function formatUsageDay(timestamp: string): string {
return formatUsageDayWithLocale(timestamp, undefined);
}
export function formatUsageDayWithLocale(timestamp: string, locale?: string): string {
const date = new Date(timestamp); const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp; if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, { return new Intl.DateTimeFormat(locale, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
}).format(date); }).format(date);
@@ -71,13 +75,14 @@ export function getUsageDaySortKey(timestamp: string): number {
export function groupUsageHistory( export function groupUsageHistory(
entries: UsageHistoryEntry[], entries: UsageHistoryEntry[],
groupBy: UsageGroupBy, groupBy: UsageGroupBy,
options: { locale?: string; unknownLabel?: string } = {},
): UsageGroup[] { ): UsageGroup[] {
const grouped = new Map<string, UsageGroup>(); const grouped = new Map<string, UsageGroup>();
for (const entry of entries) { for (const entry of entries) {
const label = groupBy === 'model' const label = groupBy === 'model'
? (entry.model || 'Unknown') ? (entry.model || options.unknownLabel || 'Unknown')
: formatUsageDay(entry.timestamp); : formatUsageDayWithLocale(entry.timestamp, options.locale);
const current = grouped.get(label) ?? { const current = grouped.get(label) ?? {
label, label,
totalTokens: 0, totalTokens: 0,