mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-13 16:20:51 +08:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab3922f9e6 | ||
|
|
5dbff4c3e0 | ||
|
|
4dde62ac58 | ||
|
|
1d3335746b | ||
|
|
c6d00e8b3f | ||
|
|
9ef7d439d2 | ||
|
|
c53a231c41 | ||
|
|
705e6dac54 | ||
|
|
daef2521f1 | ||
|
|
de0753f0ce | ||
|
|
d027d04f64 | ||
|
|
c4ca9be7b5 | ||
|
|
180a4ccab4 | ||
|
|
78512f8039 | ||
|
|
7cdede6de8 | ||
|
|
7ec5329576 | ||
|
|
5d0232e5de | ||
|
|
15c5f742f4 | ||
|
|
b4cd8c946d | ||
|
|
ee9b9f6e14 | ||
|
|
01abe3dc02 | ||
|
|
b957d05636 | ||
|
|
2a4ccff96e | ||
|
|
b5f869ed25 | ||
|
|
50c1b0f4b3 | ||
|
|
887600c03a | ||
|
|
0fdebacc0b | ||
|
|
4d5bb7e575 | ||
|
|
2d841c0a2f | ||
|
|
e40c3488fe | ||
|
|
04686aafc8 | ||
|
|
9476afc41c | ||
|
|
ab6a1a412c | ||
|
|
2cf1e23351 | ||
|
|
0089d4a705 | ||
|
|
c726fbc379 | ||
|
|
83f6a1a9f9 |
38
package-lock.json
generated
38
package-lock.json
generated
@@ -72,7 +72,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -467,7 +466,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
|
||||
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1243,18 +1241,6 @@
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@openai/codex": {
|
||||
"version": "0.98.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.98.0.tgz",
|
||||
"integrity": "sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex": "bin/codex.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
||||
@@ -1945,7 +1931,6 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2033,7 +2018,6 @@
|
||||
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.48.1",
|
||||
"@typescript-eslint/types": "8.48.1",
|
||||
@@ -2351,7 +2335,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2401,13 +2384,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -2563,7 +2546,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -2828,7 +2810,6 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3305,7 +3286,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -3635,7 +3615,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3742,7 +3721,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3760,7 +3738,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -3869,7 +3846,6 @@
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -4052,7 +4028,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4129,7 +4104,6 @@
|
||||
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4259,7 +4233,6 @@
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
@@ -4287,7 +4260,6 @@
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -426,8 +426,24 @@ function PayloadRulesEditor({
|
||||
protocolFirst?: boolean;
|
||||
onChange: (next: PayloadRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t, i18n.resolvedLanguage]
|
||||
);
|
||||
const payloadValueTypeOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t, i18n.resolvedLanguage]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
@@ -533,7 +549,7 @@ function PayloadRulesEditor({
|
||||
<>
|
||||
<ToastSelect
|
||||
value={model.protocol ?? ''}
|
||||
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
@@ -561,7 +577,7 @@ function PayloadRulesEditor({
|
||||
/>
|
||||
<ToastSelect
|
||||
value={model.protocol ?? ''}
|
||||
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
@@ -603,7 +619,7 @@ function PayloadRulesEditor({
|
||||
/>
|
||||
<ToastSelect
|
||||
value={param.valueType}
|
||||
options={VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS}
|
||||
options={payloadValueTypeOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_type')}
|
||||
onChange={(nextValue) =>
|
||||
@@ -669,8 +685,16 @@ function PayloadFilterRulesEditor({
|
||||
disabled?: boolean;
|
||||
onChange: (next: PayloadFilterRule[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t, i18n.resolvedLanguage]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
@@ -738,7 +762,7 @@ function PayloadFilterRulesEditor({
|
||||
/>
|
||||
<ToastSelect
|
||||
value={model.protocol ?? ''}
|
||||
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
|
||||
@@ -87,7 +87,7 @@ export function OpenAISection({
|
||||
<ProviderList<OpenAIProviderConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.name}
|
||||
keyField={(_, index) => `openai-provider-${index}`}
|
||||
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EmptyState } from '@/components/ui/EmptyState';
|
||||
interface ProviderListProps<T> {
|
||||
items: T[];
|
||||
loading: boolean;
|
||||
keyField: (item: T) => string;
|
||||
keyField: (item: T, index: number) => string;
|
||||
renderContent: (item: T, index: number) => ReactNode;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
@@ -48,7 +48,7 @@ export function ProviderList<T>({
|
||||
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
||||
return (
|
||||
<div
|
||||
key={keyField(item)}
|
||||
key={keyField(item, index)}
|
||||
className="item-row"
|
||||
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
||||
>
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
export { QuotaSection } from './QuotaSection';
|
||||
export { QuotaCard } from './QuotaCard';
|
||||
export { useQuotaLoader } from './useQuotaLoader';
|
||||
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
||||
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
||||
export type { QuotaConfig } from './quotaConfigs';
|
||||
|
||||
@@ -10,6 +10,10 @@ import type {
|
||||
AntigravityModelsPayload,
|
||||
AntigravityQuotaState,
|
||||
AuthFileItem,
|
||||
ClaudeExtraUsage,
|
||||
ClaudeQuotaState,
|
||||
ClaudeQuotaWindow,
|
||||
ClaudeUsagePayload,
|
||||
CodexRateLimitInfo,
|
||||
CodexQuotaState,
|
||||
CodexUsageWindow,
|
||||
@@ -23,6 +27,9 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_URLS,
|
||||
ANTIGRAVITY_REQUEST_HEADERS,
|
||||
CLAUDE_USAGE_URL,
|
||||
CLAUDE_REQUEST_HEADERS,
|
||||
CLAUDE_USAGE_WINDOW_KEYS,
|
||||
CODEX_USAGE_URL,
|
||||
CODEX_REQUEST_HEADERS,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
@@ -34,6 +41,7 @@ import {
|
||||
normalizeQuotaFraction,
|
||||
normalizeStringValue,
|
||||
parseAntigravityPayload,
|
||||
parseClaudeUsagePayload,
|
||||
parseCodexUsagePayload,
|
||||
parseGeminiCliQuotaPayload,
|
||||
resolveCodexChatgptAccountId,
|
||||
@@ -46,6 +54,7 @@ import {
|
||||
createStatusError,
|
||||
getStatusFromError,
|
||||
isAntigravityFile,
|
||||
isClaudeFile,
|
||||
isCodexFile,
|
||||
isDisabledAuthFile,
|
||||
isGeminiCliFile,
|
||||
@@ -56,15 +65,17 @@ import styles from '@/pages/QuotaPage.module.scss';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||
|
||||
export interface QuotaStore {
|
||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||
claudeQuota: Record<string, ClaudeQuotaState>;
|
||||
codexQuota: Record<string, CodexQuotaState>;
|
||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
|
||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||
clearQuotaCache: () => void;
|
||||
@@ -558,6 +569,149 @@ const renderGeminiCliItems = (
|
||||
});
|
||||
};
|
||||
|
||||
const buildClaudeQuotaWindows = (
|
||||
payload: ClaudeUsagePayload,
|
||||
t: TFunction
|
||||
): ClaudeQuotaWindow[] => {
|
||||
const windows: ClaudeQuotaWindow[] = [];
|
||||
|
||||
for (const { key, id, labelKey } of CLAUDE_USAGE_WINDOW_KEYS) {
|
||||
const window = payload[key as keyof ClaudeUsagePayload];
|
||||
if (!window || typeof window !== 'object' || !('utilization' in window)) continue;
|
||||
const typedWindow = window as { utilization: number; resets_at: string };
|
||||
const usedPercent = normalizeNumberValue(typedWindow.utilization);
|
||||
const resetLabel = formatQuotaResetTime(typedWindow.resets_at);
|
||||
windows.push({
|
||||
id,
|
||||
label: t(labelKey),
|
||||
labelKey,
|
||||
usedPercent,
|
||||
resetLabel,
|
||||
});
|
||||
}
|
||||
|
||||
return windows;
|
||||
};
|
||||
|
||||
const fetchClaudeQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('claude_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CLAUDE_USAGE_URL,
|
||||
header: { ...CLAUDE_REQUEST_HEADERS },
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
}
|
||||
|
||||
const payload = parseClaudeUsagePayload(result.body ?? result.bodyText);
|
||||
if (!payload) {
|
||||
throw new Error(t('claude_quota.empty_windows'));
|
||||
}
|
||||
|
||||
const windows = buildClaudeQuotaWindows(payload, t);
|
||||
return { windows, extraUsage: payload.extra_usage };
|
||||
};
|
||||
|
||||
const renderClaudeItems = (
|
||||
quota: ClaudeQuotaState,
|
||||
t: TFunction,
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h, Fragment } = React;
|
||||
const windows = quota.windows ?? [];
|
||||
const extraUsage = quota.extraUsage ?? null;
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (extraUsage && extraUsage.is_enabled) {
|
||||
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'extra', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.extra_usage_label')),
|
||||
h('span', { className: styleMap.codexPlanValue }, usedLabel)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (windows.length === 0) {
|
||||
nodes.push(
|
||||
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('claude_quota.empty_windows'))
|
||||
);
|
||||
return h(Fragment, null, ...nodes);
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
...windows.map((window) => {
|
||||
const used = window.usedPercent;
|
||||
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
|
||||
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
|
||||
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
|
||||
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: window.id, className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel }, windowLabel),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||
h('span', { className: styleMap.quotaReset }, window.resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return h(Fragment, null, ...nodes);
|
||||
};
|
||||
|
||||
export const CLAUDE_CONFIG: QuotaConfig<
|
||||
ClaudeQuotaState,
|
||||
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
|
||||
> = {
|
||||
type: 'claude',
|
||||
i18nPrefix: 'claude_quota',
|
||||
filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
|
||||
fetchQuota: fetchClaudeQuota,
|
||||
storeSelector: (state) => state.claudeQuota,
|
||||
storeSetter: 'setClaudeQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', windows: [] }),
|
||||
buildSuccessState: (data) => ({
|
||||
status: 'success',
|
||||
windows: data.windows,
|
||||
extraUsage: data.extraUsage,
|
||||
}),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
windows: [],
|
||||
error: message,
|
||||
errorStatus: status,
|
||||
}),
|
||||
cardClassName: styles.claudeCard,
|
||||
controlsClassName: styles.claudeControls,
|
||||
controlClassName: styles.claudeControl,
|
||||
gridClassName: styles.claudeGrid,
|
||||
renderQuotaItems: renderClaudeItems,
|
||||
};
|
||||
|
||||
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||
type: 'antigravity',
|
||||
i18nPrefix: 'antigravity_quota',
|
||||
|
||||
@@ -10,6 +10,8 @@ interface HeaderInputListProps {
|
||||
disabled?: boolean;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
removeButtonTitle?: string;
|
||||
removeButtonAriaLabel?: string;
|
||||
}
|
||||
|
||||
export function HeaderInputList({
|
||||
@@ -18,7 +20,9 @@ export function HeaderInputList({
|
||||
addLabel,
|
||||
disabled = false,
|
||||
keyPlaceholder = 'X-Custom-Header',
|
||||
valuePlaceholder = 'value'
|
||||
valuePlaceholder = 'value',
|
||||
removeButtonTitle = 'Remove',
|
||||
removeButtonAriaLabel = 'Remove',
|
||||
}: HeaderInputListProps) {
|
||||
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
|
||||
|
||||
@@ -61,8 +65,8 @@ export function HeaderInputList({
|
||||
size="sm"
|
||||
onClick={() => removeEntry(index)}
|
||||
disabled={disabled || currentEntries.length <= 1}
|
||||
title="Remove"
|
||||
aria-label="Remove"
|
||||
title={removeButtonTitle}
|
||||
aria-label={removeButtonAriaLabel}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</Button>
|
||||
|
||||
@@ -6,10 +6,18 @@ import type { ModelEntry } from './modelInputListUtils';
|
||||
interface ModelInputListProps {
|
||||
entries: ModelEntry[];
|
||||
onChange: (entries: ModelEntry[]) => void;
|
||||
addLabel: string;
|
||||
addLabel?: string;
|
||||
disabled?: boolean;
|
||||
namePlaceholder?: string;
|
||||
aliasPlaceholder?: string;
|
||||
hideAddButton?: boolean;
|
||||
onAdd?: () => void;
|
||||
className?: string;
|
||||
rowClassName?: string;
|
||||
inputClassName?: string;
|
||||
removeButtonClassName?: string;
|
||||
removeButtonTitle?: string;
|
||||
removeButtonAriaLabel?: string;
|
||||
}
|
||||
|
||||
export function ModelInputList({
|
||||
@@ -18,9 +26,20 @@ export function ModelInputList({
|
||||
addLabel,
|
||||
disabled = false,
|
||||
namePlaceholder = 'model-name',
|
||||
aliasPlaceholder = 'alias (optional)'
|
||||
aliasPlaceholder = 'alias (optional)',
|
||||
hideAddButton = false,
|
||||
onAdd,
|
||||
className = '',
|
||||
rowClassName = '',
|
||||
inputClassName = '',
|
||||
removeButtonClassName = '',
|
||||
removeButtonTitle = 'Remove',
|
||||
removeButtonAriaLabel = 'Remove',
|
||||
}: ModelInputListProps) {
|
||||
const currentEntries = entries.length ? entries : [{ name: '', alias: '' }];
|
||||
const containerClassName = ['header-input-list', className].filter(Boolean).join(' ');
|
||||
const inputClassNames = ['input', inputClassName].filter(Boolean).join(' ');
|
||||
const rowClassNames = ['header-input-row', rowClassName].filter(Boolean).join(' ');
|
||||
|
||||
const updateEntry = (index: number, field: 'name' | 'alias', value: string) => {
|
||||
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
|
||||
@@ -28,7 +47,11 @@ export function ModelInputList({
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
onChange([...currentEntries, { name: '', alias: '' }]);
|
||||
if (onAdd) {
|
||||
onAdd();
|
||||
} else {
|
||||
onChange([...currentEntries, { name: '', alias: '' }]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
@@ -37,12 +60,12 @@ export function ModelInputList({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="header-input-list">
|
||||
<div className={containerClassName}>
|
||||
{currentEntries.map((entry, index) => (
|
||||
<Fragment key={index}>
|
||||
<div className="header-input-row">
|
||||
<div className={rowClassNames}>
|
||||
<input
|
||||
className="input"
|
||||
className={inputClassNames}
|
||||
placeholder={namePlaceholder}
|
||||
value={entry.name}
|
||||
onChange={(e) => updateEntry(index, 'name', e.target.value)}
|
||||
@@ -50,7 +73,7 @@ export function ModelInputList({
|
||||
/>
|
||||
<span className="header-separator">→</span>
|
||||
<input
|
||||
className="input"
|
||||
className={inputClassNames}
|
||||
placeholder={aliasPlaceholder}
|
||||
value={entry.alias}
|
||||
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
|
||||
@@ -61,17 +84,20 @@ export function ModelInputList({
|
||||
size="sm"
|
||||
onClick={() => removeEntry(index)}
|
||||
disabled={disabled || currentEntries.length <= 1}
|
||||
title="Remove"
|
||||
aria-label="Remove"
|
||||
className={removeButtonClassName}
|
||||
title={removeButtonTitle}
|
||||
aria-label={removeButtonAriaLabel}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
||||
{addLabel}
|
||||
</Button>
|
||||
{!hideAddButton && addLabel && (
|
||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
|
||||
import { formatCompactNumber, formatUsd, type ApiStats } from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ApiDetailsCardProps {
|
||||
@@ -10,9 +10,14 @@ export interface ApiDetailsCardProps {
|
||||
hasPrices: boolean;
|
||||
}
|
||||
|
||||
type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||
const [sortKey, setSortKey] = useState<ApiSortKey>('requests');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const toggleExpand = (endpoint: string) => {
|
||||
setExpandedApis((prev) => {
|
||||
@@ -26,65 +31,125 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
|
||||
});
|
||||
};
|
||||
|
||||
const handleSort = (key: ApiSortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir(key === 'endpoint' ? 'asc' : 'desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...apiStats];
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
list.sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'endpoint': return dir * a.endpoint.localeCompare(b.endpoint);
|
||||
case 'requests': return dir * (a.totalRequests - b.totalRequests);
|
||||
case 'tokens': return dir * (a.totalTokens - b.totalTokens);
|
||||
case 'cost': return dir * (a.totalCost - b.totalCost);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}, [apiStats, sortKey, sortDir]);
|
||||
|
||||
const arrow = (key: ApiSortKey) =>
|
||||
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.api_details')}>
|
||||
<Card title={t('usage_stats.api_details')} className={styles.detailsFixedCard}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : apiStats.length > 0 ? (
|
||||
<div className={styles.apiList}>
|
||||
{apiStats.map((api) => (
|
||||
<div key={api.endpoint} className={styles.apiItem}>
|
||||
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
|
||||
<div className={styles.apiInfo}>
|
||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||
<div className={styles.apiStats}>
|
||||
<span className={styles.apiBadge}>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)}
|
||||
</span>
|
||||
{hasPrices && api.totalCost > 0 && (
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
||||
) : sorted.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.apiSortBar}>
|
||||
{([
|
||||
['endpoint', 'usage_stats.api_endpoint'],
|
||||
['requests', 'usage_stats.requests_count'],
|
||||
['tokens', 'usage_stats.tokens_count'],
|
||||
...(hasPrices ? [['cost', 'usage_stats.total_cost']] : []),
|
||||
] as [ApiSortKey, string][]).map(([key, labelKey]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={sortKey === key}
|
||||
className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
|
||||
onClick={() => handleSort(key)}
|
||||
>
|
||||
{t(labelKey)}{arrow(key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.apiList}>
|
||||
{sorted.map((api, index) => {
|
||||
const isExpanded = expandedApis.has(api.endpoint);
|
||||
const panelId = `api-models-${index}`;
|
||||
|
||||
return (
|
||||
<div key={api.endpoint} className={styles.apiItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.apiHeader}
|
||||
onClick={() => toggleExpand(api.endpoint)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={panelId}
|
||||
>
|
||||
<div className={styles.apiInfo}>
|
||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||
<div className={styles.apiStats}>
|
||||
<span className={styles.apiBadge}>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.tokens_count')}: {formatCompactNumber(api.totalTokens)}
|
||||
</span>
|
||||
{hasPrices && api.totalCost > 0 && (
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.expandIcon}>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div id={panelId} className={styles.apiModels}>
|
||||
{Object.entries(api.models).map(([model, stats]) => (
|
||||
<div key={model} className={styles.modelRow}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
<span className={styles.modelStat}>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stats.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.modelStat}>{formatCompactNumber(stats.tokens)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.expandIcon}>
|
||||
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedApis.has(api.endpoint) && (
|
||||
<div className={styles.apiModels}>
|
||||
{Object.entries(api.models).map(([model, stats]) => (
|
||||
<div key={model} className={styles.modelRow}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
<span className={styles.modelStat}>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stats.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
|
||||
144
src/components/usage/CostTrendChart.tsx
Normal file
144
src/components/usage/CostTrendChart.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ScriptableContext } from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
buildHourlyCostSeries,
|
||||
buildDailyCostSeries,
|
||||
formatUsd,
|
||||
type ModelPrice
|
||||
} from '@/utils/usage';
|
||||
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface CostTrendChartProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
isMobile: boolean;
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
hourWindowHours?: number;
|
||||
}
|
||||
|
||||
const COST_COLOR = '#f59e0b';
|
||||
const COST_BG = 'rgba(245, 158, 11, 0.15)';
|
||||
|
||||
function buildGradient(ctx: ScriptableContext<'line'>) {
|
||||
const chart = ctx.chart;
|
||||
const area = chart.chartArea;
|
||||
if (!area) return COST_BG;
|
||||
const gradient = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom);
|
||||
gradient.addColorStop(0, 'rgba(245, 158, 11, 0.28)');
|
||||
gradient.addColorStop(0.6, 'rgba(245, 158, 11, 0.12)');
|
||||
gradient.addColorStop(1, 'rgba(245, 158, 11, 0.02)');
|
||||
return gradient;
|
||||
}
|
||||
|
||||
export function CostTrendChart({
|
||||
usage,
|
||||
loading,
|
||||
isDark,
|
||||
isMobile,
|
||||
modelPrices,
|
||||
hourWindowHours
|
||||
}: CostTrendChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
const { chartData, chartOptions, hasData } = useMemo(() => {
|
||||
if (!hasPrices || !usage) {
|
||||
return { chartData: { labels: [], datasets: [] }, chartOptions: {}, hasData: false };
|
||||
}
|
||||
|
||||
const series =
|
||||
period === 'hour'
|
||||
? buildHourlyCostSeries(usage, modelPrices, hourWindowHours)
|
||||
: buildDailyCostSeries(usage, modelPrices);
|
||||
|
||||
const data = {
|
||||
labels: series.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: t('usage_stats.total_cost'),
|
||||
data: series.data,
|
||||
borderColor: COST_COLOR,
|
||||
backgroundColor: buildGradient,
|
||||
pointBackgroundColor: COST_COLOR,
|
||||
pointBorderColor: COST_COLOR,
|
||||
fill: true,
|
||||
tension: 0.35
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
|
||||
const options = {
|
||||
...baseOptions,
|
||||
scales: {
|
||||
...baseOptions.scales,
|
||||
y: {
|
||||
...baseOptions.scales?.y,
|
||||
ticks: {
|
||||
...(baseOptions.scales?.y && 'ticks' in baseOptions.scales.y ? baseOptions.scales.y.ticks : {}),
|
||||
callback: (value: string | number) => formatUsd(Number(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { chartData: data, chartOptions: options, hasData: series.hasData };
|
||||
}, [usage, period, isDark, isMobile, modelPrices, hasPrices, hourWindowHours, t]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('usage_stats.cost_trend')}
|
||||
extra={
|
||||
<div className={styles.periodButtons}>
|
||||
<Button
|
||||
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setPeriod('hour')}
|
||||
>
|
||||
{t('usage_stats.by_hour')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setPeriod('day')}
|
||||
>
|
||||
{t('usage_stats.by_day')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : !hasPrices ? (
|
||||
<div className={styles.hint}>{t('usage_stats.cost_need_price')}</div>
|
||||
) : !hasData ? (
|
||||
<div className={styles.hint}>{t('usage_stats.cost_no_data')}</div>
|
||||
) : (
|
||||
<div className={styles.chartWrapper}>
|
||||
<div className={styles.chartArea}>
|
||||
<div className={styles.chartScroller}>
|
||||
<div
|
||||
className={styles.chartCanvas}
|
||||
style={
|
||||
period === 'hour'
|
||||
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
334
src/components/usage/CredentialStatsCard.tsx
Normal file
334
src/components/usage/CredentialStatsCard.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
computeKeyStats,
|
||||
collectUsageDetails,
|
||||
buildCandidateUsageSourceIds,
|
||||
formatCompactNumber
|
||||
} from '@/utils/usage';
|
||||
import { authFilesApi } from '@/services/api/authFiles';
|
||||
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
|
||||
import type { AuthFileItem } from '@/types/authFile';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface CredentialStatsCardProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
geminiKeys: GeminiKeyConfig[];
|
||||
claudeConfigs: ProviderKeyConfig[];
|
||||
codexConfigs: ProviderKeyConfig[];
|
||||
vertexConfigs: ProviderKeyConfig[];
|
||||
openaiProviders: OpenAIProviderConfig[];
|
||||
}
|
||||
|
||||
interface CredentialInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface CredentialRow {
|
||||
key: string;
|
||||
displayName: string;
|
||||
type: string;
|
||||
success: number;
|
||||
failure: number;
|
||||
total: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
interface CredentialBucket {
|
||||
success: number;
|
||||
failure: number;
|
||||
}
|
||||
|
||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CredentialStatsCard({
|
||||
usage,
|
||||
loading,
|
||||
geminiKeys,
|
||||
claudeConfigs,
|
||||
codexConfigs,
|
||||
vertexConfigs,
|
||||
openaiProviders,
|
||||
}: CredentialStatsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
|
||||
|
||||
// Fetch auth files for auth_index-based matching
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
authFilesApi
|
||||
.list()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
|
||||
if (!Array.isArray(files)) return;
|
||||
const map = new Map<string, CredentialInfo>();
|
||||
files.forEach((file) => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const key = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (key) {
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
setAuthFileMap(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates).
|
||||
// Auth files are used purely for name resolution of unmatched source IDs.
|
||||
const rows = useMemo((): CredentialRow[] => {
|
||||
if (!usage) return [];
|
||||
const { bySource } = computeKeyStats(usage);
|
||||
const details = collectUsageDetails(usage);
|
||||
const result: CredentialRow[] = [];
|
||||
const consumedSourceIds = new Set<string>();
|
||||
const authIndexToRowIndex = new Map<string, number>();
|
||||
const sourceToAuthIndex = new Map<string, string>();
|
||||
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
|
||||
|
||||
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
|
||||
const target = result[index];
|
||||
if (!target) return;
|
||||
target.success += bucket.success;
|
||||
target.failure += bucket.failure;
|
||||
target.total = target.success + target.failure;
|
||||
target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100;
|
||||
};
|
||||
|
||||
// Aggregate all candidate source IDs for one provider config into a single row
|
||||
const addConfigRow = (
|
||||
apiKey: string,
|
||||
prefix: string | undefined,
|
||||
name: string,
|
||||
type: string,
|
||||
rowKey: string,
|
||||
) => {
|
||||
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((id) => {
|
||||
const bucket = bySource[id];
|
||||
if (bucket) {
|
||||
success += bucket.success;
|
||||
failure += bucket.failure;
|
||||
consumedSourceIds.add(id);
|
||||
}
|
||||
});
|
||||
const total = success + failure;
|
||||
if (total > 0) {
|
||||
result.push({
|
||||
key: rowKey,
|
||||
displayName: name,
|
||||
type,
|
||||
success,
|
||||
failure,
|
||||
total,
|
||||
successRate: (success / total) * 100,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Provider rows — one row per config, stats merged across all its candidate source IDs
|
||||
geminiKeys.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
|
||||
claudeConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
|
||||
codexConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
|
||||
vertexConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
|
||||
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).
|
||||
openaiProviders.forEach((provider, providerIndex) => {
|
||||
const prefix = provider.prefix;
|
||||
const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||
|
||||
const candidates = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
|
||||
});
|
||||
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((id) => {
|
||||
const bucket = bySource[id];
|
||||
if (bucket) {
|
||||
success += bucket.success;
|
||||
failure += bucket.failure;
|
||||
consumedSourceIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
const total = success + failure;
|
||||
if (total > 0) {
|
||||
result.push({
|
||||
key: `openai:${providerIndex}`,
|
||||
displayName,
|
||||
type: 'openai',
|
||||
success,
|
||||
failure,
|
||||
total,
|
||||
successRate: (success / total) * 100,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Build source → auth file name mapping for remaining unmatched entries.
|
||||
// Also collect fallback stats for details without source but with auth_index.
|
||||
const sourceToAuthFile = new Map<string, CredentialInfo>();
|
||||
details.forEach((d) => {
|
||||
const authIdx = normalizeAuthIndexValue(d.auth_index);
|
||||
if (!d.source) {
|
||||
if (!authIdx) return;
|
||||
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
|
||||
if (d.failed === true) {
|
||||
fallback.failure += 1;
|
||||
} else {
|
||||
fallback.success += 1;
|
||||
}
|
||||
fallbackByAuthIndex.set(authIdx, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authIdx || consumedSourceIds.has(d.source)) return;
|
||||
if (!sourceToAuthIndex.has(d.source)) {
|
||||
sourceToAuthIndex.set(d.source, authIdx);
|
||||
}
|
||||
if (!sourceToAuthFile.has(d.source)) {
|
||||
const mapped = authFileMap.get(authIdx);
|
||||
if (mapped) sourceToAuthFile.set(d.source, mapped);
|
||||
}
|
||||
});
|
||||
|
||||
// Remaining unmatched bySource entries — resolve name from auth files if possible
|
||||
Object.entries(bySource).forEach(([key, bucket]) => {
|
||||
if (consumedSourceIds.has(key)) return;
|
||||
const total = bucket.success + bucket.failure;
|
||||
const authFile = sourceToAuthFile.get(key);
|
||||
const row = {
|
||||
key,
|
||||
displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key),
|
||||
type: authFile?.type || '',
|
||||
success: bucket.success,
|
||||
failure: bucket.failure,
|
||||
total,
|
||||
successRate: total > 0 ? (bucket.success / total) * 100 : 100,
|
||||
};
|
||||
const rowIndex = result.push(row) - 1;
|
||||
const authIdx = sourceToAuthIndex.get(key);
|
||||
if (authIdx && !authIndexToRowIndex.has(authIdx)) {
|
||||
authIndexToRowIndex.set(authIdx, rowIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Include requests that have auth_index but missing source.
|
||||
fallbackByAuthIndex.forEach((bucket, authIdx) => {
|
||||
if (bucket.success + bucket.failure === 0) return;
|
||||
|
||||
const mapped = authFileMap.get(authIdx);
|
||||
let targetRowIndex = authIndexToRowIndex.get(authIdx);
|
||||
if (targetRowIndex === undefined && mapped) {
|
||||
const matchedIndex = result.findIndex(
|
||||
(row) => row.displayName === mapped.name && row.type === mapped.type
|
||||
);
|
||||
if (matchedIndex >= 0) {
|
||||
targetRowIndex = matchedIndex;
|
||||
authIndexToRowIndex.set(authIdx, matchedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetRowIndex !== undefined) {
|
||||
mergeBucketToRow(targetRowIndex, bucket);
|
||||
return;
|
||||
}
|
||||
|
||||
const total = bucket.success + bucket.failure;
|
||||
const rowIndex = result.push({
|
||||
key: `auth:${authIdx}`,
|
||||
displayName: mapped?.name || authIdx,
|
||||
type: mapped?.type || '',
|
||||
success: bucket.success,
|
||||
failure: bucket.failure,
|
||||
total,
|
||||
successRate: (bucket.success / total) * 100
|
||||
}) - 1;
|
||||
authIndexToRowIndex.set(authIdx, rowIndex);
|
||||
});
|
||||
|
||||
return result.sort((a, b) => b.total - a.total);
|
||||
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.credential_stats')}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : rows.length > 0 ? (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.credential_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.success_rate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className={styles.modelCell}>
|
||||
<span>{row.displayName}</span>
|
||||
{row.type && (
|
||||
<span className={styles.credentialType}>{row.type}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{formatCompactNumber(row.total)}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{row.success.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{row.failure.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
row.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: row.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{row.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
|
||||
import { formatCompactNumber, formatUsd } from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ModelStat {
|
||||
@@ -18,43 +19,137 @@ export interface ModelStatsCardProps {
|
||||
hasPrices: boolean;
|
||||
}
|
||||
|
||||
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
interface ModelStatWithRate extends ModelStat {
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>('requests');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir(key === 'model' ? 'asc' : 'desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = useMemo((): ModelStatWithRate[] => {
|
||||
const list: ModelStatWithRate[] = modelStats.map((s) => ({
|
||||
...s,
|
||||
successRate: s.requests > 0 ? (s.successCount / s.requests) * 100 : 100,
|
||||
}));
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
list.sort((a, b) => {
|
||||
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
|
||||
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
|
||||
});
|
||||
return list;
|
||||
}, [modelStats, sortKey, sortDir]);
|
||||
|
||||
const arrow = (key: SortKey) =>
|
||||
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
|
||||
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.models')}>
|
||||
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : modelStats.length > 0 ? (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.model_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.tokens_count')}</th>
|
||||
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modelStats.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
) : sorted.length > 0 ? (
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('model')}
|
||||
>
|
||||
{t('usage_stats.model_name')}{arrow('model')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('requests')}
|
||||
>
|
||||
{t('usage_stats.requests_count')}{arrow('requests')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('tokens')}
|
||||
>
|
||||
{t('usage_stats.tokens_count')}{arrow('tokens')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('successRate')}
|
||||
>
|
||||
{t('usage_stats.success_rate')}{arrow('successRate')}
|
||||
</button>
|
||||
</th>
|
||||
{hasPrices && (
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('cost')}
|
||||
>
|
||||
{t('usage_stats.total_cost')}{arrow('cost')}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatCompactNumber(stat.tokens)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
stat.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: stat.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||
import {
|
||||
formatTokensInMillions,
|
||||
formatCompactNumber,
|
||||
formatPerMinuteValue,
|
||||
formatUsd,
|
||||
calculateTokenBreakdown,
|
||||
@@ -81,14 +81,14 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
||||
accent: '#8b5cf6',
|
||||
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
||||
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
||||
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
|
||||
value: loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0),
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
||||
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
|
||||
</span>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
||||
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
@@ -119,7 +119,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
||||
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||
meta: (
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
|
||||
</span>
|
||||
),
|
||||
trend: sparklines.tpm
|
||||
@@ -135,7 +135,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
|
||||
</span>
|
||||
{!hasPrices && (
|
||||
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||
|
||||
145
src/components/usage/TokenBreakdownChart.tsx
Normal file
145
src/components/usage/TokenBreakdownChart.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
buildHourlyTokenBreakdown,
|
||||
buildDailyTokenBreakdown,
|
||||
type TokenCategory
|
||||
} from '@/utils/usage';
|
||||
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
|
||||
input: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.25)' },
|
||||
output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' },
|
||||
cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' },
|
||||
reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }
|
||||
};
|
||||
|
||||
const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning'];
|
||||
|
||||
export interface TokenBreakdownChartProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
isMobile: boolean;
|
||||
hourWindowHours?: number;
|
||||
}
|
||||
|
||||
export function TokenBreakdownChart({
|
||||
usage,
|
||||
loading,
|
||||
isDark,
|
||||
isMobile,
|
||||
hourWindowHours
|
||||
}: TokenBreakdownChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
|
||||
|
||||
const { chartData, chartOptions } = useMemo(() => {
|
||||
const series =
|
||||
period === 'hour'
|
||||
? buildHourlyTokenBreakdown(usage, hourWindowHours)
|
||||
: buildDailyTokenBreakdown(usage);
|
||||
const categoryLabels: Record<TokenCategory, string> = {
|
||||
input: t('usage_stats.input_tokens'),
|
||||
output: t('usage_stats.output_tokens'),
|
||||
cached: t('usage_stats.cached_tokens'),
|
||||
reasoning: t('usage_stats.reasoning_tokens')
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: series.labels,
|
||||
datasets: CATEGORIES.map((cat) => ({
|
||||
label: categoryLabels[cat],
|
||||
data: series.dataByCategory[cat],
|
||||
borderColor: TOKEN_COLORS[cat].border,
|
||||
backgroundColor: TOKEN_COLORS[cat].bg,
|
||||
pointBackgroundColor: TOKEN_COLORS[cat].border,
|
||||
pointBorderColor: TOKEN_COLORS[cat].border,
|
||||
fill: true,
|
||||
tension: 0.35
|
||||
}))
|
||||
};
|
||||
|
||||
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
|
||||
const options = {
|
||||
...baseOptions,
|
||||
scales: {
|
||||
...baseOptions.scales,
|
||||
y: {
|
||||
...baseOptions.scales?.y,
|
||||
stacked: true
|
||||
},
|
||||
x: {
|
||||
...baseOptions.scales?.x,
|
||||
stacked: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { chartData: data, chartOptions: options };
|
||||
}, [usage, period, isDark, isMobile, hourWindowHours, t]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('usage_stats.token_breakdown')}
|
||||
extra={
|
||||
<div className={styles.periodButtons}>
|
||||
<Button
|
||||
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setPeriod('hour')}
|
||||
>
|
||||
{t('usage_stats.by_hour')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setPeriod('day')}
|
||||
>
|
||||
{t('usage_stats.by_day')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : chartData.labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<div className={styles.chartLegend} aria-label="Chart legend">
|
||||
{chartData.datasets.map((dataset, index) => (
|
||||
<div
|
||||
key={`${dataset.label}-${index}`}
|
||||
className={styles.legendItem}
|
||||
title={dataset.label}
|
||||
>
|
||||
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
|
||||
<span className={styles.legendLabel}>{dataset.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.chartArea}>
|
||||
<div className={styles.chartScroller}>
|
||||
<div
|
||||
className={styles.chartCanvas}
|
||||
style={
|
||||
period === 'hour'
|
||||
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface UseChartDataOptions {
|
||||
chartLines: string[];
|
||||
isDark: boolean;
|
||||
isMobile: boolean;
|
||||
hourWindowHours?: number;
|
||||
}
|
||||
|
||||
export interface UseChartDataReturn {
|
||||
@@ -26,20 +27,21 @@ export function useChartData({
|
||||
usage,
|
||||
chartLines,
|
||||
isDark,
|
||||
isMobile
|
||||
isMobile,
|
||||
hourWindowHours
|
||||
}: UseChartDataOptions): UseChartDataReturn {
|
||||
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
||||
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
||||
|
||||
const requestsChartData = useMemo(() => {
|
||||
if (!usage) return { labels: [], datasets: [] };
|
||||
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
|
||||
}, [usage, requestsPeriod, chartLines]);
|
||||
return buildChartData(usage, requestsPeriod, 'requests', chartLines, { hourWindowHours });
|
||||
}, [usage, requestsPeriod, chartLines, hourWindowHours]);
|
||||
|
||||
const tokensChartData = useMemo(() => {
|
||||
if (!usage) return { labels: [], datasets: [] };
|
||||
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
||||
}, [usage, tokensPeriod, chartLines]);
|
||||
return buildChartData(usage, tokensPeriod, 'tokens', chartLines, { hourWindowHours });
|
||||
}, [usage, tokensPeriod, chartLines, hourWindowHours]);
|
||||
|
||||
const requestsChartOptions = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface UseUsageDataReturn {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
lastRefreshedAt: Date | null;
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
||||
loadUsage: () => Promise<void>;
|
||||
@@ -38,6 +39,7 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
@@ -47,6 +49,7 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
const data = await usageApi.getUsage();
|
||||
const payload = (data?.usage ?? data) as unknown;
|
||||
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
|
||||
setLastRefreshedAt(new Date());
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||
setError(message);
|
||||
@@ -140,6 +143,7 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
usage,
|
||||
loading,
|
||||
error,
|
||||
lastRefreshedAt,
|
||||
modelPrices,
|
||||
setModelPrices: handleSetModelPrices,
|
||||
loadUsage,
|
||||
|
||||
@@ -26,3 +26,12 @@ export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
|
||||
|
||||
export { PriceSettingsCard } from './PriceSettingsCard';
|
||||
export type { PriceSettingsCardProps } from './PriceSettingsCard';
|
||||
|
||||
export { CredentialStatsCard } from './CredentialStatsCard';
|
||||
export type { CredentialStatsCardProps } from './CredentialStatsCard';
|
||||
|
||||
export { TokenBreakdownChart } from './TokenBreakdownChart';
|
||||
export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
|
||||
|
||||
export { CostTrendChart } from './CostTrendChart';
|
||||
export type { CostTrendChartProps } from './CostTrendChart';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
PayloadParamValueType,
|
||||
@@ -8,10 +8,6 @@ import type {
|
||||
} from '@/types/visualConfig';
|
||||
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
|
||||
|
||||
function hasOwn(obj: unknown, key: string): obj is Record<string, unknown> {
|
||||
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
@@ -48,53 +44,58 @@ function parseApiKeysText(raw: unknown): string {
|
||||
return keys.join('\n');
|
||||
}
|
||||
|
||||
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asRecord(parent[key]);
|
||||
if (existing) return existing;
|
||||
const next: Record<string, unknown> = {};
|
||||
parent[key] = next;
|
||||
return next;
|
||||
type YamlDocument = ReturnType<typeof parseDocument>;
|
||||
type YamlPath = string[];
|
||||
|
||||
function docHas(doc: YamlDocument, path: YamlPath): boolean {
|
||||
return doc.hasIn(path);
|
||||
}
|
||||
|
||||
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void {
|
||||
const value = asRecord(parent[key]);
|
||||
if (!value) return;
|
||||
if (Object.keys(value).length === 0) delete parent[key];
|
||||
function ensureMapInDoc(doc: YamlDocument, path: YamlPath): void {
|
||||
const existing = doc.getIn(path, true);
|
||||
if (isMap(existing)) return;
|
||||
doc.setIn(path, {});
|
||||
}
|
||||
|
||||
function setBoolean(obj: Record<string, unknown>, key: string, value: boolean): void {
|
||||
function deleteIfMapEmpty(doc: YamlDocument, path: YamlPath): void {
|
||||
const value = doc.getIn(path, true);
|
||||
if (!isMap(value)) return;
|
||||
if (value.items.length === 0) doc.deleteIn(path);
|
||||
}
|
||||
|
||||
function setBooleanInDoc(doc: YamlDocument, path: YamlPath, value: boolean): void {
|
||||
if (value) {
|
||||
obj[key] = true;
|
||||
doc.setIn(path, true);
|
||||
return;
|
||||
}
|
||||
if (hasOwn(obj, key)) obj[key] = false;
|
||||
if (docHas(doc, path)) doc.setIn(path, false);
|
||||
}
|
||||
|
||||
function setString(obj: Record<string, unknown>, key: string, value: unknown): void {
|
||||
function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
|
||||
const safe = typeof value === 'string' ? value : '';
|
||||
const trimmed = safe.trim();
|
||||
if (trimmed !== '') {
|
||||
obj[key] = safe;
|
||||
doc.setIn(path, safe);
|
||||
return;
|
||||
}
|
||||
if (hasOwn(obj, key)) delete obj[key];
|
||||
if (docHas(doc, path)) doc.deleteIn(path);
|
||||
}
|
||||
|
||||
function setIntFromString(obj: Record<string, unknown>, key: string, value: unknown): void {
|
||||
function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
|
||||
const safe = typeof value === 'string' ? value : '';
|
||||
const trimmed = safe.trim();
|
||||
if (trimmed === '') {
|
||||
if (hasOwn(obj, key)) delete obj[key];
|
||||
if (docHas(doc, path)) doc.deleteIn(path);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
obj[key] = parsed;
|
||||
doc.setIn(path, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasOwn(obj, key)) delete obj[key];
|
||||
if (docHas(doc, path)) doc.deleteIn(path);
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
@@ -351,78 +352,95 @@ export function useVisualConfig() {
|
||||
const applyVisualChangesToYaml = useCallback(
|
||||
(currentYaml: string): string => {
|
||||
try {
|
||||
const parsed = (parseYaml(currentYaml) || {}) as Record<string, unknown>;
|
||||
const doc = parseDocument(currentYaml);
|
||||
if (doc.errors.length > 0) return currentYaml;
|
||||
if (!isMap(doc.contents)) {
|
||||
doc.contents = doc.createNode({}) as unknown as typeof doc.contents;
|
||||
}
|
||||
const values = visualValues;
|
||||
|
||||
setString(parsed, 'host', values.host);
|
||||
setIntFromString(parsed, 'port', values.port);
|
||||
setStringInDoc(doc, ['host'], values.host);
|
||||
setIntFromStringInDoc(doc, ['port'], values.port);
|
||||
|
||||
if (
|
||||
hasOwn(parsed, 'tls') ||
|
||||
docHas(doc, ['tls']) ||
|
||||
values.tlsEnable ||
|
||||
values.tlsCert.trim() ||
|
||||
values.tlsKey.trim()
|
||||
) {
|
||||
const tls = ensureRecord(parsed, 'tls');
|
||||
setBoolean(tls, 'enable', values.tlsEnable);
|
||||
setString(tls, 'cert', values.tlsCert);
|
||||
setString(tls, 'key', values.tlsKey);
|
||||
deleteIfEmpty(parsed, 'tls');
|
||||
ensureMapInDoc(doc, ['tls']);
|
||||
setBooleanInDoc(doc, ['tls', 'enable'], values.tlsEnable);
|
||||
setStringInDoc(doc, ['tls', 'cert'], values.tlsCert);
|
||||
setStringInDoc(doc, ['tls', 'key'], values.tlsKey);
|
||||
deleteIfMapEmpty(doc, ['tls']);
|
||||
}
|
||||
|
||||
if (
|
||||
hasOwn(parsed, 'remote-management') ||
|
||||
docHas(doc, ['remote-management']) ||
|
||||
values.rmAllowRemote ||
|
||||
values.rmSecretKey.trim() ||
|
||||
values.rmDisableControlPanel ||
|
||||
values.rmPanelRepo.trim()
|
||||
) {
|
||||
const rm = ensureRecord(parsed, 'remote-management');
|
||||
setBoolean(rm, 'allow-remote', values.rmAllowRemote);
|
||||
setString(rm, 'secret-key', values.rmSecretKey);
|
||||
setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel);
|
||||
setString(rm, 'panel-github-repository', values.rmPanelRepo);
|
||||
if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo'];
|
||||
deleteIfEmpty(parsed, 'remote-management');
|
||||
ensureMapInDoc(doc, ['remote-management']);
|
||||
setBooleanInDoc(doc, ['remote-management', 'allow-remote'], values.rmAllowRemote);
|
||||
setStringInDoc(doc, ['remote-management', 'secret-key'], values.rmSecretKey);
|
||||
setBooleanInDoc(
|
||||
doc,
|
||||
['remote-management', 'disable-control-panel'],
|
||||
values.rmDisableControlPanel
|
||||
);
|
||||
setStringInDoc(doc, ['remote-management', 'panel-github-repository'], values.rmPanelRepo);
|
||||
if (docHas(doc, ['remote-management', 'panel-repo'])) {
|
||||
doc.deleteIn(['remote-management', 'panel-repo']);
|
||||
}
|
||||
deleteIfMapEmpty(doc, ['remote-management']);
|
||||
}
|
||||
|
||||
setString(parsed, 'auth-dir', values.authDir);
|
||||
setStringInDoc(doc, ['auth-dir'], values.authDir);
|
||||
if (values.apiKeysText !== baselineValues.apiKeysText) {
|
||||
const apiKeys = values.apiKeysText
|
||||
.split('\n')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean);
|
||||
if (apiKeys.length > 0) {
|
||||
parsed['api-keys'] = apiKeys;
|
||||
} else if (hasOwn(parsed, 'api-keys')) {
|
||||
delete parsed['api-keys'];
|
||||
doc.setIn(['api-keys'], apiKeys);
|
||||
} else if (docHas(doc, ['api-keys'])) {
|
||||
doc.deleteIn(['api-keys']);
|
||||
}
|
||||
}
|
||||
|
||||
setBoolean(parsed, 'debug', values.debug);
|
||||
setBooleanInDoc(doc, ['debug'], values.debug);
|
||||
|
||||
setBoolean(parsed, 'commercial-mode', values.commercialMode);
|
||||
setBoolean(parsed, 'logging-to-file', values.loggingToFile);
|
||||
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb);
|
||||
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled);
|
||||
setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
|
||||
setBooleanInDoc(doc, ['logging-to-file'], values.loggingToFile);
|
||||
setIntFromStringInDoc(doc, ['logs-max-total-size-mb'], values.logsMaxTotalSizeMb);
|
||||
setBooleanInDoc(doc, ['usage-statistics-enabled'], values.usageStatisticsEnabled);
|
||||
|
||||
setString(parsed, 'proxy-url', values.proxyUrl);
|
||||
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix);
|
||||
setIntFromString(parsed, 'request-retry', values.requestRetry);
|
||||
setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval);
|
||||
setBoolean(parsed, 'ws-auth', values.wsAuth);
|
||||
setStringInDoc(doc, ['proxy-url'], values.proxyUrl);
|
||||
setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix);
|
||||
setIntFromStringInDoc(doc, ['request-retry'], values.requestRetry);
|
||||
setIntFromStringInDoc(doc, ['max-retry-interval'], values.maxRetryInterval);
|
||||
setBooleanInDoc(doc, ['ws-auth'], values.wsAuth);
|
||||
|
||||
if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) {
|
||||
const quota = ensureRecord(parsed, 'quota-exceeded');
|
||||
quota['switch-project'] = values.quotaSwitchProject;
|
||||
quota['switch-preview-model'] = values.quotaSwitchPreviewModel;
|
||||
deleteIfEmpty(parsed, 'quota-exceeded');
|
||||
if (
|
||||
docHas(doc, ['quota-exceeded']) ||
|
||||
!values.quotaSwitchProject ||
|
||||
!values.quotaSwitchPreviewModel
|
||||
) {
|
||||
ensureMapInDoc(doc, ['quota-exceeded']);
|
||||
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
|
||||
doc.setIn(
|
||||
['quota-exceeded', 'switch-preview-model'],
|
||||
values.quotaSwitchPreviewModel
|
||||
);
|
||||
deleteIfMapEmpty(doc, ['quota-exceeded']);
|
||||
}
|
||||
|
||||
if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') {
|
||||
const routing = ensureRecord(parsed, 'routing');
|
||||
routing.strategy = values.routingStrategy;
|
||||
deleteIfEmpty(parsed, 'routing');
|
||||
if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
|
||||
ensureMapInDoc(doc, ['routing']);
|
||||
doc.setIn(['routing', 'strategy'], values.routingStrategy);
|
||||
deleteIfMapEmpty(doc, ['routing']);
|
||||
}
|
||||
|
||||
const keepaliveSeconds =
|
||||
@@ -435,42 +453,55 @@ export function useVisualConfig() {
|
||||
: '';
|
||||
|
||||
const streamingDefined =
|
||||
hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim();
|
||||
docHas(doc, ['streaming']) || keepaliveSeconds.trim() || bootstrapRetries.trim();
|
||||
if (streamingDefined) {
|
||||
const streaming = ensureRecord(parsed, 'streaming');
|
||||
setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds);
|
||||
setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries);
|
||||
deleteIfEmpty(parsed, 'streaming');
|
||||
ensureMapInDoc(doc, ['streaming']);
|
||||
setIntFromStringInDoc(doc, ['streaming', 'keepalive-seconds'], keepaliveSeconds);
|
||||
setIntFromStringInDoc(doc, ['streaming', 'bootstrap-retries'], bootstrapRetries);
|
||||
deleteIfMapEmpty(doc, ['streaming']);
|
||||
}
|
||||
|
||||
setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval);
|
||||
setIntFromStringInDoc(
|
||||
doc,
|
||||
['nonstream-keepalive-interval'],
|
||||
nonstreamKeepaliveInterval
|
||||
);
|
||||
|
||||
if (
|
||||
hasOwn(parsed, 'payload') ||
|
||||
docHas(doc, ['payload']) ||
|
||||
values.payloadDefaultRules.length > 0 ||
|
||||
values.payloadOverrideRules.length > 0 ||
|
||||
values.payloadFilterRules.length > 0
|
||||
) {
|
||||
const payload = ensureRecord(parsed, 'payload');
|
||||
ensureMapInDoc(doc, ['payload']);
|
||||
if (values.payloadDefaultRules.length > 0) {
|
||||
payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules);
|
||||
} else if (hasOwn(payload, 'default')) {
|
||||
delete payload.default;
|
||||
doc.setIn(
|
||||
['payload', 'default'],
|
||||
serializePayloadRulesForYaml(values.payloadDefaultRules)
|
||||
);
|
||||
} else if (docHas(doc, ['payload', 'default'])) {
|
||||
doc.deleteIn(['payload', 'default']);
|
||||
}
|
||||
if (values.payloadOverrideRules.length > 0) {
|
||||
payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules);
|
||||
} else if (hasOwn(payload, 'override')) {
|
||||
delete payload.override;
|
||||
doc.setIn(
|
||||
['payload', 'override'],
|
||||
serializePayloadRulesForYaml(values.payloadOverrideRules)
|
||||
);
|
||||
} else if (docHas(doc, ['payload', 'override'])) {
|
||||
doc.deleteIn(['payload', 'override']);
|
||||
}
|
||||
if (values.payloadFilterRules.length > 0) {
|
||||
payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules);
|
||||
} else if (hasOwn(payload, 'filter')) {
|
||||
delete payload.filter;
|
||||
doc.setIn(
|
||||
['payload', 'filter'],
|
||||
serializePayloadFilterRulesForYaml(values.payloadFilterRules)
|
||||
);
|
||||
} else if (docHas(doc, ['payload', 'filter'])) {
|
||||
doc.deleteIn(['payload', 'filter']);
|
||||
}
|
||||
deleteIfEmpty(parsed, 'payload');
|
||||
deleteIfMapEmpty(doc, ['payload']);
|
||||
}
|
||||
|
||||
return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 });
|
||||
return doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
|
||||
} catch {
|
||||
return currentYaml;
|
||||
}
|
||||
@@ -498,18 +529,66 @@ export function useVisualConfig() {
|
||||
}
|
||||
|
||||
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
|
||||
{ value: '', label: '默认' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'openai-response', label: 'OpenAI Response' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{
|
||||
value: '',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_default',
|
||||
defaultLabel: 'Default',
|
||||
},
|
||||
{
|
||||
value: 'openai',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_openai',
|
||||
defaultLabel: 'OpenAI',
|
||||
},
|
||||
{
|
||||
value: 'openai-response',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_openai_response',
|
||||
defaultLabel: 'OpenAI Response',
|
||||
},
|
||||
{
|
||||
value: 'gemini',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_gemini',
|
||||
defaultLabel: 'Gemini',
|
||||
},
|
||||
{
|
||||
value: 'claude',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_claude',
|
||||
defaultLabel: 'Claude',
|
||||
},
|
||||
{
|
||||
value: 'codex',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_codex',
|
||||
defaultLabel: 'Codex',
|
||||
},
|
||||
{
|
||||
value: 'antigravity',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_antigravity',
|
||||
defaultLabel: 'Antigravity',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
|
||||
{ value: 'string', label: '字符串' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'boolean', label: '布尔' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>;
|
||||
{
|
||||
value: 'string',
|
||||
labelKey: 'config_management.visual.payload_rules.value_type_string',
|
||||
defaultLabel: 'String',
|
||||
},
|
||||
{
|
||||
value: 'number',
|
||||
labelKey: 'config_management.visual.payload_rules.value_type_number',
|
||||
defaultLabel: 'Number',
|
||||
},
|
||||
{
|
||||
value: 'boolean',
|
||||
labelKey: 'config_management.visual.payload_rules.value_type_boolean',
|
||||
defaultLabel: 'Boolean',
|
||||
},
|
||||
{
|
||||
value: 'json',
|
||||
labelKey: 'config_management.visual.payload_rules.value_type_json',
|
||||
defaultLabel: 'JSON',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
value: PayloadParamValueType;
|
||||
labelKey: string;
|
||||
defaultLabel: string;
|
||||
}>;
|
||||
|
||||
@@ -38,13 +38,16 @@
|
||||
"quota_update_required": "Please update the CPA version or check for updates",
|
||||
"quota_check_credential": "Please check the credential status",
|
||||
"copy": "Copy",
|
||||
"status": "Status",
|
||||
"action": "Action",
|
||||
"custom_headers_label": "Custom Headers",
|
||||
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
|
||||
"custom_headers_add": "Add Header",
|
||||
"custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header",
|
||||
"custom_headers_value_placeholder": "Header value",
|
||||
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
|
||||
"model_alias_placeholder": "Model alias (optional)"
|
||||
"model_alias_placeholder": "Model alias (optional)",
|
||||
"invalid_provider_index": "Invalid provider index."
|
||||
},
|
||||
"title": {
|
||||
"main": "CLI Proxy API Management Center",
|
||||
@@ -333,7 +336,13 @@
|
||||
"openai_test_success": "Test succeeded. The model responded.",
|
||||
"openai_test_failed": "Test failed",
|
||||
"openai_test_select_placeholder": "Choose from current models",
|
||||
"openai_test_select_empty": "No models configured. Add models first"
|
||||
"openai_test_select_empty": "No models configured. Add models first",
|
||||
"openai_test_single_action": "Test",
|
||||
"openai_test_all_action": "Test All Keys",
|
||||
"openai_test_all_hint": "Test connection status for all keys",
|
||||
"openai_test_all_success": "All {{count}} keys passed the test",
|
||||
"openai_test_all_failed": "All {{count}} keys failed the test",
|
||||
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "Auth Files Management",
|
||||
@@ -410,15 +419,25 @@
|
||||
"status_toggle_label": "Enabled",
|
||||
"status_enabled_success": "\"{{name}}\" enabled",
|
||||
"status_disabled_success": "\"{{name}}\" disabled",
|
||||
"prefix_proxy_button": "Edit prefix/proxy_url",
|
||||
"prefix_proxy_loading": "Loading credential...",
|
||||
"prefix_proxy_source_label": "Credential JSON",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_proxy_button": "Edit Auth Fields",
|
||||
"auth_field_editor_title": "Edit Auth Fields - {{name}}",
|
||||
"prefix_proxy_loading": "Loading auth file...",
|
||||
"prefix_proxy_source_label": "Auth file JSON (preview)",
|
||||
"prefix_label": "Prefix (prefix)",
|
||||
"proxy_url_label": "Proxy URL (proxy_url)",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
|
||||
"priority_label": "Priority (priority)",
|
||||
"priority_placeholder": "e.g. 10 or -1",
|
||||
"priority_hint": "Integers only. Invalid values are ignored. Larger value means higher priority.",
|
||||
"excluded_models_label": "Excluded models (excluded_models)",
|
||||
"excluded_models_placeholder": "Comma or newline separated, e.g. model-a, gpt-5-*, *-preview",
|
||||
"excluded_models_hint": "Saved as an array and normalized by trim/lowercase/dedup/sort.",
|
||||
"disable_cooling_label": "Disable cooling (disable_cooling)",
|
||||
"disable_cooling_placeholder": "e.g. true / false / 1 / 0",
|
||||
"disable_cooling_hint": "Supports booleans, numeric 0/non-0, and strings like true/false/1/0; unparseable values are ignored.",
|
||||
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
|
||||
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
||||
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
|
||||
},
|
||||
@@ -434,6 +453,26 @@
|
||||
"refresh_button": "Refresh Quota",
|
||||
"fetch_all": "Fetch All"
|
||||
},
|
||||
"claude_quota": {
|
||||
"title": "Claude Quota",
|
||||
"empty_title": "No Claude OAuth Files",
|
||||
"empty_desc": "Log in with Claude OAuth to view quota.",
|
||||
"idle": "Click here to refresh quota",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
"empty_windows": "No quota data available",
|
||||
"refresh_button": "Refresh Quota",
|
||||
"fetch_all": "Fetch All",
|
||||
"five_hour": "5-hour limit",
|
||||
"seven_day": "7-day limit",
|
||||
"seven_day_oauth_apps": "7-day OAuth apps",
|
||||
"seven_day_opus": "7-day Opus",
|
||||
"seven_day_sonnet": "7-day Sonnet",
|
||||
"seven_day_cowork": "7-day Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "Extra Usage"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex Quota",
|
||||
"empty_title": "No Codex Auth Files",
|
||||
@@ -721,6 +760,11 @@
|
||||
"api_details": "API Details",
|
||||
"by_hour": "By Hour",
|
||||
"by_day": "By Day",
|
||||
"range_filter": "Time Range",
|
||||
"range_all": "All Time",
|
||||
"range_7h": "Last 7 Hours",
|
||||
"range_24h": "Last 24 Hours",
|
||||
"range_7d": "Last 7 Days",
|
||||
"refresh": "Refresh",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
@@ -768,7 +812,13 @@
|
||||
"cost_axis_label": "Cost ($)",
|
||||
"cost_need_price": "Set a model price to view cost stats",
|
||||
"cost_need_usage": "No usage data available to calculate cost",
|
||||
"cost_no_data": "No cost data yet"
|
||||
"cost_no_data": "No cost data yet",
|
||||
"credential_stats": "Credential Statistics",
|
||||
"credential_name": "Credential",
|
||||
"token_breakdown": "Token Type Breakdown",
|
||||
"input_tokens": "Input Tokens",
|
||||
"output_tokens": "Output Tokens",
|
||||
"last_updated": "Updated"
|
||||
},
|
||||
"stats": {
|
||||
"success": "Success",
|
||||
@@ -966,6 +1016,17 @@
|
||||
"add_param": "Add Parameter",
|
||||
"no_rules": "No rules",
|
||||
"add_rule": "Add Rule",
|
||||
"provider_default": "Default",
|
||||
"provider_openai": "OpenAI",
|
||||
"provider_openai_response": "OpenAI Response",
|
||||
"provider_gemini": "Gemini",
|
||||
"provider_claude": "Claude",
|
||||
"provider_codex": "Codex",
|
||||
"provider_antigravity": "Antigravity",
|
||||
"value_type_string": "String",
|
||||
"value_type_number": "Number",
|
||||
"value_type_boolean": "Boolean",
|
||||
"value_type_json": "JSON",
|
||||
"value_string": "String value",
|
||||
"value_number": "Number value (e.g., 0.7)",
|
||||
"value_boolean": "true or false",
|
||||
|
||||
@@ -38,13 +38,16 @@
|
||||
"quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений",
|
||||
"quota_check_credential": "Пожалуйста, проверьте статус учётных данных",
|
||||
"copy": "Копировать",
|
||||
"status": "Статус",
|
||||
"action": "Действие",
|
||||
"custom_headers_label": "Пользовательские заголовки",
|
||||
"custom_headers_hint": "Необязательно — HTTP-заголовки для отправки с запросом. Оставьте пустым для удаления.",
|
||||
"custom_headers_add": "Добавить заголовок",
|
||||
"custom_headers_key_placeholder": "Имя заголовка, например X-Custom-Header",
|
||||
"custom_headers_value_placeholder": "Значение заголовка",
|
||||
"model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022",
|
||||
"model_alias_placeholder": "Псевдоним модели (необязательно)"
|
||||
"model_alias_placeholder": "Псевдоним модели (необязательно)",
|
||||
"invalid_provider_index": "Неверный индекс провайдера."
|
||||
},
|
||||
"title": {
|
||||
"main": "Центр управления CLI Proxy API",
|
||||
@@ -333,7 +336,13 @@
|
||||
"openai_test_success": "Тест выполнен успешно. Модель ответила.",
|
||||
"openai_test_failed": "Тест не выполнен",
|
||||
"openai_test_select_placeholder": "Выберите из текущих моделей",
|
||||
"openai_test_select_empty": "Модели не настроены. Сначала добавьте модели"
|
||||
"openai_test_select_empty": "Модели не настроены. Сначала добавьте модели",
|
||||
"openai_test_single_action": "Тест",
|
||||
"openai_test_all_action": "Тестировать все ключи",
|
||||
"openai_test_all_hint": "Проверить состояние подключения для всех ключей",
|
||||
"openai_test_all_success": "Все {{count}} ключей прошли тест",
|
||||
"openai_test_all_failed": "Все {{count}} ключей не прошли тест",
|
||||
"openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "Управление файлами авторизации",
|
||||
@@ -410,15 +419,25 @@
|
||||
"status_toggle_label": "Включено",
|
||||
"status_enabled_success": "\"{{name}}\" включён",
|
||||
"status_disabled_success": "\"{{name}}\" отключён",
|
||||
"prefix_proxy_button": "Изменить prefix/proxy_url",
|
||||
"prefix_proxy_loading": "Загрузка учётных данных...",
|
||||
"prefix_proxy_source_label": "JSON учётных данных",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_proxy_button": "Редактировать поля файла авторизации",
|
||||
"auth_field_editor_title": "Редактировать поля файла авторизации - {{name}}",
|
||||
"prefix_proxy_loading": "Загрузка файла авторизации...",
|
||||
"prefix_proxy_source_label": "JSON файла авторизации (предпросмотр)",
|
||||
"prefix_label": "Префикс (prefix)",
|
||||
"proxy_url_label": "URL прокси (proxy_url)",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "Эти учётные данные не являются JSON-объектом и не могут быть изменены.",
|
||||
"prefix_proxy_saved_success": "\"{{name}}\" успешно обновлён",
|
||||
"priority_label": "Приоритет (priority)",
|
||||
"priority_placeholder": "например: 10 или -1",
|
||||
"priority_hint": "Только целые числа. Некорректные значения игнорируются. Чем больше число, тем выше приоритет.",
|
||||
"excluded_models_label": "Исключённые модели (excluded_models)",
|
||||
"excluded_models_placeholder": "Через запятую или с новой строки, например: model-a, gpt-5-*, *-preview",
|
||||
"excluded_models_hint": "Сохраняется как массив; значения trim/нижний регистр/без дублей/с сортировкой.",
|
||||
"disable_cooling_label": "Отключение охлаждения (disable_cooling)",
|
||||
"disable_cooling_placeholder": "например: true / false / 1 / 0",
|
||||
"disable_cooling_hint": "Поддерживает boolean, числа 0/не 0 и строки true/false/1/0; непарсируемые значения игнорируются.",
|
||||
"prefix_proxy_invalid_json": "Этот файл авторизации не является JSON-объектом, поэтому поля нельзя редактировать.",
|
||||
"prefix_proxy_saved_success": "Файл авторизации \"{{name}}\" успешно обновлён",
|
||||
"card_tools_title": "Инструменты",
|
||||
"quota_refresh_single": "Обновить квоту",
|
||||
"quota_refresh_hint": "Обновить квоту только для этих учётных данных",
|
||||
@@ -437,6 +456,26 @@
|
||||
"refresh_button": "Обновить квоту",
|
||||
"fetch_all": "Получить все"
|
||||
},
|
||||
"claude_quota": {
|
||||
"title": "Квота Claude",
|
||||
"empty_title": "Файлы авторизации Claude OAuth отсутствуют",
|
||||
"empty_desc": "Войдите через Claude OAuth, чтобы увидеть квоту.",
|
||||
"idle": "Не загружено. Нажмите \"Обновить квоту\".",
|
||||
"loading": "Загрузка квоты...",
|
||||
"load_failed": "Не удалось загрузить квоту: {{message}}",
|
||||
"missing_auth_index": "В файле авторизации отсутствует auth_index",
|
||||
"empty_windows": "Данные по квоте отсутствуют",
|
||||
"refresh_button": "Обновить квоту",
|
||||
"fetch_all": "Получить все",
|
||||
"five_hour": "Лимит на 5 часов",
|
||||
"seven_day": "Лимит на 7 дней",
|
||||
"seven_day_oauth_apps": "7 дней OAuth приложения",
|
||||
"seven_day_opus": "7 дней Opus",
|
||||
"seven_day_sonnet": "7 дней Sonnet",
|
||||
"seven_day_cowork": "7 дней Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "Дополнительное использование"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Квота Codex",
|
||||
"empty_title": "Файлы авторизации Codex отсутствуют",
|
||||
@@ -724,6 +763,11 @@
|
||||
"api_details": "Детали API",
|
||||
"by_hour": "По часам",
|
||||
"by_day": "По дням",
|
||||
"range_filter": "Диапазон времени",
|
||||
"range_all": "За всё время",
|
||||
"range_7h": "Последние 7 часов",
|
||||
"range_24h": "Последние 24 часа",
|
||||
"range_7d": "Последние 7 дней",
|
||||
"refresh": "Обновить",
|
||||
"export": "Экспорт",
|
||||
"import": "Импорт",
|
||||
@@ -771,7 +815,13 @@
|
||||
"cost_axis_label": "Стоимость ($)",
|
||||
"cost_need_price": "Задайте стоимость модели, чтобы увидеть статистику затрат",
|
||||
"cost_need_usage": "Нет данных использования для расчёта стоимости",
|
||||
"cost_no_data": "Данных о стоимости ещё нет"
|
||||
"cost_no_data": "Данных о стоимости ещё нет",
|
||||
"credential_stats": "Статистика учётных данных",
|
||||
"credential_name": "Учётные данные",
|
||||
"token_breakdown": "Распределение типов токенов",
|
||||
"input_tokens": "Входные токены",
|
||||
"output_tokens": "Выходные токены",
|
||||
"last_updated": "Обновлено"
|
||||
},
|
||||
"stats": {
|
||||
"success": "Успех",
|
||||
@@ -971,6 +1021,17 @@
|
||||
"add_param": "Добавить параметр",
|
||||
"no_rules": "Правил нет",
|
||||
"add_rule": "Добавить правило",
|
||||
"provider_default": "По умолчанию",
|
||||
"provider_openai": "OpenAI",
|
||||
"provider_openai_response": "OpenAI Response",
|
||||
"provider_gemini": "Gemini",
|
||||
"provider_claude": "Claude",
|
||||
"provider_codex": "Codex",
|
||||
"provider_antigravity": "Antigravity",
|
||||
"value_type_string": "Строка",
|
||||
"value_type_number": "Число",
|
||||
"value_type_boolean": "Булево",
|
||||
"value_type_json": "JSON",
|
||||
"value_string": "Строковое значение",
|
||||
"value_number": "Числовое значение (например, 0.7)",
|
||||
"value_boolean": "true или false",
|
||||
|
||||
@@ -38,13 +38,16 @@
|
||||
"quota_update_required": "请更新 CPA 版本或检查更新",
|
||||
"quota_check_credential": "请检查凭证状态",
|
||||
"copy": "复制",
|
||||
"status": "状态",
|
||||
"action": "操作",
|
||||
"custom_headers_label": "自定义请求头",
|
||||
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
|
||||
"custom_headers_add": "添加请求头",
|
||||
"custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header",
|
||||
"custom_headers_value_placeholder": "Header 值",
|
||||
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
|
||||
"model_alias_placeholder": "模型别名 (可选)"
|
||||
"model_alias_placeholder": "模型别名 (可选)",
|
||||
"invalid_provider_index": "无效的提供商索引。"
|
||||
},
|
||||
"title": {
|
||||
"main": "CLI Proxy API Management Center",
|
||||
@@ -333,7 +336,13 @@
|
||||
"openai_test_success": "测试成功,模型可用。",
|
||||
"openai_test_failed": "测试失败",
|
||||
"openai_test_select_placeholder": "从当前模型列表选择",
|
||||
"openai_test_select_empty": "当前未配置模型,请先添加模型"
|
||||
"openai_test_select_empty": "当前未配置模型,请先添加模型",
|
||||
"openai_test_single_action": "测试",
|
||||
"openai_test_all_action": "一键测试全部密钥",
|
||||
"openai_test_all_hint": "测试所有密钥的连接状态",
|
||||
"openai_test_all_success": "所有 {{count}} 个密钥测试通过",
|
||||
"openai_test_all_failed": "所有 {{count}} 个密钥测试失败",
|
||||
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "认证文件管理",
|
||||
@@ -410,15 +419,25 @@
|
||||
"status_toggle_label": "启用",
|
||||
"status_enabled_success": "已启用 \"{{name}}\"",
|
||||
"status_disabled_success": "已停用 \"{{name}}\"",
|
||||
"prefix_proxy_button": "配置 prefix/proxy_url",
|
||||
"prefix_proxy_loading": "正在加载凭证文件...",
|
||||
"prefix_proxy_source_label": "凭证 JSON",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_proxy_button": "编辑认证文件字段",
|
||||
"auth_field_editor_title": "编辑认证文件字段 - {{name}}",
|
||||
"prefix_proxy_loading": "正在加载认证文件...",
|
||||
"prefix_proxy_source_label": "认证文件 JSON(预览)",
|
||||
"prefix_label": "前缀(prefix)",
|
||||
"proxy_url_label": "代理 URL(proxy_url)",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
|
||||
"priority_label": "优先级(priority)",
|
||||
"priority_placeholder": "例如: 10 或 -1",
|
||||
"priority_hint": "仅支持整数;非法值会被忽略。数值越大优先级越高。",
|
||||
"excluded_models_label": "排除模型(excluded_models)",
|
||||
"excluded_models_placeholder": "用逗号或换行分隔,例如: model-a, gpt-5-*, *-preview",
|
||||
"excluded_models_hint": "保存为数组;会自动 trim、小写、去重并排序。",
|
||||
"disable_cooling_label": "禁用冷却(disable_cooling)",
|
||||
"disable_cooling_placeholder": "例如: true / false / 1 / 0",
|
||||
"disable_cooling_hint": "支持布尔值、0/非0 数字或字符串 true/false/1/0;无法解析时忽略。",
|
||||
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
|
||||
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
|
||||
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
|
||||
},
|
||||
@@ -434,6 +453,26 @@
|
||||
"refresh_button": "刷新额度",
|
||||
"fetch_all": "获取全部"
|
||||
},
|
||||
"claude_quota": {
|
||||
"title": "Claude 额度",
|
||||
"empty_title": "暂无 Claude OAuth 认证",
|
||||
"empty_desc": "使用 Claude OAuth 登录后即可查看额度。",
|
||||
"idle": "点击此处刷新额度",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
"empty_windows": "暂无额度数据",
|
||||
"refresh_button": "刷新额度",
|
||||
"fetch_all": "获取全部",
|
||||
"five_hour": "5 小时限额",
|
||||
"seven_day": "7 天限额",
|
||||
"seven_day_oauth_apps": "7 天 OAuth 应用",
|
||||
"seven_day_opus": "7 天 Opus",
|
||||
"seven_day_sonnet": "7 天 Sonnet",
|
||||
"seven_day_cowork": "7 天 Cowork",
|
||||
"iguana_necktie": "Iguana Necktie",
|
||||
"extra_usage_label": "额外用量"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex 额度",
|
||||
"empty_title": "暂无 Codex 认证",
|
||||
@@ -721,6 +760,11 @@
|
||||
"api_details": "API 详细统计",
|
||||
"by_hour": "按小时",
|
||||
"by_day": "按天",
|
||||
"range_filter": "时间范围",
|
||||
"range_all": "全部时间",
|
||||
"range_7h": "最近7小时",
|
||||
"range_24h": "最近24小时",
|
||||
"range_7d": "最近7天",
|
||||
"refresh": "刷新",
|
||||
"export": "导出数据",
|
||||
"import": "导入数据",
|
||||
@@ -768,7 +812,13 @@
|
||||
"cost_axis_label": "花费 ($)",
|
||||
"cost_need_price": "请先设置模型价格",
|
||||
"cost_need_usage": "暂无使用数据,无法计算花费",
|
||||
"cost_no_data": "没有可计算的花费数据"
|
||||
"cost_no_data": "没有可计算的花费数据",
|
||||
"credential_stats": "凭证统计",
|
||||
"credential_name": "凭证",
|
||||
"token_breakdown": "Token 类型分布",
|
||||
"input_tokens": "输入 Tokens",
|
||||
"output_tokens": "输出 Tokens",
|
||||
"last_updated": "更新于"
|
||||
},
|
||||
"stats": {
|
||||
"success": "成功",
|
||||
@@ -966,6 +1016,17 @@
|
||||
"add_param": "添加参数",
|
||||
"no_rules": "暂无规则",
|
||||
"add_rule": "添加规则",
|
||||
"provider_default": "默认",
|
||||
"provider_openai": "OpenAI",
|
||||
"provider_openai_response": "OpenAI Response",
|
||||
"provider_gemini": "Gemini",
|
||||
"provider_claude": "Claude",
|
||||
"provider_codex": "Codex",
|
||||
"provider_antigravity": "Antigravity",
|
||||
"value_type_string": "字符串",
|
||||
"value_type_number": "数字",
|
||||
"value_type_boolean": "布尔",
|
||||
"value_type_json": "JSON",
|
||||
"value_string": "字符串值",
|
||||
"value_number": "数字值 (如 0.7)",
|
||||
"value_boolean": "true 或 false",
|
||||
|
||||
@@ -302,6 +302,8 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
||||
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={loading || saving || disableControls}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
||||
|
||||
@@ -210,7 +210,7 @@ export function AiProvidersClaudeEditPage() {
|
||||
<Card>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{invalidIndexParam || invalidIndex ? (
|
||||
<div className="hint">Invalid provider index.</div>
|
||||
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
@@ -245,6 +245,8 @@ export function AiProvidersClaudeEditPage() {
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="form-group">
|
||||
@@ -255,6 +257,8 @@ export function AiProvidersClaudeEditPage() {
|
||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -210,7 +210,7 @@ export function AiProvidersCodexEditPage() {
|
||||
<Card>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{invalidIndexParam || invalidIndex ? (
|
||||
<div className="hint">Invalid provider index.</div>
|
||||
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
@@ -245,6 +245,8 @@ export function AiProvidersCodexEditPage() {
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="form-group">
|
||||
|
||||
@@ -193,7 +193,7 @@ export function AiProvidersGeminiEditPage() {
|
||||
<Card>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{invalidIndexParam || invalidIndex ? (
|
||||
<div className="hint">Invalid provider index.</div>
|
||||
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
@@ -224,6 +224,8 @@ export function AiProvidersGeminiEditPage() {
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="form-group">
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
||||
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||
|
||||
type LocationState = { fromAiProviders?: boolean } | null;
|
||||
|
||||
@@ -29,6 +30,9 @@ export type OpenAIEditOutletContext = {
|
||||
setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>;
|
||||
testMessage: string;
|
||||
setTestMessage: Dispatch<SetStateAction<string>>;
|
||||
keyTestStatuses: KeyTestStatus[];
|
||||
setDraftKeyTestStatus: (keyIndex: number, status: KeyTestStatus) => void;
|
||||
resetDraftKeyTestStatuses: (count: number) => void;
|
||||
availableModels: string[];
|
||||
handleBack: () => void;
|
||||
handleSave: () => Promise<void>;
|
||||
@@ -73,8 +77,6 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
const isCacheValid = useConfigStore((state) => state.isCacheValid);
|
||||
|
||||
const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
|
||||
@@ -99,11 +101,14 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
|
||||
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
|
||||
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
|
||||
const setDraftKeyTestStatus = useOpenAIEditDraftStore((state) => state.setDraftKeyTestStatus);
|
||||
const resetDraftKeyTestStatuses = useOpenAIEditDraftStore((state) => state.resetDraftKeyTestStatuses);
|
||||
|
||||
const form = draft?.form ?? buildEmptyForm();
|
||||
const testModel = draft?.testModel ?? '';
|
||||
const testStatus = draft?.testStatus ?? 'idle';
|
||||
const testMessage = draft?.testMessage ?? '';
|
||||
const keyTestStatuses = draft?.keyTestStatuses ?? [];
|
||||
|
||||
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
|
||||
(action) => {
|
||||
@@ -134,6 +139,20 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
[draftKey, setDraftTestMessage]
|
||||
);
|
||||
|
||||
const handleSetDraftKeyTestStatus = useCallback(
|
||||
(keyIndex: number, status: KeyTestStatus) => {
|
||||
setDraftKeyTestStatus(draftKey, keyIndex, status);
|
||||
},
|
||||
[draftKey, setDraftKeyTestStatus]
|
||||
);
|
||||
|
||||
const handleResetDraftKeyTestStatuses = useCallback(
|
||||
(count: number) => {
|
||||
resetDraftKeyTestStatuses(draftKey, count);
|
||||
},
|
||||
[draftKey, resetDraftKeyTestStatuses]
|
||||
);
|
||||
|
||||
const initialData = useMemo(() => {
|
||||
if (editIndex === null) return undefined;
|
||||
return providers[editIndex];
|
||||
@@ -215,6 +234,7 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
testModel: initialTestModel,
|
||||
testStatus: 'idle',
|
||||
testMessage: '',
|
||||
keyTestStatuses: [],
|
||||
});
|
||||
} else {
|
||||
initDraft(draftKey, {
|
||||
@@ -222,6 +242,7 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
testMessage: '',
|
||||
keyTestStatuses: [],
|
||||
});
|
||||
}
|
||||
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
||||
@@ -312,9 +333,18 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
: [...providers, payload];
|
||||
|
||||
await providersApi.saveOpenAIProviders(nextList);
|
||||
setProviders(nextList);
|
||||
updateConfigValue('openai-compatibility', nextList);
|
||||
clearCache('openai-compatibility');
|
||||
|
||||
let syncedProviders = nextList;
|
||||
try {
|
||||
const latest = await fetchConfig('openai-compatibility', true);
|
||||
if (Array.isArray(latest)) {
|
||||
syncedProviders = latest as OpenAIProviderConfig[];
|
||||
}
|
||||
} catch {
|
||||
// 保存成功后刷新失败时,回退到本地计算结果,避免页面数据为空或回退
|
||||
}
|
||||
|
||||
setProviders(syncedProviders);
|
||||
showNotification(
|
||||
editIndex !== null
|
||||
? t('notification.openai_provider_updated')
|
||||
@@ -328,15 +358,14 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
clearCache,
|
||||
editIndex,
|
||||
fetchConfig,
|
||||
form,
|
||||
handleBack,
|
||||
providers,
|
||||
testModel,
|
||||
showNotification,
|
||||
t,
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
const resolvedLoading = !draft?.initialized;
|
||||
@@ -359,6 +388,9 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
setTestStatus,
|
||||
testMessage,
|
||||
setTestMessage,
|
||||
keyTestStatuses,
|
||||
setDraftKeyTestStatus: handleSetDraftKeyTestStatus,
|
||||
resetDraftKeyTestStatuses: handleResetDraftKeyTestStatuses,
|
||||
availableModels,
|
||||
handleBack,
|
||||
handleSave,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -14,6 +14,7 @@ import type { ApiKeyEntry } from '@/types';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
|
||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||
|
||||
@@ -25,6 +26,72 @@ const getErrorMessage = (err: unknown) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
// Status icon components
|
||||
function StatusLoadingIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className={styles.statusIconSpin}>
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
|
||||
<path
|
||||
d="M8 1A7 7 0 0 1 8 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusSuccessIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="8" fill="var(--success-color, #22c55e)" />
|
||||
<path
|
||||
d="M4.5 8L7 10.5L11.5 6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusErrorIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="8" fill="var(--danger-color, #ef4444)" />
|
||||
<path
|
||||
d="M5 5L11 11M11 5L5 11"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIdleIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="var(--text-tertiary, #9ca3af)" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: KeyTestStatus['status'] }) {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return <StatusLoadingIcon />;
|
||||
case 'success':
|
||||
return <StatusSuccessIcon />;
|
||||
case 'error':
|
||||
return <StatusErrorIcon />;
|
||||
default:
|
||||
return <StatusIdleIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
export function AiProvidersOpenAIEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -44,6 +111,9 @@ export function AiProvidersOpenAIEditPage() {
|
||||
setTestStatus,
|
||||
testMessage,
|
||||
setTestMessage,
|
||||
keyTestStatuses,
|
||||
setDraftKeyTestStatus,
|
||||
resetDraftKeyTestStatuses,
|
||||
availableModels,
|
||||
handleBack,
|
||||
handleSave,
|
||||
@@ -54,6 +124,7 @@ export function AiProvidersOpenAIEditPage() {
|
||||
: t('ai_providers.openai_add_modal_title');
|
||||
|
||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||
const [isTestingKeys, setIsTestingKeys] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -65,80 +136,131 @@ export function AiProvidersOpenAIEditPage() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleBack]);
|
||||
|
||||
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex;
|
||||
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTestingKeys;
|
||||
const hasConfiguredModels = form.modelEntries.some((entry) => entry.name.trim());
|
||||
const hasTestableKeys = form.apiKeyEntries.some((entry) => entry.apiKey?.trim());
|
||||
const connectivityConfigSignature = useMemo(() => {
|
||||
const headersSignature = form.headers
|
||||
.map((entry) => `${entry.key.trim()}:${entry.value.trim()}`)
|
||||
.join('|');
|
||||
const modelsSignature = form.modelEntries
|
||||
.map((entry) => `${entry.name.trim()}:${entry.alias.trim()}`)
|
||||
.join('|');
|
||||
return [form.baseUrl.trim(), testModel.trim(), headersSignature, modelsSignature].join('||');
|
||||
}, [form.baseUrl, form.headers, form.modelEntries, testModel]);
|
||||
const previousConnectivityConfigRef = useRef(connectivityConfigSignature);
|
||||
|
||||
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
||||
|
||||
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
||||
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
||||
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
||||
};
|
||||
|
||||
const removeEntry = (idx: number) => {
|
||||
const next = list.filter((_, i) => i !== idx);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
|
||||
}));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{list.map((entry, index) => (
|
||||
<div key={index} className="item-row">
|
||||
<div className="item-meta">
|
||||
<Input
|
||||
label={`${t('common.api_key')} #${index + 1}`}
|
||||
value={entry.apiKey}
|
||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
||||
disabled={saving || disableControls}
|
||||
/>
|
||||
<Input
|
||||
label={t('common.proxy_url')}
|
||||
value={entry.proxyUrl ?? ''}
|
||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
||||
disabled={saving || disableControls}
|
||||
/>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEntry(index)}
|
||||
disabled={saving || disableControls || list.length <= 1}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={addEntry}
|
||||
disabled={saving || disableControls}
|
||||
>
|
||||
{t('ai_providers.openai_keys_add_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const openOpenaiModelDiscovery = () => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
|
||||
useEffect(() => {
|
||||
if (previousConnectivityConfigRef.current === connectivityConfigSignature) {
|
||||
return;
|
||||
}
|
||||
navigate('models');
|
||||
};
|
||||
previousConnectivityConfigRef.current = connectivityConfigSignature;
|
||||
resetDraftKeyTestStatuses(form.apiKeyEntries.length);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}, [
|
||||
connectivityConfigSignature,
|
||||
form.apiKeyEntries.length,
|
||||
resetDraftKeyTestStatuses,
|
||||
setTestStatus,
|
||||
setTestMessage,
|
||||
]);
|
||||
|
||||
// Test a single key by index
|
||||
const runSingleKeyTest = useCallback(
|
||||
async (keyIndex: number): Promise<boolean> => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
showNotification(t('notification.openai_test_url_required'), 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
showNotification(t('notification.openai_test_url_required'), 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const keyEntry = form.apiKeyEntries[keyIndex];
|
||||
if (!keyEntry?.apiKey?.trim()) {
|
||||
setDraftKeyTestStatus(keyIndex, { status: 'error', message: t('notification.openai_test_key_required') });
|
||||
return false;
|
||||
}
|
||||
|
||||
const modelName = testModel.trim() || availableModels[0] || '';
|
||||
if (!modelName) {
|
||||
showNotification(t('notification.openai_test_model_required'), 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const customHeaders = buildHeaderObject(form.headers);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
};
|
||||
if (!headers.Authorization && !headers['authorization']) {
|
||||
headers.Authorization = `Bearer ${keyEntry.apiKey.trim()}`;
|
||||
}
|
||||
|
||||
// Set loading state for this key
|
||||
setDraftKeyTestStatus(keyIndex, { status: 'loading', message: '' });
|
||||
|
||||
try {
|
||||
const result = await apiCallApi.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: endpoint,
|
||||
header: Object.keys(headers).length ? headers : undefined,
|
||||
data: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
stream: false,
|
||||
max_tokens: 5,
|
||||
}),
|
||||
},
|
||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
setDraftKeyTestStatus(keyIndex, { status: 'success', message: '' });
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
const errorCode =
|
||||
typeof err === 'object' && err !== null && 'code' in err
|
||||
? String((err as { code?: string }).code)
|
||||
: '';
|
||||
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
||||
const errorMessage = isTimeout
|
||||
? t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
|
||||
: message;
|
||||
setDraftKeyTestStatus(keyIndex, { status: 'error', message: errorMessage });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[form.baseUrl, form.apiKeyEntries, form.headers, testModel, availableModels, t, setDraftKeyTestStatus, showNotification]
|
||||
);
|
||||
|
||||
const testSingleKey = useCallback(
|
||||
async (keyIndex: number): Promise<boolean> => {
|
||||
if (isTestingKeys) return false;
|
||||
setIsTestingKeys(true);
|
||||
try {
|
||||
return await runSingleKeyTest(keyIndex);
|
||||
} finally {
|
||||
setIsTestingKeys(false);
|
||||
}
|
||||
},
|
||||
[isTestingKeys, runSingleKeyTest]
|
||||
);
|
||||
|
||||
// Test all keys
|
||||
const testAllKeys = useCallback(async () => {
|
||||
if (isTestingKeys) return;
|
||||
|
||||
const testOpenaiProviderConnection = async () => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
const message = t('notification.openai_test_url_required');
|
||||
@@ -157,15 +279,6 @@ export function AiProvidersOpenAIEditPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
|
||||
if (!firstKeyEntry) {
|
||||
const message = t('notification.openai_test_key_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const modelName = testModel.trim() || availableModels[0] || '';
|
||||
if (!modelName) {
|
||||
const message = t('notification.openai_test_model_required');
|
||||
@@ -175,56 +288,194 @@ export function AiProvidersOpenAIEditPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const customHeaders = buildHeaderObject(form.headers);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
};
|
||||
if (!headers.Authorization && !headers['authorization']) {
|
||||
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
|
||||
const validKeyIndexes = form.apiKeyEntries
|
||||
.map((entry, index) => (entry.apiKey?.trim() ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
if (validKeyIndexes.length === 0) {
|
||||
const message = t('notification.openai_test_key_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTestingKeys(true);
|
||||
setTestStatus('loading');
|
||||
setTestMessage(t('ai_providers.openai_test_running'));
|
||||
resetDraftKeyTestStatuses(form.apiKeyEntries.length);
|
||||
|
||||
try {
|
||||
const result = await apiCallApi.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: endpoint,
|
||||
header: Object.keys(headers).length ? headers : undefined,
|
||||
data: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
stream: false,
|
||||
max_tokens: 5,
|
||||
}),
|
||||
},
|
||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
||||
);
|
||||
const results = await Promise.all(validKeyIndexes.map((index) => runSingleKeyTest(index)));
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
const successCount = results.filter(Boolean).length;
|
||||
const failCount = validKeyIndexes.length - successCount;
|
||||
|
||||
setTestStatus('success');
|
||||
setTestMessage(t('ai_providers.openai_test_success'));
|
||||
} catch (err: unknown) {
|
||||
setTestStatus('error');
|
||||
const message = getErrorMessage(err);
|
||||
const errorCode =
|
||||
typeof err === 'object' && err !== null && 'code' in err
|
||||
? String((err as { code?: string }).code)
|
||||
: '';
|
||||
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
||||
if (isTimeout) {
|
||||
setTestMessage(
|
||||
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
|
||||
);
|
||||
if (failCount === 0) {
|
||||
const message = t('ai_providers.openai_test_all_success', { count: successCount });
|
||||
setTestStatus('success');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'success');
|
||||
} else if (successCount === 0) {
|
||||
const message = t('ai_providers.openai_test_all_failed', { count: failCount });
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
} else {
|
||||
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
|
||||
const message = t('ai_providers.openai_test_all_partial', { success: successCount, failed: failCount });
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'warning');
|
||||
}
|
||||
} finally {
|
||||
setIsTestingKeys(false);
|
||||
}
|
||||
}, [
|
||||
isTestingKeys,
|
||||
form.baseUrl,
|
||||
form.apiKeyEntries,
|
||||
testModel,
|
||||
availableModels,
|
||||
t,
|
||||
setTestStatus,
|
||||
setTestMessage,
|
||||
resetDraftKeyTestStatuses,
|
||||
runSingleKeyTest,
|
||||
showNotification,
|
||||
]);
|
||||
|
||||
const openOpenaiModelDiscovery = () => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
|
||||
return;
|
||||
}
|
||||
navigate('models');
|
||||
};
|
||||
|
||||
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
||||
|
||||
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
||||
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
||||
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
||||
setDraftKeyTestStatus(idx, { status: 'idle', message: '' });
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
};
|
||||
|
||||
const removeEntry = (idx: number) => {
|
||||
const next = list.filter((_, i) => i !== idx);
|
||||
const nextLength = next.length ? next.length : 1;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
|
||||
}));
|
||||
resetDraftKeyTestStatuses(nextLength);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
||||
resetDraftKeyTestStatuses(list.length + 1);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.keyEntriesList}>
|
||||
<div className={styles.keyEntriesToolbar}>
|
||||
<span className={styles.keyEntriesCount}>
|
||||
{t('ai_providers.openai_keys_count')}: {list.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={addEntry}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
className={styles.addKeyButton}
|
||||
>
|
||||
{t('ai_providers.openai_keys_add_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.keyTableShell}>
|
||||
{/* 表头 */}
|
||||
<div className={styles.keyTableHeader}>
|
||||
<div className={styles.keyTableColIndex}>#</div>
|
||||
<div className={styles.keyTableColStatus}>{t('common.status')}</div>
|
||||
<div className={styles.keyTableColKey}>{t('common.api_key')}</div>
|
||||
<div className={styles.keyTableColProxy}>{t('common.proxy_url')}</div>
|
||||
<div className={styles.keyTableColAction}>{t('common.action')}</div>
|
||||
</div>
|
||||
|
||||
{/* 数据行 */}
|
||||
{list.map((entry, index) => {
|
||||
const keyStatus = keyTestStatuses[index]?.status ?? 'idle';
|
||||
const canTestKey = Boolean(entry.apiKey?.trim()) && hasConfiguredModels;
|
||||
|
||||
return (
|
||||
<div key={index} className={styles.keyTableRow}>
|
||||
{/* 序号 */}
|
||||
<div className={styles.keyTableColIndex}>{index + 1}</div>
|
||||
|
||||
{/* 状态指示灯 */}
|
||||
<div
|
||||
className={styles.keyTableColStatus}
|
||||
title={keyTestStatuses[index]?.message || ''}
|
||||
>
|
||||
<StatusIcon status={keyStatus} />
|
||||
</div>
|
||||
|
||||
{/* Key 输入框 */}
|
||||
<div className={styles.keyTableColKey}>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.apiKey}
|
||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
className={`input ${styles.keyTableInput}`}
|
||||
placeholder={t('ai_providers.openai_key_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Proxy 输入框 */}
|
||||
<div className={styles.keyTableColProxy}>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.proxyUrl ?? ''}
|
||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
className={`input ${styles.keyTableInput}`}
|
||||
placeholder={t('ai_providers.openai_proxy_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className={styles.keyTableColAction}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void testSingleKey(index)}
|
||||
disabled={saving || disableControls || isTestingKeys || !canTestKey}
|
||||
loading={keyStatus === 'loading'}
|
||||
>
|
||||
{t('ai_providers.openai_test_single_action')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEntry(index)}
|
||||
disabled={saving || disableControls || isTestingKeys || list.length <= 1}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -245,14 +496,14 @@ export function AiProvidersOpenAIEditPage() {
|
||||
>
|
||||
<Card>
|
||||
{invalidIndexParam || invalidIndex ? (
|
||||
<div className="hint">Invalid provider index.</div>
|
||||
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_name_label')}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
disabled={saving || disableControls}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
@@ -260,13 +511,13 @@ export function AiProvidersOpenAIEditPage() {
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
disabled={saving || disableControls}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_url_label')}
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
disabled={saving || disableControls}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
/>
|
||||
|
||||
<HeaderInputList
|
||||
@@ -275,77 +526,109 @@ export function AiProvidersOpenAIEditPage() {
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
disabled={saving || disableControls}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{hasIndexParam
|
||||
? t('ai_providers.openai_edit_modal_models_label')
|
||||
: t('ai_providers.openai_add_modal_models_label')}
|
||||
</label>
|
||||
{/* 模型配置区域 - 统一布局 */}
|
||||
<div className={styles.modelConfigSection}>
|
||||
{/* 标题行 */}
|
||||
<div className={styles.modelConfigHeader}>
|
||||
<label className={styles.modelConfigTitle}>
|
||||
{hasIndexParam
|
||||
? t('ai_providers.openai_edit_modal_models_label')
|
||||
: t('ai_providers.openai_add_modal_models_label')}
|
||||
</label>
|
||||
<div className={styles.modelConfigToolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setForm((prev) => ({
|
||||
...prev,
|
||||
modelEntries: [...prev.modelEntries, { name: '', alias: '' }]
|
||||
}))}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
>
|
||||
{t('ai_providers.openai_models_add_btn')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={openOpenaiModelDiscovery}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
>
|
||||
{t('ai_providers.openai_models_fetch_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示文本 */}
|
||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
||||
|
||||
{/* 模型列表 */}
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
addLabel={t('ai_providers.openai_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={saving || disableControls}
|
||||
disabled={saving || disableControls || isTestingKeys}
|
||||
hideAddButton
|
||||
className={styles.modelInputList}
|
||||
rowClassName={styles.modelInputRow}
|
||||
inputClassName={styles.modelInputField}
|
||||
removeButtonClassName={styles.modelRowRemoveButton}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={openOpenaiModelDiscovery}
|
||||
disabled={saving || disableControls}
|
||||
>
|
||||
{t('ai_providers.openai_models_fetch_button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_test_title')}</label>
|
||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<select
|
||||
className={`input ${styles.openaiTestSelect}`}
|
||||
value={testModel}
|
||||
onChange={(e) => {
|
||||
setTestModel(e.target.value);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}}
|
||||
disabled={saving || disableControls || availableModels.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{availableModels.length
|
||||
? t('ai_providers.openai_test_select_placeholder')
|
||||
: t('ai_providers.openai_test_select_empty')}
|
||||
</option>
|
||||
{form.modelEntries
|
||||
.filter((entry) => entry.name.trim())
|
||||
.map((entry, idx) => {
|
||||
const name = entry.name.trim();
|
||||
const alias = entry.alias.trim();
|
||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
||||
return (
|
||||
<option key={`${name}-${idx}`} value={name}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<Button
|
||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
||||
className={`${styles.openaiTestButton} ${
|
||||
testStatus === 'success' ? styles.openaiTestButtonSuccess : ''
|
||||
}`}
|
||||
onClick={() => void testOpenaiProviderConnection()}
|
||||
loading={testStatus === 'loading'}
|
||||
disabled={saving || disableControls || availableModels.length === 0}
|
||||
>
|
||||
{t('ai_providers.openai_test_action')}
|
||||
</Button>
|
||||
{/* 测试区域 */}
|
||||
<div className={styles.modelTestPanel}>
|
||||
<div className={styles.modelTestMeta}>
|
||||
<label className={styles.modelTestLabel}>{t('ai_providers.openai_test_title')}</label>
|
||||
<span className={styles.modelTestHint}>{t('ai_providers.openai_test_hint')}</span>
|
||||
</div>
|
||||
<div className={styles.modelTestControls}>
|
||||
<select
|
||||
className={`input ${styles.openaiTestSelect}`}
|
||||
value={testModel}
|
||||
onChange={(e) => {
|
||||
setTestModel(e.target.value);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}}
|
||||
disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || availableModels.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{availableModels.length
|
||||
? t('ai_providers.openai_test_select_placeholder')
|
||||
: t('ai_providers.openai_test_select_empty')}
|
||||
</option>
|
||||
{form.modelEntries
|
||||
.filter((entry) => entry.name.trim())
|
||||
.map((entry, idx) => {
|
||||
const name = entry.name.trim();
|
||||
const alias = entry.alias.trim();
|
||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
||||
return (
|
||||
<option key={`${name}-${idx}`} value={name}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<Button
|
||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => void testAllKeys()}
|
||||
loading={testStatus === 'loading'}
|
||||
disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || !hasConfiguredModels || !hasTestableKeys}
|
||||
title={t('ai_providers.openai_test_all_hint')}
|
||||
className={styles.modelTestAllButton}
|
||||
>
|
||||
{t('ai_providers.openai_test_all_action')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{testMessage && (
|
||||
<div
|
||||
@@ -362,8 +645,11 @@ export function AiProvidersOpenAIEditPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||
<div className={`form-group ${styles.keyEntriesSection}`}>
|
||||
<div className={styles.keyEntriesHeader}>
|
||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||
<span className={styles.keyEntriesHint}>{t('ai_providers.openai_keys_hint')}</span>
|
||||
</div>
|
||||
{renderKeyEntries(form.apiKeyEntries)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -387,19 +387,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 连通性测试按钮高度对齐
|
||||
.openaiTestSelect {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.openaiTestButton {
|
||||
flex: 1 1 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// 状态监测栏
|
||||
.statusBar {
|
||||
display: flex;
|
||||
@@ -473,6 +460,318 @@
|
||||
background: var(--failure-badge-bg, #fee2e2);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Model Config Section - Unified Layout
|
||||
// ============================================
|
||||
|
||||
.modelConfigSection {
|
||||
margin-bottom: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.modelConfigHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include mobile {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.modelConfigTitle {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modelConfigToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
:global(.btn) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.modelInputList {
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.modelInputRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
|
||||
gap: $spacing-sm;
|
||||
align-items: center;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
row-gap: $spacing-xs;
|
||||
|
||||
> :nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> :nth-child(3) {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
> :nth-child(4) {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modelInputField {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelRowRemoveButton {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.modelTestPanel {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
margin-top: $spacing-sm;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.modelTestMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelTestLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modelTestHint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modelTestControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: $spacing-xs;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Key Entry Styles - Table Design
|
||||
// ============================================
|
||||
|
||||
.keyEntriesSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.keyEntriesHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.keyEntriesHint {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.keyEntriesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.keyEntriesToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.keyEntriesCount {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.keyTableShell {
|
||||
overflow-x: auto;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
// 表头
|
||||
.keyTableHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 46px 56px minmax(220px, 1.4fr) minmax(200px, 1.1fr) 180px;
|
||||
gap: $spacing-sm;
|
||||
min-width: 760px;
|
||||
padding: 10px $spacing-md;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom: none;
|
||||
border-radius: $radius-md $radius-md 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: none;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 数据行
|
||||
.keyTableRow {
|
||||
display: grid;
|
||||
grid-template-columns: 46px 56px minmax(220px, 1.4fr) minmax(200px, 1.1fr) 180px;
|
||||
gap: $spacing-sm;
|
||||
min-width: 760px;
|
||||
padding: 10px $spacing-md;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
align-items: center;
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 $radius-md $radius-md;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
// 列定义
|
||||
.keyTableColIndex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.keyTableColStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.keyTableColKey,
|
||||
.keyTableColProxy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.keyTableColAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-xs;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.keyTableInput {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
min-height: 38px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.addKeyButton {
|
||||
align-self: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.openaiTestSelect {
|
||||
flex: 1 1 260px;
|
||||
min-width: 180px;
|
||||
max-width: 380px;
|
||||
|
||||
@include mobile {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modelTestAllButton {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusIconWrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusIconSpin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色主题适配
|
||||
:global([data-theme='dark']) {
|
||||
.headerBadge {
|
||||
|
||||
@@ -218,7 +218,7 @@ export function AiProvidersVertexEditPage() {
|
||||
<Card>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{invalidIndexParam || invalidIndex ? (
|
||||
<div className="hint">Invalid provider index.</div>
|
||||
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
@@ -256,6 +256,8 @@ export function AiProvidersVertexEditPage() {
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="form-group">
|
||||
@@ -266,6 +268,8 @@ export function AiProvidersVertexEditPage() {
|
||||
addLabel={t('ai_providers.vertex_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
removeButtonTitle={t('common.delete')}
|
||||
removeButtonAriaLabel={t('common.delete')}
|
||||
disabled={disableControls || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
||||
|
||||
@@ -80,12 +80,13 @@
|
||||
|
||||
.filterTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
@@ -101,12 +102,19 @@
|
||||
}
|
||||
|
||||
.filterTagLabel {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterTagCount {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
min-width: 2ch;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,9 @@ const MIN_CARD_PAGE_SIZE = 3;
|
||||
const MAX_CARD_PAGE_SIZE = 30;
|
||||
const MAX_AUTH_FILE_SIZE = 50 * 1024;
|
||||
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
||||
const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
|
||||
const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
|
||||
const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
|
||||
|
||||
const clampCardPageSize = (value: number) =>
|
||||
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||
@@ -170,6 +173,34 @@ const writeAuthFilesUiState = (state: AuthFilesUiState) => {
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const copied = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return copied;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
interface PrefixProxyEditorState {
|
||||
fileName: string;
|
||||
loading: boolean;
|
||||
@@ -180,7 +211,54 @@ interface PrefixProxyEditorState {
|
||||
json: Record<string, unknown> | null;
|
||||
prefix: string;
|
||||
proxyUrl: string;
|
||||
priority: string;
|
||||
excludedModelsText: string;
|
||||
disableCooling: string;
|
||||
}
|
||||
|
||||
const parsePriorityValue = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? value : undefined;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !INTEGER_STRING_PATTERN.test(trimmed)) return undefined;
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const normalizeExcludedModels = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
value.forEach((entry) => {
|
||||
const model = String(entry ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!model || seen.has(model)) return;
|
||||
seen.add(model);
|
||||
normalized.push(model);
|
||||
});
|
||||
|
||||
return normalized.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const parseExcludedModelsText = (value: string): string[] =>
|
||||
normalizeExcludedModels(value.split(/[\n,]+/));
|
||||
|
||||
const parseDisableCoolingValue = (value: unknown): boolean | undefined => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value !== 0;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (TRUTHY_TEXT_VALUES.has(normalized)) return true;
|
||||
if (FALSY_TEXT_VALUES.has(normalized)) return false;
|
||||
return undefined;
|
||||
};
|
||||
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -413,11 +491,36 @@ export function AuthFilesPage() {
|
||||
if ('proxy_url' in next || prefixProxyEditor.proxyUrl.trim()) {
|
||||
next.proxy_url = prefixProxyEditor.proxyUrl;
|
||||
}
|
||||
|
||||
const parsedPriority = parsePriorityValue(prefixProxyEditor.priority);
|
||||
if (parsedPriority !== undefined) {
|
||||
next.priority = parsedPriority;
|
||||
} else if ('priority' in next) {
|
||||
delete next.priority;
|
||||
}
|
||||
|
||||
const excludedModels = parseExcludedModelsText(prefixProxyEditor.excludedModelsText);
|
||||
if (excludedModels.length > 0) {
|
||||
next.excluded_models = excludedModels;
|
||||
} else if ('excluded_models' in next) {
|
||||
delete next.excluded_models;
|
||||
}
|
||||
|
||||
const parsedDisableCooling = parseDisableCoolingValue(prefixProxyEditor.disableCooling);
|
||||
if (parsedDisableCooling !== undefined) {
|
||||
next.disable_cooling = parsedDisableCooling;
|
||||
} else if ('disable_cooling' in next) {
|
||||
delete next.disable_cooling;
|
||||
}
|
||||
|
||||
return JSON.stringify(next);
|
||||
}, [
|
||||
prefixProxyEditor?.json,
|
||||
prefixProxyEditor?.prefix,
|
||||
prefixProxyEditor?.proxyUrl,
|
||||
prefixProxyEditor?.priority,
|
||||
prefixProxyEditor?.excludedModelsText,
|
||||
prefixProxyEditor?.disableCooling,
|
||||
prefixProxyEditor?.rawText,
|
||||
]);
|
||||
|
||||
@@ -829,6 +932,9 @@ export function AuthFilesPage() {
|
||||
json: null,
|
||||
prefix: '',
|
||||
proxyUrl: '',
|
||||
priority: '',
|
||||
excludedModelsText: '',
|
||||
disableCooling: '',
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -870,6 +976,9 @@ export function AuthFilesPage() {
|
||||
const originalText = JSON.stringify(json);
|
||||
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
|
||||
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
|
||||
const priority = parsePriorityValue(json.priority);
|
||||
const excludedModels = normalizeExcludedModels(json.excluded_models);
|
||||
const disableCooling = parseDisableCoolingValue(json.disable_cooling);
|
||||
|
||||
setPrefixProxyEditor((prev) => {
|
||||
if (!prev || prev.fileName !== name) return prev;
|
||||
@@ -881,6 +990,10 @@ export function AuthFilesPage() {
|
||||
json,
|
||||
prefix,
|
||||
proxyUrl,
|
||||
priority: priority !== undefined ? String(priority) : '',
|
||||
excludedModelsText: excludedModels.join('\n'),
|
||||
disableCooling:
|
||||
disableCooling === undefined ? '' : disableCooling ? 'true' : 'false',
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
@@ -894,11 +1007,17 @@ export function AuthFilesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrefixProxyChange = (field: 'prefix' | 'proxyUrl', value: string) => {
|
||||
const handlePrefixProxyChange = (
|
||||
field: 'prefix' | 'proxyUrl' | 'priority' | 'excludedModelsText' | 'disableCooling',
|
||||
value: string
|
||||
) => {
|
||||
setPrefixProxyEditor((prev) => {
|
||||
if (!prev) return prev;
|
||||
if (field === 'prefix') return { ...prev, prefix: value };
|
||||
return { ...prev, proxyUrl: value };
|
||||
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
|
||||
if (field === 'priority') return { ...prev, priority: value };
|
||||
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
|
||||
return { ...prev, disableCooling: value };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1016,14 +1135,28 @@ export function AuthFilesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const copyTextWithNotification = async (text: string) => {
|
||||
const copied = await copyToClipboard(text);
|
||||
showNotification(
|
||||
copied
|
||||
? t('notification.link_copied', { defaultValue: 'Copied to clipboard' })
|
||||
: t('notification.copy_failed', { defaultValue: 'Copy failed' }),
|
||||
copied ? 'success' : 'error'
|
||||
);
|
||||
};
|
||||
|
||||
// 检查模型是否被 OAuth 排除
|
||||
const isModelExcluded = (modelId: string, providerType: string): boolean => {
|
||||
const providerKey = normalizeProviderKey(providerType);
|
||||
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
|
||||
return excludedModels.some((pattern) => {
|
||||
if (pattern.includes('*')) {
|
||||
// 支持通配符匹配
|
||||
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
|
||||
// 支持通配符匹配:先转义正则特殊字符,再将 * 视为通配符
|
||||
const regexSafePattern = pattern
|
||||
.split('*')
|
||||
.map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('.*');
|
||||
const regex = new RegExp(`^${regexSafePattern}$`, 'i');
|
||||
return regex.test(modelId);
|
||||
}
|
||||
return pattern.toLowerCase() === modelId.toLowerCase();
|
||||
@@ -2046,9 +2179,7 @@ export function AuthFilesPage() {
|
||||
onClick={() => {
|
||||
if (selectedFile) {
|
||||
const text = JSON.stringify(selectedFile, null, 2);
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showNotification(t('notification.link_copied'), 'success');
|
||||
});
|
||||
void copyTextWithNotification(text);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -2104,11 +2235,7 @@ export function AuthFilesPage() {
|
||||
key={model.id}
|
||||
className={`${styles.modelItem} ${isExcluded ? styles.modelItemExcluded : ''}`}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(model.id);
|
||||
showNotification(
|
||||
t('notification.link_copied', { defaultValue: '已复制到剪贴板' }),
|
||||
'success'
|
||||
);
|
||||
void copyTextWithNotification(model.id);
|
||||
}}
|
||||
title={
|
||||
isExcluded
|
||||
@@ -2135,7 +2262,7 @@ export function AuthFilesPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* prefix/proxy_url 编辑弹窗 */}
|
||||
{/* 认证文件字段编辑弹窗 */}
|
||||
<Modal
|
||||
open={Boolean(prefixProxyEditor)}
|
||||
onClose={() => setPrefixProxyEditor(null)}
|
||||
@@ -2143,7 +2270,7 @@ export function AuthFilesPage() {
|
||||
width={720}
|
||||
title={
|
||||
prefixProxyEditor?.fileName
|
||||
? `${t('auth_files.prefix_proxy_button')} - ${prefixProxyEditor.fileName}`
|
||||
? t('auth_files.auth_field_editor_title', { name: prefixProxyEditor.fileName })
|
||||
: t('auth_files.prefix_proxy_button')
|
||||
}
|
||||
footer={
|
||||
@@ -2211,6 +2338,42 @@ export function AuthFilesPage() {
|
||||
}
|
||||
onChange={(e) => handlePrefixProxyChange('proxyUrl', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('auth_files.priority_label')}
|
||||
value={prefixProxyEditor.priority}
|
||||
placeholder={t('auth_files.priority_placeholder')}
|
||||
hint={t('auth_files.priority_hint')}
|
||||
disabled={
|
||||
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
|
||||
}
|
||||
onChange={(e) => handlePrefixProxyChange('priority', e.target.value)}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('auth_files.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
value={prefixProxyEditor.excludedModelsText}
|
||||
placeholder={t('auth_files.excluded_models_placeholder')}
|
||||
rows={4}
|
||||
disabled={
|
||||
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
|
||||
}
|
||||
onChange={(e) =>
|
||||
handlePrefixProxyChange('excludedModelsText', e.target.value)
|
||||
}
|
||||
/>
|
||||
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('auth_files.disable_cooling_label')}
|
||||
value={prefixProxyEditor.disableCooling}
|
||||
placeholder={t('auth_files.disable_cooling_placeholder')}
|
||||
hint={t('auth_files.disable_cooling_hint')}
|
||||
disabled={
|
||||
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
|
||||
}
|
||||
onChange={(e) => handlePrefixProxyChange('disableCooling', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -400,6 +400,8 @@ export function LogsPage() {
|
||||
startY: number;
|
||||
fired: boolean;
|
||||
} | null>(null);
|
||||
const logRequestInFlightRef = useRef(false);
|
||||
const pendingFullReloadRef = useRef(false);
|
||||
|
||||
// 保存最新时间戳用于增量获取
|
||||
const latestTimestampRef = useRef<number>(0);
|
||||
@@ -424,6 +426,15 @@ export function LogsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logRequestInFlightRef.current) {
|
||||
if (!incremental) {
|
||||
pendingFullReloadRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logRequestInFlightRef.current = true;
|
||||
|
||||
if (!incremental) {
|
||||
setLoading(true);
|
||||
}
|
||||
@@ -474,6 +485,11 @@ export function LogsPage() {
|
||||
if (!incremental) {
|
||||
setLoading(false);
|
||||
}
|
||||
logRequestInFlightRef.current = false;
|
||||
if (pendingFullReloadRef.current) {
|
||||
pendingFullReloadRef.current = false;
|
||||
void loadLogs(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
}
|
||||
|
||||
.antigravityGrid,
|
||||
.claudeGrid,
|
||||
.codexGrid,
|
||||
.geminiCliGrid {
|
||||
display: grid;
|
||||
@@ -115,6 +116,7 @@
|
||||
}
|
||||
|
||||
.antigravityControls,
|
||||
.claudeControls,
|
||||
.codexControls,
|
||||
.geminiCliControls {
|
||||
display: flex;
|
||||
@@ -125,6 +127,7 @@
|
||||
}
|
||||
|
||||
.antigravityControl,
|
||||
.claudeControl,
|
||||
.codexControl,
|
||||
.geminiCliControl {
|
||||
display: flex;
|
||||
@@ -145,6 +148,12 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.claudeCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(252, 228, 236, 0.18),
|
||||
rgba(252, 228, 236, 0));
|
||||
}
|
||||
|
||||
.antigravityCard {
|
||||
background-image: linear-gradient(180deg,
|
||||
rgba(224, 247, 250, 0.12),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { authFilesApi, configFileApi } from '@/services/api';
|
||||
import {
|
||||
QuotaSection,
|
||||
ANTIGRAVITY_CONFIG,
|
||||
CLAUDE_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
GEMINI_CLI_CONFIG
|
||||
} from '@/components/quota';
|
||||
@@ -69,6 +70,12 @@ export function QuotaPage() {
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<QuotaSection
|
||||
config={CLAUDE_CONFIG}
|
||||
files={files}
|
||||
loading={loading}
|
||||
disabled={disableControls}
|
||||
/>
|
||||
<QuotaSection
|
||||
config={ANTIGRAVITY_CONFIG}
|
||||
files={files}
|
||||
|
||||
@@ -25,6 +25,121 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lastRefreshed {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeRangeGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timeRangeLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeRangeSelectWrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timeRangeSelect {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 164px;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background-color: var(--bg-primary);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
&[aria-expanded='true'] {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.timeRangeSelectedText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeRangeSelectIcon {
|
||||
display: inline-flex;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
[aria-expanded='true'] > & {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.timeRangeDropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
padding: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.timeRangeOption {
|
||||
padding: 8px 12px;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.timeRangeOptionActive {
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
background: rgba(59, 130, 246, 0.10);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
@@ -314,6 +429,42 @@
|
||||
}
|
||||
|
||||
// API List (80%比例)
|
||||
.apiSortBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.apiSortBtn {
|
||||
padding: 4px 10px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.apiSortBtnActive {
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
background: rgba(59, 130, 246, 0.10);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.apiList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -328,16 +479,27 @@
|
||||
}
|
||||
|
||||
.apiHeader {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.apiInfo {
|
||||
@@ -418,6 +580,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-height cards with internal scrolling (API details / model stats)
|
||||
.detailsFixedCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 520px;
|
||||
overflow: hidden;
|
||||
|
||||
@include mobile {
|
||||
height: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
.detailsScroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
// Table (80%比例)
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
@@ -441,6 +623,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th.sortableHeader {
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -450,12 +641,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sortHeaderButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.modelCell {
|
||||
font-weight: 500;
|
||||
max-width: 240px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.credentialType {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: $radius-full;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.requestCountCell {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
} from 'chart.js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { IconChevronDown } from '@/components/ui/icons';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import { useThemeStore, useConfigStore } from '@/stores';
|
||||
import {
|
||||
StatCards,
|
||||
UsageChart,
|
||||
@@ -23,11 +24,20 @@ import {
|
||||
ApiDetailsCard,
|
||||
ModelStatsCard,
|
||||
PriceSettingsCard,
|
||||
CredentialStatsCard,
|
||||
TokenBreakdownChart,
|
||||
CostTrendChart,
|
||||
useUsageData,
|
||||
useSparklines,
|
||||
useChartData
|
||||
} from '@/components/usage';
|
||||
import { getModelNamesFromUsage, getApiStats, getModelStats } from '@/utils/usage';
|
||||
import {
|
||||
getModelNamesFromUsage,
|
||||
getApiStats,
|
||||
getModelStats,
|
||||
filterUsageByTimeRange,
|
||||
type UsageTimeRange
|
||||
} from '@/utils/usage';
|
||||
import styles from './UsagePage.module.scss';
|
||||
|
||||
// Register Chart.js components
|
||||
@@ -42,17 +52,93 @@ ChartJS.register(
|
||||
Filler
|
||||
);
|
||||
|
||||
const CHART_LINES_STORAGE_KEY = 'cli-proxy-usage-chart-lines-v1';
|
||||
const TIME_RANGE_STORAGE_KEY = 'cli-proxy-usage-time-range-v1';
|
||||
const DEFAULT_CHART_LINES = ['all'];
|
||||
const DEFAULT_TIME_RANGE: UsageTimeRange = '24h';
|
||||
const MAX_CHART_LINES = 9;
|
||||
const TIME_RANGE_OPTIONS: ReadonlyArray<{ value: UsageTimeRange; labelKey: string }> = [
|
||||
{ value: 'all', labelKey: 'usage_stats.range_all' },
|
||||
{ value: '7h', labelKey: 'usage_stats.range_7h' },
|
||||
{ value: '24h', labelKey: 'usage_stats.range_24h' },
|
||||
{ value: '7d', labelKey: 'usage_stats.range_7d' },
|
||||
];
|
||||
const HOUR_WINDOW_BY_TIME_RANGE: Record<Exclude<UsageTimeRange, 'all'>, number> = {
|
||||
'7h': 7,
|
||||
'24h': 24,
|
||||
'7d': 7 * 24
|
||||
};
|
||||
|
||||
const isUsageTimeRange = (value: unknown): value is UsageTimeRange =>
|
||||
value === '7h' || value === '24h' || value === '7d' || value === 'all';
|
||||
|
||||
const normalizeChartLines = (value: unknown, maxLines = MAX_CHART_LINES): string[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return DEFAULT_CHART_LINES;
|
||||
}
|
||||
|
||||
const filtered = value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, maxLines);
|
||||
|
||||
return filtered.length ? filtered : DEFAULT_CHART_LINES;
|
||||
};
|
||||
|
||||
const loadChartLines = (): string[] => {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return DEFAULT_CHART_LINES;
|
||||
}
|
||||
const raw = localStorage.getItem(CHART_LINES_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return DEFAULT_CHART_LINES;
|
||||
}
|
||||
return normalizeChartLines(JSON.parse(raw));
|
||||
} catch {
|
||||
return DEFAULT_CHART_LINES;
|
||||
}
|
||||
};
|
||||
|
||||
const loadTimeRange = (): UsageTimeRange => {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return DEFAULT_TIME_RANGE;
|
||||
}
|
||||
const raw = localStorage.getItem(TIME_RANGE_STORAGE_KEY);
|
||||
return isUsageTimeRange(raw) ? raw : DEFAULT_TIME_RANGE;
|
||||
} catch {
|
||||
return DEFAULT_TIME_RANGE;
|
||||
}
|
||||
};
|
||||
|
||||
export function UsagePage() {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
const config = useConfigStore((state) => state.config);
|
||||
|
||||
// Time range dropdown
|
||||
const [timeRangeOpen, setTimeRangeOpen] = useState(false);
|
||||
const timeRangeRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeRangeOpen) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!timeRangeRef.current?.contains(event.target as Node)) setTimeRangeOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [timeRangeOpen]);
|
||||
|
||||
// Data hook
|
||||
const {
|
||||
usage,
|
||||
loading,
|
||||
error,
|
||||
lastRefreshedAt,
|
||||
modelPrices,
|
||||
setModelPrices,
|
||||
loadUsage,
|
||||
@@ -67,8 +153,41 @@ export function UsagePage() {
|
||||
useHeaderRefresh(loadUsage);
|
||||
|
||||
// Chart lines state
|
||||
const [chartLines, setChartLines] = useState<string[]>(['all']);
|
||||
const MAX_CHART_LINES = 9;
|
||||
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
|
||||
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
|
||||
|
||||
const filteredUsage = useMemo(
|
||||
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
|
||||
[usage, timeRange]
|
||||
);
|
||||
const hourWindowHours =
|
||||
timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
|
||||
|
||||
const handleChartLinesChange = useCallback((lines: string[]) => {
|
||||
setChartLines(normalizeChartLines(lines));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(CHART_LINES_STORAGE_KEY, JSON.stringify(chartLines));
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}, [chartLines]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(TIME_RANGE_STORAGE_KEY, timeRange);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}, [timeRange]);
|
||||
|
||||
// Sparklines hook
|
||||
const {
|
||||
@@ -77,7 +196,7 @@ export function UsagePage() {
|
||||
rpmSparkline,
|
||||
tpmSparkline,
|
||||
costSparkline
|
||||
} = useSparklines({ usage, loading });
|
||||
} = useSparklines({ usage: filteredUsage, loading });
|
||||
|
||||
// Chart data hook
|
||||
const {
|
||||
@@ -89,12 +208,18 @@ export function UsagePage() {
|
||||
tokensChartData,
|
||||
requestsChartOptions,
|
||||
tokensChartOptions
|
||||
} = useChartData({ usage, chartLines, isDark, isMobile });
|
||||
} = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours });
|
||||
|
||||
// Derived data
|
||||
const modelNames = useMemo(() => getModelNamesFromUsage(usage), [usage]);
|
||||
const apiStats = useMemo(() => getApiStats(usage, modelPrices), [usage, modelPrices]);
|
||||
const modelStats = useMemo(() => getModelStats(usage, modelPrices), [usage, modelPrices]);
|
||||
const apiStats = useMemo(
|
||||
() => getApiStats(filteredUsage, modelPrices),
|
||||
[filteredUsage, modelPrices]
|
||||
);
|
||||
const modelStats = useMemo(
|
||||
() => getModelStats(filteredUsage, modelPrices),
|
||||
[filteredUsage, modelPrices]
|
||||
);
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
return (
|
||||
@@ -111,6 +236,47 @@ export function UsagePage() {
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
||||
<div className={styles.headerActions}>
|
||||
<div className={styles.timeRangeGroup}>
|
||||
<span className={styles.timeRangeLabel}>{t('usage_stats.range_filter')}</span>
|
||||
<div className={styles.timeRangeSelectWrap} ref={timeRangeRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.timeRangeSelect}
|
||||
onClick={() => setTimeRangeOpen((prev) => !prev)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={timeRangeOpen}
|
||||
>
|
||||
<span className={styles.timeRangeSelectedText}>
|
||||
{t(TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.labelKey ?? 'usage_stats.range_24h')}
|
||||
</span>
|
||||
<span className={styles.timeRangeSelectIcon} aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</button>
|
||||
{timeRangeOpen && (
|
||||
<div className={styles.timeRangeDropdown} role="listbox" aria-label={t('usage_stats.range_filter')}>
|
||||
{TIME_RANGE_OPTIONS.map((opt) => {
|
||||
const active = opt.value === timeRange;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`${styles.timeRangeOption} ${active ? styles.timeRangeOptionActive : ''}`}
|
||||
onClick={() => {
|
||||
setTimeRange(opt.value);
|
||||
setTimeRangeOpen(false);
|
||||
}}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -144,6 +310,11 @@ export function UsagePage() {
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImportChange}
|
||||
/>
|
||||
{lastRefreshedAt && (
|
||||
<span className={styles.lastRefreshed}>
|
||||
{t('usage_stats.last_updated')}: {lastRefreshedAt.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +322,7 @@ export function UsagePage() {
|
||||
|
||||
{/* Stats Overview Cards */}
|
||||
<StatCards
|
||||
usage={usage}
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
modelPrices={modelPrices}
|
||||
sparklines={{
|
||||
@@ -168,7 +339,7 @@ export function UsagePage() {
|
||||
chartLines={chartLines}
|
||||
modelNames={modelNames}
|
||||
maxLines={MAX_CHART_LINES}
|
||||
onChange={setChartLines}
|
||||
onChange={handleChartLinesChange}
|
||||
/>
|
||||
|
||||
{/* Charts Grid */}
|
||||
@@ -195,12 +366,42 @@ export function UsagePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Breakdown Chart */}
|
||||
<TokenBreakdownChart
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
isDark={isDark}
|
||||
isMobile={isMobile}
|
||||
hourWindowHours={hourWindowHours}
|
||||
/>
|
||||
|
||||
{/* Cost Trend Chart */}
|
||||
<CostTrendChart
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
isDark={isDark}
|
||||
isMobile={isMobile}
|
||||
modelPrices={modelPrices}
|
||||
hourWindowHours={hourWindowHours}
|
||||
/>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className={styles.detailsGrid}>
|
||||
<ApiDetailsCard apiStats={apiStats} loading={loading} hasPrices={hasPrices} />
|
||||
<ModelStatsCard modelStats={modelStats} loading={loading} hasPrices={hasPrices} />
|
||||
</div>
|
||||
|
||||
{/* Credential Stats */}
|
||||
<CredentialStatsCard
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
geminiKeys={config?.geminiApiKeys || []}
|
||||
claudeConfigs={config?.claudeApiKeys || []}
|
||||
codexConfigs={config?.codexApiKeys || []}
|
||||
vertexConfigs={config?.vertexApiKeys || []}
|
||||
openaiProviders={config?.openaiCompatibility || []}
|
||||
/>
|
||||
|
||||
{/* Price Settings */}
|
||||
<PriceSettingsCard
|
||||
modelNames={modelNames}
|
||||
|
||||
@@ -15,12 +15,18 @@ import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||
|
||||
export type OpenAITestStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
export type KeyTestStatus = {
|
||||
status: OpenAITestStatus;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type OpenAIEditDraft = {
|
||||
initialized: boolean;
|
||||
form: OpenAIFormState;
|
||||
testModel: string;
|
||||
testStatus: OpenAITestStatus;
|
||||
testMessage: string;
|
||||
keyTestStatuses: KeyTestStatus[];
|
||||
};
|
||||
|
||||
interface OpenAIEditDraftState {
|
||||
@@ -31,6 +37,8 @@ interface OpenAIEditDraftState {
|
||||
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
|
||||
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
|
||||
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
|
||||
setDraftKeyTestStatus: (draftKey: string, keyIndex: number, status: KeyTestStatus) => void;
|
||||
resetDraftKeyTestStatuses: (draftKey: string, count: number) => void;
|
||||
clearDraft: (key: string) => void;
|
||||
}
|
||||
|
||||
@@ -53,6 +61,7 @@ const buildEmptyDraft = (): OpenAIEditDraft => ({
|
||||
testModel: '',
|
||||
testStatus: 'idle',
|
||||
testMessage: '',
|
||||
keyTestStatuses: [],
|
||||
});
|
||||
|
||||
export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) => ({
|
||||
@@ -135,6 +144,38 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) =
|
||||
});
|
||||
},
|
||||
|
||||
setDraftKeyTestStatus: (draftKey, keyIndex, status) => {
|
||||
if (!draftKey) return;
|
||||
set((state) => {
|
||||
const existing = state.drafts[draftKey] ?? buildEmptyDraft();
|
||||
const nextStatuses = [...existing.keyTestStatuses];
|
||||
nextStatuses[keyIndex] = status;
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[draftKey]: { ...existing, initialized: true, keyTestStatuses: nextStatuses },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
resetDraftKeyTestStatuses: (draftKey, count) => {
|
||||
if (!draftKey) return;
|
||||
set((state) => {
|
||||
const existing = state.drafts[draftKey] ?? buildEmptyDraft();
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[draftKey]: {
|
||||
...existing,
|
||||
initialized: true,
|
||||
keyTestStatuses: Array.from({ length: count }, () => ({ status: 'idle', message: '' })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearDraft: (key) => {
|
||||
if (!key) return;
|
||||
set((state) => {
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
|
||||
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
interface QuotaStoreState {
|
||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||
claudeQuota: Record<string, ClaudeQuotaState>;
|
||||
codexQuota: Record<string, CodexQuotaState>;
|
||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
|
||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||
clearQuotaCache: () => void;
|
||||
@@ -26,12 +28,17 @@ const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
|
||||
|
||||
export const useQuotaStore = create<QuotaStoreState>((set) => ({
|
||||
antigravityQuota: {},
|
||||
claudeQuota: {},
|
||||
codexQuota: {},
|
||||
geminiCliQuota: {},
|
||||
setAntigravityQuota: (updater) =>
|
||||
set((state) => ({
|
||||
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
|
||||
})),
|
||||
setClaudeQuota: (updater) =>
|
||||
set((state) => ({
|
||||
claudeQuota: resolveUpdater(updater, state.claudeQuota)
|
||||
})),
|
||||
setCodexQuota: (updater) =>
|
||||
set((state) => ({
|
||||
codexQuota: resolveUpdater(updater, state.codexQuota)
|
||||
@@ -43,6 +50,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
|
||||
clearQuotaCache: () =>
|
||||
set({
|
||||
antigravityQuota: {},
|
||||
claudeQuota: {},
|
||||
codexQuota: {},
|
||||
geminiCliQuota: {}
|
||||
})
|
||||
|
||||
@@ -97,6 +97,46 @@ export interface CodexUsagePayload {
|
||||
codeReviewRateLimit?: CodexRateLimitInfo | null;
|
||||
}
|
||||
|
||||
// Claude API payload types
|
||||
export interface ClaudeUsageWindow {
|
||||
utilization: number;
|
||||
resets_at: string;
|
||||
}
|
||||
|
||||
export interface ClaudeExtraUsage {
|
||||
is_enabled: boolean;
|
||||
monthly_limit: number;
|
||||
used_credits: number;
|
||||
utilization: number | null;
|
||||
}
|
||||
|
||||
export interface ClaudeUsagePayload {
|
||||
five_hour?: ClaudeUsageWindow | null;
|
||||
seven_day?: ClaudeUsageWindow | null;
|
||||
seven_day_oauth_apps?: ClaudeUsageWindow | null;
|
||||
seven_day_opus?: ClaudeUsageWindow | null;
|
||||
seven_day_sonnet?: ClaudeUsageWindow | null;
|
||||
seven_day_cowork?: ClaudeUsageWindow | null;
|
||||
iguana_necktie?: ClaudeUsageWindow | null;
|
||||
extra_usage?: ClaudeExtraUsage | null;
|
||||
}
|
||||
|
||||
export interface ClaudeQuotaWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
usedPercent: number | null;
|
||||
resetLabel: string;
|
||||
}
|
||||
|
||||
export interface ClaudeQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
windows: ClaudeQuotaWindow[];
|
||||
extraUsage?: ClaudeExtraUsage | null;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
// Quota state types
|
||||
export interface AntigravityQuotaGroup {
|
||||
id: string;
|
||||
|
||||
@@ -151,6 +151,25 @@ export const GEMINI_CLI_GROUP_LOOKUP = new Map(
|
||||
|
||||
export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
|
||||
|
||||
// Claude API configuration
|
||||
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
||||
|
||||
export const CLAUDE_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-beta': 'oauth-2025-04-20',
|
||||
};
|
||||
|
||||
export const CLAUDE_USAGE_WINDOW_KEYS = [
|
||||
{ key: 'five_hour', id: 'five-hour', labelKey: 'claude_quota.five_hour' },
|
||||
{ key: 'seven_day', id: 'seven-day', labelKey: 'claude_quota.seven_day' },
|
||||
{ key: 'seven_day_oauth_apps', id: 'seven-day-oauth-apps', labelKey: 'claude_quota.seven_day_oauth_apps' },
|
||||
{ key: 'seven_day_opus', id: 'seven-day-opus', labelKey: 'claude_quota.seven_day_opus' },
|
||||
{ key: 'seven_day_sonnet', id: 'seven-day-sonnet', labelKey: 'claude_quota.seven_day_sonnet' },
|
||||
{ key: 'seven_day_cowork', id: 'seven-day-cowork', labelKey: 'claude_quota.seven_day_cowork' },
|
||||
{ key: 'iguana_necktie', id: 'iguana-necktie', labelKey: 'claude_quota.iguana_necktie' },
|
||||
] as const;
|
||||
|
||||
// Codex API configuration
|
||||
export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Normalization and parsing functions for quota data.
|
||||
*/
|
||||
|
||||
import type { CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
|
||||
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
|
||||
|
||||
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
|
||||
|
||||
@@ -129,6 +129,23 @@ export function parseAntigravityPayload(payload: unknown): Record<string, unknow
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseClaudeUsagePayload(payload: unknown): ClaudeUsagePayload | null {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as ClaudeUsagePayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as ClaudeUsagePayload;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
|
||||
@@ -14,6 +14,23 @@ export function isAntigravityFile(file: AuthFileItem): boolean {
|
||||
return resolveAuthProvider(file) === 'antigravity';
|
||||
}
|
||||
|
||||
export function isClaudeFile(file: AuthFileItem): boolean {
|
||||
return resolveAuthProvider(file) === 'claude';
|
||||
}
|
||||
|
||||
export function isClaudeOAuthFile(file: AuthFileItem): boolean {
|
||||
if (!isClaudeFile(file)) return false;
|
||||
const metadata =
|
||||
file && typeof file.metadata === 'object' && file.metadata !== null
|
||||
? (file.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
const accessToken =
|
||||
metadata && typeof metadata.access_token === 'string'
|
||||
? metadata.access_token.trim()
|
||||
: '';
|
||||
return accessToken.includes('sk-ant-oat');
|
||||
}
|
||||
|
||||
export function isCodexFile(file: AuthFileItem): boolean {
|
||||
return resolveAuthProvider(file) === 'codex';
|
||||
}
|
||||
|
||||
@@ -61,8 +61,15 @@ export interface ApiStats {
|
||||
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
|
||||
}
|
||||
|
||||
export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
|
||||
|
||||
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
||||
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
|
||||
const USAGE_TIME_RANGE_MS: Record<Exclude<UsageTimeRange, 'all'>, number> = {
|
||||
'7h': 7 * 60 * 60 * 1000,
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -73,6 +80,140 @@ const getApisRecord = (usageData: unknown): Record<string, unknown> | null => {
|
||||
return isRecord(apisRaw) ? apisRaw : null;
|
||||
};
|
||||
|
||||
interface UsageSummary {
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
const createUsageSummary = (): UsageSummary => ({
|
||||
totalRequests: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
totalTokens: 0
|
||||
});
|
||||
|
||||
const toUsageSummaryFields = (summary: UsageSummary) => ({
|
||||
total_requests: summary.totalRequests,
|
||||
success_count: summary.successCount,
|
||||
failure_count: summary.failureCount,
|
||||
total_tokens: summary.totalTokens
|
||||
});
|
||||
|
||||
const isDetailWithinWindow = (detail: unknown, windowStart: number, nowMs: number): detail is Record<string, unknown> => {
|
||||
if (!isRecord(detail) || typeof detail.timestamp !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return false;
|
||||
}
|
||||
return timestamp >= windowStart && timestamp <= nowMs;
|
||||
};
|
||||
|
||||
const updateSummaryFromDetails = (summary: UsageSummary, details: unknown[]) => {
|
||||
details.forEach((detail) => {
|
||||
const detailRecord = isRecord(detail) ? detail : null;
|
||||
if (!detailRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
summary.totalRequests += 1;
|
||||
if (detailRecord.failed === true) {
|
||||
summary.failureCount += 1;
|
||||
} else {
|
||||
summary.successCount += 1;
|
||||
}
|
||||
summary.totalTokens += extractTotalTokens(detailRecord);
|
||||
});
|
||||
};
|
||||
|
||||
export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T {
|
||||
if (range === 'all') {
|
||||
return usageData;
|
||||
}
|
||||
|
||||
const usageRecord = isRecord(usageData) ? usageData : null;
|
||||
const apis = getApisRecord(usageData);
|
||||
if (!usageRecord || !apis) {
|
||||
return usageData;
|
||||
}
|
||||
|
||||
const rangeMs = USAGE_TIME_RANGE_MS[range];
|
||||
if (!Number.isFinite(rangeMs) || rangeMs <= 0) {
|
||||
return usageData;
|
||||
}
|
||||
|
||||
const windowStart = nowMs - rangeMs;
|
||||
const filteredApis: Record<string, unknown> = {};
|
||||
const totalSummary = createUsageSummary();
|
||||
|
||||
Object.entries(apis).forEach(([apiName, apiEntry]) => {
|
||||
if (!isRecord(apiEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const models = isRecord(apiEntry.models) ? apiEntry.models : null;
|
||||
if (!models) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredModels: Record<string, unknown> = {};
|
||||
const apiSummary = createUsageSummary();
|
||||
|
||||
Object.entries(models).forEach(([modelName, modelEntry]) => {
|
||||
if (!isRecord(modelEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailsRaw = Array.isArray(modelEntry.details) ? modelEntry.details : [];
|
||||
const filteredDetails = detailsRaw.filter((detail) =>
|
||||
isDetailWithinWindow(detail, windowStart, nowMs)
|
||||
);
|
||||
|
||||
if (!filteredDetails.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelSummary = createUsageSummary();
|
||||
updateSummaryFromDetails(modelSummary, filteredDetails);
|
||||
|
||||
filteredModels[modelName] = {
|
||||
...modelEntry,
|
||||
...toUsageSummaryFields(modelSummary),
|
||||
details: filteredDetails
|
||||
};
|
||||
|
||||
apiSummary.totalRequests += modelSummary.totalRequests;
|
||||
apiSummary.successCount += modelSummary.successCount;
|
||||
apiSummary.failureCount += modelSummary.failureCount;
|
||||
apiSummary.totalTokens += modelSummary.totalTokens;
|
||||
});
|
||||
|
||||
if (Object.keys(filteredModels).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
filteredApis[apiName] = {
|
||||
...apiEntry,
|
||||
...toUsageSummaryFields(apiSummary),
|
||||
models: filteredModels
|
||||
};
|
||||
|
||||
totalSummary.totalRequests += apiSummary.totalRequests;
|
||||
totalSummary.successCount += apiSummary.successCount;
|
||||
totalSummary.failureCount += apiSummary.failureCount;
|
||||
totalSummary.totalTokens += apiSummary.totalTokens;
|
||||
});
|
||||
|
||||
return {
|
||||
...usageRecord,
|
||||
...toUsageSummaryFields(totalSummary),
|
||||
apis: filteredApis
|
||||
} as T;
|
||||
}
|
||||
|
||||
const normalizeAuthIndex = (value: unknown) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
@@ -246,17 +387,6 @@ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) =>
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 tokens 为百万单位
|
||||
*/
|
||||
export function formatTokensInMillions(value: number): string {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return '0.00M';
|
||||
}
|
||||
return `${(num / 1_000_000).toFixed(2)}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化每分钟数值
|
||||
*/
|
||||
@@ -735,23 +865,28 @@ export function formatDayLabel(date: Date): string {
|
||||
*/
|
||||
export function buildHourlySeriesByModel(
|
||||
usageData: unknown,
|
||||
metric: 'requests' | 'tokens' = 'requests'
|
||||
metric: 'requests' | 'tokens' = 'requests',
|
||||
hourWindow: number = 24
|
||||
): {
|
||||
labels: string[];
|
||||
dataByModel: Map<string, number[]>;
|
||||
hasData: boolean;
|
||||
} {
|
||||
const hourMs = 60 * 60 * 1000;
|
||||
const resolvedHourWindow =
|
||||
Number.isFinite(hourWindow) && hourWindow > 0
|
||||
? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31)
|
||||
: 24;
|
||||
const now = new Date();
|
||||
const currentHour = new Date(now);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
const earliestBucket = new Date(currentHour);
|
||||
earliestBucket.setHours(earliestBucket.getHours() - 23);
|
||||
earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1));
|
||||
const earliestTime = earliestBucket.getTime();
|
||||
|
||||
const labels: string[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
for (let i = 0; i < resolvedHourWindow; i++) {
|
||||
const bucketStart = earliestTime + i * hourMs;
|
||||
labels.push(formatHourLabel(new Date(bucketStart)));
|
||||
}
|
||||
@@ -927,10 +1062,11 @@ export function buildChartData(
|
||||
usageData: unknown,
|
||||
period: 'hour' | 'day' = 'day',
|
||||
metric: 'requests' | 'tokens' = 'requests',
|
||||
selectedModels: string[] = []
|
||||
selectedModels: string[] = [],
|
||||
options: { hourWindowHours?: number } = {}
|
||||
): ChartData {
|
||||
const baseSeries = period === 'hour'
|
||||
? buildHourlySeriesByModel(usageData, metric)
|
||||
? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
|
||||
: buildDailySeriesByModel(usageData, metric);
|
||||
|
||||
const { labels, dataByModel } = baseSeries;
|
||||
@@ -992,9 +1128,8 @@ export interface StatusBarData {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算状态栏数据(最近1小时,分为20个5分钟的时间块)
|
||||
* 注意:20个块 × 5分钟 = 100分钟,但我们只使用最近60分钟的数据
|
||||
* 所以实际只有最后12个块可能有数据,前8个块将始终为 idle
|
||||
* 计算状态栏数据(最近200分钟,分为20个10分钟的时间块)
|
||||
* 每个时间块代表窗口内的一个等长区间,用于展示成功/失败趋势
|
||||
*/
|
||||
export function calculateStatusBarData(
|
||||
usageDetails: UsageDetail[],
|
||||
@@ -1131,3 +1266,208 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str
|
||||
byAuthIndex: authIndexStats
|
||||
};
|
||||
}
|
||||
|
||||
export type TokenCategory = 'input' | 'output' | 'cached' | 'reasoning';
|
||||
|
||||
export interface TokenBreakdownSeries {
|
||||
labels: string[];
|
||||
dataByCategory: Record<TokenCategory, number[]>;
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 token 类别构建小时级别的堆叠序列
|
||||
*/
|
||||
export function buildHourlyTokenBreakdown(
|
||||
usageData: unknown,
|
||||
hourWindow: number = 24
|
||||
): TokenBreakdownSeries {
|
||||
const hourMs = 60 * 60 * 1000;
|
||||
const resolvedHourWindow =
|
||||
Number.isFinite(hourWindow) && hourWindow > 0
|
||||
? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31)
|
||||
: 24;
|
||||
const now = new Date();
|
||||
const currentHour = new Date(now);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
const earliestBucket = new Date(currentHour);
|
||||
earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1));
|
||||
const earliestTime = earliestBucket.getTime();
|
||||
|
||||
const labels: string[] = [];
|
||||
for (let i = 0; i < resolvedHourWindow; i++) {
|
||||
labels.push(formatHourLabel(new Date(earliestTime + i * hourMs)));
|
||||
}
|
||||
|
||||
const dataByCategory: Record<TokenCategory, number[]> = {
|
||||
input: new Array(labels.length).fill(0),
|
||||
output: new Array(labels.length).fill(0),
|
||||
cached: new Array(labels.length).fill(0),
|
||||
reasoning: new Array(labels.length).fill(0),
|
||||
};
|
||||
|
||||
const details = collectUsageDetails(usageData);
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
const bucketStart = normalized.getTime();
|
||||
const lastBucketTime = earliestTime + (labels.length - 1) * hourMs;
|
||||
if (bucketStart < earliestTime || bucketStart > lastBucketTime) return;
|
||||
const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs);
|
||||
if (bucketIndex < 0 || bucketIndex >= labels.length) return;
|
||||
|
||||
const tokens = detail.tokens;
|
||||
const input = typeof tokens.input_tokens === 'number' ? Math.max(tokens.input_tokens, 0) : 0;
|
||||
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
|
||||
const cached = Math.max(
|
||||
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0,
|
||||
);
|
||||
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
|
||||
dataByCategory.input[bucketIndex] += input;
|
||||
dataByCategory.output[bucketIndex] += output;
|
||||
dataByCategory.cached[bucketIndex] += cached;
|
||||
dataByCategory.reasoning[bucketIndex] += reasoning;
|
||||
hasData = true;
|
||||
});
|
||||
|
||||
return { labels, dataByCategory, hasData };
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 token 类别构建日级别的堆叠序列
|
||||
*/
|
||||
export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeries {
|
||||
const details = collectUsageDetails(usageData);
|
||||
const dayMap: Record<string, Record<TokenCategory, number>> = {};
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
|
||||
if (!dayMap[dayLabel]) {
|
||||
dayMap[dayLabel] = { input: 0, output: 0, cached: 0, reasoning: 0 };
|
||||
}
|
||||
|
||||
const tokens = detail.tokens;
|
||||
const input = typeof tokens.input_tokens === 'number' ? Math.max(tokens.input_tokens, 0) : 0;
|
||||
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
|
||||
const cached = Math.max(
|
||||
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0,
|
||||
);
|
||||
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
|
||||
dayMap[dayLabel].input += input;
|
||||
dayMap[dayLabel].output += output;
|
||||
dayMap[dayLabel].cached += cached;
|
||||
dayMap[dayLabel].reasoning += reasoning;
|
||||
hasData = true;
|
||||
});
|
||||
|
||||
const labels = Object.keys(dayMap).sort();
|
||||
const dataByCategory: Record<TokenCategory, number[]> = {
|
||||
input: labels.map((l) => dayMap[l].input),
|
||||
output: labels.map((l) => dayMap[l].output),
|
||||
cached: labels.map((l) => dayMap[l].cached),
|
||||
reasoning: labels.map((l) => dayMap[l].reasoning),
|
||||
};
|
||||
|
||||
return { labels, dataByCategory, hasData };
|
||||
}
|
||||
|
||||
export interface CostSeries {
|
||||
labels: string[];
|
||||
data: number[];
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按小时构建费用时间序列
|
||||
*/
|
||||
export function buildHourlyCostSeries(
|
||||
usageData: unknown,
|
||||
modelPrices: Record<string, ModelPrice>,
|
||||
hourWindow: number = 24
|
||||
): CostSeries {
|
||||
const hourMs = 60 * 60 * 1000;
|
||||
const resolvedHourWindow =
|
||||
Number.isFinite(hourWindow) && hourWindow > 0
|
||||
? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31)
|
||||
: 24;
|
||||
const now = new Date();
|
||||
const currentHour = new Date(now);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
const earliestBucket = new Date(currentHour);
|
||||
earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1));
|
||||
const earliestTime = earliestBucket.getTime();
|
||||
|
||||
const labels: string[] = [];
|
||||
for (let i = 0; i < resolvedHourWindow; i++) {
|
||||
labels.push(formatHourLabel(new Date(earliestTime + i * hourMs)));
|
||||
}
|
||||
|
||||
const data = new Array(labels.length).fill(0);
|
||||
const details = collectUsageDetails(usageData);
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
const bucketStart = normalized.getTime();
|
||||
const lastBucketTime = earliestTime + (labels.length - 1) * hourMs;
|
||||
if (bucketStart < earliestTime || bucketStart > lastBucketTime) return;
|
||||
const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs);
|
||||
if (bucketIndex < 0 || bucketIndex >= labels.length) return;
|
||||
|
||||
const cost = calculateCost(detail, modelPrices);
|
||||
if (cost > 0) {
|
||||
data[bucketIndex] += cost;
|
||||
hasData = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { labels, data, hasData };
|
||||
}
|
||||
|
||||
/**
|
||||
* 按天构建费用时间序列
|
||||
*/
|
||||
export function buildDailyCostSeries(
|
||||
usageData: unknown,
|
||||
modelPrices: Record<string, ModelPrice>
|
||||
): CostSeries {
|
||||
const details = collectUsageDetails(usageData);
|
||||
const dayMap: Record<string, number> = {};
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
|
||||
const cost = calculateCost(detail, modelPrices);
|
||||
if (cost > 0) {
|
||||
dayMap[dayLabel] = (dayMap[dayLabel] || 0) + cost;
|
||||
hasData = true;
|
||||
}
|
||||
});
|
||||
|
||||
const labels = Object.keys(dayMap).sort();
|
||||
const data = labels.map((l) => dayMap[l]);
|
||||
|
||||
return { labels, data, hasData };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user