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
66 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 | ||
|
|
027ab483d4 | ||
|
|
535c303aec | ||
|
|
6c2cd761ba | ||
|
|
3783bec983 | ||
|
|
b90239d39c | ||
|
|
f8d66917fd | ||
|
|
36bfd0fa6a | ||
|
|
709ce4c8dd | ||
|
|
525b152a76 | ||
|
|
e053854544 | ||
|
|
0b54b6de64 | ||
|
|
0c8686cefa | ||
|
|
385117d01a | ||
|
|
700bff1d03 | ||
|
|
680b24026c | ||
|
|
2da4099d0b | ||
|
|
8acef95e5a | ||
|
|
c892d939c7 | ||
|
|
50ab96c3ed | ||
|
|
0bb8090686 | ||
|
|
cade2647d6 | ||
|
|
3661530f5f | ||
|
|
f833f0dfd2 | ||
|
|
d5ccef8b24 | ||
|
|
ad6a3bd732 | ||
|
|
ad1387d076 | ||
|
|
26fa1ea98e | ||
|
|
e568e4a2b5 | ||
|
|
4a0386472d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
skills
|
||||
|
||||
# Editor directories and files
|
||||
settings.local.json
|
||||
|
||||
26
package-lock.json
generated
26
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",
|
||||
@@ -1933,7 +1931,6 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2021,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",
|
||||
@@ -2339,7 +2335,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2389,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"
|
||||
}
|
||||
},
|
||||
@@ -2551,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"
|
||||
},
|
||||
@@ -2816,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",
|
||||
@@ -3293,7 +3286,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -3623,7 +3615,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3730,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"
|
||||
}
|
||||
@@ -3748,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"
|
||||
},
|
||||
@@ -3857,7 +3846,6 @@
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -4040,7 +4028,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4117,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",
|
||||
@@ -4247,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"
|
||||
},
|
||||
@@ -4275,7 +4260,6 @@
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation, type Location } from 'react-router-dom';
|
||||
import gsap from 'gsap';
|
||||
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
|
||||
import './PageTransition.scss';
|
||||
|
||||
interface PageTransitionProps {
|
||||
@@ -27,8 +26,6 @@ const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
|
||||
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
|
||||
const IOS_EXIT_DIM_OPACITY = 0.72;
|
||||
|
||||
type LayerStatus = 'current' | 'exiting' | 'stacked';
|
||||
|
||||
type Layer = {
|
||||
key: string;
|
||||
location: Location;
|
||||
@@ -39,16 +36,6 @@ type TransitionDirection = 'forward' | 'backward';
|
||||
|
||||
type TransitionVariant = 'vertical' | 'ios';
|
||||
|
||||
type PageTransitionLayerContextValue = {
|
||||
status: LayerStatus;
|
||||
};
|
||||
|
||||
const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(null);
|
||||
|
||||
export function usePageTransitionLayer() {
|
||||
return useContext(PageTransitionLayerContext);
|
||||
}
|
||||
|
||||
export function PageTransition({
|
||||
render,
|
||||
getRouteOrder,
|
||||
|
||||
15
src/components/common/PageTransitionLayer.ts
Normal file
15
src/components/common/PageTransitionLayer.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type LayerStatus = 'current' | 'exiting' | 'stacked';
|
||||
|
||||
type PageTransitionLayerContextValue = {
|
||||
status: LayerStatus;
|
||||
};
|
||||
|
||||
export const PageTransitionLayerContext =
|
||||
createContext<PageTransitionLayerContextValue | null>(null);
|
||||
|
||||
export function usePageTransitionLayer() {
|
||||
return useContext(PageTransitionLayerContext);
|
||||
}
|
||||
|
||||
37
src/components/config/VisualConfigEditor.module.scss
Normal file
37
src/components/config/VisualConfigEditor.module.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
.payloadRuleModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadRuleModelRowProtocolFirst {
|
||||
grid-template-columns: 160px 1fr auto;
|
||||
}
|
||||
|
||||
.payloadRuleParamRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 140px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadFilterModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.payloadRuleModelRow,
|
||||
.payloadRuleModelRowProtocolFirst,
|
||||
.payloadRuleParamRow,
|
||||
.payloadFilterModelRow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.payloadRowActionButton {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconChevronDown } from '@/components/ui/icons';
|
||||
import { ConfigSection } from '@/components/config/ConfigSection';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import styles from './VisualConfigEditor.module.scss';
|
||||
import type {
|
||||
PayloadFilterRule,
|
||||
PayloadModelEntry,
|
||||
@@ -200,6 +202,7 @@ function ApiKeysCardEditor({
|
||||
onChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const apiKeys = useMemo(
|
||||
() =>
|
||||
value
|
||||
@@ -262,6 +265,34 @@ function ApiKeysCardEditor({
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleCopy = async (apiKey: string) => {
|
||||
const copyByExecCommand = () => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = apiKey;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const copied = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (!copied) throw new Error('copy_failed');
|
||||
};
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(apiKey);
|
||||
} else {
|
||||
copyByExecCommand();
|
||||
}
|
||||
showNotification(t('notification.link_copied'), 'success');
|
||||
} catch {
|
||||
showNotification(t('notification.copy_failed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
@@ -293,6 +324,9 @@ function ApiKeysCardEditor({
|
||||
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleCopy(key)} disabled={disabled}>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}>
|
||||
{t('config_management.visual.common.edit')}
|
||||
</Button>
|
||||
@@ -358,7 +392,7 @@ function StringListEditor({
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={placeholder}
|
||||
@@ -392,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));
|
||||
@@ -471,7 +521,15 @@ function PayloadRulesEditor({
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
@@ -483,17 +541,15 @@ function PayloadRulesEditor({
|
||||
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
|
||||
<div
|
||||
key={model.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: protocolFirst ? '160px 1fr auto' : '1fr 160px auto',
|
||||
gap: 8,
|
||||
}}
|
||||
className={[styles.payloadRuleModelRow, protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{protocolFirst ? (
|
||||
<>
|
||||
<ToastSelect
|
||||
value={model.protocol ?? ''}
|
||||
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
@@ -521,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) =>
|
||||
@@ -532,7 +588,13 @@ function PayloadRulesEditor({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => removeModel(ruleIndex, modelIndex)} disabled={disabled}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -547,7 +609,7 @@ function PayloadRulesEditor({
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.params')}</div>
|
||||
{(rule.params.length ? rule.params : []).map((param, paramIndex) => (
|
||||
<div key={param.id} style={{ display: 'grid', gridTemplateColumns: '1fr 140px 1fr auto', gap: 8 }}>
|
||||
<div key={param.id} className={styles.payloadRuleParamRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||
@@ -557,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) =>
|
||||
@@ -571,7 +633,13 @@ function PayloadRulesEditor({
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeParam(ruleIndex, paramIndex)} disabled={disabled}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeParam(ruleIndex, paramIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -617,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));
|
||||
@@ -658,7 +734,15 @@ function PayloadFilterRulesEditor({
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||
{t('config_management.visual.common.delete')}
|
||||
@@ -668,7 +752,7 @@ function PayloadFilterRulesEditor({
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
|
||||
{rule.models.map((model, modelIndex) => (
|
||||
<div key={model.id} style={{ display: 'grid', gridTemplateColumns: '1fr 160px auto', gap: 8 }}>
|
||||
<div key={model.id} className={styles.payloadFilterModelRow}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||
@@ -678,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) =>
|
||||
@@ -687,7 +771,13 @@ function PayloadFilterRulesEditor({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeModel(ruleIndex, modelIndex)} disabled={disabled}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.payloadRowActionButton}
|
||||
onClick={() => removeModel(ruleIndex, modelIndex)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('config_management.visual.common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -711,6 +801,20 @@ function PayloadFilterRulesEditor({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('config_management.visual.payload_rules.no_rules')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="sm" onClick={addRule} disabled={disabled}>
|
||||
{t('config_management.visual.payload_rules.add_rule')}
|
||||
@@ -877,15 +981,6 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
||||
onChange={(e) => onChange({ logsMaxTotalSizeMb: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
label={t('config_management.visual.sections.system.usage_retention_days')}
|
||||
type="number"
|
||||
placeholder="30"
|
||||
value={values.usageRecordsRetentionDays}
|
||||
onChange={(e) => onChange({ usageRecordsRetentionDays: e.target.value })}
|
||||
disabled={disabled}
|
||||
hint={t('config_management.visual.sections.system.usage_retention_hint')}
|
||||
/>
|
||||
</SectionGrid>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { PageTransition } from '@/components/common/PageTransition';
|
||||
import { MainRoutes } from '@/router/MainRoutes';
|
||||
import {
|
||||
@@ -33,8 +31,10 @@ import {
|
||||
useNotificationStore,
|
||||
useThemeStore,
|
||||
} from '@/stores';
|
||||
import { configApi, versionApi } from '@/services/api';
|
||||
import { versionApi } from '@/services/api';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||
import { isSupportedLanguage } from '@/utils/language';
|
||||
|
||||
const sidebarIcons: Record<string, ReactNode> = {
|
||||
dashboard: <IconLayoutDashboard size={18} />,
|
||||
@@ -172,44 +172,36 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||
};
|
||||
|
||||
export function MainLayout() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const location = useLocation();
|
||||
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
|
||||
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
const versionTapCount = useRef(0);
|
||||
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const fullBrandName = 'CLI Proxy API Management Center';
|
||||
const abbrBrandName = t('title.abbr');
|
||||
const requestLogEnabled = config?.requestLog ?? false;
|
||||
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
||||
const isLogsPage = location.pathname.startsWith('/logs');
|
||||
|
||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||
@@ -241,7 +233,7 @@ export function MainLayout() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 将主内容区的中心点写入 CSS 变量,供底部浮层(如配置面板操作栏)对齐到内容区而非整窗
|
||||
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
|
||||
useLayoutEffect(() => {
|
||||
const updateContentCenter = () => {
|
||||
const el = contentRef.current;
|
||||
@@ -269,6 +261,7 @@ export function MainLayout() {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
window.removeEventListener('resize', updateContentCenter);
|
||||
document.documentElement.style.removeProperty('--content-center-x');
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -286,18 +279,30 @@ export function MainLayout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestLogModalOpen && !requestLogTouched) {
|
||||
setRequestLogDraft(requestLogEnabled);
|
||||
if (!languageMenuOpen) {
|
||||
return;
|
||||
}
|
||||
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (versionTapTimer.current) {
|
||||
clearTimeout(versionTapTimer.current);
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!languageMenuRef.current?.contains(event.target as Node)) {
|
||||
setLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [languageMenuOpen]);
|
||||
|
||||
const handleBrandClick = useCallback(() => {
|
||||
if (!brandExpanded) {
|
||||
@@ -312,59 +317,20 @@ export function MainLayout() {
|
||||
}
|
||||
}, [brandExpanded]);
|
||||
|
||||
const openRequestLogModal = useCallback(() => {
|
||||
setRequestLogTouched(false);
|
||||
setRequestLogDraft(requestLogEnabled);
|
||||
setRequestLogModalOpen(true);
|
||||
}, [requestLogEnabled]);
|
||||
|
||||
const handleRequestLogClose = useCallback(() => {
|
||||
setRequestLogModalOpen(false);
|
||||
setRequestLogTouched(false);
|
||||
const toggleLanguageMenu = useCallback(() => {
|
||||
setLanguageMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleVersionTap = useCallback(() => {
|
||||
versionTapCount.current += 1;
|
||||
if (versionTapTimer.current) {
|
||||
clearTimeout(versionTapTimer.current);
|
||||
}
|
||||
versionTapTimer.current = setTimeout(() => {
|
||||
versionTapCount.current = 0;
|
||||
}, 1500);
|
||||
|
||||
if (versionTapCount.current >= 7) {
|
||||
versionTapCount.current = 0;
|
||||
if (versionTapTimer.current) {
|
||||
clearTimeout(versionTapTimer.current);
|
||||
versionTapTimer.current = null;
|
||||
const handleLanguageSelect = useCallback(
|
||||
(nextLanguage: string) => {
|
||||
if (!isSupportedLanguage(nextLanguage)) {
|
||||
return;
|
||||
}
|
||||
openRequestLogModal();
|
||||
}
|
||||
}, [openRequestLogModal]);
|
||||
|
||||
const handleRequestLogSave = async () => {
|
||||
if (!canEditRequestLog) return;
|
||||
if (!requestLogDirty) {
|
||||
setRequestLogModalOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = requestLogEnabled;
|
||||
setRequestLogSaving(true);
|
||||
updateConfigValue('request-log', requestLogDraft);
|
||||
|
||||
try {
|
||||
await configApi.updateRequestLog(requestLogDraft);
|
||||
clearCache('request-log');
|
||||
showNotification(t('notification.request_log_updated'), 'success');
|
||||
setRequestLogModalOpen(false);
|
||||
} catch (error: any) {
|
||||
updateConfigValue('request-log', previous);
|
||||
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setRequestLogSaving(false);
|
||||
}
|
||||
};
|
||||
setLanguage(nextLanguage);
|
||||
setLanguageMenuOpen(false);
|
||||
},
|
||||
[setLanguage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig().catch(() => {
|
||||
@@ -475,7 +441,8 @@ export function MainLayout() {
|
||||
setCheckingVersion(true);
|
||||
try {
|
||||
const data = await versionApi.checkLatest();
|
||||
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
|
||||
const comparison = compareVersions(latest, serverVersion);
|
||||
|
||||
if (!latest) {
|
||||
@@ -493,8 +460,11 @@ export function MainLayout() {
|
||||
} else {
|
||||
showNotification(t('system_info.version_is_latest'), 'success');
|
||||
}
|
||||
} catch (error: any) {
|
||||
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||
const suffix = message ? `: ${message}` : '';
|
||||
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
|
||||
} finally {
|
||||
setCheckingVersion(false);
|
||||
}
|
||||
@@ -566,9 +536,36 @@ export function MainLayout() {
|
||||
>
|
||||
{headerIcons.update}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleLanguageMenu}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={languageMenuOpen}
|
||||
>
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
{languageMenuOpen && (
|
||||
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
className={`language-menu-option ${language === lang ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageSelect(lang)}
|
||||
role="menuitemradio"
|
||||
aria-checked={language === lang}
|
||||
>
|
||||
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
|
||||
{language === lang ? <span className="language-menu-check">✓</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
@@ -612,57 +609,8 @@ export function MainLayout() {
|
||||
scrollContainerRef={contentRef}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<footer className="footer">
|
||||
<span>
|
||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span className="footer-version" onClick={handleVersionTap}>
|
||||
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span>
|
||||
{t('footer.build_date')}:{' '}
|
||||
{serverBuildDate
|
||||
? new Date(serverBuildDate).toLocaleString(i18n.language)
|
||||
: t('system_info.version_unknown')}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={requestLogModalOpen}
|
||||
onClose={handleRequestLogClose}
|
||||
title={t('basic_settings.request_log_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRequestLogSave}
|
||||
loading={requestLogSaving}
|
||||
disabled={!canEditRequestLog || !requestLogDirty}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="request-log-modal">
|
||||
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.request_log_enable')}
|
||||
labelPosition="left"
|
||||
checked={requestLogDraft}
|
||||
disabled={!canEditRequestLog || requestLogSaving}
|
||||
onChange={(value) => {
|
||||
setRequestLogDraft(value);
|
||||
setRequestLogTouched(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -285,7 +285,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// updateLines is called after layout is calculated, ensuring elements are in place.
|
||||
updateLines();
|
||||
const raf = requestAnimationFrame(updateLines);
|
||||
window.addEventListener('resize', updateLines);
|
||||
return () => {
|
||||
@@ -295,7 +294,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
|
||||
}, [updateLines, aliasNodes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateLines();
|
||||
const raf = requestAnimationFrame(updateLines);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [providerGroupHeights, updateLines]);
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { ampcodeApi } from '@/services/api';
|
||||
import type { AmpcodeConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
|
||||
import type { AmpcodeFormState } from '../types';
|
||||
|
||||
interface AmpcodeModalProps {
|
||||
isOpen: boolean;
|
||||
disableControls: boolean;
|
||||
onClose: () => void;
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange?.(loading || saving);
|
||||
}, [loading, saving, onBusyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
initializedRef.current = false;
|
||||
setLoading(false);
|
||||
setSaving(false);
|
||||
setError('');
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setForm(buildAmpcodeFormState(null));
|
||||
onBusyChange?.(false);
|
||||
return;
|
||||
}
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
setLoading(true);
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setError('');
|
||||
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const ampcode = await ampcodeApi.getAmpcode();
|
||||
setLoaded(true);
|
||||
updateConfigValue('ampcode', ampcode);
|
||||
clearCache('ampcode');
|
||||
setForm(buildAmpcodeFormState(ampcode));
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
||||
|
||||
const clearAmpcodeUpstreamApiKey = async () => {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
|
||||
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await ampcodeApi.clearUpstreamApiKey();
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = { ...previous };
|
||||
delete next.upstreamApiKey;
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const performSaveAmpcode = async () => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const upstreamUrl = form.upstreamUrl.trim();
|
||||
const overrideKey = form.upstreamApiKey.trim();
|
||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
||||
|
||||
if (upstreamUrl) {
|
||||
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
||||
} else {
|
||||
await ampcodeApi.clearUpstreamUrl();
|
||||
}
|
||||
|
||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
await ampcodeApi.saveModelMappings(modelMappings);
|
||||
} else {
|
||||
await ampcodeApi.clearModelMappings();
|
||||
}
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
||||
}
|
||||
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = {
|
||||
upstreamUrl: upstreamUrl || undefined,
|
||||
forceModelMappings: form.forceModelMappings,
|
||||
};
|
||||
|
||||
if (previous.upstreamApiKey) {
|
||||
next.upstreamApiKey = previous.upstreamApiKey;
|
||||
}
|
||||
|
||||
if (Array.isArray(previous.modelMappings)) {
|
||||
next.modelMappings = previous.modelMappings;
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
next.upstreamApiKey = overrideKey;
|
||||
}
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
next.modelMappings = modelMappings;
|
||||
} else {
|
||||
delete next.modelMappings;
|
||||
}
|
||||
}
|
||||
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_updated'), 'success');
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
||||
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
||||
variant: 'secondary', // Not dangerous, just a warning
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: performSaveAmpcode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await performSaveAmpcode();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('ai_providers.ampcode_modal_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<Input
|
||||
label={t('ai_providers.ampcode_upstream_url_label')}
|
||||
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
||||
value={form.upstreamUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
||||
disabled={loading || saving}
|
||||
hint={t('ai_providers.ampcode_upstream_url_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.ampcode_upstream_api_key_label')}
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
|
||||
type="password"
|
||||
value={form.upstreamApiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
||||
disabled={loading || saving}
|
||||
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
marginTop: -8,
|
||||
marginBottom: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div className="hint" style={{ margin: 0 }}>
|
||||
{t('ai_providers.ampcode_upstream_api_key_current', {
|
||||
key: config?.ampcode?.upstreamApiKey
|
||||
? maskApiKey(config.ampcode.upstreamApiKey)
|
||||
: t('common.not_set'),
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={clearAmpcodeUpstreamApiKey}
|
||||
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
|
||||
>
|
||||
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||
checked={form.forceModelMappings}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
|
||||
<ModelInputList
|
||||
entries={form.mappingEntries}
|
||||
onChange={(entries) => {
|
||||
setMappingsDirty(true);
|
||||
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
||||
}}
|
||||
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')}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { headersToEntries } from '@/utils/headers';
|
||||
import { excludedModelsToText } from '../utils';
|
||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
||||
|
||||
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
export function ClaudeModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: ClaudeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (initialData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.claude_edit_modal_title')
|
||||
: t('ai_providers.claude_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.claude_add_modal_key_label')}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.claude_add_modal_url_label')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.claude_add_modal_proxy_label')}
|
||||
value={form.proxyUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={form.headers}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.claude_models_label')}</label>
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { headersToEntries } from '@/utils/headers';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { excludedModelsToText } from '../utils';
|
||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
||||
|
||||
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
export function CodexModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: CodexModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (initialData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.codex_edit_modal_title')
|
||||
: t('ai_providers.codex_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_key_label')}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_url_label')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_proxy_label')}
|
||||
value={form.proxyUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={form.headers}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { headersToEntries } from '@/utils/headers';
|
||||
import { excludedModelsToText } from '../utils';
|
||||
import type { GeminiFormState, ProviderModalProps } from '../types';
|
||||
|
||||
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): GeminiFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
headers: [],
|
||||
excludedModels: [],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
export function GeminiModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: GeminiModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (initialData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
const handleSave = () => {
|
||||
void onSave(form, editIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.gemini_edit_modal_title')
|
||||
: t('ai_providers.gemini_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={isSaving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.gemini_add_modal_key_label')}
|
||||
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.gemini_base_url_label')}
|
||||
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={form.headers}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { modelsApi } from '@/services/api';
|
||||
import type { ApiKeyEntry } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
||||
import { buildOpenAIModelsEndpoint } from '../utils';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
|
||||
interface OpenAIDiscoveryModalProps {
|
||||
isOpen: boolean;
|
||||
baseUrl: string;
|
||||
headers: HeaderEntry[];
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
onClose: () => void;
|
||||
onApply: (selected: ModelInfo[]) => void;
|
||||
}
|
||||
|
||||
export function OpenAIDiscoveryModal({
|
||||
isOpen,
|
||||
baseUrl,
|
||||
headers,
|
||||
apiKeyEntries,
|
||||
onClose,
|
||||
onApply,
|
||||
}: OpenAIDiscoveryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
const filter = search.trim().toLowerCase();
|
||||
if (!filter) return models;
|
||||
return models.filter((model) => {
|
||||
const name = (model.name || '').toLowerCase();
|
||||
const alias = (model.alias || '').toLowerCase();
|
||||
const desc = (model.description || '').toLowerCase();
|
||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||
});
|
||||
}, [models, search]);
|
||||
|
||||
const fetchOpenaiModelDiscovery = useCallback(
|
||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
||||
const trimmedBaseUrl = baseUrl.trim();
|
||||
if (!trimmedBaseUrl) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const headerObject = buildHeaderObject(headers);
|
||||
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
||||
const list = await modelsApi.fetchModelsViaApiCall(
|
||||
trimmedBaseUrl,
|
||||
hasAuthHeader ? undefined : firstKey,
|
||||
headerObject
|
||||
);
|
||||
setModels(list);
|
||||
} catch (err: unknown) {
|
||||
if (allowFallback) {
|
||||
try {
|
||||
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
|
||||
setModels(list);
|
||||
return;
|
||||
} catch (fallbackErr: unknown) {
|
||||
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
|
||||
setModels([]);
|
||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
|
||||
}
|
||||
} else {
|
||||
setModels([]);
|
||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiKeyEntries, baseUrl, headers, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
|
||||
setModels([]);
|
||||
setSearch('');
|
||||
setSelected(new Set());
|
||||
setError('');
|
||||
void fetchOpenaiModelDiscovery();
|
||||
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
|
||||
|
||||
const toggleSelection = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||
onApply(selectedModels);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('ai_providers.openai_models_fetch_title')}
|
||||
width={720}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||
{t('ai_providers.openai_models_fetch_back')}
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={loading}>
|
||||
{t('ai_providers.openai_models_fetch_apply')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="hint" style={{ marginBottom: 8 }}>
|
||||
{t('ai_providers.openai_models_fetch_hint')}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input className="input" readOnly value={endpoint} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
||||
loading={loading}
|
||||
>
|
||||
{t('ai_providers.openai_models_fetch_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('ai_providers.openai_models_search_label')}
|
||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
key={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${checked ? styles.modelDiscoveryRowSelected : ''}`}
|
||||
>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleSelection(model.name)} />
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && <span className={styles.modelDiscoveryAlias}>{model.alias}</span>}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
|
||||
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
|
||||
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
|
||||
|
||||
const OPENAI_TEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): OpenAIFormState => ({
|
||||
name: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
headers: [],
|
||||
apiKeyEntries: [buildApiKeyEntry()],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
testModel: undefined,
|
||||
});
|
||||
|
||||
export function OpenAIModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: OpenAIModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
|
||||
const [discoveryOpen, setDiscoveryOpen] = useState(false);
|
||||
const [testModel, setTestModel] = useState('');
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [testMessage, setTestMessage] = useState('');
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
const availableModels = useMemo(
|
||||
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
|
||||
[form.modelEntries]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setDiscoveryOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialData) {
|
||||
const modelEntries = modelsToEntries(initialData.models);
|
||||
setForm({
|
||||
name: initialData.name,
|
||||
prefix: initialData.prefix ?? '',
|
||||
baseUrl: initialData.baseUrl,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
testModel: initialData.testModel,
|
||||
modelEntries,
|
||||
apiKeyEntries: initialData.apiKeyEntries?.length
|
||||
? initialData.apiKeyEntries
|
||||
: [buildApiKeyEntry()],
|
||||
});
|
||||
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
||||
const initialModel =
|
||||
initialData.testModel && available.includes(initialData.testModel)
|
||||
? initialData.testModel
|
||||
: available[0] || '';
|
||||
setTestModel(initialModel);
|
||||
} else {
|
||||
setForm(buildEmptyForm());
|
||||
setTestModel('');
|
||||
}
|
||||
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
setDiscoveryOpen(false);
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (availableModels.length === 0) {
|
||||
if (testModel) {
|
||||
setTestModel('');
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!testModel || !availableModels.includes(testModel)) {
|
||||
setTestModel(availableModels[0]);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}
|
||||
}, [availableModels, isOpen, testModel]);
|
||||
|
||||
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)}
|
||||
/>
|
||||
<Input
|
||||
label={t('common.proxy_url')}
|
||||
value={entry.proxyUrl ?? ''}
|
||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEntry(index)}
|
||||
disabled={list.length <= 1 || isSaving}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
|
||||
{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');
|
||||
return;
|
||||
}
|
||||
setDiscoveryOpen(true);
|
||||
};
|
||||
|
||||
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
|
||||
if (!selectedModels.length) {
|
||||
setDiscoveryOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedMap = new Map<string, ModelEntry>();
|
||||
form.modelEntries.forEach((entry) => {
|
||||
const name = entry.name.trim();
|
||||
if (!name) return;
|
||||
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
||||
});
|
||||
|
||||
let addedCount = 0;
|
||||
selectedModels.forEach((model) => {
|
||||
const name = model.name.trim();
|
||||
if (!name || mergedMap.has(name)) return;
|
||||
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
||||
addedCount += 1;
|
||||
});
|
||||
|
||||
const mergedEntries = Array.from(mergedMap.values());
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
||||
}));
|
||||
|
||||
setDiscoveryOpen(false);
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const testOpenaiProviderConnection = async () => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
const message = t('notification.openai_test_url_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
const message = t('notification.openai_test_url_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
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');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
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()}`;
|
||||
}
|
||||
|
||||
setTestStatus('loading');
|
||||
setTestMessage(t('ai_providers.openai_test_running'));
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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 }));
|
||||
} else {
|
||||
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.openai_edit_modal_title')
|
||||
: t('ai_providers.openai_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_name_label')}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_url_label')}
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
|
||||
<HeaderInputList
|
||||
entries={form.headers}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{editIndex !== null
|
||||
? t('ai_providers.openai_edit_modal_models_label')
|
||||
: t('ai_providers.openai_add_modal_models_label')}
|
||||
</label>
|
||||
<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={isSaving}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
|
||||
{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={isSaving || 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={testOpenaiProviderConnection}
|
||||
loading={testStatus === 'loading'}
|
||||
disabled={isSaving || availableModels.length === 0}
|
||||
>
|
||||
{t('ai_providers.openai_test_action')}
|
||||
</Button>
|
||||
</div>
|
||||
{testMessage && (
|
||||
<div
|
||||
className={`status-badge ${
|
||||
testStatus === 'error' ? 'error' : testStatus === 'success' ? 'success' : 'muted'
|
||||
}`}
|
||||
>
|
||||
{testMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||
{renderKeyEntries(form.apiKeyEntries)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<OpenAIDiscoveryModal
|
||||
isOpen={discoveryOpen}
|
||||
baseUrl={form.baseUrl}
|
||||
headers={form.headers}
|
||||
apiKeyEntries={form.apiKeyEntries}
|
||||
onClose={() => setDiscoveryOpen(false)}
|
||||
onApply={applyOpenaiModelDiscoverySelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransition';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
@@ -135,8 +135,9 @@ export function ProviderNav() {
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleScroll);
|
||||
handleScroll();
|
||||
const raf = requestAnimationFrame(handleScroll);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
contentScroller?.removeEventListener('scroll', handleScroll);
|
||||
@@ -168,7 +169,8 @@ export function ProviderNav() {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
updateIndicator(activeProvider);
|
||||
const raf = requestAnimationFrame(() => updateIndicator(activeProvider));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [activeProvider, shouldShow, updateIndicator]);
|
||||
|
||||
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { headersToEntries } from '@/utils/headers';
|
||||
import type { ProviderModalProps, VertexFormState } from '../types';
|
||||
|
||||
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): VertexFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
});
|
||||
|
||||
export function VertexModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: VertexModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (initialData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
modelEntries: modelsToEntries(initialData.models),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.vertex_edit_modal_title')
|
||||
: t('ai_providers.vertex_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.vertex_add_modal_key_label')}
|
||||
placeholder={t('ai_providers.vertex_add_modal_key_placeholder')}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.prefix_label')}
|
||||
placeholder={t('ai_providers.prefix_placeholder')}
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||
hint={t('ai_providers.prefix_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.vertex_add_modal_url_label')}
|
||||
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.vertex_add_modal_proxy_label')}
|
||||
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
|
||||
value={form.proxyUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
/>
|
||||
<HeaderInputList
|
||||
entries={form.headers}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||
addLabel={t('common.custom_headers_add')}
|
||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.vertex_models_label')}</label>
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
addLabel={t('ai_providers.vertex_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,6 @@ import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
|
||||
import type { HeaderEntry } from '@/utils/headers';
|
||||
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||
|
||||
export type ProviderModal =
|
||||
| { type: 'gemini'; index: number | null }
|
||||
| { type: 'codex'; index: number | null }
|
||||
| { type: 'claude'; index: number | null }
|
||||
| { type: 'vertex'; index: number | null }
|
||||
| { type: 'ampcode'; index: null }
|
||||
| { type: 'openai'; index: number | null };
|
||||
|
||||
export interface ModelEntry {
|
||||
name: string;
|
||||
alias: string;
|
||||
@@ -58,12 +50,3 @@ export interface ProviderSectionProps<TConfig> {
|
||||
onDelete: (index: number) => void;
|
||||
onToggle?: (index: number, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
|
||||
isOpen: boolean;
|
||||
editIndex: number | null;
|
||||
initialData?: TConfig;
|
||||
onClose: () => void;
|
||||
onSave: (data: TPayload, index: number | null) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -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,16 +27,21 @@ 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,
|
||||
GEMINI_CLI_REQUEST_HEADERS,
|
||||
normalizeAuthIndexValue,
|
||||
normalizeGeminiCliModelId,
|
||||
normalizeNumberValue,
|
||||
normalizePlanType,
|
||||
normalizeQuotaFraction,
|
||||
normalizeStringValue,
|
||||
parseAntigravityPayload,
|
||||
parseClaudeUsagePayload,
|
||||
parseCodexUsagePayload,
|
||||
parseGeminiCliQuotaPayload,
|
||||
resolveCodexChatgptAccountId,
|
||||
@@ -45,6 +54,7 @@ import {
|
||||
createStatusError,
|
||||
getStatusFromError,
|
||||
isAntigravityFile,
|
||||
isClaudeFile,
|
||||
isCodexFile,
|
||||
isDisabledAuthFile,
|
||||
isGeminiCliFile,
|
||||
@@ -55,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;
|
||||
@@ -368,7 +380,7 @@ const fetchGeminiCliQuota = async (
|
||||
|
||||
const parsedBuckets = buckets
|
||||
.map((bucket) => {
|
||||
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
||||
const modelId = normalizeGeminiCliModelId(bucket.modelId ?? bucket.model_id);
|
||||
if (!modelId) return null;
|
||||
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||
const remainingFractionRaw = normalizeQuotaFraction(
|
||||
@@ -557,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>
|
||||
|
||||
@@ -16,11 +16,53 @@ const CLOSE_ANIMATION_DURATION = 350;
|
||||
const MODAL_LOCK_CLASS = 'modal-open';
|
||||
let activeModalCount = 0;
|
||||
|
||||
const scrollLockSnapshot = {
|
||||
scrollY: 0,
|
||||
contentScrollTop: 0,
|
||||
contentEl: null as HTMLElement | null,
|
||||
bodyPosition: '',
|
||||
bodyTop: '',
|
||||
bodyLeft: '',
|
||||
bodyRight: '',
|
||||
bodyWidth: '',
|
||||
bodyOverflow: '',
|
||||
htmlOverflow: '',
|
||||
};
|
||||
|
||||
const resolveContentScrollContainer = () => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const contentEl = document.querySelector('.content');
|
||||
return contentEl instanceof HTMLElement ? contentEl : null;
|
||||
};
|
||||
|
||||
const lockScroll = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (activeModalCount === 0) {
|
||||
document.body?.classList.add(MODAL_LOCK_CLASS);
|
||||
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const contentEl = resolveContentScrollContainer();
|
||||
|
||||
scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0;
|
||||
scrollLockSnapshot.contentEl = contentEl;
|
||||
scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0;
|
||||
scrollLockSnapshot.bodyPosition = body.style.position;
|
||||
scrollLockSnapshot.bodyTop = body.style.top;
|
||||
scrollLockSnapshot.bodyLeft = body.style.left;
|
||||
scrollLockSnapshot.bodyRight = body.style.right;
|
||||
scrollLockSnapshot.bodyWidth = body.style.width;
|
||||
scrollLockSnapshot.bodyOverflow = body.style.overflow;
|
||||
scrollLockSnapshot.htmlOverflow = html.style.overflow;
|
||||
|
||||
body.classList.add(MODAL_LOCK_CLASS);
|
||||
html.classList.add(MODAL_LOCK_CLASS);
|
||||
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollLockSnapshot.scrollY}px`;
|
||||
body.style.left = '0';
|
||||
body.style.right = '0';
|
||||
body.style.width = '100%';
|
||||
body.style.overflow = 'hidden';
|
||||
html.style.overflow = 'hidden';
|
||||
}
|
||||
activeModalCount += 1;
|
||||
};
|
||||
@@ -29,8 +71,31 @@ const unlockScroll = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
activeModalCount = Math.max(0, activeModalCount - 1);
|
||||
if (activeModalCount === 0) {
|
||||
document.body?.classList.remove(MODAL_LOCK_CLASS);
|
||||
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const scrollY = scrollLockSnapshot.scrollY;
|
||||
const contentScrollTop = scrollLockSnapshot.contentScrollTop;
|
||||
const contentEl = scrollLockSnapshot.contentEl;
|
||||
|
||||
body.classList.remove(MODAL_LOCK_CLASS);
|
||||
html.classList.remove(MODAL_LOCK_CLASS);
|
||||
|
||||
body.style.position = scrollLockSnapshot.bodyPosition;
|
||||
body.style.top = scrollLockSnapshot.bodyTop;
|
||||
body.style.left = scrollLockSnapshot.bodyLeft;
|
||||
body.style.right = scrollLockSnapshot.bodyRight;
|
||||
body.style.width = scrollLockSnapshot.bodyWidth;
|
||||
body.style.overflow = scrollLockSnapshot.bodyOverflow;
|
||||
html.style.overflow = scrollLockSnapshot.htmlOverflow;
|
||||
|
||||
if (contentEl) {
|
||||
contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' });
|
||||
}
|
||||
window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' });
|
||||
|
||||
scrollLockSnapshot.scrollY = 0;
|
||||
scrollLockSnapshot.contentScrollTop = 0;
|
||||
scrollLockSnapshot.contentEl = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,51 +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}>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests}
|
||||
</span>
|
||||
<span className={styles.apiBadge}>
|
||||
Tokens: {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}>
|
||||
{stats.requests} {t('usage_stats.requests_count')}
|
||||
</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 () => {
|
||||
@@ -45,8 +47,9 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
setError('');
|
||||
try {
|
||||
const data = await usageApi.getUsage();
|
||||
const payload = data?.usage ?? data;
|
||||
setUsage(payload);
|
||||
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';
|
||||
|
||||
@@ -13,7 +13,7 @@ interface UseApiOptions<T> {
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
export function useApi<T = any, Args extends any[] = any[]>(
|
||||
export function useApi<T = unknown, Args extends unknown[] = unknown[]>(
|
||||
apiFunction: (...args: Args) => Promise<T>,
|
||||
options: UseApiOptions<T> = {}
|
||||
) {
|
||||
@@ -38,8 +38,9 @@ export function useApi<T = any, Args extends any[] = any[]>(
|
||||
|
||||
options.onSuccess?.(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorObj = err as Error;
|
||||
} catch (err: unknown) {
|
||||
const errorObj =
|
||||
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
|
||||
setError(errorObj);
|
||||
|
||||
if (options.showErrorNotification !== false) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
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 {
|
||||
@@ -102,67 +103,125 @@ function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueType; value: string } {
|
||||
if (typeof raw === 'number') {
|
||||
return { valueType: 'number', value: String(raw) };
|
||||
}
|
||||
|
||||
if (typeof raw === 'boolean') {
|
||||
return { valueType: 'boolean', value: String(raw) };
|
||||
}
|
||||
|
||||
if (raw === null || typeof raw === 'object') {
|
||||
try {
|
||||
const json = JSON.stringify(raw, null, 2);
|
||||
return { valueType: 'json', value: json ?? 'null' };
|
||||
} catch {
|
||||
return { valueType: 'json', value: String(raw) };
|
||||
}
|
||||
}
|
||||
|
||||
return { valueType: 'string', value: String(raw ?? '') };
|
||||
}
|
||||
|
||||
const PAYLOAD_PROTOCOL_VALUES = [
|
||||
'openai',
|
||||
'openai-response',
|
||||
'gemini',
|
||||
'claude',
|
||||
'codex',
|
||||
'antigravity',
|
||||
] as const;
|
||||
type PayloadProtocol = (typeof PAYLOAD_PROTOCOL_VALUES)[number];
|
||||
|
||||
function parsePayloadProtocol(raw: unknown): PayloadProtocol | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
return PAYLOAD_PROTOCOL_VALUES.includes(raw as PayloadProtocol)
|
||||
? (raw as PayloadProtocol)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parsePayloadRules(rules: unknown): PayloadRule[] {
|
||||
if (!Array.isArray(rules)) return [];
|
||||
|
||||
return rules.map((rule, index) => ({
|
||||
id: `payload-rule-${index}`,
|
||||
models: Array.isArray((rule as any)?.models)
|
||||
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
|
||||
id: `model-${index}-${modelIndex}`,
|
||||
name: typeof model === 'string' ? model : model?.name || '',
|
||||
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
|
||||
}))
|
||||
: [],
|
||||
params: (rule as any)?.params
|
||||
? Object.entries((rule as any).params as Record<string, unknown>).map(([path, value], pIndex) => ({
|
||||
id: `param-${index}-${pIndex}`,
|
||||
path,
|
||||
valueType:
|
||||
typeof value === 'number'
|
||||
? 'number'
|
||||
: typeof value === 'boolean'
|
||||
? 'boolean'
|
||||
: typeof value === 'object'
|
||||
? 'json'
|
||||
: 'string',
|
||||
value: String(value),
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
return rules.map((rule, index) => {
|
||||
const record = asRecord(rule) ?? {};
|
||||
|
||||
const modelsRaw = record.models;
|
||||
const models = Array.isArray(modelsRaw)
|
||||
? modelsRaw.map((model, modelIndex) => {
|
||||
const modelRecord = asRecord(model);
|
||||
const nameRaw =
|
||||
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
|
||||
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
|
||||
return {
|
||||
id: `model-${index}-${modelIndex}`,
|
||||
name,
|
||||
protocol: parsePayloadProtocol(modelRecord?.protocol),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const paramsRecord = asRecord(record.params);
|
||||
const params = paramsRecord
|
||||
? Object.entries(paramsRecord).map(([path, value], pIndex) => {
|
||||
const parsedValue = parsePayloadParamValue(value);
|
||||
return {
|
||||
id: `param-${index}-${pIndex}`,
|
||||
path,
|
||||
valueType: parsedValue.valueType,
|
||||
value: parsedValue.value,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return { id: `payload-rule-${index}`, models, params };
|
||||
});
|
||||
}
|
||||
|
||||
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
|
||||
if (!Array.isArray(rules)) return [];
|
||||
|
||||
return rules.map((rule, index) => ({
|
||||
id: `payload-filter-rule-${index}`,
|
||||
models: Array.isArray((rule as any)?.models)
|
||||
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
|
||||
id: `filter-model-${index}-${modelIndex}`,
|
||||
name: typeof model === 'string' ? model : model?.name || '',
|
||||
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
|
||||
}))
|
||||
: [],
|
||||
params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [],
|
||||
}));
|
||||
return rules.map((rule, index) => {
|
||||
const record = asRecord(rule) ?? {};
|
||||
|
||||
const modelsRaw = record.models;
|
||||
const models = Array.isArray(modelsRaw)
|
||||
? modelsRaw.map((model, modelIndex) => {
|
||||
const modelRecord = asRecord(model);
|
||||
const nameRaw =
|
||||
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
|
||||
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
|
||||
return {
|
||||
id: `filter-model-${index}-${modelIndex}`,
|
||||
name,
|
||||
protocol: parsePayloadProtocol(modelRecord?.protocol),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const paramsRaw = record.params;
|
||||
const params = Array.isArray(paramsRaw) ? paramsRaw.map(String) : [];
|
||||
|
||||
return { id: `payload-filter-rule-${index}`, models, params };
|
||||
});
|
||||
}
|
||||
|
||||
function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
|
||||
function serializePayloadRulesForYaml(rules: PayloadRule[]): Array<Record<string, unknown>> {
|
||||
return rules
|
||||
.map((rule) => {
|
||||
const models = (rule.models || [])
|
||||
.filter((m) => m.name?.trim())
|
||||
.map((m) => {
|
||||
const obj: Record<string, any> = { name: m.name.trim() };
|
||||
const obj: Record<string, unknown> = { name: m.name.trim() };
|
||||
if (m.protocol) obj.protocol = m.protocol;
|
||||
return obj;
|
||||
});
|
||||
|
||||
const params: Record<string, any> = {};
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const param of rule.params || []) {
|
||||
if (!param.path?.trim()) continue;
|
||||
let value: any = param.value;
|
||||
let value: unknown = param.value;
|
||||
if (param.valueType === 'number') {
|
||||
const num = Number(param.value);
|
||||
value = Number.isFinite(num) ? num : param.value;
|
||||
@@ -183,13 +242,15 @@ function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
|
||||
.filter((rule) => rule.models.length > 0);
|
||||
}
|
||||
|
||||
function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] {
|
||||
function serializePayloadFilterRulesForYaml(
|
||||
rules: PayloadFilterRule[]
|
||||
): Array<Record<string, unknown>> {
|
||||
return rules
|
||||
.map((rule) => {
|
||||
const models = (rule.models || [])
|
||||
.filter((m) => m.name?.trim())
|
||||
.map((m) => {
|
||||
const obj: Record<string, any> = { name: m.name.trim() };
|
||||
const obj: Record<string, unknown> = { name: m.name.trim() };
|
||||
if (m.protocol) obj.protocol = m.protocol;
|
||||
return obj;
|
||||
});
|
||||
@@ -208,154 +269,178 @@ export function useVisualConfig() {
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
});
|
||||
|
||||
const baselineValues = useRef<VisualConfigValues>({ ...DEFAULT_VISUAL_VALUES });
|
||||
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
|
||||
...DEFAULT_VISUAL_VALUES,
|
||||
});
|
||||
|
||||
const visualDirty = useMemo(() => {
|
||||
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current);
|
||||
}, [visualValues]);
|
||||
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
|
||||
}, [baselineValues, visualValues]);
|
||||
|
||||
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
|
||||
try {
|
||||
const parsed: any = parseYaml(yamlContent) || {};
|
||||
const parsedRaw: unknown = parseYaml(yamlContent) || {};
|
||||
const parsed = asRecord(parsedRaw) ?? {};
|
||||
const tls = asRecord(parsed.tls);
|
||||
const remoteManagement = asRecord(parsed['remote-management']);
|
||||
const quotaExceeded = asRecord(parsed['quota-exceeded']);
|
||||
const routing = asRecord(parsed.routing);
|
||||
const payload = asRecord(parsed.payload);
|
||||
const streaming = asRecord(parsed.streaming);
|
||||
|
||||
const newValues: VisualConfigValues = {
|
||||
host: parsed.host || '',
|
||||
port: String(parsed.port || ''),
|
||||
host: typeof parsed.host === 'string' ? parsed.host : '',
|
||||
port: String(parsed.port ?? ''),
|
||||
|
||||
tlsEnable: Boolean(parsed.tls?.enable),
|
||||
tlsCert: parsed.tls?.cert || '',
|
||||
tlsKey: parsed.tls?.key || '',
|
||||
tlsEnable: Boolean(tls?.enable),
|
||||
tlsCert: typeof tls?.cert === 'string' ? tls.cert : '',
|
||||
tlsKey: typeof tls?.key === 'string' ? tls.key : '',
|
||||
|
||||
rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']),
|
||||
rmSecretKey: parsed['remote-management']?.['secret-key'] || '',
|
||||
rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']),
|
||||
rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
|
||||
rmSecretKey:
|
||||
typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '',
|
||||
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
|
||||
rmPanelRepo:
|
||||
parsed['remote-management']?.['panel-github-repository'] ??
|
||||
parsed['remote-management']?.['panel-repo'] ??
|
||||
'',
|
||||
typeof remoteManagement?.['panel-github-repository'] === 'string'
|
||||
? remoteManagement['panel-github-repository']
|
||||
: typeof remoteManagement?.['panel-repo'] === 'string'
|
||||
? remoteManagement['panel-repo']
|
||||
: '',
|
||||
|
||||
authDir: parsed['auth-dir'] || '',
|
||||
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
|
||||
apiKeysText: parseApiKeysText(parsed['api-keys']),
|
||||
|
||||
debug: Boolean(parsed.debug),
|
||||
commercialMode: Boolean(parsed['commercial-mode']),
|
||||
loggingToFile: Boolean(parsed['logging-to-file']),
|
||||
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] || ''),
|
||||
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
|
||||
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
|
||||
usageRecordsRetentionDays: String(parsed['usage-records-retention-days'] ?? ''),
|
||||
|
||||
proxyUrl: parsed['proxy-url'] || '',
|
||||
proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '',
|
||||
forceModelPrefix: Boolean(parsed['force-model-prefix']),
|
||||
requestRetry: String(parsed['request-retry'] || ''),
|
||||
maxRetryInterval: String(parsed['max-retry-interval'] || ''),
|
||||
requestRetry: String(parsed['request-retry'] ?? ''),
|
||||
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
|
||||
wsAuth: Boolean(parsed['ws-auth']),
|
||||
|
||||
quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true),
|
||||
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||
quotaSwitchPreviewModel: Boolean(
|
||||
parsed['quota-exceeded']?.['switch-preview-model'] ?? true
|
||||
quotaExceeded?.['switch-preview-model'] ?? true
|
||||
),
|
||||
|
||||
routingStrategy: (parsed.routing?.strategy || 'round-robin') as 'round-robin' | 'fill-first',
|
||||
routingStrategy:
|
||||
routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||
|
||||
payloadDefaultRules: parsePayloadRules(parsed.payload?.default),
|
||||
payloadOverrideRules: parsePayloadRules(parsed.payload?.override),
|
||||
payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter),
|
||||
payloadDefaultRules: parsePayloadRules(payload?.default),
|
||||
payloadOverrideRules: parsePayloadRules(payload?.override),
|
||||
payloadFilterRules: parsePayloadFilterRules(payload?.filter),
|
||||
|
||||
streaming: {
|
||||
keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''),
|
||||
bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''),
|
||||
keepaliveSeconds: String(streaming?.['keepalive-seconds'] ?? ''),
|
||||
bootstrapRetries: String(streaming?.['bootstrap-retries'] ?? ''),
|
||||
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
|
||||
},
|
||||
};
|
||||
|
||||
setVisualValuesState(newValues);
|
||||
baselineValues.current = deepClone(newValues);
|
||||
setBaselineValues(deepClone(newValues));
|
||||
} catch {
|
||||
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
|
||||
baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES);
|
||||
setBaselineValues(deepClone(DEFAULT_VISUAL_VALUES));
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
if (values.apiKeysText !== baselineValues.current.apiKeysText) {
|
||||
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);
|
||||
setIntFromString(
|
||||
parsed,
|
||||
'usage-records-retention-days',
|
||||
values.usageRecordsRetentionDays
|
||||
);
|
||||
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 =
|
||||
@@ -368,47 +453,60 @@ 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;
|
||||
}
|
||||
},
|
||||
[visualValues]
|
||||
[baselineValues, visualValues]
|
||||
);
|
||||
|
||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||
@@ -431,17 +529,66 @@ export function useVisualConfig() {
|
||||
}
|
||||
|
||||
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
|
||||
{ value: '', label: '默认' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ 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;
|
||||
}>;
|
||||
|
||||
@@ -6,12 +6,14 @@ import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
import en from './locales/en.json';
|
||||
import ru from './locales/ru.json';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
en: { translation: en }
|
||||
en: { translation: en },
|
||||
ru: { translation: ru }
|
||||
},
|
||||
lng: getInitialLanguage(),
|
||||
fallbackLng: 'zh-CN',
|
||||
|
||||
@@ -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",
|
||||
@@ -376,6 +385,7 @@
|
||||
"filter_qwen": "Qwen",
|
||||
"filter_gemini": "Gemini",
|
||||
"filter_gemini-cli": "GeminiCLI",
|
||||
"filter_kimi": "Kimi",
|
||||
"filter_aistudio": "AIStudio",
|
||||
"filter_claude": "Claude",
|
||||
"filter_codex": "Codex",
|
||||
@@ -387,6 +397,7 @@
|
||||
"type_qwen": "Qwen",
|
||||
"type_gemini": "Gemini",
|
||||
"type_gemini-cli": "GeminiCLI",
|
||||
"type_kimi": "Kimi",
|
||||
"type_aistudio": "AIStudio",
|
||||
"type_claude": "Claude",
|
||||
"type_codex": "Codex",
|
||||
@@ -403,23 +414,30 @@
|
||||
"models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.",
|
||||
"models_unsupported": "This feature is not supported in the current version",
|
||||
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
|
||||
"models_excluded_badge": "Excluded",
|
||||
"models_excluded_hint": "This model is excluded by OAuth",
|
||||
"models_excluded_badge": "Disabled",
|
||||
"models_excluded_hint": "This OAuth model is disabled",
|
||||
"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",
|
||||
"card_tools_title": "Tools",
|
||||
"quota_refresh_single": "Refresh quota",
|
||||
"quota_refresh_hint": "Refresh quota for this credential only",
|
||||
"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}}"
|
||||
},
|
||||
@@ -427,7 +445,7 @@
|
||||
"title": "Antigravity Quota",
|
||||
"empty_title": "No Antigravity Auth Files",
|
||||
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
|
||||
"idle": "Not loaded. Click Refresh Button.",
|
||||
"idle": "Click here to refresh quota",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
@@ -435,11 +453,31 @@
|
||||
"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",
|
||||
"empty_desc": "Upload a Codex credential to view quota.",
|
||||
"idle": "Not loaded. Click Refresh Button.",
|
||||
"idle": "Click here to refresh quota",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
@@ -461,7 +499,7 @@
|
||||
"title": "Gemini CLI Quota",
|
||||
"empty_title": "No Gemini CLI Auth Files",
|
||||
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
|
||||
"idle": "Not loaded. Click Refresh Button.",
|
||||
"idle": "Click here to refresh quota",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
@@ -491,43 +529,43 @@
|
||||
"result_file": "Persisted file"
|
||||
},
|
||||
"oauth_excluded": {
|
||||
"title": "OAuth Excluded Models",
|
||||
"description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.",
|
||||
"add": "Add Exclusion",
|
||||
"add_title": "Add provider exclusion",
|
||||
"edit_title": "Edit exclusions for {{provider}}",
|
||||
"title": "OAuth Model Disablement",
|
||||
"description": "Per-provider model disablement is shown as cards; click a card to edit or delete. Wildcards * are supported and the scope follows the auth file filter.",
|
||||
"add": "Add Disablement",
|
||||
"add_title": "Add provider model disablement",
|
||||
"edit_title": "Edit model disablement for {{provider}}",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing...",
|
||||
"provider_label": "Provider",
|
||||
"provider_auto": "Follow current filter",
|
||||
"provider_placeholder": "e.g. gemini-cli",
|
||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
||||
"models_label": "Models to exclude",
|
||||
"models_label": "Models to disable",
|
||||
"models_loading": "Loading models...",
|
||||
"models_unsupported": "Current CPA version does not support fetching model lists.",
|
||||
"models_loaded": "{{count}} models loaded. Check the models to exclude.",
|
||||
"models_loaded": "{{count}} models loaded. Check the models to disable.",
|
||||
"no_models_available": "No models available for this provider.",
|
||||
"save": "Save/Update",
|
||||
"saving": "Saving...",
|
||||
"save_success": "Excluded models updated",
|
||||
"save_failed": "Failed to update excluded models",
|
||||
"save_success": "Model disablement updated",
|
||||
"save_failed": "Failed to update model disablement",
|
||||
"delete": "Delete Provider",
|
||||
"delete_confirm": "Delete the exclusion list for {{provider}}?",
|
||||
"delete_success": "Exclusion list removed",
|
||||
"delete_failed": "Failed to delete exclusion list",
|
||||
"delete_confirm": "Delete model disablement for {{provider}}?",
|
||||
"delete_success": "Provider model disablement removed",
|
||||
"delete_failed": "Failed to delete model disablement",
|
||||
"deleting": "Deleting...",
|
||||
"no_models": "No excluded models",
|
||||
"model_count": "{{count}} models excluded",
|
||||
"list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.",
|
||||
"list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.",
|
||||
"disconnected": "Connect to the server to view exclusions",
|
||||
"load_failed": "Failed to load exclusion list",
|
||||
"no_models": "No disabled models configured",
|
||||
"model_count": "{{count}} models disabled",
|
||||
"list_empty_all": "No provider model disablement yet; click “Add Disablement” to create one.",
|
||||
"list_empty_filtered": "No disabled items in this scope; click “Add Disablement” to add.",
|
||||
"disconnected": "Connect to the server to view model disablement",
|
||||
"load_failed": "Failed to load model disablement",
|
||||
"provider_required": "Please enter a provider first",
|
||||
"scope_all": "Scope: All providers",
|
||||
"scope_provider": "Scope: {{provider}}",
|
||||
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
|
||||
"upgrade_required": "Current CPA version does not support OAuth model disablement. Please upgrade.",
|
||||
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
||||
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
|
||||
"upgrade_required_desc": "The current server version does not support fetching OAuth model disablement. Please upgrade to the latest CPA (CLI Proxy API) version and try again."
|
||||
},
|
||||
"oauth_model_alias": {
|
||||
"title": "OAuth Model Aliases",
|
||||
@@ -640,6 +678,17 @@
|
||||
"gemini_cli_oauth_status_error": "Authentication failed:",
|
||||
"gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:",
|
||||
"gemini_cli_oauth_polling_error": "Failed to check authentication status:",
|
||||
"kimi_oauth_title": "Kimi OAuth",
|
||||
"kimi_oauth_button": "Start Kimi Login",
|
||||
"kimi_oauth_hint": "Login to Kimi service through OAuth device flow, automatically obtain and save authentication files.",
|
||||
"kimi_oauth_url_label": "Authorization URL:",
|
||||
"kimi_open_link": "Open Link",
|
||||
"kimi_copy_link": "Copy Link",
|
||||
"kimi_oauth_status_waiting": "Waiting for authentication...",
|
||||
"kimi_oauth_status_success": "Authentication successful!",
|
||||
"kimi_oauth_status_error": "Authentication failed:",
|
||||
"kimi_oauth_start_error": "Failed to start Kimi OAuth:",
|
||||
"kimi_oauth_polling_error": "Failed to check authentication status:",
|
||||
"qwen_oauth_title": "Qwen OAuth",
|
||||
"qwen_oauth_button": "Start Qwen Login",
|
||||
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
|
||||
@@ -711,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",
|
||||
@@ -758,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",
|
||||
@@ -877,14 +937,12 @@
|
||||
"debug": "Debug Mode",
|
||||
"debug_desc": "Enable verbose debug logging",
|
||||
"commercial_mode": "Commercial Mode",
|
||||
"commercial_mode_desc": "Disable high-overhead middleware to reduce memory under high concurrency",
|
||||
"commercial_mode_desc": "Disable high-overhead middleware to support high concurrency",
|
||||
"logging_to_file": "Log to File",
|
||||
"logging_to_file_desc": "Save logs to rotating files",
|
||||
"logging_to_file_desc": "Save logs to files",
|
||||
"usage_statistics": "Usage Statistics",
|
||||
"usage_statistics_desc": "Collect usage statistics",
|
||||
"logs_max_size": "Log File Size Limit (MB)",
|
||||
"usage_retention_days": "Usage Records Retention Days",
|
||||
"usage_retention_hint": "0 means no limit (no cleanup)"
|
||||
"logs_max_size": "Log File Size Limit (MB)"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network Configuration",
|
||||
@@ -958,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",
|
||||
@@ -981,6 +1050,7 @@
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Management Center Info",
|
||||
"about_title": "CLI Proxy API Management Center",
|
||||
"connection_status_title": "Connection Status",
|
||||
"api_status_label": "API Status:",
|
||||
"config_status_label": "Config Status:",
|
||||
@@ -1085,12 +1155,15 @@
|
||||
"gemini_api_key": "Gemini API key",
|
||||
"codex_api_key": "Codex API key",
|
||||
"claude_api_key": "Claude API key",
|
||||
"commercial_mode_restart_required": "Commercial mode setting changed. Please restart the service for it to take effect",
|
||||
"copy_failed": "Copy failed",
|
||||
"link_copied": "Link copied to clipboard"
|
||||
},
|
||||
"language": {
|
||||
"switch": "Language",
|
||||
"chinese": "中文",
|
||||
"english": "English"
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
"theme": {
|
||||
"switch": "Theme",
|
||||
|
||||
1191
src/i18n/locales/ru.json
Normal file
1191
src/i18n/locales/ru.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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": "认证文件管理",
|
||||
@@ -376,6 +385,7 @@
|
||||
"filter_qwen": "Qwen",
|
||||
"filter_gemini": "Gemini",
|
||||
"filter_gemini-cli": "GeminiCLI",
|
||||
"filter_kimi": "Kimi",
|
||||
"filter_aistudio": "AIStudio",
|
||||
"filter_claude": "Claude",
|
||||
"filter_codex": "Codex",
|
||||
@@ -387,6 +397,7 @@
|
||||
"type_qwen": "Qwen",
|
||||
"type_gemini": "Gemini",
|
||||
"type_gemini-cli": "GeminiCLI",
|
||||
"type_kimi": "Kimi",
|
||||
"type_aistudio": "AIStudio",
|
||||
"type_claude": "Claude",
|
||||
"type_codex": "Codex",
|
||||
@@ -403,23 +414,30 @@
|
||||
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
|
||||
"models_unsupported": "当前版本不支持此功能",
|
||||
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
||||
"models_excluded_badge": "已排除",
|
||||
"models_excluded_hint": "此模型已被 OAuth 排除",
|
||||
"models_excluded_badge": "已禁用",
|
||||
"models_excluded_hint": "此 OAuth 模型已被禁用",
|
||||
"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}}\"",
|
||||
"card_tools_title": "配置面板",
|
||||
"quota_refresh_single": "刷新额度",
|
||||
"quota_refresh_hint": "仅刷新当前凭证的额度数据",
|
||||
"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}}"
|
||||
},
|
||||
@@ -427,7 +445,7 @@
|
||||
"title": "Antigravity 额度",
|
||||
"empty_title": "暂无 Antigravity 认证",
|
||||
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||
"idle": "点击此处刷新额度",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
@@ -435,11 +453,31 @@
|
||||
"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 认证",
|
||||
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||
"idle": "点击此处刷新额度",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
@@ -461,7 +499,7 @@
|
||||
"title": "Gemini CLI 额度",
|
||||
"empty_title": "暂无 Gemini CLI 认证",
|
||||
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||
"idle": "点击此处刷新额度",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
@@ -491,43 +529,43 @@
|
||||
"result_file": "存储文件"
|
||||
},
|
||||
"oauth_excluded": {
|
||||
"title": "OAuth 排除列表",
|
||||
"title": "OAuth 模型禁用",
|
||||
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
|
||||
"add": "新增排除",
|
||||
"add_title": "新增提供商排除列表",
|
||||
"edit_title": "编辑 {{provider}} 的排除列表",
|
||||
"add": "新增禁用",
|
||||
"add_title": "新增提供商模型禁用",
|
||||
"edit_title": "编辑 {{provider}} 的模型禁用",
|
||||
"refresh": "刷新",
|
||||
"refreshing": "刷新中...",
|
||||
"provider_label": "提供商",
|
||||
"provider_auto": "跟随当前过滤",
|
||||
"provider_placeholder": "例如 gemini-cli / openai",
|
||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||
"models_label": "排除的模型",
|
||||
"models_label": "禁用的模型",
|
||||
"models_loading": "正在加载模型列表...",
|
||||
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
|
||||
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
|
||||
"models_loaded": "已加载 {{count}} 个模型,勾选要禁用的模型。",
|
||||
"no_models_available": "该提供商暂无可用模型列表。",
|
||||
"save": "保存/更新",
|
||||
"saving": "正在保存...",
|
||||
"save_success": "排除列表已更新",
|
||||
"save_failed": "更新排除列表失败",
|
||||
"save_success": "模型禁用已更新",
|
||||
"save_failed": "更新模型禁用失败",
|
||||
"delete": "删除提供商",
|
||||
"delete_confirm": "确定要删除 {{provider}} 的排除列表吗?",
|
||||
"delete_success": "已删除该提供商的排除列表",
|
||||
"delete_failed": "删除排除列表失败",
|
||||
"delete_confirm": "确定要删除 {{provider}} 的模型禁用吗?",
|
||||
"delete_success": "已删除该提供商的模型禁用",
|
||||
"delete_failed": "删除模型禁用失败",
|
||||
"deleting": "正在删除...",
|
||||
"no_models": "未配置排除模型",
|
||||
"model_count": "排除 {{count}} 个模型",
|
||||
"list_empty_all": "暂无任何提供商的排除列表,点击“新增排除”创建。",
|
||||
"list_empty_filtered": "当前筛选下没有排除项,点击“新增排除”添加。",
|
||||
"disconnected": "请先连接服务器以查看排除列表",
|
||||
"load_failed": "加载排除列表失败",
|
||||
"no_models": "未配置禁用模型",
|
||||
"model_count": "禁用 {{count}} 个模型",
|
||||
"list_empty_all": "暂无任何提供商的模型禁用,点击“新增禁用”创建。",
|
||||
"list_empty_filtered": "当前筛选下没有禁用项,点击“新增禁用”添加。",
|
||||
"disconnected": "请先连接服务器以查看模型禁用",
|
||||
"load_failed": "加载模型禁用失败",
|
||||
"provider_required": "请先填写提供商名称",
|
||||
"scope_all": "当前范围:全局(显示所有提供商)",
|
||||
"scope_provider": "当前范围:{{provider}}",
|
||||
"upgrade_required": "当前 CPA 版本不支持模型排除列表,请升级 CPA 版本",
|
||||
"upgrade_required": "当前 CPA 版本不支持 OAuth 模型禁用,请升级 CPA 版本",
|
||||
"upgrade_required_title": "需要升级 CPA 版本",
|
||||
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||
"upgrade_required_desc": "当前服务器版本不支持获取 OAuth 模型禁用功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||
},
|
||||
"oauth_model_alias": {
|
||||
"title": "OAuth 模型别名",
|
||||
@@ -640,6 +678,17 @@
|
||||
"gemini_cli_oauth_status_error": "认证失败:",
|
||||
"gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:",
|
||||
"gemini_cli_oauth_polling_error": "检查认证状态失败:",
|
||||
"kimi_oauth_title": "Kimi OAuth",
|
||||
"kimi_oauth_button": "开始 Kimi 登录",
|
||||
"kimi_oauth_hint": "通过设备授权流程登录 Kimi 服务,自动获取并保存认证文件。",
|
||||
"kimi_oauth_url_label": "授权链接:",
|
||||
"kimi_open_link": "打开链接",
|
||||
"kimi_copy_link": "复制链接",
|
||||
"kimi_oauth_status_waiting": "等待认证中...",
|
||||
"kimi_oauth_status_success": "认证成功!",
|
||||
"kimi_oauth_status_error": "认证失败:",
|
||||
"kimi_oauth_start_error": "启动 Kimi OAuth 失败:",
|
||||
"kimi_oauth_polling_error": "检查认证状态失败:",
|
||||
"qwen_oauth_title": "Qwen OAuth",
|
||||
"qwen_oauth_button": "开始 Qwen 登录",
|
||||
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
|
||||
@@ -711,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": "导入数据",
|
||||
@@ -758,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": "成功",
|
||||
@@ -877,14 +937,12 @@
|
||||
"debug": "调试模式",
|
||||
"debug_desc": "启用详细的调试日志",
|
||||
"commercial_mode": "商业模式",
|
||||
"commercial_mode_desc": "禁用高开销中间件以减少高并发内存",
|
||||
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
|
||||
"logging_to_file": "写入日志文件",
|
||||
"logging_to_file_desc": "将日志保存到滚动文件",
|
||||
"logging_to_file_desc": "将日志保存到文件",
|
||||
"usage_statistics": "使用统计",
|
||||
"usage_statistics_desc": "收集使用统计信息",
|
||||
"logs_max_size": "日志文件大小限制 (MB)",
|
||||
"usage_retention_days": "使用记录保留天数",
|
||||
"usage_retention_hint": "0 为无限制(不清理)"
|
||||
"logs_max_size": "日志文件大小限制 (MB)"
|
||||
},
|
||||
"network": {
|
||||
"title": "网络配置",
|
||||
@@ -958,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",
|
||||
@@ -981,6 +1050,7 @@
|
||||
},
|
||||
"system_info": {
|
||||
"title": "管理中心信息",
|
||||
"about_title": "CLI Proxy API Management Center",
|
||||
"connection_status_title": "连接状态",
|
||||
"api_status_label": "API 状态:",
|
||||
"config_status_label": "配置状态:",
|
||||
@@ -1085,12 +1155,15 @@
|
||||
"gemini_api_key": "Gemini API密钥",
|
||||
"codex_api_key": "Codex API密钥",
|
||||
"claude_api_key": "Claude API密钥",
|
||||
"commercial_mode_restart_required": "商业模式开关已变更,请重启服务后生效",
|
||||
"copy_failed": "复制失败",
|
||||
"link_copied": "已复制"
|
||||
},
|
||||
"language": {
|
||||
"switch": "语言",
|
||||
"chinese": "中文",
|
||||
"english": "English"
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
"theme": {
|
||||
"switch": "主题",
|
||||
|
||||
@@ -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]);
|
||||
@@ -243,7 +264,7 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}
|
||||
}, [availableModels, loading, testModel]);
|
||||
}, [availableModels, loading, setTestMessage, setTestModel, setTestStatus, testModel]);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
@@ -280,12 +301,20 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const name = form.name.trim();
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
|
||||
if (!name || !baseUrl) {
|
||||
showNotification(t('notification.openai_provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: OpenAIProviderConfig = {
|
||||
name: form.name.trim(),
|
||||
name,
|
||||
prefix: form.prefix?.trim() || undefined,
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
baseUrl,
|
||||
headers: buildHeaderObject(form.headers),
|
||||
apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({
|
||||
apiKey: entry.apiKey.trim(),
|
||||
@@ -304,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')
|
||||
@@ -320,15 +358,14 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
clearCache,
|
||||
editIndex,
|
||||
fetchConfig,
|
||||
form,
|
||||
handleBack,
|
||||
providers,
|
||||
testModel,
|
||||
showNotification,
|
||||
t,
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
const resolvedLoading = !draft?.initialized;
|
||||
@@ -351,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>
|
||||
|
||||
@@ -26,6 +26,7 @@ const OAUTH_PROVIDER_PRESETS = [
|
||||
'claude',
|
||||
'codex',
|
||||
'qwen',
|
||||
'kimi',
|
||||
'iflow',
|
||||
];
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const OAUTH_PROVIDER_PRESETS = [
|
||||
'claude',
|
||||
'codex',
|
||||
'qwen',
|
||||
'kimi',
|
||||
'iflow',
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -185,10 +193,10 @@
|
||||
}
|
||||
|
||||
.fileGridQuotaManaged {
|
||||
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
@@ -414,6 +422,24 @@
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
|
||||
.quotaMessageAction {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaError {
|
||||
font-size: 12px;
|
||||
color: var(--danger-color);
|
||||
@@ -487,17 +513,6 @@
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.fileCardLayoutQuota {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 156px;
|
||||
gap: $spacing-md;
|
||||
align-items: stretch;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.fileCardMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -506,41 +521,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fileCardSidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
padding-left: $spacing-md;
|
||||
border-left: 1px dashed var(--border-color);
|
||||
|
||||
@include mobile {
|
||||
border-left: none;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
padding-left: 0;
|
||||
padding-top: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.fileCardSidebarHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.fileCardSidebarTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fileCardSidebarHint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -813,7 +793,7 @@
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
// OAuth 排除列表
|
||||
// OAuth 模型禁用
|
||||
.excludedList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -861,7 +841,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// OAuth 排除列表表单:提供商快捷标签
|
||||
// OAuth 模型禁用表单:提供商快捷标签
|
||||
.providerField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
IconChevronUp,
|
||||
IconDownload,
|
||||
IconInfo,
|
||||
IconRefreshCw,
|
||||
IconTrash2,
|
||||
} from '@/components/ui/icons';
|
||||
import type { TFunction } from 'i18next';
|
||||
@@ -49,6 +49,10 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||
dark: { bg: '#1b5e20', text: '#81c784' },
|
||||
},
|
||||
kimi: {
|
||||
light: { bg: '#fff4e5', text: '#ad6800' },
|
||||
dark: { bg: '#7c4a03', text: '#ffd591' },
|
||||
},
|
||||
gemini: {
|
||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||||
@@ -91,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)));
|
||||
@@ -166,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;
|
||||
@@ -176,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)) {
|
||||
@@ -248,6 +330,8 @@ export function AuthFilesPage() {
|
||||
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
|
||||
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
|
||||
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||
@@ -407,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,
|
||||
]);
|
||||
|
||||
@@ -504,7 +613,7 @@ export function AuthFilesPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加载 OAuth 排除列表
|
||||
// 加载 OAuth 模型禁用
|
||||
const loadExcluded = useCallback(async () => {
|
||||
try {
|
||||
const res = await authFilesApi.getOauthExcludedModels();
|
||||
@@ -563,14 +672,15 @@ export function AuthFilesPage() {
|
||||
useHeaderRefresh(handleHeaderRefresh);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCurrentLayer) return;
|
||||
loadFiles();
|
||||
loadKeyStats();
|
||||
loadExcluded();
|
||||
loadModelAlias();
|
||||
}, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
|
||||
}, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
|
||||
|
||||
// 定时刷新状态数据(每240秒)
|
||||
useInterval(loadKeyStats, 240_000);
|
||||
useInterval(loadKeyStats, isCurrentLayer ? 240_000 : null);
|
||||
|
||||
// 提取所有存在的类型
|
||||
const existingTypes = useMemo(() => {
|
||||
@@ -822,6 +932,9 @@ export function AuthFilesPage() {
|
||||
json: null,
|
||||
prefix: '',
|
||||
proxyUrl: '',
|
||||
priority: '',
|
||||
excludedModelsText: '',
|
||||
disableCooling: '',
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -863,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;
|
||||
@@ -874,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,
|
||||
};
|
||||
});
|
||||
@@ -887,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 };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1009,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();
|
||||
@@ -1468,11 +1608,14 @@ export function AuthFilesPage() {
|
||||
return GEMINI_CLI_CONFIG;
|
||||
};
|
||||
|
||||
const getQuotaState = (type: QuotaProviderType, fileName: string) => {
|
||||
if (type === 'antigravity') return antigravityQuota[fileName];
|
||||
if (type === 'codex') return codexQuota[fileName];
|
||||
return geminiCliQuota[fileName];
|
||||
};
|
||||
const getQuotaState = useCallback(
|
||||
(type: QuotaProviderType, fileName: string) => {
|
||||
if (type === 'antigravity') return antigravityQuota[fileName];
|
||||
if (type === 'codex') return codexQuota[fileName];
|
||||
return geminiCliQuota[fileName];
|
||||
},
|
||||
[antigravityQuota, codexQuota, geminiCliQuota]
|
||||
);
|
||||
|
||||
const updateQuotaState = useCallback(
|
||||
(
|
||||
@@ -1547,6 +1690,7 @@ export function AuthFilesPage() {
|
||||
| { status?: string; error?: string; errorStatus?: number }
|
||||
| undefined;
|
||||
const quotaStatus = quota?.status ?? 'idle';
|
||||
const canRefreshQuota = !disableControls && !item.disabled;
|
||||
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||
t,
|
||||
quota?.errorStatus,
|
||||
@@ -1558,7 +1702,14 @@ export function AuthFilesPage() {
|
||||
{quotaStatus === 'loading' ? (
|
||||
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
|
||||
) : quotaStatus === 'idle' ? (
|
||||
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
|
||||
onClick={() => void refreshQuotaForFile(item, quotaType)}
|
||||
disabled={!canRefreshQuota}
|
||||
>
|
||||
{t(`${config.i18nPrefix}.idle`)}
|
||||
</button>
|
||||
) : quotaStatus === 'error' ? (
|
||||
<div className={styles.quotaError}>
|
||||
{t(`${config.i18nPrefix}.load_failed`, {
|
||||
@@ -1586,8 +1737,6 @@ export function AuthFilesPage() {
|
||||
quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null;
|
||||
|
||||
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
||||
const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined;
|
||||
const quotaRefreshing = quotaState?.status === 'loading';
|
||||
|
||||
const providerCardClass =
|
||||
quotaType === 'antigravity'
|
||||
@@ -1604,7 +1753,7 @@ export function AuthFilesPage() {
|
||||
className={`${styles.fileCard} ${providerCardClass} ${item.disabled ? styles.fileCardDisabled : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`${styles.fileCardLayout} ${showQuotaLayout ? styles.fileCardLayoutQuota : ''}`}
|
||||
className={styles.fileCardLayout}
|
||||
>
|
||||
<div className={styles.fileCardMain}>
|
||||
<div className={styles.cardHeader}>
|
||||
@@ -1722,29 +1871,6 @@ export function AuthFilesPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showQuotaLayout && quotaType && (
|
||||
<div className={styles.fileCardSidebar}>
|
||||
<div className={styles.fileCardSidebarHeader}>
|
||||
<span className={styles.fileCardSidebarTitle}>
|
||||
{t('auth_files.card_tools_title')}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className={styles.iconButton}
|
||||
onClick={() => void refreshQuotaForFile(item, quotaType)}
|
||||
disabled={disableControls || item.disabled}
|
||||
loading={quotaRefreshing}
|
||||
title={t('auth_files.quota_refresh_single')}
|
||||
aria-label={t('auth_files.quota_refresh_single')}
|
||||
>
|
||||
{!quotaRefreshing && <IconRefreshCw className={styles.actionIcon} size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.fileCardSidebarHint}>{t('auth_files.quota_refresh_hint')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1886,7 +2012,7 @@ export function AuthFilesPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* OAuth 排除列表卡片 */}
|
||||
{/* OAuth 模型禁用卡片 */}
|
||||
<Card
|
||||
title={t('oauth_excluded.title')}
|
||||
extra={
|
||||
@@ -2053,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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -2111,16 +2235,12 @@ 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
|
||||
? t('auth_files.models_excluded_hint', {
|
||||
defaultValue: '此模型已被 OAuth 排除',
|
||||
defaultValue: '此 OAuth 模型已被禁用',
|
||||
})
|
||||
: t('common.copy', { defaultValue: '点击复制' })
|
||||
}
|
||||
@@ -2132,7 +2252,7 @@ export function AuthFilesPage() {
|
||||
{model.type && <span className={styles.modelType}>{model.type}</span>}
|
||||
{isExcluded && (
|
||||
<span className={styles.modelExcludedBadge}>
|
||||
{t('auth_files.models_excluded_badge', { defaultValue: '已排除' })}
|
||||
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -2142,7 +2262,7 @@ export function AuthFilesPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* prefix/proxy_url 编辑弹窗 */}
|
||||
{/* 认证文件字段编辑弹窗 */}
|
||||
<Modal
|
||||
open={Boolean(prefixProxyEditor)}
|
||||
onClose={() => setPrefixProxyEditor(null)}
|
||||
@@ -2150,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={
|
||||
@@ -2218,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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -17,6 +18,16 @@ import styles from './ConfigPage.module.scss';
|
||||
|
||||
type ConfigEditorTab = 'visual' | 'source';
|
||||
|
||||
function readCommercialModeFromYaml(yamlContent: string): boolean {
|
||||
try {
|
||||
const parsed = parseYaml(yamlContent);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
||||
return Boolean((parsed as Record<string, unknown>)['commercial-mode']);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
@@ -78,12 +89,19 @@ export function ConfigPage() {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const previousCommercialMode = readCommercialModeFromYaml(content);
|
||||
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
|
||||
const nextCommercialMode = readCommercialModeFromYaml(nextContent);
|
||||
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
|
||||
await configFileApi.saveConfigYaml(nextContent);
|
||||
const latestContent = await configFileApi.fetchConfigYaml();
|
||||
setDirty(false);
|
||||
setContent(nextContent);
|
||||
loadVisualValuesFromYaml(nextContent);
|
||||
setContent(latestContent);
|
||||
loadVisualValuesFromYaml(latestContent);
|
||||
showNotification(t('config_management.save_success'), 'success');
|
||||
if (commercialModeChanged) {
|
||||
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
|
||||
|
||||
@@ -62,14 +62,23 @@ export function DashboardPage() {
|
||||
apiKeysCache.current = [];
|
||||
}, [apiBase, config?.apiKeys]);
|
||||
|
||||
const normalizeApiKeyList = (input: any): string[] => {
|
||||
const normalizeApiKeyList = (input: unknown): string[] => {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
|
||||
input.forEach((item) => {
|
||||
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
|
||||
const trimmed = String(value || '').trim();
|
||||
const record =
|
||||
item !== null && typeof item === 'object' && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>)
|
||||
: null;
|
||||
const value =
|
||||
typeof item === 'string'
|
||||
? item
|
||||
: record
|
||||
? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key)
|
||||
: '';
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
keys.push(trimmed);
|
||||
|
||||
@@ -167,9 +167,24 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// 语言切换按钮
|
||||
.languageBtn {
|
||||
// 语言下拉选择
|
||||
.languageSelect {
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// 连接信息框
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/Input';
|
||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||
import { isSupportedLanguage } from '@/utils/language';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import type { ApiError } from '@/types';
|
||||
import styles from './LoginPage.module.scss';
|
||||
@@ -13,11 +15,20 @@ import styles from './LoginPage.module.scss';
|
||||
/**
|
||||
* 将 API 错误转换为本地化的用户友好消息
|
||||
*/
|
||||
function getLocalizedErrorMessage(error: any, t: (key: string) => string): string {
|
||||
const apiError = error as ApiError;
|
||||
const status = apiError?.status;
|
||||
const code = apiError?.code;
|
||||
const message = apiError?.message || '';
|
||||
type RedirectState = { from?: { pathname?: string } };
|
||||
|
||||
function getLocalizedErrorMessage(error: unknown, t: (key: string) => string): string {
|
||||
const apiError = error as Partial<ApiError>;
|
||||
const status = typeof apiError.status === 'number' ? apiError.status : undefined;
|
||||
const code = typeof apiError.code === 'string' ? apiError.code : undefined;
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof apiError.message === 'string'
|
||||
? apiError.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: '';
|
||||
|
||||
// 根据 HTTP 状态码判断
|
||||
if (status === 401) {
|
||||
@@ -59,7 +70,7 @@ export function LoginPage() {
|
||||
const location = useLocation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||
@@ -78,7 +89,16 @@ export function LoginPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
|
||||
const handleLanguageChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedLanguage = event.target.value;
|
||||
if (!isSupportedLanguage(selectedLanguage)) {
|
||||
return;
|
||||
}
|
||||
setLanguage(selectedLanguage);
|
||||
},
|
||||
[setLanguage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -88,7 +108,7 @@ export function LoginPage() {
|
||||
setAutoLoginSuccess(true);
|
||||
// 延迟跳转,让用户看到成功动画
|
||||
setTimeout(() => {
|
||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||
const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
|
||||
navigate(redirect, { replace: true });
|
||||
}, 1500);
|
||||
} else {
|
||||
@@ -124,7 +144,7 @@ export function LoginPage() {
|
||||
});
|
||||
showNotification(t('common.connected_status'), 'success');
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const message = getLocalizedErrorMessage(err, t);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
|
||||
@@ -144,7 +164,7 @@ export function LoginPage() {
|
||||
);
|
||||
|
||||
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
|
||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||
const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
|
||||
return <Navigate to={redirect} replace />;
|
||||
}
|
||||
|
||||
@@ -185,17 +205,19 @@ export function LoginPage() {
|
||||
<div className={styles.loginHeader}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.title}>{t('title.login')}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.languageBtn}
|
||||
onClick={toggleLanguage}
|
||||
<select
|
||||
className={styles.languageSelect}
|
||||
value={language}
|
||||
onChange={handleLanguageChange}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{nextLanguageLabel}
|
||||
</Button>
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{t(LANGUAGE_LABEL_KEYS[lang])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.subtitle}>{t('login.subtitle')}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -643,6 +659,29 @@ export function LogsPage() {
|
||||
|
||||
const canLoadMore = !isSearching && logState.visibleFrom > 0;
|
||||
|
||||
const prependVisibleLines = useCallback(() => {
|
||||
const node = logViewerRef.current;
|
||||
if (!node) return;
|
||||
if (pendingPrependScrollRef.current) return;
|
||||
if (isSearching) return;
|
||||
|
||||
setLogState((prev) => {
|
||||
if (prev.visibleFrom <= 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
pendingPrependScrollRef.current = {
|
||||
scrollHeight: node.scrollHeight,
|
||||
scrollTop: node.scrollTop,
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
|
||||
};
|
||||
});
|
||||
}, [isSearching]);
|
||||
|
||||
const handleLogScroll = () => {
|
||||
const node = logViewerRef.current;
|
||||
if (!node) return;
|
||||
@@ -651,14 +690,7 @@ export function LogsPage() {
|
||||
if (pendingPrependScrollRef.current) return;
|
||||
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
|
||||
|
||||
pendingPrependScrollRef.current = {
|
||||
scrollHeight: node.scrollHeight,
|
||||
scrollTop: node.scrollTop,
|
||||
};
|
||||
setLogState((prev) => ({
|
||||
...prev,
|
||||
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
|
||||
}));
|
||||
prependVisibleLines();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -671,6 +703,53 @@ export function LogsPage() {
|
||||
pendingPrependScrollRef.current = null;
|
||||
}, [logState.visibleFrom]);
|
||||
|
||||
const tryAutoLoadMoreUntilScrollable = useCallback(() => {
|
||||
const node = logViewerRef.current;
|
||||
if (!node) return;
|
||||
if (!canLoadMore) return;
|
||||
if (isSearching) return;
|
||||
if (pendingPrependScrollRef.current) return;
|
||||
|
||||
const hasVerticalOverflow = node.scrollHeight > node.clientHeight + 1;
|
||||
if (hasVerticalOverflow) return;
|
||||
|
||||
prependVisibleLines();
|
||||
}, [canLoadMore, isSearching, prependVisibleLines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (activeTab !== 'logs') return;
|
||||
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
tryAutoLoadMoreUntilScrollable();
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [
|
||||
activeTab,
|
||||
loading,
|
||||
tryAutoLoadMoreUntilScrollable,
|
||||
filteredLines.length,
|
||||
showRawLogs,
|
||||
logState.visibleFrom,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'logs') return;
|
||||
|
||||
const onResize = () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
tryAutoLoadMoreUntilScrollable();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [activeTab, tryAutoLoadMoreUntilScrollable]);
|
||||
|
||||
const copyLogLine = async (raw: string) => {
|
||||
const ok = await copyToClipboard(raw);
|
||||
if (ok) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconKimiLight from '@/assets/icons/kimi-light.svg';
|
||||
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
|
||||
import iconQwen from '@/assets/icons/qwen.svg';
|
||||
import iconIflow from '@/assets/icons/iflow.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
@@ -54,11 +56,27 @@ interface VertexImportState {
|
||||
result?: VertexImportResult;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (isRecord(error) && typeof error.message === 'string') return error.message;
|
||||
return typeof error === 'string' ? error : '';
|
||||
}
|
||||
|
||||
function getErrorStatus(error: unknown): number | undefined {
|
||||
if (!isRecord(error)) return undefined;
|
||||
return typeof error.status === 'number' ? error.status : undefined;
|
||||
}
|
||||
|
||||
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } },
|
||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
||||
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } },
|
||||
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
|
||||
];
|
||||
|
||||
@@ -124,8 +142,8 @@ export function OAuthPage() {
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
}
|
||||
} catch (err: any) {
|
||||
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
||||
} catch (err: unknown) {
|
||||
updateProviderState(provider, { status: 'error', error: getErrorMessage(err), polling: false });
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
}
|
||||
@@ -156,9 +174,13 @@ export function OAuthPage() {
|
||||
if (res.state) {
|
||||
startPolling(provider, res.state);
|
||||
}
|
||||
} catch (err: any) {
|
||||
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
||||
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
updateProviderState(provider, { status: 'error', error: message, polling: false });
|
||||
showNotification(
|
||||
`${t(getAuthKey(provider, 'oauth_start_error'))}${message ? ` ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -187,13 +209,15 @@ export function OAuthPage() {
|
||||
await oauthApi.submitCallback(provider, redirectUrl);
|
||||
updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' });
|
||||
showNotification(t('auth_login.oauth_callback_success'), 'success');
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const status = getErrorStatus(err);
|
||||
const message = getErrorMessage(err);
|
||||
const errorMessage =
|
||||
err?.status === 404
|
||||
status === 404
|
||||
? t('auth_login.oauth_callback_upgrade_hint', {
|
||||
defaultValue: 'Please update CLI Proxy API or check the connection.'
|
||||
})
|
||||
: err?.message;
|
||||
: message || undefined;
|
||||
updateProviderState(provider, {
|
||||
callbackSubmitting: false,
|
||||
callbackStatus: 'error',
|
||||
@@ -233,15 +257,19 @@ export function OAuthPage() {
|
||||
}));
|
||||
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.status === 409) {
|
||||
} catch (err: unknown) {
|
||||
if (getErrorStatus(err) === 409) {
|
||||
const message = t('auth_login.iflow_cookie_config_duplicate');
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
|
||||
showNotification(message, 'warning');
|
||||
return;
|
||||
}
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: err?.message, errorType: 'error' }));
|
||||
showNotification(`${t('auth_login.iflow_cookie_start_error')} ${err?.message || ''}`, 'error');
|
||||
const message = getErrorMessage(err);
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' }));
|
||||
showNotification(
|
||||
`${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -289,8 +317,8 @@ export function OAuthPage() {
|
||||
};
|
||||
setVertexState((prev) => ({ ...prev, loading: false, result }));
|
||||
showNotification(t('vertex_import.success'), 'success');
|
||||
} catch (err: any) {
|
||||
const message = err?.message || '';
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setVertexState((prev) => ({
|
||||
...prev,
|
||||
loading: 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}
|
||||
|
||||
@@ -15,6 +15,108 @@
|
||||
gap: $spacing-xl;
|
||||
}
|
||||
|
||||
.aboutCard {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aboutHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-lg 0 $spacing-xl;
|
||||
}
|
||||
|
||||
.aboutLogo {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 26px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.aboutTitle {
|
||||
width: min(100%, 920px);
|
||||
font-size: clamp(28px, 4.2vw, 44px);
|
||||
font-weight: 800;
|
||||
line-height: 1.12;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.aboutInfoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: $spacing-md;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.infoTile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 120px;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tapTile {
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
|
||||
color: inherit;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tileValue {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tileSub {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.aboutActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -231,3 +333,29 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.aboutLogo {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.aboutTitle {
|
||||
width: min(100%, 24ch);
|
||||
font-size: clamp(22px, 6.6vw, 34px);
|
||||
font-weight: 700;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.012em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.aboutTitle {
|
||||
width: min(100%, 19ch);
|
||||
font-size: clamp(20px, 7.2vw, 28px);
|
||||
font-weight: 600;
|
||||
line-height: 1.22;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
|
||||
import { configApi } from '@/services/api';
|
||||
import { apiKeysApi } from '@/services/api/apiKeys';
|
||||
import { classifyModels } from '@/utils/models';
|
||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
@@ -39,6 +43,8 @@ export function SystemPage() {
|
||||
const auth = useAuthStore();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
|
||||
const models = useModelsStore((state) => state.models);
|
||||
const modelsLoading = useModelsStore((state) => state.loading);
|
||||
@@ -46,14 +52,29 @@ export function SystemPage() {
|
||||
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
|
||||
|
||||
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
|
||||
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
|
||||
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||
|
||||
const apiKeysCache = useRef<string[]>([]);
|
||||
const versionTapCount = useRef(0);
|
||||
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const otherLabel = useMemo(
|
||||
() => (i18n.language?.toLowerCase().startsWith('zh') ? '其他' : 'Other'),
|
||||
[i18n.language]
|
||||
);
|
||||
const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]);
|
||||
const requestLogEnabled = config?.requestLog ?? false;
|
||||
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||
const canEditRequestLog = auth.connectionStatus === 'connected' && Boolean(config);
|
||||
|
||||
const appVersion = __APP_VERSION__ || t('system_info.version_unknown');
|
||||
const apiVersion = auth.serverVersion || t('system_info.version_unknown');
|
||||
const buildTime = auth.serverBuildDate
|
||||
? new Date(auth.serverBuildDate).toLocaleString(i18n.language)
|
||||
: t('system_info.version_unknown');
|
||||
|
||||
const getIconForCategory = (categoryId: string): string | null => {
|
||||
const iconEntry = MODEL_CATEGORY_ICONS[categoryId];
|
||||
@@ -62,14 +83,23 @@ export function SystemPage() {
|
||||
return resolvedTheme === 'dark' ? iconEntry.dark : iconEntry.light;
|
||||
};
|
||||
|
||||
const normalizeApiKeyList = (input: any): string[] => {
|
||||
const normalizeApiKeyList = (input: unknown): string[] => {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
|
||||
input.forEach((item) => {
|
||||
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
|
||||
const trimmed = String(value || '').trim();
|
||||
const record =
|
||||
item !== null && typeof item === 'object' && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>)
|
||||
: null;
|
||||
const value =
|
||||
typeof item === 'string'
|
||||
? item
|
||||
: record
|
||||
? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key)
|
||||
: '';
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
keys.push(trimmed);
|
||||
@@ -130,9 +160,12 @@ export function SystemPage() {
|
||||
type: hasModels ? 'success' : 'warning',
|
||||
message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty')
|
||||
});
|
||||
} catch (err: any) {
|
||||
const message = `${t('system_info.models_error')}: ${err?.message || ''}`;
|
||||
setModelStatus({ type: 'error', message });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
||||
const suffix = message ? `: ${message}` : '';
|
||||
const text = `${t('system_info.models_error')}${suffix}`;
|
||||
setModelStatus({ type: 'error', message: text });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -152,12 +185,85 @@ export function SystemPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const openRequestLogModal = useCallback(() => {
|
||||
setRequestLogTouched(false);
|
||||
setRequestLogDraft(requestLogEnabled);
|
||||
setRequestLogModalOpen(true);
|
||||
}, [requestLogEnabled]);
|
||||
|
||||
const handleInfoVersionTap = useCallback(() => {
|
||||
versionTapCount.current += 1;
|
||||
if (versionTapTimer.current) {
|
||||
clearTimeout(versionTapTimer.current);
|
||||
}
|
||||
|
||||
if (versionTapCount.current >= 7) {
|
||||
versionTapCount.current = 0;
|
||||
versionTapTimer.current = null;
|
||||
openRequestLogModal();
|
||||
return;
|
||||
}
|
||||
|
||||
versionTapTimer.current = setTimeout(() => {
|
||||
versionTapCount.current = 0;
|
||||
versionTapTimer.current = null;
|
||||
}, 1500);
|
||||
}, [openRequestLogModal]);
|
||||
|
||||
const handleRequestLogClose = useCallback(() => {
|
||||
setRequestLogModalOpen(false);
|
||||
setRequestLogTouched(false);
|
||||
}, []);
|
||||
|
||||
const handleRequestLogSave = async () => {
|
||||
if (!canEditRequestLog) return;
|
||||
if (!requestLogDirty) {
|
||||
setRequestLogModalOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = requestLogEnabled;
|
||||
setRequestLogSaving(true);
|
||||
updateConfigValue('request-log', requestLogDraft);
|
||||
|
||||
try {
|
||||
await configApi.updateRequestLog(requestLogDraft);
|
||||
clearCache('request-log');
|
||||
showNotification(t('notification.request_log_updated'), 'success');
|
||||
setRequestLogModalOpen(false);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||
updateConfigValue('request-log', previous);
|
||||
showNotification(
|
||||
`${t('notification.update_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setRequestLogSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestLogModalOpen && !requestLogTouched) {
|
||||
setRequestLogDraft(requestLogEnabled);
|
||||
}
|
||||
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (versionTapTimer.current) {
|
||||
clearTimeout(versionTapTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -167,33 +273,43 @@ export function SystemPage() {
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('system_info.title')}</h1>
|
||||
<div className={styles.content}>
|
||||
<Card
|
||||
title={t('system_info.connection_status_title')}
|
||||
extra={
|
||||
<Card className={styles.aboutCard}>
|
||||
<div className={styles.aboutHeader}>
|
||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
|
||||
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.aboutInfoGrid}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.infoTile} ${styles.tapTile}`}
|
||||
onClick={handleInfoVersionTap}
|
||||
>
|
||||
<div className={styles.tileLabel}>{t('footer.version')}</div>
|
||||
<div className={styles.tileValue}>{appVersion}</div>
|
||||
</button>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
|
||||
<div className={styles.tileValue}>{apiVersion}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
|
||||
<div className={styles.tileValue}>{buildTime}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoTile}>
|
||||
<div className={styles.tileLabel}>{t('connection.status')}</div>
|
||||
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status`)}</div>
|
||||
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.aboutActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid cols-2">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{t('connection.server_address')}</div>
|
||||
<div className="stat-value">{auth.apiBase || '-'}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{t('footer.api_version')}</div>
|
||||
<div className="stat-value">{auth.serverVersion || t('system_info.version_unknown')}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{t('footer.build_date')}</div>
|
||||
<div className="stat-value">
|
||||
{auth.serverBuildDate ? new Date(auth.serverBuildDate).toLocaleString() : t('system_info.version_unknown')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{t('connection.status')}</div>
|
||||
<div className="stat-value">{t(`common.${auth.connectionStatus}_status` as any)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -312,6 +428,40 @@ export function SystemPage() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={requestLogModalOpen}
|
||||
onClose={handleRequestLogClose}
|
||||
title={t('basic_settings.request_log_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRequestLogSave}
|
||||
loading={requestLogSaving}
|
||||
disabled={!canEditRequestLog || !requestLogDirty}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="request-log-modal">
|
||||
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
|
||||
<ToggleSwitch
|
||||
label={t('basic_settings.request_log_enable')}
|
||||
labelPosition="left"
|
||||
checked={requestLogDraft}
|
||||
disabled={!canEditRequestLog || requestLogSaving}
|
||||
onChange={(value) => {
|
||||
setRequestLogDraft(value);
|
||||
setRequestLogTouched(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const ampcodeApi = {
|
||||
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
||||
|
||||
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
||||
const data = await apiClient.get('/ampcode/model-mappings');
|
||||
const data = await apiClient.get<Record<string, unknown>>('/ampcode/model-mappings');
|
||||
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
||||
return normalizeAmpcodeModelMappings(list);
|
||||
},
|
||||
|
||||
@@ -13,14 +13,14 @@ export interface ApiCallRequest {
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface ApiCallResult<T = any> {
|
||||
export interface ApiCallResult<T = unknown> {
|
||||
statusCode: number;
|
||||
header: Record<string, string[]>;
|
||||
bodyText: string;
|
||||
body: T | null;
|
||||
}
|
||||
|
||||
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
|
||||
const normalizeBody = (input: unknown): { bodyText: string; body: unknown | null } => {
|
||||
if (input === undefined || input === null) {
|
||||
return { bodyText: '', body: null };
|
||||
}
|
||||
@@ -46,13 +46,24 @@ const normalizeBody = (input: unknown): { bodyText: string; body: any | null } =
|
||||
};
|
||||
|
||||
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object';
|
||||
|
||||
const status = result.statusCode;
|
||||
const body = result.body;
|
||||
const bodyText = result.bodyText;
|
||||
let message = '';
|
||||
|
||||
if (body && typeof body === 'object') {
|
||||
message = body?.error?.message || body?.error || body?.message || '';
|
||||
if (isRecord(body)) {
|
||||
const errorValue = body.error;
|
||||
if (isRecord(errorValue) && typeof errorValue.message === 'string') {
|
||||
message = errorValue.message;
|
||||
} else if (typeof errorValue === 'string') {
|
||||
message = errorValue;
|
||||
}
|
||||
if (!message && typeof body.message === 'string') {
|
||||
message = body.message;
|
||||
}
|
||||
} else if (typeof body === 'string') {
|
||||
message = body;
|
||||
}
|
||||
@@ -71,7 +82,7 @@ export const apiCallApi = {
|
||||
payload: ApiCallRequest,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiCallResult> => {
|
||||
const response = await apiClient.post('/api-call', payload, config);
|
||||
const response = await apiClient.post<Record<string, unknown>>('/api-call', payload, config);
|
||||
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
|
||||
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
|
||||
const { bodyText, body } = normalizeBody(response?.body);
|
||||
|
||||
@@ -6,9 +6,9 @@ import { apiClient } from './client';
|
||||
|
||||
export const apiKeysApi = {
|
||||
async list(): Promise<string[]> {
|
||||
const data = await apiClient.get('/api-keys');
|
||||
const keys = (data && (data['api-keys'] ?? data.apiKeys)) as unknown;
|
||||
return Array.isArray(keys) ? (keys as string[]) : [];
|
||||
const data = await apiClient.get<Record<string, unknown>>('/api-keys');
|
||||
const keys = data['api-keys'] ?? data.apiKeys;
|
||||
return Array.isArray(keys) ? keys.map((key) => String(key)) : [];
|
||||
},
|
||||
|
||||
replace: (keys: string[]) => apiClient.put('/api-keys', keys),
|
||||
|
||||
@@ -171,15 +171,25 @@ export const authFilesApi = {
|
||||
|
||||
// 获取认证凭证支持的模型
|
||||
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
||||
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);
|
||||
return (data && Array.isArray(data['models'])) ? data['models'] : [];
|
||||
const data = await apiClient.get<Record<string, unknown>>(
|
||||
`/auth-files/models?name=${encodeURIComponent(name)}`
|
||||
);
|
||||
const models = data.models ?? data['models'];
|
||||
return Array.isArray(models)
|
||||
? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[])
|
||||
: [];
|
||||
},
|
||||
|
||||
// 获取指定 channel 的模型定义
|
||||
async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
||||
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
|
||||
if (!normalizedChannel) return [];
|
||||
const data = await apiClient.get(`/model-definitions/${encodeURIComponent(normalizedChannel)}`);
|
||||
return (data && Array.isArray(data['models'])) ? data['models'] : [];
|
||||
const data = await apiClient.get<Record<string, unknown>>(
|
||||
`/model-definitions/${encodeURIComponent(normalizedChannel)}`
|
||||
);
|
||||
const models = data.models ?? data['models'];
|
||||
return Array.isArray(models)
|
||||
? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[])
|
||||
: [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,7 +62,10 @@ class ApiClient {
|
||||
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
||||
}
|
||||
|
||||
private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
|
||||
private readHeader(
|
||||
headers: Record<string, unknown> | undefined,
|
||||
keys: string[]
|
||||
): string | null {
|
||||
if (!headers) return null;
|
||||
|
||||
const normalizeValue = (value: unknown): string | null => {
|
||||
@@ -75,7 +78,7 @@ class ApiClient {
|
||||
return text ? text : null;
|
||||
};
|
||||
|
||||
const headerGetter = (headers as { get?: (name: string) => any }).get;
|
||||
const headerGetter = (headers as { get?: (name: string) => unknown }).get;
|
||||
if (typeof headerGetter === 'function') {
|
||||
for (const key of keys) {
|
||||
const match = normalizeValue(headerGetter.call(headers, key));
|
||||
@@ -84,8 +87,8 @@ class ApiClient {
|
||||
}
|
||||
|
||||
const entries =
|
||||
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
|
||||
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
|
||||
typeof (headers as { entries?: () => Iterable<[string, unknown]> }).entries === 'function'
|
||||
? Array.from((headers as { entries: () => Iterable<[string, unknown]> }).entries())
|
||||
: Object.entries(headers);
|
||||
|
||||
const normalized = Object.fromEntries(
|
||||
@@ -147,10 +150,22 @@ class ApiClient {
|
||||
/**
|
||||
* 错误处理
|
||||
*/
|
||||
private handleError(error: any): ApiError {
|
||||
private handleError(error: unknown): ApiError {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object';
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const responseData = error.response?.data as any;
|
||||
const message = responseData?.error || responseData?.message || error.message || 'Request failed';
|
||||
const responseData: unknown = error.response?.data;
|
||||
const responseRecord = isRecord(responseData) ? responseData : null;
|
||||
const errorValue = responseRecord?.error;
|
||||
const message =
|
||||
typeof errorValue === 'string'
|
||||
? errorValue
|
||||
: isRecord(errorValue) && typeof errorValue.message === 'string'
|
||||
? errorValue.message
|
||||
: typeof responseRecord?.message === 'string'
|
||||
? responseRecord.message
|
||||
: error.message || 'Request failed';
|
||||
const apiError = new Error(message) as ApiError;
|
||||
apiError.name = 'ApiError';
|
||||
apiError.status = error.response?.status;
|
||||
@@ -166,7 +181,9 @@ class ApiClient {
|
||||
return apiError;
|
||||
}
|
||||
|
||||
const fallback = new Error(error?.message || 'Unknown error occurred') as ApiError;
|
||||
const fallbackMessage =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error occurred';
|
||||
const fallback = new Error(fallbackMessage) as ApiError;
|
||||
fallback.name = 'ApiError';
|
||||
return fallback;
|
||||
}
|
||||
@@ -174,7 +191,7 @@ class ApiClient {
|
||||
/**
|
||||
* GET 请求
|
||||
*/
|
||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
async get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.instance.get<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
@@ -182,7 +199,7 @@ class ApiClient {
|
||||
/**
|
||||
* POST 请求
|
||||
*/
|
||||
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
async post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.instance.post<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
@@ -190,7 +207,7 @@ class ApiClient {
|
||||
/**
|
||||
* PUT 请求
|
||||
*/
|
||||
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
async put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.instance.put<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
@@ -198,7 +215,7 @@ class ApiClient {
|
||||
/**
|
||||
* PATCH 请求
|
||||
*/
|
||||
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
async patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.instance.patch<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
@@ -206,7 +223,7 @@ class ApiClient {
|
||||
/**
|
||||
* DELETE 请求
|
||||
*/
|
||||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
async delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.instance.delete<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
@@ -221,7 +238,11 @@ class ApiClient {
|
||||
/**
|
||||
* 发送 FormData
|
||||
*/
|
||||
async postForm<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<T> {
|
||||
async postForm<T = unknown>(
|
||||
url: string,
|
||||
formData: FormData,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const response = await this.instance.post<T>(url, formData, {
|
||||
...config,
|
||||
headers: {
|
||||
|
||||
@@ -72,8 +72,10 @@ export const configApi = {
|
||||
* 获取日志总大小上限(MB)
|
||||
*/
|
||||
async getLogsMaxTotalSizeMb(): Promise<number> {
|
||||
const data = await apiClient.get('/logs-max-total-size-mb');
|
||||
return data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
|
||||
const data = await apiClient.get<Record<string, unknown>>('/logs-max-total-size-mb');
|
||||
const value = data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -91,8 +93,8 @@ export const configApi = {
|
||||
* 获取强制模型前缀开关
|
||||
*/
|
||||
async getForceModelPrefix(): Promise<boolean> {
|
||||
const data = await apiClient.get('/force-model-prefix');
|
||||
return data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false;
|
||||
const data = await apiClient.get<Record<string, unknown>>('/force-model-prefix');
|
||||
return Boolean(data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -104,8 +106,9 @@ export const configApi = {
|
||||
* 获取路由策略
|
||||
*/
|
||||
async getRoutingStrategy(): Promise<string> {
|
||||
const data = await apiClient.get('/routing/strategy');
|
||||
return data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy ?? 'round-robin';
|
||||
const data = await apiClient.get<Record<string, unknown>>('/routing/strategy');
|
||||
const strategy = data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy;
|
||||
return typeof strategy === 'string' ? strategy : 'round-robin';
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ export const configFileApi = {
|
||||
responseType: 'text',
|
||||
headers: { Accept: 'application/yaml, text/yaml, text/plain' }
|
||||
});
|
||||
const data = response.data as any;
|
||||
const data: unknown = response.data;
|
||||
if (typeof data === 'string') return data;
|
||||
if (data === undefined || data === null) return '';
|
||||
return String(data);
|
||||
|
||||
@@ -9,6 +9,7 @@ export type OAuthProvider =
|
||||
| 'anthropic'
|
||||
| 'antigravity'
|
||||
| 'gemini-cli'
|
||||
| 'kimi'
|
||||
| 'qwen';
|
||||
|
||||
export interface OAuthStartResponse {
|
||||
|
||||
@@ -18,12 +18,22 @@ import type {
|
||||
|
||||
const serializeHeaders = (headers?: Record<string, string>) => (headers && Object.keys(headers).length ? headers : undefined);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const extractArrayPayload = (data: unknown, key: string): unknown[] => {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (!isRecord(data)) return [];
|
||||
const candidate = data[key] ?? data.items ?? data.data ?? data;
|
||||
return Array.isArray(candidate) ? candidate : [];
|
||||
};
|
||||
|
||||
const serializeModelAliases = (models?: ModelAlias[]) =>
|
||||
Array.isArray(models)
|
||||
? models
|
||||
.map((model) => {
|
||||
if (!model?.name) return null;
|
||||
const payload: Record<string, any> = { name: model.name };
|
||||
const payload: Record<string, unknown> = { name: model.name };
|
||||
if (model.alias && model.alias !== model.name) {
|
||||
payload.alias = model.alias;
|
||||
}
|
||||
@@ -39,7 +49,7 @@ const serializeModelAliases = (models?: ModelAlias[]) =>
|
||||
: undefined;
|
||||
|
||||
const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
|
||||
const payload: Record<string, any> = { 'api-key': entry.apiKey };
|
||||
const payload: Record<string, unknown> = { 'api-key': entry.apiKey };
|
||||
if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl;
|
||||
const headers = serializeHeaders(entry.headers);
|
||||
if (headers) payload.headers = headers;
|
||||
@@ -47,7 +57,7 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
|
||||
};
|
||||
|
||||
const serializeProviderKey = (config: ProviderKeyConfig) => {
|
||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
||||
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
|
||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||
@@ -74,7 +84,7 @@ const serializeVertexModelAliases = (models?: ModelAlias[]) =>
|
||||
: undefined;
|
||||
|
||||
const serializeVertexKey = (config: ProviderKeyConfig) => {
|
||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
||||
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
|
||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||
@@ -86,7 +96,7 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
|
||||
};
|
||||
|
||||
const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
||||
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
|
||||
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||
const headers = serializeHeaders(config.headers);
|
||||
@@ -98,7 +108,7 @@ const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
||||
};
|
||||
|
||||
const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
|
||||
const payload: Record<string, any> = {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: provider.name,
|
||||
'base-url': provider.baseUrl,
|
||||
'api-key-entries': Array.isArray(provider.apiKeyEntries)
|
||||
@@ -118,8 +128,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
|
||||
export const providersApi = {
|
||||
async getGeminiKeys(): Promise<GeminiKeyConfig[]> {
|
||||
const data = await apiClient.get('/gemini-api-key');
|
||||
const list = (data && (data['gemini-api-key'] ?? data.items ?? data)) as any;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const list = extractArrayPayload(data, 'gemini-api-key');
|
||||
return list.map((item) => normalizeGeminiKeyConfig(item)).filter(Boolean) as GeminiKeyConfig[];
|
||||
},
|
||||
|
||||
@@ -134,8 +143,7 @@ export const providersApi = {
|
||||
|
||||
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
|
||||
const data = await apiClient.get('/codex-api-key');
|
||||
const list = (data && (data['codex-api-key'] ?? data.items ?? data)) as any;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const list = extractArrayPayload(data, 'codex-api-key');
|
||||
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
|
||||
},
|
||||
|
||||
@@ -150,8 +158,7 @@ export const providersApi = {
|
||||
|
||||
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
|
||||
const data = await apiClient.get('/claude-api-key');
|
||||
const list = (data && (data['claude-api-key'] ?? data.items ?? data)) as any;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const list = extractArrayPayload(data, 'claude-api-key');
|
||||
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
|
||||
},
|
||||
|
||||
@@ -166,8 +173,7 @@ export const providersApi = {
|
||||
|
||||
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
|
||||
const data = await apiClient.get('/vertex-api-key');
|
||||
const list = (data && (data['vertex-api-key'] ?? data.items ?? data)) as any;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const list = extractArrayPayload(data, 'vertex-api-key');
|
||||
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
|
||||
},
|
||||
|
||||
@@ -182,8 +188,7 @@ export const providersApi = {
|
||||
|
||||
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
|
||||
const data = await apiClient.get('/openai-compatibility');
|
||||
const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const list = extractArrayPayload(data, 'openai-compatibility');
|
||||
return list.map((item) => normalizeOpenAIProvider(item)).filter(Boolean) as OpenAIProviderConfig[];
|
||||
},
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import type {
|
||||
import type { Config } from '@/types/config';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
|
||||
const normalizeBoolean = (value: any): boolean | undefined => {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const normalizeBoolean = (value: unknown): boolean | undefined => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
@@ -22,11 +25,17 @@ const normalizeBoolean = (value: any): boolean | undefined => {
|
||||
return Boolean(value);
|
||||
};
|
||||
|
||||
const normalizeModelAliases = (models: any): ModelAlias[] => {
|
||||
const normalizeModelAliases = (models: unknown): ModelAlias[] => {
|
||||
if (!Array.isArray(models)) return [];
|
||||
return models
|
||||
.map((item) => {
|
||||
if (!item) return null;
|
||||
if (item === undefined || item === null) return null;
|
||||
if (typeof item === 'string') {
|
||||
const trimmed = item.trim();
|
||||
return trimmed ? ({ name: trimmed } satisfies ModelAlias) : null;
|
||||
}
|
||||
if (!isRecord(item)) return null;
|
||||
|
||||
const name = item.name || item.id || item.model;
|
||||
if (!name) return null;
|
||||
const alias = item.alias || item.display_name || item.displayName;
|
||||
@@ -37,7 +46,10 @@ const normalizeModelAliases = (models: any): ModelAlias[] => {
|
||||
entry.alias = String(alias);
|
||||
}
|
||||
if (priority !== undefined) {
|
||||
entry.priority = Number(priority);
|
||||
const parsed = Number(priority);
|
||||
if (Number.isFinite(parsed)) {
|
||||
entry.priority = parsed;
|
||||
}
|
||||
}
|
||||
if (testModel) {
|
||||
entry.testModel = String(testModel);
|
||||
@@ -47,13 +59,17 @@ const normalizeModelAliases = (models: any): ModelAlias[] => {
|
||||
.filter(Boolean) as ModelAlias[];
|
||||
};
|
||||
|
||||
const normalizeHeaders = (headers: any) => {
|
||||
const normalizeHeaders = (headers: unknown) => {
|
||||
if (!headers || typeof headers !== 'object') return undefined;
|
||||
const normalized = buildHeaderObject(headers as Record<string, string>);
|
||||
const normalized = buildHeaderObject(
|
||||
Array.isArray(headers)
|
||||
? (headers as Array<{ key: string; value: string }>)
|
||||
: (headers as Record<string, string | undefined | null>)
|
||||
);
|
||||
return Object.keys(normalized).length ? normalized : undefined;
|
||||
};
|
||||
|
||||
const normalizeExcludedModels = (input: any): string[] => {
|
||||
const normalizeExcludedModels = (input: unknown): string[] => {
|
||||
const rawList = Array.isArray(input) ? input : typeof input === 'string' ? input.split(/[\n,]/) : [];
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
@@ -70,20 +86,22 @@ const normalizeExcludedModels = (input: any): string[] => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizePrefix = (value: any): string | undefined => {
|
||||
const normalizePrefix = (value: unknown): string | undefined => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
|
||||
if (!entry) return null;
|
||||
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
|
||||
const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
|
||||
if (entry === undefined || entry === null) return null;
|
||||
const record = isRecord(entry) ? entry : null;
|
||||
const apiKey =
|
||||
record?.['api-key'] ?? record?.apiKey ?? record?.key ?? (typeof entry === 'string' ? entry : '');
|
||||
const trimmed = String(apiKey || '').trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const proxyUrl = entry['proxy-url'] ?? entry.proxyUrl;
|
||||
const headers = normalizeHeaders(entry.headers);
|
||||
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
|
||||
const headers = record ? normalizeHeaders(record.headers) : undefined;
|
||||
|
||||
return {
|
||||
apiKey: trimmed,
|
||||
@@ -92,33 +110,38 @@ const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeProviderKeyConfig = (item: any): ProviderKeyConfig | null => {
|
||||
if (!item) return null;
|
||||
const apiKey = item['api-key'] ?? item.apiKey ?? (typeof item === 'string' ? item : '');
|
||||
const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => {
|
||||
if (item === undefined || item === null) return null;
|
||||
const record = isRecord(item) ? item : null;
|
||||
const apiKey = record?.['api-key'] ?? record?.apiKey ?? (typeof item === 'string' ? item : '');
|
||||
const trimmed = String(apiKey || '').trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const config: ProviderKeyConfig = { apiKey: trimmed };
|
||||
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
|
||||
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
|
||||
if (prefix) config.prefix = prefix;
|
||||
const baseUrl = item['base-url'] ?? item.baseUrl;
|
||||
const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
|
||||
const baseUrl = record ? record['base-url'] ?? record.baseUrl : undefined;
|
||||
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
|
||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||
if (proxyUrl) config.proxyUrl = String(proxyUrl);
|
||||
const headers = normalizeHeaders(item.headers);
|
||||
const headers = normalizeHeaders(record?.headers);
|
||||
if (headers) config.headers = headers;
|
||||
const models = normalizeModelAliases(item.models);
|
||||
const models = normalizeModelAliases(record?.models);
|
||||
if (models.length) config.models = models;
|
||||
const excludedModels = normalizeExcludedModels(
|
||||
item['excluded-models'] ?? item.excludedModels ?? item['excluded_models'] ?? item.excluded_models
|
||||
record?.['excluded-models'] ??
|
||||
record?.excludedModels ??
|
||||
record?.['excluded_models'] ??
|
||||
record?.excluded_models
|
||||
);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
return config;
|
||||
};
|
||||
|
||||
const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
|
||||
if (!item) return null;
|
||||
let apiKey = item['api-key'] ?? item.apiKey;
|
||||
const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
|
||||
if (item === undefined || item === null) return null;
|
||||
const record = isRecord(item) ? item : null;
|
||||
let apiKey = record?.['api-key'] ?? record?.apiKey;
|
||||
if (!apiKey && typeof item === 'string') {
|
||||
apiKey = item;
|
||||
}
|
||||
@@ -126,19 +149,19 @@ const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
|
||||
if (!trimmed) return null;
|
||||
|
||||
const config: GeminiKeyConfig = { apiKey: trimmed };
|
||||
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
|
||||
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
|
||||
if (prefix) config.prefix = prefix;
|
||||
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
|
||||
const baseUrl = record ? record['base-url'] ?? record.baseUrl ?? record['base_url'] : undefined;
|
||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||
const headers = normalizeHeaders(item.headers);
|
||||
const headers = normalizeHeaders(record?.headers);
|
||||
if (headers) config.headers = headers;
|
||||
const excludedModels = normalizeExcludedModels(item['excluded-models'] ?? item.excludedModels);
|
||||
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
return config;
|
||||
};
|
||||
|
||||
const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null => {
|
||||
if (!provider || typeof provider !== 'object') return null;
|
||||
const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null => {
|
||||
if (!isRecord(provider)) return null;
|
||||
const name = provider.name || provider.id;
|
||||
const baseUrl = provider['base-url'] ?? provider.baseUrl;
|
||||
if (!name || !baseUrl) return null;
|
||||
@@ -146,11 +169,11 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
|
||||
let apiKeyEntries: ApiKeyEntry[] = [];
|
||||
if (Array.isArray(provider['api-key-entries'])) {
|
||||
apiKeyEntries = provider['api-key-entries']
|
||||
.map((entry: any) => normalizeApiKeyEntry(entry))
|
||||
.map((entry) => normalizeApiKeyEntry(entry))
|
||||
.filter(Boolean) as ApiKeyEntry[];
|
||||
} else if (Array.isArray(provider['api-keys'])) {
|
||||
apiKeyEntries = provider['api-keys']
|
||||
.map((key: any) => normalizeApiKeyEntry({ 'api-key': key }))
|
||||
.map((key) => normalizeApiKeyEntry({ 'api-key': key }))
|
||||
.filter(Boolean) as ApiKeyEntry[];
|
||||
}
|
||||
|
||||
@@ -174,10 +197,10 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeOauthExcluded = (payload: any): Record<string, string[]> | undefined => {
|
||||
if (!payload || typeof payload !== 'object') return undefined;
|
||||
const normalizeOauthExcluded = (payload: unknown): Record<string, string[]> | undefined => {
|
||||
if (!isRecord(payload)) return undefined;
|
||||
const source = payload['oauth-excluded-models'] ?? payload.items ?? payload;
|
||||
if (!source || typeof source !== 'object') return undefined;
|
||||
if (!isRecord(source)) return undefined;
|
||||
const map: Record<string, string[]> = {};
|
||||
Object.entries(source).forEach(([provider, models]) => {
|
||||
const key = String(provider || '').trim();
|
||||
@@ -188,13 +211,13 @@ const normalizeOauthExcluded = (payload: any): Record<string, string[]> | undefi
|
||||
return map;
|
||||
};
|
||||
|
||||
const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => {
|
||||
const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] => {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const seen = new Set<string>();
|
||||
const mappings: AmpcodeModelMapping[] = [];
|
||||
|
||||
input.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return;
|
||||
if (!isRecord(entry)) return;
|
||||
const from = String(entry.from ?? entry['from'] ?? '').trim();
|
||||
const to = String(entry.to ?? entry['to'] ?? '').trim();
|
||||
if (!from || !to) return;
|
||||
@@ -207,9 +230,10 @@ const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => {
|
||||
return mappings;
|
||||
};
|
||||
|
||||
const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
|
||||
const source = payload?.ampcode ?? payload;
|
||||
if (!source || typeof source !== 'object') return undefined;
|
||||
const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined => {
|
||||
const sourceRaw = isRecord(payload) ? (payload.ampcode ?? payload) : payload;
|
||||
if (!isRecord(sourceRaw)) return undefined;
|
||||
const source = sourceRaw;
|
||||
|
||||
const config: AmpcodeConfig = {};
|
||||
const upstreamUrl = source['upstream-url'] ?? source.upstreamUrl ?? source['upstream_url'];
|
||||
@@ -237,70 +261,94 @@ const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
|
||||
/**
|
||||
* 规范化 /config 返回值
|
||||
*/
|
||||
export const normalizeConfigResponse = (raw: any): Config => {
|
||||
const config: Config = { raw: raw || {} };
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
export const normalizeConfigResponse = (raw: unknown): Config => {
|
||||
const config: Config = { raw: isRecord(raw) ? raw : {} };
|
||||
if (!isRecord(raw)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
config.debug = raw.debug;
|
||||
config.proxyUrl = raw['proxy-url'] ?? raw.proxyUrl;
|
||||
config.requestRetry = raw['request-retry'] ?? raw.requestRetry;
|
||||
config.debug = normalizeBoolean(raw.debug);
|
||||
const proxyUrl = raw['proxy-url'] ?? raw.proxyUrl;
|
||||
config.proxyUrl =
|
||||
typeof proxyUrl === 'string' ? proxyUrl : proxyUrl === undefined || proxyUrl === null ? undefined : String(proxyUrl);
|
||||
const requestRetry = raw['request-retry'] ?? raw.requestRetry;
|
||||
if (typeof requestRetry === 'number' && Number.isFinite(requestRetry)) {
|
||||
config.requestRetry = requestRetry;
|
||||
} else if (typeof requestRetry === 'string' && requestRetry.trim() !== '') {
|
||||
const parsed = Number(requestRetry);
|
||||
if (Number.isFinite(parsed)) {
|
||||
config.requestRetry = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const quota = raw['quota-exceeded'] ?? raw.quotaExceeded;
|
||||
if (quota && typeof quota === 'object') {
|
||||
if (isRecord(quota)) {
|
||||
config.quotaExceeded = {
|
||||
switchProject: quota['switch-project'] ?? quota.switchProject,
|
||||
switchPreviewModel: quota['switch-preview-model'] ?? quota.switchPreviewModel
|
||||
switchProject: normalizeBoolean(quota['switch-project'] ?? quota.switchProject),
|
||||
switchPreviewModel: normalizeBoolean(quota['switch-preview-model'] ?? quota.switchPreviewModel)
|
||||
};
|
||||
}
|
||||
|
||||
config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled;
|
||||
config.requestLog = raw['request-log'] ?? raw.requestLog;
|
||||
config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile;
|
||||
config.logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
|
||||
config.wsAuth = raw['ws-auth'] ?? raw.wsAuth;
|
||||
config.forceModelPrefix = raw['force-model-prefix'] ?? raw.forceModelPrefix;
|
||||
const routing = raw.routing;
|
||||
if (routing && typeof routing === 'object') {
|
||||
config.routingStrategy = routing.strategy ?? routing['strategy'];
|
||||
} else {
|
||||
config.routingStrategy = raw['routing-strategy'] ?? raw.routingStrategy;
|
||||
config.usageStatisticsEnabled = normalizeBoolean(
|
||||
raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled
|
||||
);
|
||||
config.requestLog = normalizeBoolean(raw['request-log'] ?? raw.requestLog);
|
||||
config.loggingToFile = normalizeBoolean(raw['logging-to-file'] ?? raw.loggingToFile);
|
||||
const logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
|
||||
if (typeof logsMaxTotalSizeMb === 'number' && Number.isFinite(logsMaxTotalSizeMb)) {
|
||||
config.logsMaxTotalSizeMb = logsMaxTotalSizeMb;
|
||||
} else if (typeof logsMaxTotalSizeMb === 'string' && logsMaxTotalSizeMb.trim() !== '') {
|
||||
const parsed = Number(logsMaxTotalSizeMb);
|
||||
if (Number.isFinite(parsed)) {
|
||||
config.logsMaxTotalSizeMb = parsed;
|
||||
}
|
||||
}
|
||||
config.wsAuth = normalizeBoolean(raw['ws-auth'] ?? raw.wsAuth);
|
||||
config.forceModelPrefix = normalizeBoolean(raw['force-model-prefix'] ?? raw.forceModelPrefix);
|
||||
const routing = raw.routing;
|
||||
const strategyRaw = isRecord(routing)
|
||||
? (routing.strategy ?? routing['strategy'])
|
||||
: (raw['routing-strategy'] ?? raw.routingStrategy);
|
||||
if (strategyRaw !== undefined && strategyRaw !== null) {
|
||||
config.routingStrategy = String(strategyRaw);
|
||||
}
|
||||
const apiKeysRaw = raw['api-keys'] ?? raw.apiKeys;
|
||||
if (Array.isArray(apiKeysRaw)) {
|
||||
config.apiKeys = apiKeysRaw.map((key) => String(key)).filter((key) => key.trim() !== '');
|
||||
}
|
||||
config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys;
|
||||
|
||||
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
|
||||
if (Array.isArray(geminiList)) {
|
||||
config.geminiApiKeys = geminiList
|
||||
.map((item: any) => normalizeGeminiKeyConfig(item))
|
||||
.map((item) => normalizeGeminiKeyConfig(item))
|
||||
.filter(Boolean) as GeminiKeyConfig[];
|
||||
}
|
||||
|
||||
const codexList = raw['codex-api-key'] ?? raw.codexApiKey ?? raw.codexApiKeys;
|
||||
if (Array.isArray(codexList)) {
|
||||
config.codexApiKeys = codexList
|
||||
.map((item: any) => normalizeProviderKeyConfig(item))
|
||||
.map((item) => normalizeProviderKeyConfig(item))
|
||||
.filter(Boolean) as ProviderKeyConfig[];
|
||||
}
|
||||
|
||||
const claudeList = raw['claude-api-key'] ?? raw.claudeApiKey ?? raw.claudeApiKeys;
|
||||
if (Array.isArray(claudeList)) {
|
||||
config.claudeApiKeys = claudeList
|
||||
.map((item: any) => normalizeProviderKeyConfig(item))
|
||||
.map((item) => normalizeProviderKeyConfig(item))
|
||||
.filter(Boolean) as ProviderKeyConfig[];
|
||||
}
|
||||
|
||||
const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys;
|
||||
if (Array.isArray(vertexList)) {
|
||||
config.vertexApiKeys = vertexList
|
||||
.map((item: any) => normalizeProviderKeyConfig(item))
|
||||
.map((item) => normalizeProviderKeyConfig(item))
|
||||
.filter(Boolean) as ProviderKeyConfig[];
|
||||
}
|
||||
|
||||
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
|
||||
if (Array.isArray(openaiList)) {
|
||||
config.openaiCompatibility = openaiList
|
||||
.map((item: any) => normalizeOpenAIProvider(item))
|
||||
.map((item) => normalizeOpenAIProvider(item))
|
||||
.filter(Boolean) as OpenAIProviderConfig[];
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const usageApi = {
|
||||
/**
|
||||
* 获取使用统计原始数据
|
||||
*/
|
||||
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
|
||||
getUsage: () => apiClient.get<Record<string, unknown>>('/usage', { timeout: USAGE_TIMEOUT_MS }),
|
||||
|
||||
/**
|
||||
* 导出使用统计快照
|
||||
@@ -42,10 +42,10 @@ export const usageApi = {
|
||||
/**
|
||||
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
|
||||
*/
|
||||
async getKeyStats(usageData?: any): Promise<KeyStats> {
|
||||
async getKeyStats(usageData?: unknown): Promise<KeyStats> {
|
||||
let payload = usageData;
|
||||
if (!payload) {
|
||||
const response = await apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS });
|
||||
const response = await apiClient.get<Record<string, unknown>>('/usage', { timeout: USAGE_TIMEOUT_MS });
|
||||
payload = response?.usage ?? response;
|
||||
}
|
||||
return computeKeyStats(payload);
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export const versionApi = {
|
||||
checkLatest: () => apiClient.get('/latest-version')
|
||||
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version')
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ class SecureStorageService {
|
||||
/**
|
||||
* 存储数据
|
||||
*/
|
||||
setItem(key: string, value: any, options: StorageOptions = {}): void {
|
||||
setItem(key: string, value: unknown, options: StorageOptions = {}): void {
|
||||
const { encrypt = true } = options;
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
@@ -30,7 +30,7 @@ class SecureStorageService {
|
||||
/**
|
||||
* 获取数据
|
||||
*/
|
||||
getItem<T = any>(key: string, options: StorageOptions = {}): T | null {
|
||||
getItem<T = unknown>(key: string, options: StorageOptions = {}): T | null {
|
||||
const { encrypt = true } = options;
|
||||
|
||||
const raw = localStorage.getItem(key);
|
||||
@@ -84,7 +84,7 @@ class SecureStorageService {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: any = raw;
|
||||
let parsed: unknown = raw;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
|
||||
@@ -117,10 +117,16 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
} else {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: 'Connection failed';
|
||||
set({
|
||||
connectionStatus: 'error',
|
||||
connectionError: error.message || 'Connection failed'
|
||||
connectionError: message || 'Connection failed'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { configApi } from '@/services/api/config';
|
||||
import { CACHE_EXPIRY_MS } from '@/utils/constants';
|
||||
|
||||
interface ConfigCache {
|
||||
data: any;
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@ interface ConfigState {
|
||||
error: string | null;
|
||||
|
||||
// 操作
|
||||
fetchConfig: (section?: RawConfigSection, forceRefresh?: boolean) => Promise<Config | any>;
|
||||
updateConfigValue: (section: RawConfigSection, value: any) => void;
|
||||
fetchConfig: {
|
||||
(section?: undefined, forceRefresh?: boolean): Promise<Config>;
|
||||
(section: RawConfigSection, forceRefresh?: boolean): Promise<unknown>;
|
||||
};
|
||||
updateConfigValue: (section: RawConfigSection, value: unknown) => void;
|
||||
clearCache: (section?: RawConfigSection) => void;
|
||||
isCacheValid: (section?: RawConfigSection) => boolean;
|
||||
}
|
||||
@@ -105,7 +108,7 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchConfig: async (section, forceRefresh = false) => {
|
||||
fetchConfig: (async (section?: RawConfigSection, forceRefresh: boolean = false) => {
|
||||
const { cache, isCacheValid } = get();
|
||||
|
||||
// 检查缓存
|
||||
@@ -163,10 +166,12 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
});
|
||||
|
||||
return section ? extractSectionValue(data, section) : data;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to fetch config';
|
||||
if (requestId === configRequestToken) {
|
||||
set({
|
||||
error: error.message || 'Failed to fetch config',
|
||||
error: message || 'Failed to fetch config',
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
@@ -176,7 +181,7 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
inFlightConfigRequest = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
}) as ConfigState['fetchConfig'],
|
||||
|
||||
updateConfigValue: (section, value) => {
|
||||
set((state) => {
|
||||
@@ -186,61 +191,61 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
|
||||
switch (section) {
|
||||
case 'debug':
|
||||
nextConfig.debug = value;
|
||||
nextConfig.debug = value as Config['debug'];
|
||||
break;
|
||||
case 'proxy-url':
|
||||
nextConfig.proxyUrl = value;
|
||||
nextConfig.proxyUrl = value as Config['proxyUrl'];
|
||||
break;
|
||||
case 'request-retry':
|
||||
nextConfig.requestRetry = value;
|
||||
nextConfig.requestRetry = value as Config['requestRetry'];
|
||||
break;
|
||||
case 'quota-exceeded':
|
||||
nextConfig.quotaExceeded = value;
|
||||
nextConfig.quotaExceeded = value as Config['quotaExceeded'];
|
||||
break;
|
||||
case 'usage-statistics-enabled':
|
||||
nextConfig.usageStatisticsEnabled = value;
|
||||
nextConfig.usageStatisticsEnabled = value as Config['usageStatisticsEnabled'];
|
||||
break;
|
||||
case 'request-log':
|
||||
nextConfig.requestLog = value;
|
||||
nextConfig.requestLog = value as Config['requestLog'];
|
||||
break;
|
||||
case 'logging-to-file':
|
||||
nextConfig.loggingToFile = value;
|
||||
nextConfig.loggingToFile = value as Config['loggingToFile'];
|
||||
break;
|
||||
case 'logs-max-total-size-mb':
|
||||
nextConfig.logsMaxTotalSizeMb = value;
|
||||
nextConfig.logsMaxTotalSizeMb = value as Config['logsMaxTotalSizeMb'];
|
||||
break;
|
||||
case 'ws-auth':
|
||||
nextConfig.wsAuth = value;
|
||||
nextConfig.wsAuth = value as Config['wsAuth'];
|
||||
break;
|
||||
case 'force-model-prefix':
|
||||
nextConfig.forceModelPrefix = value;
|
||||
nextConfig.forceModelPrefix = value as Config['forceModelPrefix'];
|
||||
break;
|
||||
case 'routing/strategy':
|
||||
nextConfig.routingStrategy = value;
|
||||
nextConfig.routingStrategy = value as Config['routingStrategy'];
|
||||
break;
|
||||
case 'api-keys':
|
||||
nextConfig.apiKeys = value;
|
||||
nextConfig.apiKeys = value as Config['apiKeys'];
|
||||
break;
|
||||
case 'ampcode':
|
||||
nextConfig.ampcode = value;
|
||||
nextConfig.ampcode = value as Config['ampcode'];
|
||||
break;
|
||||
case 'gemini-api-key':
|
||||
nextConfig.geminiApiKeys = value;
|
||||
nextConfig.geminiApiKeys = value as Config['geminiApiKeys'];
|
||||
break;
|
||||
case 'codex-api-key':
|
||||
nextConfig.codexApiKeys = value;
|
||||
nextConfig.codexApiKeys = value as Config['codexApiKeys'];
|
||||
break;
|
||||
case 'claude-api-key':
|
||||
nextConfig.claudeApiKeys = value;
|
||||
nextConfig.claudeApiKeys = value as Config['claudeApiKeys'];
|
||||
break;
|
||||
case 'vertex-api-key':
|
||||
nextConfig.vertexApiKeys = value;
|
||||
nextConfig.vertexApiKeys = value as Config['vertexApiKeys'];
|
||||
break;
|
||||
case 'openai-compatibility':
|
||||
nextConfig.openaiCompatibility = value;
|
||||
nextConfig.openaiCompatibility = value as Config['openaiCompatibility'];
|
||||
break;
|
||||
case 'oauth-excluded-models':
|
||||
nextConfig.oauthExcludedModels = value;
|
||||
nextConfig.oauthExcludedModels = value as Config['oauthExcludedModels'];
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
import { LANGUAGE_ORDER, STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
import i18n from '@/i18n';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
import { getInitialLanguage, isSupportedLanguage } from '@/utils/language';
|
||||
|
||||
interface LanguageState {
|
||||
language: Language;
|
||||
setLanguage: (language: Language) => void;
|
||||
setLanguage: (language: string) => void;
|
||||
toggleLanguage: () => void;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export const useLanguageStore = create<LanguageState>()(
|
||||
language: getInitialLanguage(),
|
||||
|
||||
setLanguage: (language) => {
|
||||
if (!isSupportedLanguage(language)) {
|
||||
return;
|
||||
}
|
||||
// 切换 i18next 语言
|
||||
i18n.changeLanguage(language);
|
||||
set({ language });
|
||||
@@ -29,12 +32,24 @@ export const useLanguageStore = create<LanguageState>()(
|
||||
|
||||
toggleLanguage: () => {
|
||||
const { language, setLanguage } = get();
|
||||
const newLanguage: Language = language === 'zh-CN' ? 'en' : 'zh-CN';
|
||||
setLanguage(newLanguage);
|
||||
const currentIndex = LANGUAGE_ORDER.indexOf(language);
|
||||
const nextLanguage = LANGUAGE_ORDER[(currentIndex + 1) % LANGUAGE_ORDER.length];
|
||||
setLanguage(nextLanguage);
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY_LANGUAGE
|
||||
name: STORAGE_KEY_LANGUAGE,
|
||||
merge: (persistedState, currentState) => {
|
||||
const nextLanguage = (persistedState as Partial<LanguageState>)?.language;
|
||||
if (typeof nextLanguage === 'string' && isSupportedLanguage(nextLanguage)) {
|
||||
return {
|
||||
...currentState,
|
||||
...(persistedState as Partial<LanguageState>),
|
||||
language: nextLanguage
|
||||
};
|
||||
}
|
||||
return currentState;
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -52,8 +52,9 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
});
|
||||
|
||||
return list;
|
||||
} catch (error: any) {
|
||||
const message = error?.message || 'Failed to fetch models';
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to fetch models';
|
||||
set({
|
||||
error: message,
|
||||
loading: false,
|
||||
|
||||
@@ -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: {}
|
||||
})
|
||||
|
||||
@@ -190,6 +190,67 @@
|
||||
gap: $spacing-xs;
|
||||
flex-shrink: 0;
|
||||
|
||||
.language-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
.language-menu-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: $z-dropdown;
|
||||
min-width: 164px;
|
||||
padding: $spacing-xs;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.language-menu-option {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: $radius-sm;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.language-menu-check {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.language-menu-popover {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
@@ -387,27 +448,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.footer-version {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: $spacing-lg;
|
||||
|
||||
@@ -17,8 +17,8 @@ export interface ApiClientConfig {
|
||||
export interface RequestOptions {
|
||||
method?: HttpMethod;
|
||||
headers?: Record<string, string>;
|
||||
params?: Record<string, any>;
|
||||
data?: any;
|
||||
params?: Record<string, unknown>;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
// 服务器版本信息
|
||||
@@ -31,6 +31,6 @@ export interface ServerVersion {
|
||||
export type ApiError = Error & {
|
||||
status?: number;
|
||||
code?: string;
|
||||
details?: any;
|
||||
data?: any;
|
||||
details?: unknown;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
export type AuthFileType =
|
||||
| 'qwen'
|
||||
| 'kimi'
|
||||
| 'gemini'
|
||||
| 'gemini-cli'
|
||||
| 'aistudio'
|
||||
@@ -25,7 +26,7 @@ export interface AuthFileItem {
|
||||
runtimeOnly?: boolean | string;
|
||||
disabled?: boolean;
|
||||
modified?: number;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AuthFilesResponse {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'auto';
|
||||
|
||||
export type Language = 'zh-CN' | 'en';
|
||||
export type Language = 'zh-CN' | 'en' | 'ru';
|
||||
|
||||
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface Notification {
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
export interface ApiResponse<T = unknown> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface Config {
|
||||
vertexApiKeys?: ProviderKeyConfig[];
|
||||
openaiCompatibility?: OpenAIProviderConfig[];
|
||||
oauthExcludedModels?: Record<string, string[]>;
|
||||
raw?: Record<string, any>;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type RawConfigSection =
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
details?: any;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
// 日志筛选
|
||||
|
||||
@@ -9,6 +9,7 @@ export type OAuthProvider =
|
||||
| 'anthropic'
|
||||
| 'antigravity'
|
||||
| 'gemini-cli'
|
||||
| 'kimi'
|
||||
| 'qwen';
|
||||
|
||||
// OAuth 流程状态
|
||||
|
||||
@@ -43,5 +43,5 @@ export interface OpenAIProviderConfig {
|
||||
models?: ModelAlias[];
|
||||
priority?: number;
|
||||
testModel?: string;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,7 +10,7 @@ export type PayloadParamEntry = {
|
||||
export type PayloadModelEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
protocol?: 'openai' | 'gemini' | 'claude' | 'codex' | 'antigravity';
|
||||
protocol?: 'openai' | 'openai-response' | 'gemini' | 'claude' | 'codex' | 'antigravity';
|
||||
};
|
||||
|
||||
export type PayloadRule = {
|
||||
@@ -48,7 +48,6 @@ export type VisualConfigValues = {
|
||||
loggingToFile: boolean;
|
||||
logsMaxTotalSizeMb: string;
|
||||
usageStatisticsEnabled: boolean;
|
||||
usageRecordsRetentionDays: string;
|
||||
proxyUrl: string;
|
||||
forceModelPrefix: boolean;
|
||||
requestRetry: string;
|
||||
@@ -85,7 +84,6 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
loggingToFile: false,
|
||||
logsMaxTotalSizeMb: '',
|
||||
usageStatisticsEnabled: false,
|
||||
usageRecordsRetentionDays: '',
|
||||
proxyUrl: '',
|
||||
forceModelPrefix: false,
|
||||
requestRetry: '',
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
* 从原项目 src/utils/constants.js 迁移
|
||||
*/
|
||||
|
||||
import type { Language } from '@/types';
|
||||
|
||||
const defineLanguageOrder = <T extends readonly Language[]>(
|
||||
languages: T & ([Language] extends [T[number]] ? unknown : never)
|
||||
) => languages;
|
||||
|
||||
// 缓存过期时间(毫秒)
|
||||
export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管理端压力
|
||||
|
||||
@@ -33,6 +39,15 @@ export const STORAGE_KEY_LANGUAGE = 'cli-proxy-language';
|
||||
export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
|
||||
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
|
||||
|
||||
// 语言配置
|
||||
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const);
|
||||
export const LANGUAGE_LABEL_KEYS: Record<Language, string> = {
|
||||
'zh-CN': 'language.chinese',
|
||||
en: 'language.english',
|
||||
ru: 'language.russian'
|
||||
};
|
||||
export const SUPPORTED_LANGUAGES = LANGUAGE_ORDER;
|
||||
|
||||
// 通知持续时间
|
||||
export const NOTIFICATION_DURATION_MS = 3000;
|
||||
|
||||
@@ -42,6 +57,7 @@ export const OAUTH_CARD_IDS = [
|
||||
'anthropic-oauth-card',
|
||||
'antigravity-oauth-card',
|
||||
'gemini-cli-oauth-card',
|
||||
'kimi-oauth-card',
|
||||
'qwen-oauth-card'
|
||||
];
|
||||
export const OAUTH_PROVIDERS = {
|
||||
@@ -49,6 +65,7 @@ export const OAUTH_PROVIDERS = {
|
||||
ANTHROPIC: 'anthropic',
|
||||
ANTIGRAVITY: 'antigravity',
|
||||
GEMINI_CLI: 'gemini-cli',
|
||||
KIMI: 'kimi',
|
||||
QWEN: 'qwen'
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -15,13 +15,13 @@ export function normalizeArrayResponse<T>(data: T | T[] | null | undefined): T[]
|
||||
/**
|
||||
* 防抖函数
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
export function debounce<This, Args extends unknown[], Return>(
|
||||
func: (this: This, ...args: Args) => Return,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
): (this: This, ...args: Args) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
return function (this: This, ...args: Args) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func.apply(this, args), delay);
|
||||
};
|
||||
@@ -30,13 +30,13 @@ export function debounce<T extends (...args: any[]) => any>(
|
||||
/**
|
||||
* 节流函数
|
||||
*/
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
export function throttle<This, Args extends unknown[], Return>(
|
||||
func: (this: This, ...args: Args) => Return,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
): (this: This, ...args: Args) => void {
|
||||
let inThrottle: boolean;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
return function (this: This, ...args: Args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
@@ -67,16 +67,17 @@ export function generateId(): string {
|
||||
export function deepClone<T>(obj: T): T {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as any;
|
||||
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as any;
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as unknown as T;
|
||||
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as unknown as T;
|
||||
|
||||
const clonedObj = {} as T;
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
clonedObj[key] = deepClone((obj as any)[key]);
|
||||
const source = obj as Record<string, unknown>;
|
||||
const cloned: Record<string, unknown> = {};
|
||||
for (const key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
cloned[key] = deepClone(source[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
return cloned as unknown as T;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants';
|
||||
|
||||
export const isSupportedLanguage = (value: string): value is Language =>
|
||||
SUPPORTED_LANGUAGES.includes(value as Language);
|
||||
|
||||
const parseStoredLanguage = (value: string): Language | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const candidate = parsed?.state?.language ?? parsed?.language ?? parsed;
|
||||
if (candidate === 'zh-CN' || candidate === 'en') {
|
||||
if (typeof candidate === 'string' && isSupportedLanguage(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
if (value === 'zh-CN' || value === 'en') {
|
||||
if (isSupportedLanguage(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -36,7 +39,10 @@ const getBrowserLanguage = (): Language => {
|
||||
return 'zh-CN';
|
||||
}
|
||||
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
|
||||
return raw.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
|
||||
const lower = raw.toLowerCase();
|
||||
if (lower.startsWith('zh')) return 'zh-CN';
|
||||
if (lower.startsWith('ru')) return 'ru';
|
||||
return 'en';
|
||||
};
|
||||
|
||||
export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage();
|
||||
|
||||
@@ -30,12 +30,15 @@ const matchCategory = (text: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export function normalizeModelList(payload: any, { dedupe = false } = {}): ModelInfo[] {
|
||||
const toModel = (entry: any): ModelInfo | null => {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
export function normalizeModelList(payload: unknown, { dedupe = false } = {}): ModelInfo[] {
|
||||
const toModel = (entry: unknown): ModelInfo | null => {
|
||||
if (typeof entry === 'string') {
|
||||
return { name: entry };
|
||||
}
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
if (!isRecord(entry)) {
|
||||
return null;
|
||||
}
|
||||
const name = entry.id || entry.name || entry.model || entry.value;
|
||||
@@ -57,7 +60,7 @@ export function normalizeModelList(payload: any, { dedupe = false } = {}): Model
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
models = payload.map(toModel);
|
||||
} else if (payload && typeof payload === 'object') {
|
||||
} else if (isRecord(payload)) {
|
||||
if (Array.isArray(payload.data)) {
|
||||
models = payload.data.map(toModel);
|
||||
} else if (Array.isArray(payload.models)) {
|
||||
|
||||
@@ -10,7 +10,11 @@ import type {
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState,
|
||||
} from '@/types';
|
||||
import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants';
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_GROUPS,
|
||||
GEMINI_CLI_GROUP_LOOKUP,
|
||||
GEMINI_CLI_GROUP_ORDER,
|
||||
} from './constants';
|
||||
import { normalizeQuotaFraction } from './parsers';
|
||||
import { isIgnoredGeminiCliModel } from './validators';
|
||||
|
||||
@@ -92,24 +96,40 @@ export function buildGeminiCliQuotaBuckets(
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(grouped.values()).map((bucket) => {
|
||||
const uniqueModelIds = Array.from(new Set(bucket.modelIds));
|
||||
const preferred = bucket.preferredBucket;
|
||||
const remainingFraction = preferred
|
||||
? preferred.remainingFraction
|
||||
: bucket.fallbackRemainingFraction;
|
||||
const remainingAmount = preferred ? preferred.remainingAmount : bucket.fallbackRemainingAmount;
|
||||
const resetTime = preferred ? preferred.resetTime : bucket.fallbackResetTime;
|
||||
return {
|
||||
id: bucket.id,
|
||||
label: bucket.label,
|
||||
remainingFraction,
|
||||
remainingAmount,
|
||||
resetTime,
|
||||
tokenType: bucket.tokenType,
|
||||
modelIds: uniqueModelIds,
|
||||
};
|
||||
});
|
||||
const toGroupOrder = (bucket: GeminiCliQuotaBucketGroup): number => {
|
||||
const tokenSuffix = bucket.tokenType ? `-${bucket.tokenType}` : '';
|
||||
const groupId = bucket.id.endsWith(tokenSuffix)
|
||||
? bucket.id.slice(0, bucket.id.length - tokenSuffix.length)
|
||||
: bucket.id;
|
||||
return GEMINI_CLI_GROUP_ORDER.get(groupId) ?? Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.sort((a, b) => {
|
||||
const orderDiff = toGroupOrder(a) - toGroupOrder(b);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
const tokenTypeA = a.tokenType ?? '';
|
||||
const tokenTypeB = b.tokenType ?? '';
|
||||
return tokenTypeA.localeCompare(tokenTypeB);
|
||||
})
|
||||
.map((bucket) => {
|
||||
const uniqueModelIds = Array.from(new Set(bucket.modelIds));
|
||||
const preferred = bucket.preferredBucket;
|
||||
const remainingFraction = preferred
|
||||
? preferred.remainingFraction
|
||||
: bucket.fallbackRemainingFraction;
|
||||
const remainingAmount = preferred ? preferred.remainingAmount : bucket.fallbackRemainingAmount;
|
||||
const resetTime = preferred ? preferred.resetTime : bucket.fallbackResetTime;
|
||||
return {
|
||||
id: bucket.id,
|
||||
label: bucket.label,
|
||||
remainingFraction,
|
||||
remainingAmount,
|
||||
resetTime,
|
||||
tokenType: bucket.tokenType,
|
||||
modelIds: uniqueModelIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
||||
|
||||
@@ -119,11 +119,17 @@ export const GEMINI_CLI_REQUEST_HEADERS = {
|
||||
};
|
||||
|
||||
export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
||||
{
|
||||
id: 'gemini-flash-lite-series',
|
||||
label: 'Gemini Flash Lite Series',
|
||||
preferredModelId: 'gemini-2.5-flash-lite',
|
||||
modelIds: ['gemini-2.5-flash-lite'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-flash-series',
|
||||
label: 'Gemini Flash Series',
|
||||
preferredModelId: 'gemini-3-flash-preview',
|
||||
modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'],
|
||||
modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-pro-series',
|
||||
@@ -133,6 +139,10 @@ export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const GEMINI_CLI_GROUP_ORDER = new Map(
|
||||
GEMINI_CLI_QUOTA_GROUPS.map((group, index) => [group.id, index] as const)
|
||||
);
|
||||
|
||||
export const GEMINI_CLI_GROUP_LOOKUP = new Map(
|
||||
GEMINI_CLI_QUOTA_GROUPS.flatMap((group) =>
|
||||
group.modelIds.map((modelId) => [modelId, group] as const)
|
||||
@@ -141,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,9 @@
|
||||
* 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';
|
||||
|
||||
export function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -26,6 +28,15 @@ export function normalizeStringValue(value: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeGeminiCliModelId(value: unknown): string | null {
|
||||
const modelId = normalizeStringValue(value);
|
||||
if (!modelId) return null;
|
||||
if (modelId.endsWith(GEMINI_CLI_MODEL_SUFFIX)) {
|
||||
return modelId.slice(0, -GEMINI_CLI_MODEL_SUFFIX.length);
|
||||
}
|
||||
return modelId;
|
||||
}
|
||||
|
||||
export function normalizeNumberValue(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
@@ -118,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';
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user