Compare commits

..

7 Commits

57 changed files with 5999 additions and 4925 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ CLAUDE.md
AGENTS.md
antigravity_usage.json
codex_usage.json
style.md
node_modules
dist

View File

@@ -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;

View File

@@ -131,6 +131,7 @@ export function PageTransition({
opacity: 1,
duration: TRANSITION_DURATION,
ease: 'power2.in', // slow start, fast end
clearProps: 'transform,opacity',
},
0
);

View 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>
);
}

View 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}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { AmpcodeSection } from './AmpcodeSection';

View 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>
);
}

View 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}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { ClaudeSection } from './ClaudeSection';

View 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>
);
}

View 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}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { CodexSection } from './CodexSection';

View 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>
);
}

View 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}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { GeminiSection } from './GeminiSection';

View 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>
);
}

View 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}
/>
</>
);
}

View 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}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { OpenAISection } from './OpenAISection';

View 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>
);
}

View 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>
);
}

View 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 };
};

View 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';

View 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;
}

View 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),
});

View 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;
};

View 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>
);
}

View 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';

View 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
};

View 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 };
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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
};
}

View 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
};
}

View 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
};
}

View 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

View File

@@ -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;

View File

@@ -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}>

View File

@@ -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

View File

@@ -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
View 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;
}

View 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'
};

View 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
View 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
View 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;
}

View 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;
}

View 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}-`)
);
}

View 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
View 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';