mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3446280987 | ||
|
|
82bf1806ed | ||
|
|
47f0042bf0 | ||
|
|
58154063ed | ||
|
|
cc467889d0 | ||
|
|
469e5d2ed4 | ||
|
|
6ce301d7e0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,6 +12,7 @@ CLAUDE.md
|
||||
AGENTS.md
|
||||
antigravity_usage.json
|
||||
codex_usage.json
|
||||
style.md
|
||||
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
will-change: transform, opacity;
|
||||
|
||||
// During animation, exit layer uses absolute positioning
|
||||
&--exit {
|
||||
@@ -26,6 +25,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--animating &__layer {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
// When both layers exist, current layer also needs positioning
|
||||
&--animating &__layer:not(&__layer--exit) {
|
||||
position: relative;
|
||||
|
||||
@@ -131,6 +131,7 @@ export function PageTransition({
|
||||
opacity: 1,
|
||||
duration: TRANSITION_DURATION,
|
||||
ease: 'power2.in', // slow start, fast end
|
||||
clearProps: 'transform,opacity',
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
264
src/components/providers/AmpcodeSection/AmpcodeModal.tsx
Normal file
264
src/components/providers/AmpcodeSection/AmpcodeModal.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
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 } = 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 () => {
|
||||
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
|
||||
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 saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
111
src/components/providers/AmpcodeSection/AmpcodeSection.tsx
Normal file
111
src/components/providers/AmpcodeSection/AmpcodeSection.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import iconAmp from '@/assets/icons/amp.svg';
|
||||
import type { AmpcodeConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AmpcodeModal } from './AmpcodeModal';
|
||||
|
||||
interface AmpcodeSectionProps {
|
||||
config: AmpcodeConfig | null | undefined;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
isBusy: boolean;
|
||||
isModalOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onCloseModal: () => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function AmpcodeSection({
|
||||
config,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
isBusy,
|
||||
isModalOpen,
|
||||
onOpen,
|
||||
onCloseModal,
|
||||
onBusyChange,
|
||||
}: AmpcodeSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={iconAmp} alt="" className={styles.cardTitleIcon} />
|
||||
{t('ai_providers.ampcode_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpen}
|
||||
disabled={disableControls || isSaving || isBusy || isSwitching}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_url_label')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.upstreamUrl || t('common.not_set')}</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>
|
||||
{t('ai_providers.ampcode_upstream_api_key_label')}:
|
||||
</span>
|
||||
<span className={styles.fieldValue}>
|
||||
{config?.upstreamApiKey ? maskApiKey(config.upstreamApiKey) : t('common.not_set')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>
|
||||
{t('ai_providers.ampcode_force_model_mappings_label')}:
|
||||
</span>
|
||||
<span className={styles.fieldValue}>
|
||||
{(config?.forceModelMappings ?? false) ? t('common.yes') : t('common.no')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow} style={{ marginTop: 8 }}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.modelMappings?.length || 0}</span>
|
||||
</div>
|
||||
{config?.modelMappings?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{config.modelMappings.slice(0, 5).map((mapping) => (
|
||||
<span key={`${mapping.from}→${mapping.to}`} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{mapping.from}</span>
|
||||
<span className={styles.modelAlias}>{mapping.to}</span>
|
||||
</span>
|
||||
))}
|
||||
{config.modelMappings.length > 5 && (
|
||||
<span className={styles.modelTag}>
|
||||
<span className={styles.modelName}>+{config.modelMappings.length - 5}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AmpcodeModal
|
||||
isOpen={isModalOpen}
|
||||
disableControls={disableControls}
|
||||
onClose={onCloseModal}
|
||||
onBusyChange={onBusyChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/AmpcodeSection/index.ts
Normal file
1
src/components/providers/AmpcodeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AmpcodeSection } from './AmpcodeSection';
|
||||
128
src/components/providers/ClaudeSection/ClaudeModal.tsx
Normal file
128
src/components/providers/ClaudeSection/ClaudeModal.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
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, modelsToEntries } from '@/components/ui/ModelInputList';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { buildHeaderObject, 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: 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={headersToEntries(form.headers)}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(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>
|
||||
);
|
||||
}
|
||||
202
src/components/providers/ClaudeSection/ClaudeSection.tsx
Normal file
202
src/components/providers/ClaudeSection/ClaudeSection.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import type { ProviderFormState } from '../types';
|
||||
import { ClaudeModal } from './ClaudeModal';
|
||||
|
||||
interface ClaudeSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
isModalOpen: boolean;
|
||||
modalIndex: number | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ClaudeSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: ClaudeSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
});
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={iconClaude} alt="" className={styles.cardTitleIcon} />
|
||||
{t('ai_providers.claude_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.claude_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
emptyTitle={t('ai_providers.claude_empty_title')}
|
||||
emptyDescription={t('ai_providers.claude_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.config_toggle_label')}
|
||||
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||
disabled={toggleDisabled}
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">{t('ai_providers.claude_item_title')}</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||
</div>
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.baseUrl && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.proxyUrl && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.proxyUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<span key={key} className={styles.headerBadge}>
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{configDisabled && (
|
||||
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
<span className={styles.modelCountLabel}>
|
||||
{t('ai_providers.claude_models_count')}: {item.models.length}
|
||||
</span>
|
||||
{item.models.map((model) => (
|
||||
<span key={model.name} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && model.alias !== model.name && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||
</div>
|
||||
<div className={styles.modelTagList}>
|
||||
{excludedModels.map((model) => (
|
||||
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
</span>
|
||||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} />
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ClaudeModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/ClaudeSection/index.ts
Normal file
1
src/components/providers/ClaudeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ClaudeSection } from './ClaudeSection';
|
||||
117
src/components/providers/CodexSection/CodexModal.tsx
Normal file
117
src/components/providers/CodexSection/CodexModal.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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 { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import { modelsToEntries } from '@/components/ui/ModelInputList';
|
||||
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: 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={headersToEntries(form.headers)}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(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>
|
||||
);
|
||||
}
|
||||
194
src/components/providers/CodexSection/CodexSection.tsx
Normal file
194
src/components/providers/CodexSection/CodexSection.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import type { ProviderFormState } from '../types';
|
||||
import { CodexModal } from './CodexModal';
|
||||
|
||||
interface CodexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
resolvedTheme: string;
|
||||
isModalOpen: boolean;
|
||||
modalIndex: number | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CodexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
resolvedTheme,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: CodexSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
});
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t('ai_providers.codex_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.codex_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
emptyTitle={t('ai_providers.codex_empty_title')}
|
||||
emptyDescription={t('ai_providers.codex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.config_toggle_label')}
|
||||
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||
disabled={toggleDisabled}
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">{t('ai_providers.codex_item_title')}</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||
</div>
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.baseUrl && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.proxyUrl && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.proxyUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<span key={key} className={styles.headerBadge}>
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{configDisabled && (
|
||||
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||
</div>
|
||||
<div className={styles.modelTagList}>
|
||||
{excludedModels.map((model) => (
|
||||
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
</span>
|
||||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} />
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<CodexModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/CodexSection/index.ts
Normal file
1
src/components/providers/CodexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CodexSection } from './CodexSection';
|
||||
113
src/components/providers/GeminiSection/GeminiModal.tsx
Normal file
113
src/components/providers/GeminiSection/GeminiModal.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
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 { buildHeaderObject, 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: 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={headersToEntries(form.headers)}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(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>
|
||||
);
|
||||
}
|
||||
183
src/components/providers/GeminiSection/GeminiSection.tsx
Normal file
183
src/components/providers/GeminiSection/GeminiSection.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import type { GeminiFormState } from '../types';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import { GeminiModal } from './GeminiModal';
|
||||
|
||||
interface GeminiSectionProps {
|
||||
configs: GeminiKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
isModalOpen: boolean;
|
||||
modalIndex: number | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function GeminiSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: GeminiSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
});
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={iconGemini} alt="" className={styles.cardTitleIcon} />
|
||||
{t('ai_providers.gemini_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.gemini_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<GeminiKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
emptyTitle={t('ai_providers.gemini_empty_title')}
|
||||
emptyDescription={t('ai_providers.gemini_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.config_toggle_label')}
|
||||
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||
disabled={toggleDisabled}
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">
|
||||
{t('ai_providers.gemini_item_title')} #{index + 1}
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||
</div>
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.baseUrl && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<span key={key} className={styles.headerBadge}>
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{configDisabled && (
|
||||
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||
</div>
|
||||
<div className={styles.modelTagList}>
|
||||
{excludedModels.map((model) => (
|
||||
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
</span>
|
||||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} />
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<GeminiModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/GeminiSection/index.ts
Normal file
1
src/components/providers/GeminiSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GeminiSection } from './GeminiSection';
|
||||
194
src/components/providers/OpenAISection/OpenAIDiscoveryModal.tsx
Normal file
194
src/components/providers/OpenAISection/OpenAIDiscoveryModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
432
src/components/providers/OpenAISection/OpenAIModal.tsx
Normal file
432
src/components/providers/OpenAISection/OpenAIModal.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
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, modelsToEntries } from '@/components/ui/ModelInputList';
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
206
src/components/providers/OpenAISection/OpenAISection.tsx
Normal file
206
src/components/providers/OpenAISection/OpenAISection.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||
import type { OpenAIFormState } from '../types';
|
||||
import { OpenAIModal } from './OpenAIModal';
|
||||
|
||||
interface OpenAISectionProps {
|
||||
configs: OpenAIProviderConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
resolvedTheme: string;
|
||||
isModalOpen: boolean;
|
||||
modalIndex: number | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function OpenAISection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
resolvedTheme,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: OpenAISectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((provider) => {
|
||||
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean);
|
||||
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t('ai_providers.openai_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.openai_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<OpenAIProviderConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.name}
|
||||
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item) => {
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const apiKeyEntries = item.apiKeyEntries || [];
|
||||
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">{item.name}</div>
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||
</div>
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<span key={key} className={styles.headerBadge}>
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{apiKeyEntries.length > 0 && (
|
||||
<div className={styles.apiKeyEntriesSection}>
|
||||
<div className={styles.apiKeyEntriesLabel}>
|
||||
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey);
|
||||
return (
|
||||
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||
{entry.proxyUrl && (
|
||||
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
|
||||
)}
|
||||
<div className={styles.apiKeyEntryStats}>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
|
||||
>
|
||||
<IconCheck size={12} /> {entryStats.success}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
|
||||
>
|
||||
<IconX size={12} /> {entryStats.failure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
|
||||
<span className={styles.fieldValue}>{item.models?.length || 0}</span>
|
||||
</div>
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{item.models.map((model) => (
|
||||
<span key={model.name} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && model.alias !== model.name && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{item.testModel && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Test Model:</span>
|
||||
<span className={styles.fieldValue}>{item.testModel}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
</span>
|
||||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} />
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<OpenAIModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/OpenAISection/index.ts
Normal file
1
src/components/providers/OpenAISection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { OpenAISection } from './OpenAISection';
|
||||
80
src/components/providers/ProviderList.tsx
Normal file
80
src/components/providers/ProviderList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
|
||||
interface ProviderListProps<T> {
|
||||
items: T[];
|
||||
loading: boolean;
|
||||
keyField: (item: T) => string;
|
||||
renderContent: (item: T, index: number) => ReactNode;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
deleteLabel?: string;
|
||||
actionsDisabled?: boolean;
|
||||
getRowDisabled?: (item: T, index: number) => boolean;
|
||||
renderExtraActions?: (item: T, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
export function ProviderList<T>({
|
||||
items,
|
||||
loading,
|
||||
keyField,
|
||||
renderContent,
|
||||
onEdit,
|
||||
onDelete,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
deleteLabel,
|
||||
actionsDisabled = false,
|
||||
getRowDisabled,
|
||||
renderExtraActions,
|
||||
}: ProviderListProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (loading) {
|
||||
return <div className="hint">{t('common.loading')}</div>;
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return <EmptyState title={emptyTitle} description={emptyDescription} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="item-list">
|
||||
{items.map((item, index) => {
|
||||
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
||||
return (
|
||||
<div
|
||||
key={keyField(item)}
|
||||
className="item-row"
|
||||
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
||||
>
|
||||
<div className="item-meta">{renderContent(item, index)}</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(index)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(index)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{deleteLabel || t('common.delete')}
|
||||
</Button>
|
||||
{renderExtraActions ? renderExtraActions(item, index) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/providers/ProviderStatusBar.tsx
Normal file
38
src/components/providers/ProviderStatusBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { calculateStatusBarData } from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
|
||||
interface ProviderStatusBarProps {
|
||||
statusData: ReturnType<typeof calculateStatusBarData>;
|
||||
}
|
||||
|
||||
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
|
||||
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||
const rateClass = !hasData
|
||||
? ''
|
||||
: statusData.successRate >= 90
|
||||
? styles.statusRateHigh
|
||||
: statusData.successRate >= 50
|
||||
? styles.statusRateMedium
|
||||
: styles.statusRateLow;
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
<div className={styles.statusBlocks}>
|
||||
{statusData.blocks.map((state, idx) => {
|
||||
const blockClass =
|
||||
state === 'success'
|
||||
? styles.statusBlockSuccess
|
||||
: state === 'failure'
|
||||
? styles.statusBlockFailure
|
||||
: state === 'mixed'
|
||||
? styles.statusBlockMixed
|
||||
: styles.statusBlockIdle;
|
||||
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||||
})}
|
||||
</div>
|
||||
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/providers/hooks/useProviderStats.ts
Normal file
37
src/components/providers/hooks/useProviderStats.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { usageApi } from '@/services/api';
|
||||
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
|
||||
const EMPTY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
|
||||
|
||||
export const useProviderStats = () => {
|
||||
const [keyStats, setKeyStats] = useState<KeyStats>(EMPTY_STATS);
|
||||
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||
const loadKeyStats = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const usageResponse = await usageApi.getUsage();
|
||||
const usageData = usageResponse?.usage ?? usageResponse;
|
||||
const stats = await usageApi.getKeyStats(usageData);
|
||||
setKeyStats(stats);
|
||||
setUsageDetails(collectUsageDetails(usageData));
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 定时刷新状态数据(每240秒)
|
||||
useInterval(loadKeyStats, 240_000);
|
||||
|
||||
return { keyStats, usageDetails, loadKeyStats, isLoading };
|
||||
};
|
||||
10
src/components/providers/index.ts
Normal file
10
src/components/providers/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { AmpcodeSection } from './AmpcodeSection';
|
||||
export { ClaudeSection } from './ClaudeSection';
|
||||
export { CodexSection } from './CodexSection';
|
||||
export { GeminiSection } from './GeminiSection';
|
||||
export { OpenAISection } from './OpenAISection';
|
||||
export { ProviderList } from './ProviderList';
|
||||
export { ProviderStatusBar } from './ProviderStatusBar';
|
||||
export * from './hooks/useProviderStats';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
59
src/components/providers/types.ts
Normal file
59
src/components/providers/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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: 'ampcode'; index: null }
|
||||
| { type: 'openai'; index: number | null };
|
||||
|
||||
export interface ModelEntry {
|
||||
name: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export interface OpenAIFormState {
|
||||
name: string;
|
||||
prefix: string;
|
||||
baseUrl: string;
|
||||
headers: HeaderEntry[];
|
||||
testModel?: string;
|
||||
modelEntries: ModelEntry[];
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
}
|
||||
|
||||
export interface AmpcodeFormState {
|
||||
upstreamUrl: string;
|
||||
upstreamApiKey: string;
|
||||
forceModelMappings: boolean;
|
||||
mappingEntries: ModelEntry[];
|
||||
}
|
||||
|
||||
export type GeminiFormState = GeminiKeyConfig & { excludedText: string };
|
||||
|
||||
export type ProviderFormState = ProviderKeyConfig & {
|
||||
modelEntries: ModelEntry[];
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export interface ProviderSectionProps<TConfig> {
|
||||
configs: TConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
disabled: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onAdd: () => void;
|
||||
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;
|
||||
}
|
||||
132
src/components/providers/utils.ts
Normal file
132
src/components/providers/utils.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||
import type { KeyStatBucket, KeyStats } from '@/utils/usage';
|
||||
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
|
||||
export const hasDisableAllModelsRule = (models?: string[]) =>
|
||||
Array.isArray(models) &&
|
||||
models.some((model) => String(model ?? '').trim() === DISABLE_ALL_MODELS_RULE);
|
||||
|
||||
export const stripDisableAllModelsRule = (models?: string[]) =>
|
||||
Array.isArray(models)
|
||||
? models.filter((model) => String(model ?? '').trim() !== DISABLE_ALL_MODELS_RULE)
|
||||
: [];
|
||||
|
||||
export const withDisableAllModelsRule = (models?: string[]) => {
|
||||
const base = stripDisableAllModelsRule(models);
|
||||
return [...base, DISABLE_ALL_MODELS_RULE];
|
||||
};
|
||||
|
||||
export const withoutDisableAllModelsRule = (models?: string[]) => {
|
||||
const base = stripDisableAllModelsRule(models);
|
||||
return base;
|
||||
};
|
||||
|
||||
export const parseExcludedModels = (text: string): string[] =>
|
||||
text
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export const excludedModelsToText = (models?: string[]) =>
|
||||
Array.isArray(models) ? models.join('\n') : '';
|
||||
|
||||
export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
||||
let trimmed = String(baseUrl || '').trim();
|
||||
if (!trimmed) return '';
|
||||
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
|
||||
trimmed = trimmed.replace(/\/+$/g, '');
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
trimmed = `http://${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||
};
|
||||
|
||||
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.endsWith('/chat/completions')) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`;
|
||||
};
|
||||
|
||||
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
||||
export const getStatsBySource = (
|
||||
apiKey: string,
|
||||
keyStats: KeyStats,
|
||||
maskFn: (key: string) => string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
const masked = maskFn(apiKey);
|
||||
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 };
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||
keyStats: KeyStats,
|
||||
maskFn: (key: string) => string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
let totalSuccess = 0;
|
||||
let totalFailure = 0;
|
||||
|
||||
(apiKeyEntries || []).forEach((entry) => {
|
||||
const key = entry?.apiKey || '';
|
||||
if (!key) return;
|
||||
const masked = maskFn(key);
|
||||
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
|
||||
totalSuccess += stats.success;
|
||||
totalFailure += stats.failure;
|
||||
});
|
||||
|
||||
return { success: totalSuccess, failure: totalFailure };
|
||||
};
|
||||
|
||||
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||
apiKey: input?.apiKey ?? '',
|
||||
proxyUrl: input?.proxyUrl ?? '',
|
||||
headers: input?.headers ?? {},
|
||||
});
|
||||
|
||||
export const ampcodeMappingsToEntries = (mappings?: AmpcodeModelMapping[]): ModelEntry[] => {
|
||||
if (!Array.isArray(mappings) || mappings.length === 0) {
|
||||
return [{ name: '', alias: '' }];
|
||||
}
|
||||
return mappings.map((mapping) => ({
|
||||
name: mapping.from ?? '',
|
||||
alias: mapping.to ?? '',
|
||||
}));
|
||||
};
|
||||
|
||||
export const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[] => {
|
||||
const seen = new Set<string>();
|
||||
const mappings: AmpcodeModelMapping[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const from = entry.name.trim();
|
||||
const to = entry.alias.trim();
|
||||
if (!from || !to) return;
|
||||
const key = from.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
mappings.push({ from, to });
|
||||
});
|
||||
|
||||
return mappings;
|
||||
};
|
||||
|
||||
export const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
||||
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
||||
upstreamApiKey: '',
|
||||
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
||||
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
|
||||
});
|
||||
145
src/components/quota/QuotaCard.tsx
Normal file
145
src/components/quota/QuotaCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Generic quota card component.
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
|
||||
import { TYPE_COLORS } from '@/utils/quota';
|
||||
import styles from '@/pages/QuotaPage.module.scss';
|
||||
|
||||
type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
export interface QuotaStatusState {
|
||||
status: QuotaStatus;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
export interface QuotaProgressBarProps {
|
||||
percent: number | null;
|
||||
highThreshold: number;
|
||||
mediumThreshold: number;
|
||||
}
|
||||
|
||||
export function QuotaProgressBar({
|
||||
percent,
|
||||
highThreshold,
|
||||
mediumThreshold
|
||||
}: QuotaProgressBarProps) {
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
const normalized = percent === null ? null : clamp(percent, 0, 100);
|
||||
const fillClass =
|
||||
normalized === null
|
||||
? styles.quotaBarFillMedium
|
||||
: normalized >= highThreshold
|
||||
? styles.quotaBarFillHigh
|
||||
: normalized >= mediumThreshold
|
||||
? styles.quotaBarFillMedium
|
||||
: styles.quotaBarFillLow;
|
||||
const widthPercent = Math.round(normalized ?? 0);
|
||||
|
||||
return (
|
||||
<div className={styles.quotaBar}>
|
||||
<div
|
||||
className={`${styles.quotaBarFill} ${fillClass}`}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface QuotaRenderHelpers {
|
||||
styles: typeof styles;
|
||||
QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement;
|
||||
}
|
||||
|
||||
interface QuotaCardProps<TState extends QuotaStatusState> {
|
||||
item: AuthFileItem;
|
||||
quota?: TState;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
i18nPrefix: string;
|
||||
cardClassName: string;
|
||||
defaultType: string;
|
||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||
}
|
||||
|
||||
export function QuotaCard<TState extends QuotaStatusState>({
|
||||
item,
|
||||
quota,
|
||||
resolvedTheme,
|
||||
i18nPrefix,
|
||||
cardClassName,
|
||||
defaultType,
|
||||
renderQuotaItems
|
||||
}: QuotaCardProps<TState>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const displayType = item.type || item.provider || defaultType;
|
||||
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
|
||||
const typeColor: ThemeColors =
|
||||
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
|
||||
|
||||
const quotaStatus = quota?.status ?? 'idle';
|
||||
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||
t,
|
||||
quota?.errorStatus,
|
||||
quota?.error || t('common.unknown_error')
|
||||
);
|
||||
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const key = `auth_files.filter_${type}`;
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.fileCard} ${cardClassName}`}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span
|
||||
className={styles.typeBadge}
|
||||
style={{
|
||||
backgroundColor: typeColor.bg,
|
||||
color: typeColor.text,
|
||||
...(typeColor.border ? { border: typeColor.border } : {})
|
||||
}}
|
||||
>
|
||||
{getTypeLabel(displayType)}
|
||||
</span>
|
||||
<span className={styles.fileName}>{item.name}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.quotaSection}>
|
||||
{quotaStatus === 'loading' ? (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
|
||||
) : quotaStatus === 'idle' ? (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||
) : quotaStatus === 'error' ? (
|
||||
<div className={styles.quotaError}>
|
||||
{t(`${i18nPrefix}.load_failed`, {
|
||||
message: quotaErrorMessage
|
||||
})}
|
||||
</div>
|
||||
) : quota ? (
|
||||
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
|
||||
) : (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolveQuotaErrorMessage = (
|
||||
t: TFunction,
|
||||
status: number | undefined,
|
||||
fallback: string
|
||||
): string => {
|
||||
if (status === 404) return t('common.quota_update_required');
|
||||
if (status === 403) return t('common.quota_check_credential');
|
||||
return fallback;
|
||||
};
|
||||
258
src/components/quota/QuotaSection.tsx
Normal file
258
src/components/quota/QuotaSection.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Generic quota section component.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useQuotaStore, useThemeStore } from '@/stores';
|
||||
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
||||
import { QuotaCard } from './QuotaCard';
|
||||
import type { QuotaStatusState } from './QuotaCard';
|
||||
import { useQuotaLoader } from './useQuotaLoader';
|
||||
import type { QuotaConfig } from './quotaConfigs';
|
||||
import styles from '@/pages/QuotaPage.module.scss';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||
|
||||
const MIN_CARD_PAGE_SIZE = 3;
|
||||
const MAX_CARD_PAGE_SIZE = 30;
|
||||
|
||||
const clampCardPageSize = (value: number) =>
|
||||
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||
|
||||
interface QuotaPaginationState<T> {
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
pageItems: T[];
|
||||
setPageSize: (size: number) => void;
|
||||
goToPrev: () => void;
|
||||
goToNext: () => void;
|
||||
loading: boolean;
|
||||
loadingScope: 'page' | 'all' | null;
|
||||
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
|
||||
}
|
||||
|
||||
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSizeState] = useState(() => clampCardPageSize(defaultPageSize));
|
||||
const [loading, setLoadingState] = useState(false);
|
||||
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(items.length / pageSize)),
|
||||
[items.length, pageSize]
|
||||
);
|
||||
|
||||
const currentPage = useMemo(() => Math.min(page, totalPages), [page, totalPages]);
|
||||
|
||||
const pageItems = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return items.slice(start, start + pageSize);
|
||||
}, [items, currentPage, pageSize]);
|
||||
|
||||
const setPageSize = useCallback((size: number) => {
|
||||
setPageSizeState(clampCardPageSize(size));
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const goToPrev = useCallback(() => {
|
||||
setPage((prev) => Math.max(1, prev - 1));
|
||||
}, []);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
setPage((prev) => Math.min(totalPages, prev + 1));
|
||||
}, [totalPages]);
|
||||
|
||||
const setLoading = useCallback((isLoading: boolean, scope?: 'page' | 'all' | null) => {
|
||||
setLoadingState(isLoading);
|
||||
setLoadingScope(isLoading ? (scope ?? null) : null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pageSize,
|
||||
totalPages,
|
||||
currentPage,
|
||||
pageItems,
|
||||
setPageSize,
|
||||
goToPrev,
|
||||
goToNext,
|
||||
loading,
|
||||
loadingScope,
|
||||
setLoading
|
||||
};
|
||||
};
|
||||
|
||||
interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
|
||||
config: QuotaConfig<TState, TData>;
|
||||
files: AuthFileItem[];
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
config,
|
||||
files,
|
||||
loading,
|
||||
disabled
|
||||
}: QuotaSectionProps<TState, TData>) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||
Record<string, TState>
|
||||
>;
|
||||
|
||||
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
|
||||
files,
|
||||
config.filterFn
|
||||
]);
|
||||
|
||||
const {
|
||||
pageSize,
|
||||
totalPages,
|
||||
currentPage,
|
||||
pageItems,
|
||||
setPageSize,
|
||||
goToPrev,
|
||||
goToNext,
|
||||
loading: sectionLoading,
|
||||
loadingScope,
|
||||
setLoading
|
||||
} = useQuotaPagination(filteredFiles);
|
||||
|
||||
const { quota, loadQuota } = useQuotaLoader(config);
|
||||
|
||||
const handleRefreshPage = useCallback(() => {
|
||||
loadQuota(pageItems, 'page', setLoading);
|
||||
}, [loadQuota, pageItems, setLoading]);
|
||||
|
||||
const handleRefreshAll = useCallback(() => {
|
||||
loadQuota(filteredFiles, 'all', setLoading);
|
||||
}, [loadQuota, filteredFiles, setLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (filteredFiles.length === 0) {
|
||||
setQuota({});
|
||||
return;
|
||||
}
|
||||
setQuota((prev) => {
|
||||
const nextState: Record<string, TState> = {};
|
||||
filteredFiles.forEach((file) => {
|
||||
const cached = prev[file.name];
|
||||
if (cached) {
|
||||
nextState[file.name] = cached;
|
||||
}
|
||||
});
|
||||
return nextState;
|
||||
});
|
||||
}, [filteredFiles, loading, setQuota]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t(`${config.i18nPrefix}.title`)}
|
||||
extra={
|
||||
<div className={styles.headerActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefreshPage}
|
||||
disabled={disabled || sectionLoading || pageItems.length === 0}
|
||||
loading={sectionLoading && loadingScope === 'page'}
|
||||
>
|
||||
{t(`${config.i18nPrefix}.refresh_button`)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefreshAll}
|
||||
disabled={disabled || sectionLoading || filteredFiles.length === 0}
|
||||
loading={sectionLoading && loadingScope === 'all'}
|
||||
>
|
||||
{t(`${config.i18nPrefix}.fetch_all`)}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{filteredFiles.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t(`${config.i18nPrefix}.empty_title`)}
|
||||
description={t(`${config.i18nPrefix}.empty_desc`)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={config.controlsClassName}>
|
||||
<div className={config.controlClassName}>
|
||||
<label>{t('auth_files.page_size_label')}</label>
|
||||
<input
|
||||
className={styles.pageSizeSelect}
|
||||
type="number"
|
||||
min={MIN_CARD_PAGE_SIZE}
|
||||
max={MAX_CARD_PAGE_SIZE}
|
||||
step={1}
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.valueAsNumber;
|
||||
if (!Number.isFinite(value)) return;
|
||||
setPageSize(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={config.controlClassName}>
|
||||
<label>{t('common.info')}</label>
|
||||
<div className={styles.statsInfo}>
|
||||
{filteredFiles.length} {t('auth_files.files_count')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={config.gridClassName}>
|
||||
{pageItems.map((item) => (
|
||||
<QuotaCard
|
||||
key={item.name}
|
||||
item={item}
|
||||
quota={quota[item.name]}
|
||||
resolvedTheme={resolvedTheme}
|
||||
i18nPrefix={config.i18nPrefix}
|
||||
cardClassName={config.cardClassName}
|
||||
defaultType={config.type}
|
||||
renderQuotaItems={config.renderQuotaItems}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filteredFiles.length > pageSize && (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={goToPrev}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
{t('auth_files.pagination_prev')}
|
||||
</Button>
|
||||
<div className={styles.pageInfo}>
|
||||
{t('auth_files.pagination_info', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
count: filteredFiles.length
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={goToNext}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
{t('auth_files.pagination_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
9
src/components/quota/index.ts
Normal file
9
src/components/quota/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Quota components barrel export.
|
||||
*/
|
||||
|
||||
export { QuotaSection } from './QuotaSection';
|
||||
export { QuotaCard } from './QuotaCard';
|
||||
export { useQuotaLoader } from './useQuotaLoader';
|
||||
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
||||
export type { QuotaConfig } from './quotaConfigs';
|
||||
553
src/components/quota/quotaConfigs.ts
Normal file
553
src/components/quota/quotaConfigs.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* Quota configuration definitions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type {
|
||||
AntigravityQuotaGroup,
|
||||
AntigravityModelsPayload,
|
||||
AntigravityQuotaState,
|
||||
AuthFileItem,
|
||||
CodexQuotaState,
|
||||
CodexUsageWindow,
|
||||
CodexQuotaWindow,
|
||||
CodexUsagePayload,
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState,
|
||||
GeminiCliQuotaState
|
||||
} from '@/types';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_URLS,
|
||||
ANTIGRAVITY_REQUEST_HEADERS,
|
||||
CODEX_USAGE_URL,
|
||||
CODEX_REQUEST_HEADERS,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
GEMINI_CLI_REQUEST_HEADERS,
|
||||
normalizeAuthIndexValue,
|
||||
normalizeNumberValue,
|
||||
normalizePlanType,
|
||||
normalizeQuotaFraction,
|
||||
normalizeStringValue,
|
||||
parseAntigravityPayload,
|
||||
parseCodexUsagePayload,
|
||||
parseGeminiCliQuotaPayload,
|
||||
resolveCodexChatgptAccountId,
|
||||
resolveCodexPlanType,
|
||||
resolveGeminiCliProjectId,
|
||||
formatCodexResetLabel,
|
||||
formatQuotaResetTime,
|
||||
buildAntigravityQuotaGroups,
|
||||
buildGeminiCliQuotaBuckets,
|
||||
createStatusError,
|
||||
getStatusFromError,
|
||||
isAntigravityFile,
|
||||
isCodexFile,
|
||||
isGeminiCliFile,
|
||||
isRuntimeOnlyAuthFile
|
||||
} from '@/utils/quota';
|
||||
import type { QuotaRenderHelpers } from './QuotaCard';
|
||||
import styles from '@/pages/QuotaPage.module.scss';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||
|
||||
export interface QuotaStore {
|
||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||
codexQuota: Record<string, CodexQuotaState>;
|
||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||
clearQuotaCache: () => void;
|
||||
}
|
||||
|
||||
export interface QuotaConfig<TState, TData> {
|
||||
type: QuotaType;
|
||||
i18nPrefix: string;
|
||||
filterFn: (file: AuthFileItem) => boolean;
|
||||
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
|
||||
storeSelector: (state: QuotaStore) => Record<string, TState>;
|
||||
storeSetter: keyof QuotaStore;
|
||||
buildLoadingState: () => TState;
|
||||
buildSuccessState: (data: TData) => TState;
|
||||
buildErrorState: (message: string, status?: number) => TState;
|
||||
cardClassName: string;
|
||||
controlsClassName: string;
|
||||
controlClassName: string;
|
||||
gridClassName: string;
|
||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||
}
|
||||
|
||||
const fetchAntigravityQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<AntigravityQuotaGroup[]> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('antigravity_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
let lastError = '';
|
||||
let lastStatus: number | undefined;
|
||||
let priorityStatus: number | undefined;
|
||||
let hadSuccess = false;
|
||||
|
||||
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
||||
try {
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'POST',
|
||||
url,
|
||||
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
||||
data: '{}'
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
lastError = getApiCallErrorMessage(result);
|
||||
lastStatus = result.statusCode;
|
||||
if (result.statusCode === 403 || result.statusCode === 404) {
|
||||
priorityStatus ??= result.statusCode;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
hadSuccess = true;
|
||||
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
||||
const models = payload?.models;
|
||||
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
||||
lastError = t('antigravity_quota.empty_models');
|
||||
continue;
|
||||
}
|
||||
|
||||
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
||||
if (groups.length === 0) {
|
||||
lastError = t('antigravity_quota.empty_models');
|
||||
continue;
|
||||
}
|
||||
|
||||
return groups;
|
||||
} catch (err: unknown) {
|
||||
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
||||
const status = getStatusFromError(err);
|
||||
if (status) {
|
||||
lastStatus = status;
|
||||
if (status === 403 || status === 404) {
|
||||
priorityStatus ??= status;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hadSuccess) {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
|
||||
};
|
||||
|
||||
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
|
||||
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
||||
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
||||
const windows: CodexQuotaWindow[] = [];
|
||||
|
||||
const addWindow = (
|
||||
id: string,
|
||||
labelKey: string,
|
||||
window?: CodexUsageWindow | null,
|
||||
limitReached?: boolean,
|
||||
allowed?: boolean
|
||||
) => {
|
||||
if (!window) return;
|
||||
const resetLabel = formatCodexResetLabel(window);
|
||||
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
|
||||
const isLimitReached = Boolean(limitReached) || allowed === false;
|
||||
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
||||
windows.push({
|
||||
id,
|
||||
label: t(labelKey),
|
||||
labelKey,
|
||||
usedPercent,
|
||||
resetLabel
|
||||
});
|
||||
};
|
||||
|
||||
addWindow(
|
||||
'primary',
|
||||
'codex_quota.primary_window',
|
||||
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||
rateLimit?.allowed
|
||||
);
|
||||
addWindow(
|
||||
'secondary',
|
||||
'codex_quota.secondary_window',
|
||||
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||
rateLimit?.allowed
|
||||
);
|
||||
addWindow(
|
||||
'code-review',
|
||||
'codex_quota.code_review_window',
|
||||
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
||||
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
||||
codeReviewLimit?.allowed
|
||||
);
|
||||
|
||||
return windows;
|
||||
};
|
||||
|
||||
const fetchCodexQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('codex_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const planTypeFromFile = resolveCodexPlanType(file);
|
||||
const accountId = resolveCodexChatgptAccountId(file);
|
||||
if (!accountId) {
|
||||
throw new Error(t('codex_quota.missing_account_id'));
|
||||
}
|
||||
|
||||
const requestHeader: Record<string, string> = {
|
||||
...CODEX_REQUEST_HEADERS,
|
||||
'Chatgpt-Account-Id': accountId
|
||||
};
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CODEX_USAGE_URL,
|
||||
header: requestHeader
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
}
|
||||
|
||||
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
|
||||
if (!payload) {
|
||||
throw new Error(t('codex_quota.empty_windows'));
|
||||
}
|
||||
|
||||
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
|
||||
const windows = buildCodexQuotaWindows(payload, t);
|
||||
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
|
||||
};
|
||||
|
||||
const fetchGeminiCliQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<GeminiCliQuotaBucketState[]> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('gemini_cli_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const projectId = resolveGeminiCliProjectId(file);
|
||||
if (!projectId) {
|
||||
throw new Error(t('gemini_cli_quota.missing_project_id'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'POST',
|
||||
url: GEMINI_CLI_QUOTA_URL,
|
||||
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||
data: JSON.stringify({ project: projectId })
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
}
|
||||
|
||||
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
|
||||
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
|
||||
if (buckets.length === 0) return [];
|
||||
|
||||
const parsedBuckets = buckets
|
||||
.map((bucket) => {
|
||||
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
||||
if (!modelId) return null;
|
||||
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||
const remainingFractionRaw = normalizeQuotaFraction(
|
||||
bucket.remainingFraction ?? bucket.remaining_fraction
|
||||
);
|
||||
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
|
||||
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
||||
let fallbackFraction: number | null = null;
|
||||
if (remainingAmount !== null) {
|
||||
fallbackFraction = remainingAmount <= 0 ? 0 : null;
|
||||
} else if (resetTime) {
|
||||
fallbackFraction = 0;
|
||||
}
|
||||
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
|
||||
return {
|
||||
modelId,
|
||||
tokenType,
|
||||
remainingFraction,
|
||||
remainingAmount,
|
||||
resetTime
|
||||
};
|
||||
})
|
||||
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||
|
||||
return buildGeminiCliQuotaBuckets(parsedBuckets);
|
||||
};
|
||||
|
||||
const renderAntigravityItems = (
|
||||
quota: AntigravityQuotaState,
|
||||
t: TFunction,
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h } = React;
|
||||
const groups = quota.groups ?? [];
|
||||
|
||||
if (groups.length === 0) {
|
||||
return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models'));
|
||||
}
|
||||
|
||||
return groups.map((group) => {
|
||||
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
|
||||
const percent = Math.round(clamped * 100);
|
||||
const resetLabel = formatQuotaResetTime(group.resetTime);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: group.id, className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h(
|
||||
'span',
|
||||
{ className: styleMap.quotaModel, title: group.models.join(', ') },
|
||||
group.label
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, `${percent}%`),
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderCodexItems = (
|
||||
quota: CodexQuotaState,
|
||||
t: TFunction,
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h, Fragment } = React;
|
||||
const windows = quota.windows ?? [];
|
||||
const planType = quota.planType ?? null;
|
||||
|
||||
const getPlanLabel = (pt?: string | null): string | null => {
|
||||
const normalized = normalizePlanType(pt);
|
||||
if (!normalized) return null;
|
||||
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
||||
if (normalized === 'team') return t('codex_quota.plan_team');
|
||||
if (normalized === 'free') return t('codex_quota.plan_free');
|
||||
return pt || normalized;
|
||||
};
|
||||
|
||||
const planLabel = getPlanLabel(planType);
|
||||
const isFreePlan = normalizePlanType(planType) === 'free';
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (planLabel) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'plan', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
|
||||
h('span', { className: styleMap.codexPlanValue }, planLabel)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isFreePlan) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'warning', className: styleMap.quotaWarning },
|
||||
t('codex_quota.no_access')
|
||||
)
|
||||
);
|
||||
return h(Fragment, null, ...nodes);
|
||||
}
|
||||
|
||||
if (windows.length === 0) {
|
||||
nodes.push(
|
||||
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_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);
|
||||
};
|
||||
|
||||
const renderGeminiCliItems = (
|
||||
quota: GeminiCliQuotaState,
|
||||
t: TFunction,
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h } = React;
|
||||
const buckets = quota.buckets ?? [];
|
||||
|
||||
if (buckets.length === 0) {
|
||||
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
|
||||
}
|
||||
|
||||
return buckets.map((bucket) => {
|
||||
const fraction = bucket.remainingFraction;
|
||||
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
||||
const percent = clamped === null ? null : Math.round(clamped * 100);
|
||||
const percentLabel = percent === null ? '--' : `${percent}%`;
|
||||
const remainingAmountLabel =
|
||||
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||
? null
|
||||
: t('gemini_cli_quota.remaining_amount', {
|
||||
count: bucket.remainingAmount
|
||||
});
|
||||
const titleBase =
|
||||
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
|
||||
|
||||
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: bucket.id, className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel, title }, bucket.label),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||
remainingAmountLabel
|
||||
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
|
||||
: null,
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||
type: 'antigravity',
|
||||
i18nPrefix: 'antigravity_quota',
|
||||
filterFn: (file) => isAntigravityFile(file),
|
||||
fetchQuota: fetchAntigravityQuota,
|
||||
storeSelector: (state) => state.antigravityQuota,
|
||||
storeSetter: 'setAntigravityQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', groups: [] }),
|
||||
buildSuccessState: (groups) => ({ status: 'success', groups }),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
groups: [],
|
||||
error: message,
|
||||
errorStatus: status
|
||||
}),
|
||||
cardClassName: styles.antigravityCard,
|
||||
controlsClassName: styles.antigravityControls,
|
||||
controlClassName: styles.antigravityControl,
|
||||
gridClassName: styles.antigravityGrid,
|
||||
renderQuotaItems: renderAntigravityItems
|
||||
};
|
||||
|
||||
export const CODEX_CONFIG: QuotaConfig<
|
||||
CodexQuotaState,
|
||||
{ planType: string | null; windows: CodexQuotaWindow[] }
|
||||
> = {
|
||||
type: 'codex',
|
||||
i18nPrefix: 'codex_quota',
|
||||
filterFn: (file) => isCodexFile(file),
|
||||
fetchQuota: fetchCodexQuota,
|
||||
storeSelector: (state) => state.codexQuota,
|
||||
storeSetter: 'setCodexQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', windows: [] }),
|
||||
buildSuccessState: (data) => ({
|
||||
status: 'success',
|
||||
windows: data.windows,
|
||||
planType: data.planType
|
||||
}),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
windows: [],
|
||||
error: message,
|
||||
errorStatus: status
|
||||
}),
|
||||
cardClassName: styles.codexCard,
|
||||
controlsClassName: styles.codexControls,
|
||||
controlClassName: styles.codexControl,
|
||||
gridClassName: styles.codexGrid,
|
||||
renderQuotaItems: renderCodexItems
|
||||
};
|
||||
|
||||
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||
type: 'gemini-cli',
|
||||
i18nPrefix: 'gemini_cli_quota',
|
||||
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file),
|
||||
fetchQuota: fetchGeminiCliQuota,
|
||||
storeSelector: (state) => state.geminiCliQuota,
|
||||
storeSetter: 'setGeminiCliQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
|
||||
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
buckets: [],
|
||||
error: message,
|
||||
errorStatus: status
|
||||
}),
|
||||
cardClassName: styles.geminiCliCard,
|
||||
controlsClassName: styles.geminiCliControls,
|
||||
controlClassName: styles.geminiCliControl,
|
||||
gridClassName: styles.geminiCliGrid,
|
||||
renderQuotaItems: renderGeminiCliItems
|
||||
};
|
||||
98
src/components/quota/useQuotaLoader.ts
Normal file
98
src/components/quota/useQuotaLoader.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Generic hook for quota data fetching and management.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { useQuotaStore } from '@/stores';
|
||||
import { getStatusFromError } from '@/utils/quota';
|
||||
import type { QuotaConfig } from './quotaConfigs';
|
||||
|
||||
type QuotaScope = 'page' | 'all';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||
|
||||
interface LoadQuotaResult<TData> {
|
||||
name: string;
|
||||
status: 'success' | 'error';
|
||||
data?: TData;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
export function useQuotaLoader<TState, TData>(config: QuotaConfig<TState, TData>) {
|
||||
const { t } = useTranslation();
|
||||
const quota = useQuotaStore(config.storeSelector);
|
||||
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||
Record<string, TState>
|
||||
>;
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const loadQuota = useCallback(
|
||||
async (
|
||||
targets: AuthFileItem[],
|
||||
scope: QuotaScope,
|
||||
setLoading: (loading: boolean, scope?: QuotaScope | null) => void
|
||||
) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
const requestId = ++requestIdRef.current;
|
||||
setLoading(true, scope);
|
||||
|
||||
try {
|
||||
if (targets.length === 0) return;
|
||||
|
||||
setQuota((prev) => {
|
||||
const nextState = { ...prev };
|
||||
targets.forEach((file) => {
|
||||
nextState[file.name] = config.buildLoadingState();
|
||||
});
|
||||
return nextState;
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
targets.map(async (file): Promise<LoadQuotaResult<TData>> => {
|
||||
try {
|
||||
const data = await config.fetchQuota(file, t);
|
||||
return { name: file.name, status: 'success', data };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||
const errorStatus = getStatusFromError(err);
|
||||
return { name: file.name, status: 'error', error: message, errorStatus };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
setQuota((prev) => {
|
||||
const nextState = { ...prev };
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'success') {
|
||||
nextState[result.name] = config.buildSuccessState(result.data as TData);
|
||||
} else {
|
||||
nextState[result.name] = config.buildErrorState(
|
||||
result.error || t('common.unknown_error'),
|
||||
result.errorStatus
|
||||
);
|
||||
}
|
||||
});
|
||||
return nextState;
|
||||
});
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[config, setQuota, t]
|
||||
);
|
||||
|
||||
return { quota, loadQuota };
|
||||
}
|
||||
79
src/components/usage/ApiDetailsCard.tsx
Normal file
79
src/components/usage/ApiDetailsCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ApiDetailsCardProps {
|
||||
apiStats: ApiStats[];
|
||||
loading: boolean;
|
||||
hasPrices: boolean;
|
||||
}
|
||||
|
||||
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = (endpoint: string) => {
|
||||
setExpandedApis((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(endpoint)) {
|
||||
newSet.delete(endpoint);
|
||||
} else {
|
||||
newSet.add(endpoint);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.api_details')}>
|
||||
{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)}
|
||||
</span>
|
||||
)}
|
||||
</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 className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
92
src/components/usage/ChartLineSelector.tsx
Normal file
92
src/components/usage/ChartLineSelector.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ChartLineSelectorProps {
|
||||
chartLines: string[];
|
||||
modelNames: string[];
|
||||
maxLines?: number;
|
||||
onChange: (lines: string[]) => void;
|
||||
}
|
||||
|
||||
export function ChartLineSelector({
|
||||
chartLines,
|
||||
modelNames,
|
||||
maxLines = 9,
|
||||
onChange
|
||||
}: ChartLineSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAdd = () => {
|
||||
if (chartLines.length >= maxLines) return;
|
||||
const unusedModel = modelNames.find((m) => !chartLines.includes(m));
|
||||
if (unusedModel) {
|
||||
onChange([...chartLines, unusedModel]);
|
||||
} else {
|
||||
onChange([...chartLines, 'all']);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
if (chartLines.length <= 1) return;
|
||||
const newLines = [...chartLines];
|
||||
newLines.splice(index, 1);
|
||||
onChange(newLines);
|
||||
};
|
||||
|
||||
const handleChange = (index: number, value: string) => {
|
||||
const newLines = [...chartLines];
|
||||
newLines[index] = value;
|
||||
onChange(newLines);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('usage_stats.chart_line_actions_label')}
|
||||
extra={
|
||||
<div className={styles.chartLineHeader}>
|
||||
<span className={styles.chartLineCount}>
|
||||
{chartLines.length}/{maxLines}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={chartLines.length >= maxLines}
|
||||
>
|
||||
{t('usage_stats.chart_line_add')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.chartLineList}>
|
||||
{chartLines.map((line, index) => (
|
||||
<div key={index} className={styles.chartLineItem}>
|
||||
<span className={styles.chartLineLabel}>
|
||||
{t(`usage_stats.chart_line_label_${index + 1}`)}
|
||||
</span>
|
||||
<select
|
||||
value={line}
|
||||
onChange={(e) => handleChange(index, e.target.value)}
|
||||
className={styles.select}
|
||||
>
|
||||
<option value="all">{t('usage_stats.chart_line_all')}</option>
|
||||
{modelNames.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{chartLines.length > 1 && (
|
||||
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
|
||||
{t('usage_stats.chart_line_delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
54
src/components/usage/ModelStatsCard.tsx
Normal file
54
src/components/usage/ModelStatsCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ModelStat {
|
||||
model: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface ModelStatsCardProps {
|
||||
modelStats: ModelStat[];
|
||||
loading: boolean;
|
||||
hasPrices: boolean;
|
||||
}
|
||||
|
||||
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.models')}>
|
||||
{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>{stat.requests.toLocaleString()}</td>
|
||||
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
164
src/components/usage/PriceSettingsCard.tsx
Normal file
164
src/components/usage/PriceSettingsCard.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { ModelPrice } from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface PriceSettingsCardProps {
|
||||
modelNames: string[];
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
onPricesChange: (prices: Record<string, ModelPrice>) => void;
|
||||
}
|
||||
|
||||
export function PriceSettingsCard({
|
||||
modelNames,
|
||||
modelPrices,
|
||||
onPricesChange
|
||||
}: PriceSettingsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [promptPrice, setPromptPrice] = useState('');
|
||||
const [completionPrice, setCompletionPrice] = useState('');
|
||||
const [cachePrice, setCachePrice] = useState('');
|
||||
|
||||
const handleSavePrice = () => {
|
||||
if (!selectedModel) return;
|
||||
const prompt = parseFloat(promptPrice) || 0;
|
||||
const completion = parseFloat(completionPrice) || 0;
|
||||
const cache = cachePrice.trim() === '' ? prompt : parseFloat(cachePrice) || 0;
|
||||
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache } };
|
||||
onPricesChange(newPrices);
|
||||
setSelectedModel('');
|
||||
setPromptPrice('');
|
||||
setCompletionPrice('');
|
||||
setCachePrice('');
|
||||
};
|
||||
|
||||
const handleDeletePrice = (model: string) => {
|
||||
const newPrices = { ...modelPrices };
|
||||
delete newPrices[model];
|
||||
onPricesChange(newPrices);
|
||||
};
|
||||
|
||||
const handleEditPrice = (model: string) => {
|
||||
const price = modelPrices[model];
|
||||
setSelectedModel(model);
|
||||
setPromptPrice(price?.prompt?.toString() || '');
|
||||
setCompletionPrice(price?.completion?.toString() || '');
|
||||
setCachePrice(price?.cache?.toString() || '');
|
||||
};
|
||||
|
||||
const handleModelSelect = (value: string) => {
|
||||
setSelectedModel(value);
|
||||
const price = modelPrices[value];
|
||||
if (price) {
|
||||
setPromptPrice(price.prompt.toString());
|
||||
setCompletionPrice(price.completion.toString());
|
||||
setCachePrice(price.cache.toString());
|
||||
} else {
|
||||
setPromptPrice('');
|
||||
setCompletionPrice('');
|
||||
setCachePrice('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.model_price_settings')}>
|
||||
<div className={styles.pricingSection}>
|
||||
{/* Price Form */}
|
||||
<div className={styles.priceForm}>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_name')}</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => handleModelSelect(e.target.value)}
|
||||
className={styles.select}
|
||||
>
|
||||
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
|
||||
{modelNames.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={promptPrice}
|
||||
onChange={(e) => setPromptPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={completionPrice}
|
||||
onChange={(e) => setCompletionPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={cachePrice}
|
||||
onChange={(e) => setCachePrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleSavePrice} disabled={!selectedModel}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved Prices List */}
|
||||
<div className={styles.pricesList}>
|
||||
<h4 className={styles.pricesTitle}>{t('usage_stats.saved_prices')}</h4>
|
||||
{Object.keys(modelPrices).length > 0 ? (
|
||||
<div className={styles.pricesGrid}>
|
||||
{Object.entries(modelPrices).map(([model, price]) => (
|
||||
<div key={model} className={styles.priceItem}>
|
||||
<div className={styles.priceInfo}>
|
||||
<span className={styles.priceModel}>{model}</span>
|
||||
<div className={styles.priceMeta}>
|
||||
<span>
|
||||
{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M
|
||||
</span>
|
||||
<span>
|
||||
{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M
|
||||
</span>
|
||||
<span>
|
||||
{t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.priceActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.model_price_empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
184
src/components/usage/StatCards.tsx
Normal file
184
src/components/usage/StatCards.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||
import {
|
||||
formatTokensInMillions,
|
||||
formatPerMinuteValue,
|
||||
formatUsd,
|
||||
calculateTokenBreakdown,
|
||||
calculateRecentPerMinuteRates,
|
||||
calculateTotalCost,
|
||||
type ModelPrice
|
||||
} from '@/utils/usage';
|
||||
import { sparklineOptions } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import type { SparklineBundle } from './hooks/useSparklines';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
interface StatCardData {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
accent: string;
|
||||
accentSoft: string;
|
||||
accentBorder: string;
|
||||
value: string;
|
||||
meta?: ReactNode;
|
||||
trend: SparklineBundle | null;
|
||||
}
|
||||
|
||||
export interface StatCardsProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
sparklines: {
|
||||
requests: SparklineBundle | null;
|
||||
tokens: SparklineBundle | null;
|
||||
rpm: SparklineBundle | null;
|
||||
tpm: SparklineBundle | null;
|
||||
cost: SparklineBundle | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function StatCards({ usage, loading, modelPrices, sparklines }: StatCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||
const rateStats = usage
|
||||
? calculateRecentPerMinuteRates(30, usage)
|
||||
: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 };
|
||||
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
const statsCards: StatCardData[] = [
|
||||
{
|
||||
key: 'requests',
|
||||
label: t('usage_stats.total_requests'),
|
||||
icon: <IconSatellite size={16} />,
|
||||
accent: '#3b82f6',
|
||||
accentSoft: 'rgba(59, 130, 246, 0.18)',
|
||||
accentBorder: 'rgba(59, 130, 246, 0.35)',
|
||||
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
<span className={styles.statMetaDot} style={{ backgroundColor: '#10b981' }} />
|
||||
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
||||
</span>
|
||||
<span className={styles.statMetaItem}>
|
||||
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
|
||||
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
trend: sparklines.requests
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
label: t('usage_stats.total_tokens'),
|
||||
icon: <IconDiamond size={16} />,
|
||||
accent: '#8b5cf6',
|
||||
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
||||
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
||||
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
||||
</span>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
trend: sparklines.tokens
|
||||
},
|
||||
{
|
||||
key: 'rpm',
|
||||
label: t('usage_stats.rpm_30m'),
|
||||
icon: <IconTimer size={16} />,
|
||||
accent: '#22c55e',
|
||||
accentSoft: 'rgba(34, 197, 94, 0.18)',
|
||||
accentBorder: 'rgba(34, 197, 94, 0.32)',
|
||||
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
|
||||
meta: (
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
trend: sparklines.rpm
|
||||
},
|
||||
{
|
||||
key: 'tpm',
|
||||
label: t('usage_stats.tpm_30m'),
|
||||
icon: <IconTrendingUp size={16} />,
|
||||
accent: '#f97316',
|
||||
accentSoft: 'rgba(249, 115, 22, 0.18)',
|
||||
accentBorder: 'rgba(249, 115, 22, 0.32)',
|
||||
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||
meta: (
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
|
||||
</span>
|
||||
),
|
||||
trend: sparklines.tpm
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
label: t('usage_stats.total_cost'),
|
||||
icon: <IconDollarSign size={16} />,
|
||||
accent: '#f59e0b',
|
||||
accentSoft: 'rgba(245, 158, 11, 0.18)',
|
||||
accentBorder: 'rgba(245, 158, 11, 0.32)',
|
||||
value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--',
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
|
||||
</span>
|
||||
{!hasPrices && (
|
||||
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||
{t('usage_stats.cost_need_price')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
trend: hasPrices ? sparklines.cost : null
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.statsGrid}>
|
||||
{statsCards.map((card) => (
|
||||
<div
|
||||
key={card.key}
|
||||
className={styles.statCard}
|
||||
style={
|
||||
{
|
||||
'--accent': card.accent,
|
||||
'--accent-soft': card.accentSoft,
|
||||
'--accent-border': card.accentBorder
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={styles.statCardHeader}>
|
||||
<div className={styles.statLabelGroup}>
|
||||
<span className={styles.statLabel}>{card.label}</span>
|
||||
</div>
|
||||
<span className={styles.statIconBadge}>{card.icon}</span>
|
||||
</div>
|
||||
<div className={styles.statValue}>{card.value}</div>
|
||||
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
|
||||
<div className={styles.statTrend}>
|
||||
{card.trend ? (
|
||||
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
|
||||
) : (
|
||||
<div className={styles.statTrendPlaceholder}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/components/usage/UsageChart.tsx
Normal file
91
src/components/usage/UsageChart.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ChartOptions } from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { ChartData } from '@/utils/usage';
|
||||
import { getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface UsageChartProps {
|
||||
title: string;
|
||||
period: 'hour' | 'day';
|
||||
onPeriodChange: (period: 'hour' | 'day') => void;
|
||||
chartData: ChartData;
|
||||
chartOptions: ChartOptions<'line'>;
|
||||
loading: boolean;
|
||||
isMobile: boolean;
|
||||
emptyText: string;
|
||||
}
|
||||
|
||||
export function UsageChart({
|
||||
title,
|
||||
period,
|
||||
onPeriodChange,
|
||||
chartData,
|
||||
chartOptions,
|
||||
loading,
|
||||
isMobile,
|
||||
emptyText
|
||||
}: UsageChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={title}
|
||||
extra={
|
||||
<div className={styles.periodButtons}>
|
||||
<Button
|
||||
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => onPeriodChange('hour')}
|
||||
>
|
||||
{t('usage_stats.by_hour')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => onPeriodChange('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}>{emptyText}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
8
src/components/usage/hooks/index.ts
Normal file
8
src/components/usage/hooks/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useUsageData } from './useUsageData';
|
||||
export type { UsagePayload, UseUsageDataReturn } from './useUsageData';
|
||||
|
||||
export { useSparklines } from './useSparklines';
|
||||
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './useSparklines';
|
||||
|
||||
export { useChartData } from './useChartData';
|
||||
export type { UseChartDataOptions, UseChartDataReturn } from './useChartData';
|
||||
76
src/components/usage/hooks/useChartData.ts
Normal file
76
src/components/usage/hooks/useChartData.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { ChartOptions } from 'chart.js';
|
||||
import { buildChartData, type ChartData } from '@/utils/usage';
|
||||
import { buildChartOptions } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './useUsageData';
|
||||
|
||||
export interface UseChartDataOptions {
|
||||
usage: UsagePayload | null;
|
||||
chartLines: string[];
|
||||
isDark: boolean;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export interface UseChartDataReturn {
|
||||
requestsPeriod: 'hour' | 'day';
|
||||
setRequestsPeriod: (period: 'hour' | 'day') => void;
|
||||
tokensPeriod: 'hour' | 'day';
|
||||
setTokensPeriod: (period: 'hour' | 'day') => void;
|
||||
requestsChartData: ChartData;
|
||||
tokensChartData: ChartData;
|
||||
requestsChartOptions: ChartOptions<'line'>;
|
||||
tokensChartOptions: ChartOptions<'line'>;
|
||||
}
|
||||
|
||||
export function useChartData({
|
||||
usage,
|
||||
chartLines,
|
||||
isDark,
|
||||
isMobile
|
||||
}: 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]);
|
||||
|
||||
const tokensChartData = useMemo(() => {
|
||||
if (!usage) return { labels: [], datasets: [] };
|
||||
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
||||
}, [usage, tokensPeriod, chartLines]);
|
||||
|
||||
const requestsChartOptions = useMemo(
|
||||
() =>
|
||||
buildChartOptions({
|
||||
period: requestsPeriod,
|
||||
labels: requestsChartData.labels,
|
||||
isDark,
|
||||
isMobile
|
||||
}),
|
||||
[requestsPeriod, requestsChartData.labels, isDark, isMobile]
|
||||
);
|
||||
|
||||
const tokensChartOptions = useMemo(
|
||||
() =>
|
||||
buildChartOptions({
|
||||
period: tokensPeriod,
|
||||
labels: tokensChartData.labels,
|
||||
isDark,
|
||||
isMobile
|
||||
}),
|
||||
[tokensPeriod, tokensChartData.labels, isDark, isMobile]
|
||||
);
|
||||
|
||||
return {
|
||||
requestsPeriod,
|
||||
setRequestsPeriod,
|
||||
tokensPeriod,
|
||||
setTokensPeriod,
|
||||
requestsChartData,
|
||||
tokensChartData,
|
||||
requestsChartOptions,
|
||||
tokensChartOptions
|
||||
};
|
||||
}
|
||||
138
src/components/usage/hooks/useSparklines.ts
Normal file
138
src/components/usage/hooks/useSparklines.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { collectUsageDetails, extractTotalTokens } from '@/utils/usage';
|
||||
import type { UsagePayload } from './useUsageData';
|
||||
|
||||
export interface SparklineData {
|
||||
labels: string[];
|
||||
datasets: [
|
||||
{
|
||||
data: number[];
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
fill: boolean;
|
||||
tension: number;
|
||||
pointRadius: number;
|
||||
borderWidth: number;
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export interface SparklineBundle {
|
||||
data: SparklineData;
|
||||
}
|
||||
|
||||
export interface UseSparklinesOptions {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export interface UseSparklinesReturn {
|
||||
requestsSparkline: SparklineBundle | null;
|
||||
tokensSparkline: SparklineBundle | null;
|
||||
rpmSparkline: SparklineBundle | null;
|
||||
tpmSparkline: SparklineBundle | null;
|
||||
costSparkline: SparklineBundle | null;
|
||||
}
|
||||
|
||||
export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSparklinesReturn {
|
||||
const buildLastHourSeries = useCallback(
|
||||
(metric: 'requests' | 'tokens'): { labels: string[]; data: number[] } => {
|
||||
if (!usage) return { labels: [], data: [] };
|
||||
const details = collectUsageDetails(usage);
|
||||
if (!details.length) return { labels: [], data: [] };
|
||||
|
||||
const windowMinutes = 60;
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowMinutes * 60 * 1000;
|
||||
const buckets = new Array(windowMinutes).fill(0);
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
||||
return;
|
||||
}
|
||||
const minuteIndex = Math.min(
|
||||
windowMinutes - 1,
|
||||
Math.floor((timestamp - windowStart) / 60000)
|
||||
);
|
||||
const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1;
|
||||
buckets[minuteIndex] += increment;
|
||||
});
|
||||
|
||||
const labels = buckets.map((_, idx) => {
|
||||
const date = new Date(windowStart + (idx + 1) * 60000);
|
||||
const h = date.getHours().toString().padStart(2, '0');
|
||||
const m = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${h}:${m}`;
|
||||
});
|
||||
|
||||
return { labels, data: buckets };
|
||||
},
|
||||
[usage]
|
||||
);
|
||||
|
||||
const buildSparkline = useCallback(
|
||||
(
|
||||
series: { labels: string[]; data: number[] },
|
||||
color: string,
|
||||
backgroundColor: string
|
||||
): SparklineBundle | null => {
|
||||
if (loading || !series?.data?.length) {
|
||||
return null;
|
||||
}
|
||||
const sliceStart = Math.max(series.data.length - 60, 0);
|
||||
const labels = series.labels.slice(sliceStart);
|
||||
const points = series.data.slice(sliceStart);
|
||||
return {
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: points,
|
||||
borderColor: color,
|
||||
backgroundColor,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
},
|
||||
[loading]
|
||||
);
|
||||
|
||||
const requestsSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
const tokensSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
const rpmSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
const tpmSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
const costSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
return {
|
||||
requestsSparkline,
|
||||
tokensSparkline,
|
||||
rpmSparkline,
|
||||
tpmSparkline,
|
||||
costSparkline
|
||||
};
|
||||
}
|
||||
153
src/components/usage/hooks/useUsageData.ts
Normal file
153
src/components/usage/hooks/useUsageData.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
|
||||
|
||||
export interface UsagePayload {
|
||||
total_requests?: number;
|
||||
success_count?: number;
|
||||
failure_count?: number;
|
||||
total_tokens?: number;
|
||||
apis?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UseUsageDataReturn {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
||||
loadUsage: () => Promise<void>;
|
||||
handleExport: () => Promise<void>;
|
||||
handleImport: () => void;
|
||||
handleImportChange: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||
importInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
exporting: boolean;
|
||||
importing: boolean;
|
||||
}
|
||||
|
||||
export function useUsageData(): UseUsageDataReturn {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
|
||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await usageApi.getUsage();
|
||||
const payload = data?.usage ?? data;
|
||||
setUsage(payload);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsage();
|
||||
setModelPrices(loadModelPrices());
|
||||
}, [loadUsage]);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const data = await usageApi.exportUsage();
|
||||
const exportedAt =
|
||||
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
|
||||
const safeTimestamp = Number.isNaN(exportedAt.getTime())
|
||||
? new Date().toISOString()
|
||||
: exportedAt.toISOString();
|
||||
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
|
||||
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showNotification(t('usage_stats.export_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
importInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
showNotification(t('usage_stats.import_invalid'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await usageApi.importUsage(payload);
|
||||
showNotification(
|
||||
t('usage_stats.import_success', {
|
||||
added: result?.added ?? 0,
|
||||
skipped: result?.skipped ?? 0,
|
||||
total: result?.total_requests ?? 0,
|
||||
failed: result?.failed_requests ?? 0
|
||||
}),
|
||||
'success'
|
||||
);
|
||||
await loadUsage();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetModelPrices = useCallback((prices: Record<string, ModelPrice>) => {
|
||||
setModelPrices(prices);
|
||||
saveModelPrices(prices);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
usage,
|
||||
loading,
|
||||
error,
|
||||
modelPrices,
|
||||
setModelPrices: handleSetModelPrices,
|
||||
loadUsage,
|
||||
handleExport,
|
||||
handleImport,
|
||||
handleImportChange,
|
||||
importInputRef,
|
||||
exporting,
|
||||
importing
|
||||
};
|
||||
}
|
||||
28
src/components/usage/index.ts
Normal file
28
src/components/usage/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Hooks
|
||||
export { useUsageData } from './hooks/useUsageData';
|
||||
export type { UsagePayload, UseUsageDataReturn } from './hooks/useUsageData';
|
||||
|
||||
export { useSparklines } from './hooks/useSparklines';
|
||||
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './hooks/useSparklines';
|
||||
|
||||
export { useChartData } from './hooks/useChartData';
|
||||
export type { UseChartDataOptions, UseChartDataReturn } from './hooks/useChartData';
|
||||
|
||||
// Components
|
||||
export { StatCards } from './StatCards';
|
||||
export type { StatCardsProps } from './StatCards';
|
||||
|
||||
export { UsageChart } from './UsageChart';
|
||||
export type { UsageChartProps } from './UsageChart';
|
||||
|
||||
export { ChartLineSelector } from './ChartLineSelector';
|
||||
export type { ChartLineSelectorProps } from './ChartLineSelector';
|
||||
|
||||
export { ApiDetailsCard } from './ApiDetailsCard';
|
||||
export type { ApiDetailsCardProps } from './ApiDetailsCard';
|
||||
|
||||
export { ModelStatsCard } from './ModelStatsCard';
|
||||
export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
|
||||
|
||||
export { PriceSettingsCard } from './PriceSettingsCard';
|
||||
export type { PriceSettingsCardProps } from './PriceSettingsCard';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -124,7 +124,7 @@
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
cursor: text;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
|
||||
|
||||
@@ -78,6 +78,11 @@ const OAUTH_PROVIDER_PRESETS = [
|
||||
];
|
||||
|
||||
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
||||
const MIN_CARD_PAGE_SIZE = 3;
|
||||
const MAX_CARD_PAGE_SIZE = 30;
|
||||
|
||||
const clampCardPageSize = (value: number) =>
|
||||
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||
|
||||
interface ExcludedFormState {
|
||||
provider: string;
|
||||
@@ -172,10 +177,10 @@ export function AuthFilesPage() {
|
||||
const [modelsFileType, setModelsFileType] = useState('');
|
||||
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
|
||||
|
||||
// OAuth 排除模型相关
|
||||
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||
// OAuth 排除模型相关
|
||||
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
|
||||
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
|
||||
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
|
||||
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
|
||||
const [savingExcluded, setSavingExcluded] = useState(false);
|
||||
|
||||
@@ -184,6 +189,13 @@ export function AuthFilesPage() {
|
||||
const excludedUnsupportedRef = useRef(false);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const handlePageSizeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.valueAsNumber;
|
||||
if (!Number.isFinite(value)) return;
|
||||
setPageSize(clampCardPageSize(value));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 格式化修改时间
|
||||
const formatModified = (item: AuthFileItem): string => {
|
||||
@@ -853,23 +865,18 @@ export function AuthFilesPage() {
|
||||
placeholder={t('auth_files.search_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<label>{t('auth_files.page_size_label')}</label>
|
||||
<select
|
||||
className={styles.pageSizeSelect}
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value) || 9);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value={6}>6</option>
|
||||
<option value={9}>9</option>
|
||||
<option value={12}>12</option>
|
||||
<option value={18}>18</option>
|
||||
<option value={24}>24</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<label>{t('auth_files.page_size_label')}</label>
|
||||
<input
|
||||
className={styles.pageSizeSelect}
|
||||
type="number"
|
||||
min={MIN_CARD_PAGE_SIZE}
|
||||
max={MAX_CARD_PAGE_SIZE}
|
||||
step={1}
|
||||
value={pageSize}
|
||||
onChange={handlePageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
<label>{t('common.info')}</label>
|
||||
<div className={styles.statsInfo}>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
cursor: text;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,101 @@
|
||||
* Quota management types.
|
||||
*/
|
||||
|
||||
// Theme types
|
||||
export type ThemeColors = { bg: string; text: string; border?: string };
|
||||
export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
// API payload types
|
||||
export interface GeminiCliQuotaBucket {
|
||||
modelId?: string;
|
||||
model_id?: string;
|
||||
tokenType?: string;
|
||||
token_type?: string;
|
||||
remainingFraction?: number | string;
|
||||
remaining_fraction?: number | string;
|
||||
remainingAmount?: number | string;
|
||||
remaining_amount?: number | string;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
}
|
||||
|
||||
export interface GeminiCliQuotaPayload {
|
||||
buckets?: GeminiCliQuotaBucket[];
|
||||
}
|
||||
|
||||
export interface AntigravityQuotaInfo {
|
||||
displayName?: string;
|
||||
quotaInfo?: {
|
||||
remainingFraction?: number | string;
|
||||
remaining_fraction?: number | string;
|
||||
remaining?: number | string;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
quota_info?: {
|
||||
remainingFraction?: number | string;
|
||||
remaining_fraction?: number | string;
|
||||
remaining?: number | string;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type AntigravityModelsPayload = Record<string, AntigravityQuotaInfo>;
|
||||
|
||||
export interface AntigravityQuotaGroupDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
identifiers: string[];
|
||||
labelFromModel?: boolean;
|
||||
}
|
||||
|
||||
export interface GeminiCliQuotaGroupDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
modelIds: string[];
|
||||
}
|
||||
|
||||
export interface GeminiCliParsedBucket {
|
||||
modelId: string;
|
||||
tokenType: string | null;
|
||||
remainingFraction: number | null;
|
||||
remainingAmount: number | null;
|
||||
resetTime: string | undefined;
|
||||
}
|
||||
|
||||
export interface CodexUsageWindow {
|
||||
used_percent?: number | string;
|
||||
usedPercent?: number | string;
|
||||
limit_window_seconds?: number | string;
|
||||
limitWindowSeconds?: number | string;
|
||||
reset_after_seconds?: number | string;
|
||||
resetAfterSeconds?: number | string;
|
||||
reset_at?: number | string;
|
||||
resetAt?: number | string;
|
||||
}
|
||||
|
||||
export interface CodexRateLimitInfo {
|
||||
allowed?: boolean;
|
||||
limit_reached?: boolean;
|
||||
limitReached?: boolean;
|
||||
primary_window?: CodexUsageWindow | null;
|
||||
primaryWindow?: CodexUsageWindow | null;
|
||||
secondary_window?: CodexUsageWindow | null;
|
||||
secondaryWindow?: CodexUsageWindow | null;
|
||||
}
|
||||
|
||||
export interface CodexUsagePayload {
|
||||
plan_type?: string;
|
||||
planType?: string;
|
||||
rate_limit?: CodexRateLimitInfo | null;
|
||||
rateLimit?: CodexRateLimitInfo | null;
|
||||
code_review_rate_limit?: CodexRateLimitInfo | null;
|
||||
codeReviewRateLimit?: CodexRateLimitInfo | null;
|
||||
}
|
||||
|
||||
// Quota state types
|
||||
export interface AntigravityQuotaGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
212
src/utils/quota/builders.ts
Normal file
212
src/utils/quota/builders.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Builder functions for constructing quota data structures.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AntigravityQuotaGroup,
|
||||
AntigravityQuotaGroupDefinition,
|
||||
AntigravityQuotaInfo,
|
||||
AntigravityModelsPayload,
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState
|
||||
} from '@/types';
|
||||
import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants';
|
||||
import { normalizeQuotaFraction } from './parsers';
|
||||
import { isIgnoredGeminiCliModel } from './validators';
|
||||
|
||||
export function pickEarlierResetTime(current?: string, next?: string): string | undefined {
|
||||
if (!current) return next;
|
||||
if (!next) return current;
|
||||
const currentTime = new Date(current).getTime();
|
||||
const nextTime = new Date(next).getTime();
|
||||
if (Number.isNaN(currentTime)) return next;
|
||||
if (Number.isNaN(nextTime)) return current;
|
||||
return currentTime <= nextTime ? current : next;
|
||||
}
|
||||
|
||||
export function minNullableNumber(current: number | null, next: number | null): number | null {
|
||||
if (current === null) return next;
|
||||
if (next === null) return current;
|
||||
return Math.min(current, next);
|
||||
}
|
||||
|
||||
export function buildGeminiCliQuotaBuckets(
|
||||
buckets: GeminiCliParsedBucket[]
|
||||
): GeminiCliQuotaBucketState[] {
|
||||
if (buckets.length === 0) return [];
|
||||
|
||||
const grouped = new Map<string, GeminiCliQuotaBucketState & { modelIds: string[] }>();
|
||||
|
||||
buckets.forEach((bucket) => {
|
||||
if (isIgnoredGeminiCliModel(bucket.modelId)) return;
|
||||
const group = GEMINI_CLI_GROUP_LOOKUP.get(bucket.modelId);
|
||||
const groupId = group?.id ?? bucket.modelId;
|
||||
const label = group?.label ?? bucket.modelId;
|
||||
const tokenKey = bucket.tokenType ?? '';
|
||||
const mapKey = `${groupId}::${tokenKey}`;
|
||||
const existing = grouped.get(mapKey);
|
||||
|
||||
if (!existing) {
|
||||
grouped.set(mapKey, {
|
||||
id: `${groupId}${tokenKey ? `-${tokenKey}` : ''}`,
|
||||
label,
|
||||
remainingFraction: bucket.remainingFraction,
|
||||
remainingAmount: bucket.remainingAmount,
|
||||
resetTime: bucket.resetTime,
|
||||
tokenType: bucket.tokenType,
|
||||
modelIds: [bucket.modelId]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.remainingFraction = minNullableNumber(
|
||||
existing.remainingFraction,
|
||||
bucket.remainingFraction
|
||||
);
|
||||
existing.remainingAmount = minNullableNumber(existing.remainingAmount, bucket.remainingAmount);
|
||||
existing.resetTime = pickEarlierResetTime(existing.resetTime, bucket.resetTime);
|
||||
existing.modelIds.push(bucket.modelId);
|
||||
});
|
||||
|
||||
return Array.from(grouped.values()).map((bucket) => {
|
||||
const uniqueModelIds = Array.from(new Set(bucket.modelIds));
|
||||
return {
|
||||
id: bucket.id,
|
||||
label: bucket.label,
|
||||
remainingFraction: bucket.remainingFraction,
|
||||
remainingAmount: bucket.remainingAmount,
|
||||
resetTime: bucket.resetTime,
|
||||
tokenType: bucket.tokenType,
|
||||
modelIds: uniqueModelIds
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
||||
remainingFraction: number | null;
|
||||
resetTime?: string;
|
||||
displayName?: string;
|
||||
} {
|
||||
if (!entry) {
|
||||
return { remainingFraction: null };
|
||||
}
|
||||
const quotaInfo = entry.quotaInfo ?? entry.quota_info ?? {};
|
||||
const remainingValue =
|
||||
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
|
||||
const remainingFraction = normalizeQuotaFraction(remainingValue);
|
||||
const resetValue = quotaInfo.resetTime ?? quotaInfo.reset_time;
|
||||
const resetTime = typeof resetValue === 'string' ? resetValue : undefined;
|
||||
const displayName = typeof entry.displayName === 'string' ? entry.displayName : undefined;
|
||||
|
||||
return {
|
||||
remainingFraction,
|
||||
resetTime,
|
||||
displayName
|
||||
};
|
||||
}
|
||||
|
||||
export function findAntigravityModel(
|
||||
models: AntigravityModelsPayload,
|
||||
identifier: string
|
||||
): { id: string; entry: AntigravityQuotaInfo } | null {
|
||||
const direct = models[identifier];
|
||||
if (direct) {
|
||||
return { id: identifier, entry: direct };
|
||||
}
|
||||
|
||||
const match = Object.entries(models).find(([, entry]) => {
|
||||
const name = typeof entry?.displayName === 'string' ? entry.displayName : '';
|
||||
return name.toLowerCase() === identifier.toLowerCase();
|
||||
});
|
||||
if (match) {
|
||||
return { id: match[0], entry: match[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAntigravityQuotaGroups(
|
||||
models: AntigravityModelsPayload
|
||||
): AntigravityQuotaGroup[] {
|
||||
const groups: AntigravityQuotaGroup[] = [];
|
||||
let geminiProResetTime: string | undefined;
|
||||
const [claudeDef, geminiProDef, flashDef, flashLiteDef, cuDef, geminiFlashDef, imageDef] =
|
||||
ANTIGRAVITY_QUOTA_GROUPS;
|
||||
|
||||
const buildGroup = (
|
||||
def: AntigravityQuotaGroupDefinition,
|
||||
overrideResetTime?: string
|
||||
): AntigravityQuotaGroup | null => {
|
||||
const matches = def.identifiers
|
||||
.map((identifier) => findAntigravityModel(models, identifier))
|
||||
.filter((entry): entry is { id: string; entry: AntigravityQuotaInfo } => Boolean(entry));
|
||||
|
||||
const quotaEntries = matches
|
||||
.map(({ id, entry }) => {
|
||||
const info = getAntigravityQuotaInfo(entry);
|
||||
const remainingFraction = info.remainingFraction ?? (info.resetTime ? 0 : null);
|
||||
if (remainingFraction === null) return null;
|
||||
return {
|
||||
id,
|
||||
remainingFraction,
|
||||
resetTime: info.resetTime,
|
||||
displayName: info.displayName
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||
|
||||
if (quotaEntries.length === 0) return null;
|
||||
|
||||
const remainingFraction = Math.min(...quotaEntries.map((entry) => entry.remainingFraction));
|
||||
const resetTime =
|
||||
overrideResetTime ?? quotaEntries.map((entry) => entry.resetTime).find(Boolean);
|
||||
const displayName = quotaEntries.map((entry) => entry.displayName).find(Boolean);
|
||||
const label = def.labelFromModel && displayName ? displayName : def.label;
|
||||
|
||||
return {
|
||||
id: def.id,
|
||||
label,
|
||||
models: quotaEntries.map((entry) => entry.id),
|
||||
remainingFraction,
|
||||
resetTime
|
||||
};
|
||||
};
|
||||
|
||||
const claudeGroup = buildGroup(claudeDef);
|
||||
if (claudeGroup) {
|
||||
groups.push(claudeGroup);
|
||||
}
|
||||
|
||||
const geminiProGroup = buildGroup(geminiProDef);
|
||||
if (geminiProGroup) {
|
||||
geminiProResetTime = geminiProGroup.resetTime;
|
||||
groups.push(geminiProGroup);
|
||||
}
|
||||
|
||||
const flashGroup = buildGroup(flashDef);
|
||||
if (flashGroup) {
|
||||
groups.push(flashGroup);
|
||||
}
|
||||
|
||||
const flashLiteGroup = buildGroup(flashLiteDef);
|
||||
if (flashLiteGroup) {
|
||||
groups.push(flashLiteGroup);
|
||||
}
|
||||
|
||||
const cuGroup = buildGroup(cuDef);
|
||||
if (cuGroup) {
|
||||
groups.push(cuGroup);
|
||||
}
|
||||
|
||||
const geminiFlashGroup = buildGroup(geminiFlashDef);
|
||||
if (geminiFlashGroup) {
|
||||
groups.push(geminiFlashGroup);
|
||||
}
|
||||
|
||||
const imageGroup = buildGroup(imageDef, geminiProResetTime);
|
||||
if (imageGroup) {
|
||||
groups.push(imageGroup);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
159
src/utils/quota/constants.ts
Normal file
159
src/utils/quota/constants.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Quota constants for API URLs, headers, and theme colors.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AntigravityQuotaGroupDefinition,
|
||||
GeminiCliQuotaGroupDefinition,
|
||||
TypeColorSet
|
||||
} from '@/types';
|
||||
|
||||
// Theme colors for type badges
|
||||
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
qwen: {
|
||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||
dark: { bg: '#1b5e20', text: '#81c784' }
|
||||
},
|
||||
gemini: {
|
||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' }
|
||||
},
|
||||
'gemini-cli': {
|
||||
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||
dark: { bg: '#1c3f73', text: '#a8c7ff' }
|
||||
},
|
||||
aistudio: {
|
||||
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||||
dark: { bg: '#373c42', text: '#cfd3db' }
|
||||
},
|
||||
claude: {
|
||||
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||
dark: { bg: '#880e4f', text: '#f48fb1' }
|
||||
},
|
||||
codex: {
|
||||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||
dark: { bg: '#e65100', text: '#ffb74d' }
|
||||
},
|
||||
antigravity: {
|
||||
light: { bg: '#e0f7fa', text: '#006064' },
|
||||
dark: { bg: '#004d40', text: '#80deea' }
|
||||
},
|
||||
iflow: {
|
||||
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||
dark: { bg: '#4a148c', text: '#ce93d8' }
|
||||
},
|
||||
empty: {
|
||||
light: { bg: '#f5f5f5', text: '#616161' },
|
||||
dark: { bg: '#424242', text: '#bdbdbd' }
|
||||
},
|
||||
unknown: {
|
||||
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
||||
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
|
||||
}
|
||||
};
|
||||
|
||||
// Antigravity API configuration
|
||||
export const ANTIGRAVITY_QUOTA_URLS = [
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'
|
||||
];
|
||||
|
||||
export const ANTIGRAVITY_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/1.11.5 windows/amd64'
|
||||
};
|
||||
|
||||
export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||
{
|
||||
id: 'claude-gpt',
|
||||
label: 'Claude/GPT',
|
||||
identifiers: [
|
||||
'claude-sonnet-4-5-thinking',
|
||||
'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-5',
|
||||
'gpt-oss-120b-medium'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-pro',
|
||||
label: 'Gemini 3 Pro',
|
||||
identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low']
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-flash',
|
||||
label: 'Gemini 2.5 Flash',
|
||||
identifiers: ['gemini-2.5-flash', 'gemini-2.5-flash-thinking']
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-flash-lite',
|
||||
label: 'Gemini 2.5 Flash Lite',
|
||||
identifiers: ['gemini-2.5-flash-lite']
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-cu',
|
||||
label: 'Gemini 2.5 CU',
|
||||
identifiers: ['rev19-uic3-1p']
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-flash',
|
||||
label: 'Gemini 3 Flash',
|
||||
identifiers: ['gemini-3-flash']
|
||||
},
|
||||
{
|
||||
id: 'gemini-image',
|
||||
label: 'gemini-3-pro-image',
|
||||
identifiers: ['gemini-3-pro-image'],
|
||||
labelFromModel: true
|
||||
}
|
||||
];
|
||||
|
||||
// Gemini CLI API configuration
|
||||
export const GEMINI_CLI_QUOTA_URL =
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
||||
|
||||
export const GEMINI_CLI_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
||||
{
|
||||
id: 'gemini-2-5-flash-series',
|
||||
label: 'Gemini 2.5 Flash Series',
|
||||
modelIds: ['gemini-2.5-flash', 'gemini-2.5-flash-lite']
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-pro',
|
||||
label: 'Gemini 2.5 Pro',
|
||||
modelIds: ['gemini-2.5-pro']
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-pro-preview',
|
||||
label: 'Gemini 3 Pro Preview',
|
||||
modelIds: ['gemini-3-pro-preview']
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-flash-preview',
|
||||
label: 'Gemini 3 Flash Preview',
|
||||
modelIds: ['gemini-3-flash-preview']
|
||||
}
|
||||
];
|
||||
|
||||
export const GEMINI_CLI_GROUP_LOOKUP = new Map(
|
||||
GEMINI_CLI_QUOTA_GROUPS.flatMap((group) =>
|
||||
group.modelIds.map((modelId) => [modelId, group] as const)
|
||||
)
|
||||
);
|
||||
|
||||
export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
|
||||
|
||||
// Codex API configuration
|
||||
export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
|
||||
|
||||
export const CODEX_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal'
|
||||
};
|
||||
68
src/utils/quota/formatters.ts
Normal file
68
src/utils/quota/formatters.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Formatting functions for quota display.
|
||||
*/
|
||||
|
||||
import type { CodexUsageWindow } from '@/types';
|
||||
import { normalizeNumberValue } from './parsers';
|
||||
|
||||
export function formatQuotaResetTime(value?: string): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
export function formatUnixSeconds(value: number | null): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value * 1000);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCodexResetLabel(window?: CodexUsageWindow | null): string {
|
||||
if (!window) return '-';
|
||||
const resetAt = normalizeNumberValue(window.reset_at ?? window.resetAt);
|
||||
if (resetAt !== null && resetAt > 0) {
|
||||
return formatUnixSeconds(resetAt);
|
||||
}
|
||||
const resetAfter = normalizeNumberValue(window.reset_after_seconds ?? window.resetAfterSeconds);
|
||||
if (resetAfter !== null && resetAfter > 0) {
|
||||
const targetSeconds = Math.floor(Date.now() / 1000 + resetAfter);
|
||||
return formatUnixSeconds(targetSeconds);
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
export function createStatusError(message: string, status?: number): Error & { status?: number } {
|
||||
const error = new Error(message) as Error & { status?: number };
|
||||
if (status !== undefined) {
|
||||
error.status = status;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
export function getStatusFromError(err: unknown): number | undefined {
|
||||
if (typeof err === 'object' && err !== null && 'status' in err) {
|
||||
const rawStatus = (err as { status?: unknown }).status;
|
||||
if (typeof rawStatus === 'number' && Number.isFinite(rawStatus)) {
|
||||
return rawStatus;
|
||||
}
|
||||
const asNumber = Number(rawStatus);
|
||||
if (Number.isFinite(asNumber) && asNumber > 0) {
|
||||
return asNumber;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
10
src/utils/quota/index.ts
Normal file
10
src/utils/quota/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Quota utility functions barrel export.
|
||||
*/
|
||||
|
||||
export * from './constants';
|
||||
export * from './parsers';
|
||||
export * from './resolvers';
|
||||
export * from './formatters';
|
||||
export * from './validators';
|
||||
export * from './builders';
|
||||
153
src/utils/quota/parsers.ts
Normal file
153
src/utils/quota/parsers.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Normalization and parsing functions for quota data.
|
||||
*/
|
||||
|
||||
import type { CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
|
||||
|
||||
export 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 ? trimmed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeStringValue(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeNumberValue(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeQuotaFraction(value: unknown): number | null {
|
||||
const normalized = normalizeNumberValue(value);
|
||||
if (normalized !== null) return normalized;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.endsWith('%')) {
|
||||
const parsed = Number(trimmed.slice(0, -1));
|
||||
return Number.isFinite(parsed) ? parsed / 100 : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizePlanType(value: unknown): string | null {
|
||||
const normalized = normalizeStringValue(value);
|
||||
return normalized ? normalized.toLowerCase() : null;
|
||||
}
|
||||
|
||||
export function decodeBase64UrlPayload(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const normalized = trimmed.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
if (typeof window !== 'undefined' && typeof window.atob === 'function') {
|
||||
return window.atob(padded);
|
||||
}
|
||||
if (typeof atob === 'function') {
|
||||
return atob(padded);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseIdTokenPayload(value: unknown): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') {
|
||||
return Array.isArray(value) ? null : (value as Record<string, unknown>);
|
||||
}
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
if (parsed && typeof parsed === 'object') return parsed;
|
||||
} catch {
|
||||
// Continue to JWT parsing
|
||||
}
|
||||
const segments = trimmed.split('.');
|
||||
if (segments.length < 2) return null;
|
||||
const decoded = decodeBase64UrlPayload(segments[1]);
|
||||
if (!decoded) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
||||
if (parsed && typeof parsed === 'object') return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseAntigravityPayload(payload: unknown): Record<string, unknown> | 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 Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | 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 CodexUsagePayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as CodexUsagePayload;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayload | 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 GeminiCliQuotaPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as GeminiCliQuotaPayload;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
112
src/utils/quota/resolvers.ts
Normal file
112
src/utils/quota/resolvers.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Resolver functions for extracting data from auth files.
|
||||
*/
|
||||
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import {
|
||||
normalizeStringValue,
|
||||
normalizePlanType,
|
||||
parseIdTokenPayload
|
||||
} from './parsers';
|
||||
|
||||
export function extractCodexChatgptAccountId(value: unknown): string | null {
|
||||
const payload = parseIdTokenPayload(value);
|
||||
if (!payload) return null;
|
||||
return normalizeStringValue(payload.chatgpt_account_id ?? payload.chatgptAccountId);
|
||||
}
|
||||
|
||||
export function resolveCodexChatgptAccountId(file: AuthFileItem): string | null {
|
||||
const metadata =
|
||||
file && typeof file.metadata === 'object' && file.metadata !== null
|
||||
? (file.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
const attributes =
|
||||
file && typeof file.attributes === 'object' && file.attributes !== null
|
||||
? (file.attributes as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const candidates = [file.id_token, metadata?.id_token, attributes?.id_token];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const id = extractCodexChatgptAccountId(candidate);
|
||||
if (id) return id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveCodexPlanType(file: AuthFileItem): string | null {
|
||||
const metadata =
|
||||
file && typeof file.metadata === 'object' && file.metadata !== null
|
||||
? (file.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
const attributes =
|
||||
file && typeof file.attributes === 'object' && file.attributes !== null
|
||||
? (file.attributes as Record<string, unknown>)
|
||||
: null;
|
||||
const idToken =
|
||||
file && typeof file.id_token === 'object' && file.id_token !== null
|
||||
? (file.id_token as Record<string, unknown>)
|
||||
: null;
|
||||
const metadataIdToken =
|
||||
metadata && typeof metadata.id_token === 'object' && metadata.id_token !== null
|
||||
? (metadata.id_token as Record<string, unknown>)
|
||||
: null;
|
||||
const candidates = [
|
||||
file.plan_type,
|
||||
file.planType,
|
||||
file['plan_type'],
|
||||
file['planType'],
|
||||
file.id_token,
|
||||
idToken?.plan_type,
|
||||
idToken?.planType,
|
||||
metadata?.plan_type,
|
||||
metadata?.planType,
|
||||
metadata?.id_token,
|
||||
metadataIdToken?.plan_type,
|
||||
metadataIdToken?.planType,
|
||||
attributes?.plan_type,
|
||||
attributes?.planType,
|
||||
attributes?.id_token
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const planType = normalizePlanType(candidate);
|
||||
if (planType) return planType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractGeminiCliProjectId(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const matches = Array.from(value.matchAll(/\(([^()]+)\)/g));
|
||||
if (matches.length === 0) return null;
|
||||
const candidate = matches[matches.length - 1]?.[1]?.trim();
|
||||
return candidate ? candidate : null;
|
||||
}
|
||||
|
||||
export function resolveGeminiCliProjectId(file: AuthFileItem): string | null {
|
||||
const metadata =
|
||||
file && typeof file.metadata === 'object' && file.metadata !== null
|
||||
? (file.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
const attributes =
|
||||
file && typeof file.attributes === 'object' && file.attributes !== null
|
||||
? (file.attributes as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const candidates = [
|
||||
file.account,
|
||||
file['account'],
|
||||
metadata?.account,
|
||||
attributes?.account
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const projectId = extractGeminiCliProjectId(candidate);
|
||||
if (projectId) return projectId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
36
src/utils/quota/validators.ts
Normal file
36
src/utils/quota/validators.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Validation and type checking functions for quota management.
|
||||
*/
|
||||
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { GEMINI_CLI_IGNORED_MODEL_PREFIXES } from './constants';
|
||||
|
||||
export function resolveAuthProvider(file: AuthFileItem): string {
|
||||
const raw = file.provider ?? file.type ?? '';
|
||||
return String(raw).trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isAntigravityFile(file: AuthFileItem): boolean {
|
||||
return resolveAuthProvider(file) === 'antigravity';
|
||||
}
|
||||
|
||||
export function isCodexFile(file: AuthFileItem): boolean {
|
||||
return resolveAuthProvider(file) === 'codex';
|
||||
}
|
||||
|
||||
export function isGeminiCliFile(file: AuthFileItem): boolean {
|
||||
return resolveAuthProvider(file) === 'gemini-cli';
|
||||
}
|
||||
|
||||
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
||||
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isIgnoredGeminiCliModel(modelId: string): boolean {
|
||||
return GEMINI_CLI_IGNORED_MODEL_PREFIXES.some(
|
||||
(prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`)
|
||||
);
|
||||
}
|
||||
142
src/utils/usage/chartConfig.ts
Normal file
142
src/utils/usage/chartConfig.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Chart.js configuration utilities for usage statistics
|
||||
* Extracted from UsagePage.tsx for reusability
|
||||
*/
|
||||
|
||||
import type { ChartOptions } from 'chart.js';
|
||||
|
||||
/**
|
||||
* Static sparkline chart options (no dependencies on theme/mobile)
|
||||
*/
|
||||
export const sparklineOptions: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||
scales: { x: { display: false }, y: { display: false } },
|
||||
elements: { line: { tension: 0.45 }, point: { radius: 0 } }
|
||||
};
|
||||
|
||||
export interface ChartConfigOptions {
|
||||
period: 'hour' | 'day';
|
||||
labels: string[];
|
||||
isDark: boolean;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build chart options with theme and responsive awareness
|
||||
*/
|
||||
export function buildChartOptions({
|
||||
period,
|
||||
labels,
|
||||
isDark,
|
||||
isMobile
|
||||
}: ChartConfigOptions): ChartOptions<'line'> {
|
||||
const pointRadius = isMobile && period === 'hour' ? 0 : isMobile ? 2 : 4;
|
||||
const tickFontSize = isMobile ? 10 : 12;
|
||||
const maxTickLabelCount = isMobile ? (period === 'hour' ? 8 : 6) : period === 'hour' ? 12 : 10;
|
||||
const gridColor = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(17, 24, 39, 0.06)';
|
||||
const axisBorderColor = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)';
|
||||
const tickColor = isDark ? 'rgba(255, 255, 255, 0.72)' : 'rgba(17, 24, 39, 0.72)';
|
||||
const tooltipBg = isDark ? 'rgba(17, 24, 39, 0.92)' : 'rgba(255, 255, 255, 0.98)';
|
||||
const tooltipTitle = isDark ? '#ffffff' : '#111827';
|
||||
const tooltipBody = isDark ? 'rgba(255, 255, 255, 0.86)' : '#374151';
|
||||
const tooltipBorder = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)';
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: tooltipBg,
|
||||
titleColor: tooltipTitle,
|
||||
bodyColor: tooltipBody,
|
||||
borderColor: tooltipBorder,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: true,
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: gridColor,
|
||||
drawTicks: false
|
||||
},
|
||||
border: {
|
||||
color: axisBorderColor
|
||||
},
|
||||
ticks: {
|
||||
color: tickColor,
|
||||
font: { size: tickFontSize },
|
||||
maxRotation: isMobile ? 0 : 45,
|
||||
minRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: maxTickLabelCount,
|
||||
callback: (value) => {
|
||||
const index = typeof value === 'number' ? value : Number(value);
|
||||
const raw =
|
||||
Number.isFinite(index) && labels[index] ? labels[index] : typeof value === 'string' ? value : '';
|
||||
|
||||
if (period === 'hour') {
|
||||
const [md, time] = raw.split(' ');
|
||||
if (!time) return raw;
|
||||
if (time.startsWith('00:')) {
|
||||
return md ? [md, time] : time;
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
const parts = raw.split('-');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[1]}-${parts[2]}`;
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: gridColor
|
||||
},
|
||||
border: {
|
||||
color: axisBorderColor
|
||||
},
|
||||
ticks: {
|
||||
color: tickColor,
|
||||
font: { size: tickFontSize }
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.35,
|
||||
borderWidth: isMobile ? 1.5 : 2
|
||||
},
|
||||
point: {
|
||||
borderWidth: 2,
|
||||
radius: pointRadius,
|
||||
hoverRadius: 4
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate minimum chart width for hourly data on mobile devices
|
||||
*/
|
||||
export function getHourChartMinWidth(labelCount: number, isMobile: boolean): string | undefined {
|
||||
if (!isMobile || labelCount <= 0) return undefined;
|
||||
const perPoint = 56;
|
||||
const minWidth = Math.min(labelCount * perPoint, 3000);
|
||||
return `${minWidth}px`;
|
||||
}
|
||||
6
src/utils/usage/index.ts
Normal file
6
src/utils/usage/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Chart configuration utilities
|
||||
export { sparklineOptions, buildChartOptions, getHourChartMinWidth } from './chartConfig';
|
||||
export type { ChartConfigOptions } from './chartConfig';
|
||||
|
||||
// Re-export everything from the main usage.ts for backwards compatibility
|
||||
export * from '../usage';
|
||||
Reference in New Issue
Block a user