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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
48
src/pages/Models/copy.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
344
src/pages/Models/messages.ts
Normal file
344
src/pages/Models/messages.ts
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user