fix: replace native dialogs and improve quota reset handling
- Use consistent in-app dialogs for confirmation actions - Keep quota refreshes free of prompts and automatic paid probes - Add a separate confirmation flow for xAI availability tests - Require explicit approval for Codex resets and prevent duplicate requests - Show reset outcomes and require a quota refresh before retrying failures - Cover confirmation and reset flows with fully mocked tests
This commit is contained in:
1 parent
adb708c32a
commit
911047d009
18 files changed
+571
-186
No files matched your search
@@ -9,7 +9,6 @@
|
||||
"core:window:allow-set-theme",
|
||||
"core:window:allow-set-background-color",
|
||||
"core:webview:allow-set-webview-background-color",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-message"
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export type ConfirmationOptions = {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
warning?: string;
|
||||
details?: { label: string; value: string }[];
|
||||
variant?: 'primary' | 'danger';
|
||||
};
|
||||
|
||||
type ConfirmationDialogProps = ConfirmationOptions & { onDecision: (confirmed: boolean) => void };
|
||||
|
||||
export function ConfirmationDialog({ title, message, confirmText, warning, details, variant = 'primary', onDecision }: ConfirmationDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const dialogRef = useRef<HTMLElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
const previousFocus = document.activeElement;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
cancelRef.current?.focus();
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDecision(false);
|
||||
} else if (event.key === 'Tab') {
|
||||
const buttons = Array.from(dialogRef.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? []);
|
||||
const first = buttons[0];
|
||||
const last = buttons[buttons.length - 1];
|
||||
if (event.shiftKey && (document.activeElement === first || !dialogRef.current?.contains(document.activeElement))) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && (document.activeElement === last || !dialogRef.current?.contains(document.activeElement))) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown, true);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
|
||||
};
|
||||
}, [onDecision]);
|
||||
|
||||
const content = (
|
||||
<div className="config-dialog-backdrop app-confirm-backdrop" onMouseDown={(event) => {
|
||||
if (event.currentTarget !== event.target) return;
|
||||
event.preventDefault();
|
||||
onDecision(false);
|
||||
}}>
|
||||
<section ref={dialogRef} className="config-dialog app-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId}>
|
||||
<div className="config-dialog-heading">
|
||||
<div><AlertTriangle size={20} aria-hidden="true" /><h2 id={titleId}>{title}</h2></div>
|
||||
<button type="button" className="icon-button quiet" onClick={() => onDecision(false)} title={t('common.close')}><X size={18} /></button>
|
||||
</div>
|
||||
<p id={descriptionId}>{message}</p>
|
||||
{details?.length ? <dl className="app-confirm-details">{details.map((detail) => <div key={detail.label}><dt>{detail.label}</dt><dd>{detail.value}</dd></div>)}</dl> : null}
|
||||
{warning ? <p className="app-confirm-warning">{warning}</p> : null}
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button ref={cancelRef} type="button" className="secondary-button" onClick={() => onDecision(false)}>{t('common.cancel')}</button>
|
||||
<button type="button" className={variant === 'danger' ? 'danger-button' : 'primary-button'} onClick={() => onDecision(true)}>{confirmText ?? t('common.confirm')}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
return typeof document === 'undefined' ? content : createPortal(content, document.body);
|
||||
}
|
||||
|
||||
type PendingConfirmation = { options: ConfirmationOptions; resolve: (confirmed: boolean) => void };
|
||||
|
||||
export function useConfirmation() {
|
||||
const pendingRef = useRef<PendingConfirmation | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const [pending, setPending] = useState<PendingConfirmation | null>(null);
|
||||
const askConfirmation = useCallback((options: ConfirmationOptions): Promise<boolean> => {
|
||||
if (!mountedRef.current) return Promise.resolve(false);
|
||||
pendingRef.current?.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const request = { options, resolve };
|
||||
pendingRef.current = request;
|
||||
setPending(request);
|
||||
});
|
||||
}, []);
|
||||
const decide = useCallback((confirmed: boolean) => {
|
||||
if (!pending || pendingRef.current !== pending) return;
|
||||
pendingRef.current = null;
|
||||
setPending(null);
|
||||
pending.resolve(confirmed);
|
||||
}, [pending]);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
pendingRef.current?.resolve(false);
|
||||
pendingRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
return {
|
||||
askConfirmation,
|
||||
confirmationDialog: pending ? <ConfirmationDialog {...pending.options} onDecision={decide} /> : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import type { QuotaState } from '../services/quotaService';
|
||||
|
||||
export function QuotaActionFeedback({ quota }: { quota: QuotaState }) {
|
||||
const { t } = useI18n();
|
||||
const result = quota.actionResult;
|
||||
if (!result) return null;
|
||||
const successful = result.status === 'success';
|
||||
const message = result.action === 'probe'
|
||||
? t(successful ? 'quota.probe.success' : 'quota.probe.failed', { error: result.error ?? '' })
|
||||
: t(successful ? 'quota.resetResult.submitted' : result.status === 'refresh-error'
|
||||
? 'quota.resetResult.refreshFailed' : 'quota.resetResult.failed', { error: result.error ?? '' });
|
||||
return (
|
||||
<div className={successful ? 'quota-action-feedback success' : 'quota-action-feedback error'} role={successful ? 'status' : 'alert'}>
|
||||
{successful ? <CheckCircle2 size={16} aria-hidden="true" /> : <AlertCircle size={16} aria-hidden="true" />}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+21
-2
@@ -185,6 +185,25 @@ export const jaOverrides = {
|
||||
'quota.empty.title': '照会可能なクォータがありません',
|
||||
'quota.empty.description': 'まず認証ファイルにクォータ照会対応の認証情報を追加してください。',
|
||||
'quota.unknownProvider': '不明',
|
||||
'common.confirm': '確認',
|
||||
'quota.probe.button': '利用可否をテスト',
|
||||
'quota.probe.title': 'xAI 有料 API のテスト',
|
||||
'quota.probe.confirm': '「{name}」の有料 API の利用可否をテストしますか?',
|
||||
'quota.probe.confirmButton': 'テストを実行',
|
||||
'quota.probe.running': '利用可否をテスト中…',
|
||||
'quota.probe.success': '利用可否のテストに成功しました。残りのクォータは取得されません。',
|
||||
'quota.probe.failed': '利用可否のテストに失敗しました:{error}',
|
||||
'quota.service.xaiPaidAccount': '有料 API アカウント',
|
||||
'quota.service.xaiPaidQuotaUnavailable': 'このアカウントの残りクォータは提供されていません。接続の確認は「利用可否をテスト」から実行してください。',
|
||||
'quota.service.error.xaiProbeOnly': 'この利用可否テストは xAI 認証情報のみをサポートします',
|
||||
'quota.confirm.button': 'リセットを実行',
|
||||
'quota.confirm.applicableLabel': '現在適用可能な回数',
|
||||
'quota.resetting': 'リセットを送信してクォータを更新中…',
|
||||
'quota.resetNotApplicable': '現在適用可能なリセット回数がありません。クォータを更新して確認してください',
|
||||
'quota.resetCheckFirst': '前回の結果が未確認です。重複リセットを避けるため、先にクォータを更新してください',
|
||||
'quota.resetResult.submitted': 'リセット要求を送信し、クォータを再取得しました。カードの最新クォータを確認してください。',
|
||||
'quota.resetResult.refreshFailed': 'リセット要求は送信済みですが、クォータの更新に失敗しました:{error}。再消費を避けるため、先にクォータを更新してください。',
|
||||
'quota.resetResult.failed': 'リセットの成功を確認できませんでした:{error}。再試行する前にクォータを更新してください。',
|
||||
'quota.reset': 'クォータをリセット',
|
||||
'quota.fileDisabled': '認証ファイルは無効です',
|
||||
'quota.refresh': 'クォータを取得/更新',
|
||||
@@ -199,7 +218,7 @@ export const jaOverrides = {
|
||||
'quota.subscriptionExpiry': 'サブスクリプション期限:{time}',
|
||||
'quota.resetApplicable': '現在適用可能:{count} 回',
|
||||
'quota.resetCreditsWarning': 'リセット回数の詳細取得に失敗:{error}',
|
||||
'quota.xaiProbeConfirm': 'xAI 有料アカウントはこの請求クォータ API に対応していません。短い grok-4.5 リクエストで利用可能か確認しますか?少額の料金が発生する可能性があり、残りクォータは取得できません。',
|
||||
'quota.xaiProbeConfirm': '最小限の grok-4.5 リクエストを 1 回送信し、少額の料金が発生する可能性があります。利用可否のみをテストし、残りのクォータは取得しません。',
|
||||
'quota.service.window.sevenDayFable': '7 日間 Fable 枠',
|
||||
'quota.service.xaiPaidHealth': '有料 API 利用可能',
|
||||
'quota.service.xaiPaidHealthDetail': '短い会話テストに成功。残りクォータは提供されていません',
|
||||
@@ -209,7 +228,7 @@ export const jaOverrides = {
|
||||
'quota.confirm.cost': 'この操作では手動リセットを 1 回消費します。',
|
||||
'quota.confirm.available': '現在利用可能:{count} 回',
|
||||
'quota.confirm.expiry': '最も早い有効期限:{time}',
|
||||
'quota.confirm.warning': '「確認」を押した場合のみ実行されます。「キャンセル」ではリセット回数を消費しません。',
|
||||
'quota.confirm.warning': '手動リセットを 1 回消費します。「リセットを実行」をクリックした場合のみ実行します。キャンセルや閉じる操作では消費しません。',
|
||||
'oauth.title': 'OAuth ログイン',
|
||||
'oauth.tabs.label': 'OAuth 管理ページ',
|
||||
'oauth.loginSuccess': '{provider} へのログインに成功しました',
|
||||
|
||||
+21
-2
@@ -172,6 +172,25 @@ export const en: Record<MessageKey, string> = {
|
||||
'quota.empty.title': 'No quota data available',
|
||||
'quota.empty.description': 'Add a credential that supports quota queries under Auth Files first.',
|
||||
'quota.unknownProvider': 'Unknown',
|
||||
'common.confirm': 'Confirm',
|
||||
'quota.probe.button': 'Test Availability',
|
||||
'quota.probe.title': 'Test xAI Paid API',
|
||||
'quota.probe.confirm': 'Test paid API availability for "{name}"?',
|
||||
'quota.probe.confirmButton': 'Confirm Test',
|
||||
'quota.probe.running': 'Testing availability…',
|
||||
'quota.probe.success': 'Availability test passed; this test does not report remaining quota.',
|
||||
'quota.probe.failed': 'Availability test failed: {error}',
|
||||
'quota.service.xaiPaidAccount': 'Paid API account',
|
||||
'quota.service.xaiPaidQuotaUnavailable': 'Remaining quota is not provided for this account. Use Test Availability separately to check the connection.',
|
||||
'quota.service.error.xaiProbeOnly': 'This availability test only supports xAI credentials',
|
||||
'quota.confirm.button': 'Confirm Reset',
|
||||
'quota.confirm.applicableLabel': 'Currently applicable resets',
|
||||
'quota.resetting': 'Submitting reset and refreshing quota…',
|
||||
'quota.resetNotApplicable': 'No reset credits currently apply. Refresh quota before trying again',
|
||||
'quota.resetCheckFirst': 'The previous result is unconfirmed. Refresh quota first to avoid another reset',
|
||||
'quota.resetResult.submitted': 'The reset request was submitted and quota was refreshed. Check the latest quota on this card.',
|
||||
'quota.resetResult.refreshFailed': 'The reset request was submitted, but refreshing quota failed: {error}. Refresh quota first to avoid consuming another reset credit.',
|
||||
'quota.resetResult.failed': 'Could not confirm the reset: {error}. Refresh quota before deciding whether to retry.',
|
||||
'quota.reset': 'Reset Quota',
|
||||
'quota.fileDisabled': 'Authentication file is disabled',
|
||||
'quota.refresh': 'Fetch or refresh quota',
|
||||
@@ -186,7 +205,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'quota.subscriptionExpiry': 'Subscription expires: {time}',
|
||||
'quota.resetApplicable': 'Currently applicable: {count}',
|
||||
'quota.resetCreditsWarning': 'Reset credit details unavailable: {error}',
|
||||
'quota.xaiProbeConfirm': 'Paid xAI accounts do not expose this billing quota API. Send a tiny grok-4.5 request to check availability? This may incur a small charge and does not report remaining quota.',
|
||||
'quota.xaiProbeConfirm': 'This sends one minimal grok-4.5 request and may incur a small charge. It only tests availability and does not report remaining quota.',
|
||||
'quota.service.window.sevenDayFable': '7-day Fable window',
|
||||
'quota.service.xaiPaidHealth': 'Paid API available',
|
||||
'quota.service.xaiPaidHealthDetail': 'Tiny chat probe succeeded; remaining quota is not provided',
|
||||
@@ -196,7 +215,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'quota.confirm.cost': 'This will consume one manual reset credit.',
|
||||
'quota.confirm.available': 'Currently available: {count}',
|
||||
'quota.confirm.expiry': 'Earliest expiry: {time}',
|
||||
'quota.confirm.warning': 'The reset runs only after you confirm. Canceling will not consume a reset credit.',
|
||||
'quota.confirm.warning': 'This consumes one manual reset credit. It runs only after Confirm Reset; canceling or closing does not consume a credit.',
|
||||
'oauth.title': 'OAuth Sign-In',
|
||||
'oauth.tabs.label': 'OAuth management sections',
|
||||
'oauth.loginSuccess': '{provider} sign-in succeeded',
|
||||
|
||||
@@ -171,6 +171,25 @@ export const zhCN = {
|
||||
'quota.empty.title': '暂无可查询配额',
|
||||
'quota.empty.description': '先在凭证文件中添加支持配额查询的凭据。',
|
||||
'quota.unknownProvider': '未知',
|
||||
'common.confirm': '确认',
|
||||
'quota.probe.button': '测试可用性',
|
||||
'quota.probe.title': '测试 xAI 付费 API',
|
||||
'quota.probe.confirm': '确认测试“{name}”的付费 API 可用性吗?',
|
||||
'quota.probe.confirmButton': '确认测试',
|
||||
'quota.probe.running': '正在测试可用性…',
|
||||
'quota.probe.success': '可用性测试通过;此操作不提供剩余额度。',
|
||||
'quota.probe.failed': '可用性测试失败:{error}',
|
||||
'quota.service.xaiPaidAccount': '付费 API 账号',
|
||||
'quota.service.xaiPaidQuotaUnavailable': '此账号不提供剩余额度。如需检测连接,请单独点击“测试可用性”。',
|
||||
'quota.service.error.xaiProbeOnly': '只有 xAI 凭证支持此可用性测试',
|
||||
'quota.confirm.button': '确认重置',
|
||||
'quota.confirm.applicableLabel': '当前适用次数',
|
||||
'quota.resetting': '正在提交重置并刷新额度…',
|
||||
'quota.resetNotApplicable': '当前没有适用的重置次数,请刷新额度后再确认',
|
||||
'quota.resetCheckFirst': '上次操作结果尚未确认,请先刷新额度,避免重复重置',
|
||||
'quota.resetResult.submitted': '重置请求已提交,额度已重新获取。请查看卡片中的最新额度。',
|
||||
'quota.resetResult.refreshFailed': '重置请求已提交,但额度刷新失败:{error}。请先刷新查询,避免重复消耗重置机会。',
|
||||
'quota.resetResult.failed': '无法确认重置成功:{error}。请先刷新额度,再决定是否重试。',
|
||||
'quota.reset': '重置额度',
|
||||
'quota.fileDisabled': '凭证文件已停用',
|
||||
'quota.refresh': '获取/刷新额度',
|
||||
@@ -185,7 +204,7 @@ export const zhCN = {
|
||||
'quota.subscriptionExpiry': '订阅到期:{time}',
|
||||
'quota.resetApplicable': '当前适用:{count} 次',
|
||||
'quota.resetCreditsWarning': '重置次数详情查询失败:{error}',
|
||||
'quota.xaiProbeConfirm': 'xAI 付费账户不提供此账单额度接口。是否发送极短的 grok-4.5 请求检测可用性?这可能产生少量费用,且不会返回剩余额度。',
|
||||
'quota.xaiProbeConfirm': '将发送一次极短的 grok-4.5 请求,可能产生少量费用。此操作仅测试可用性,不会返回剩余额度。',
|
||||
'quota.service.window.sevenDayFable': '7 天 Fable 窗口',
|
||||
'quota.service.xaiPaidHealth': '付费 API 可用',
|
||||
'quota.service.xaiPaidHealthDetail': '极短对话探测成功;上游未提供剩余额度',
|
||||
@@ -195,7 +214,7 @@ export const zhCN = {
|
||||
'quota.confirm.cost': '本操作将消耗 1 次主动重置机会。',
|
||||
'quota.confirm.available': '当前可用:{count} 次',
|
||||
'quota.confirm.expiry': '最早过期:{time}',
|
||||
'quota.confirm.warning': '只有点击“确定”后才会执行;点击“取消”不会消耗重置机会。',
|
||||
'quota.confirm.warning': '将消耗 1 次主动重置机会。只有点击“确认重置”才会执行,取消或关闭不会消耗次数。',
|
||||
'oauth.title': 'OAuth 登录',
|
||||
'oauth.tabs.label': 'OAuth 管理子页面',
|
||||
'oauth.loginSuccess': '{provider} 登录成功',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import {
|
||||
type CSSProperties,
|
||||
FormEvent,
|
||||
@@ -794,6 +795,7 @@ export const providerRecordWithDisabledState = (
|
||||
};
|
||||
|
||||
export function ApiAccessPage() {
|
||||
const { askConfirmation, confirmationDialog } = useConfirmation();
|
||||
const { t } = useI18n();
|
||||
const [records, setRecords] = useState(emptyRecords);
|
||||
const [activeCategory, setActiveCategory] = useState<ProviderCategory>('codex-api-key');
|
||||
@@ -1054,7 +1056,7 @@ export function ApiAccessPage() {
|
||||
};
|
||||
|
||||
const deleteRow = async (row: ProviderRow) => {
|
||||
if (!window.confirm(t('apiAccess.deleteConfirm', { remark: row.remark || row.name }))) return;
|
||||
if (!await askConfirmation({ title: t('common.delete'), message: t('apiAccess.deleteConfirm', { remark: row.remark || row.name }), confirmText: t('common.delete'), variant: 'danger' })) return;
|
||||
setFeedbackRow(providerDragId(row));
|
||||
feedback.clearNotice();
|
||||
setBusy(true);
|
||||
@@ -1176,6 +1178,7 @@ export function ApiAccessPage() {
|
||||
|
||||
return (
|
||||
<section className="page management-page api-access-page">
|
||||
{confirmationDialog}
|
||||
<header className="management-header">
|
||||
<div>
|
||||
<h1>{t('apiAccess.title')}</h1>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
|
||||
import { probeXaiWithConfirmation } from '../services/quotaActions';
|
||||
import { InlineNotice, useAppNotice } from '../appNotice';
|
||||
import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog';
|
||||
import {
|
||||
@@ -119,7 +121,7 @@ function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) {
|
||||
return (
|
||||
<div className="auth-file-quota loading">
|
||||
<LoaderCircle size={13} className="spin" />
|
||||
<span>{t('authFiles.quota.loading')}</span>
|
||||
<span>{t(quota.pendingAction === 'probe' ? 'quota.probe.running' : 'authFiles.quota.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -163,6 +165,7 @@ function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) {
|
||||
|
||||
export function AuthFileManagementPage() {
|
||||
const { t } = useI18n();
|
||||
const { askConfirmation, confirmationDialog } = useConfirmation();
|
||||
const [files, setFiles] = useState<AuthFile[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [providerFilter, setProviderFilter] = useState('all');
|
||||
@@ -226,14 +229,26 @@ export function AuthFileManagementPage() {
|
||||
if (getQuotaCacheSnapshot()[key]?.status === 'loading') return;
|
||||
const cacheGeneration = captureQuotaCacheGeneration();
|
||||
updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } }));
|
||||
const result = await loadQuota(file, {
|
||||
confirmXaiPaidProbe: () => confirm(t('quota.xaiProbeConfirm'), { title: t('quota.title'), kind: 'warning' }),
|
||||
});
|
||||
const result = await loadQuota(file);
|
||||
commitQuotaCacheIfCurrent(cacheGeneration, () => {
|
||||
updateQuotaCache((current) => ({ ...current, [key]: result }));
|
||||
});
|
||||
};
|
||||
|
||||
const probeXai = async (file: AuthFile) => {
|
||||
setError('');
|
||||
try {
|
||||
await probeXaiWithConfirmation(file, () => askConfirmation({
|
||||
title: t('quota.probe.title'),
|
||||
message: t('quota.probe.confirm', { name: fileName(file) }),
|
||||
warning: t('quota.xaiProbeConfirm'),
|
||||
confirmText: t('quota.probe.confirmButton'),
|
||||
}));
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
}
|
||||
};
|
||||
|
||||
const closeOauthModels = () => {
|
||||
if (oauthModelSaveRef.current) return;
|
||||
oauthModelRequestRef.current += 1;
|
||||
@@ -432,7 +447,7 @@ export function AuthFileManagementPage() {
|
||||
setError(t('authFiles.runtimeDeleteError'));
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t('authFiles.deleteConfirm', { name }))) return;
|
||||
if (!await askConfirmation({ title: t('common.delete'), message: t('authFiles.deleteConfirm', { name }), confirmText: t('common.delete'), variant: 'danger' })) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -473,6 +488,7 @@ export function AuthFileManagementPage() {
|
||||
|
||||
return (
|
||||
<section className="page management-page auth-files-page">
|
||||
{confirmationDialog}
|
||||
<header className="management-header">
|
||||
<div>
|
||||
<h1>{t('authFiles.title')}</h1>
|
||||
@@ -542,6 +558,7 @@ export function AuthFileManagementPage() {
|
||||
</div>
|
||||
<div className="auth-file-actions">
|
||||
{quotaProviderForFile(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void refreshQuota(file)} disabled={busy || disabled || quotas[quotaKey(file)]?.status === 'loading'}>{disabled ? t('authFiles.status.disabled') : quotas[quotaKey(file)]?.status === 'loading' ? t('authFiles.quota.querying') : quotas[quotaKey(file)]?.status === 'success' ? t('authFiles.quota.refresh') : t('authFiles.quota.fetch')}</button> : null}
|
||||
{quotaProviderForFile(file) === 'xai' ? <button type="button" className="secondary-button compact-button" onClick={() => void probeXai(file)} disabled={busy || disabled || quotas[quotaKey(file)]?.status === 'loading'}>{t('quota.probe.button')}</button> : null}
|
||||
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
|
||||
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy || oauthModelSaving || isRuntimeOnly(file) || !readString(file, 'name')} title={t(isRuntimeOnly(file) ? 'authFiles.models.runtimeUnsupported' : 'authFiles.models.settings')}>{t('authFiles.models.excludeButton')}</button> : null}
|
||||
<button type="button" className="secondary-button compact-button auth-file-priority-button" onClick={() => openPriorityEditor(file)} disabled={busy} title={t('authFiles.priority.hint')}><Pencil size={14} />{t('authFiles.priority.button', { priority })}</button>
|
||||
@@ -550,6 +567,7 @@ export function AuthFileManagementPage() {
|
||||
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
{quotaProviderForFile(file) && quotas[quotaKey(file)]?.status !== 'idle' ? <AuthFileQuotaSummary quota={quotas[quotaKey(file)] ?? idleQuota()} /> : null}
|
||||
<QuotaActionFeedback quota={quotas[quotaKey(file)] ?? idleQuota()} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
+45
-24
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, LoaderCircle, RefreshCw } from 'lucide-react';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { resetCodexQuotaWithConfirmation } from '../services/quotaActions';
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
|
||||
import { canResetCodexQuota, probeXaiWithConfirmation, resetCodexQuotaWithConfirmation } from '../services/quotaActions';
|
||||
import antigravityIcon from '../assets/icons/antigravity.svg';
|
||||
import claudeIcon from '../assets/icons/claude.svg';
|
||||
import codexIcon from '../assets/icons/codex.svg';
|
||||
@@ -44,6 +45,7 @@ const REFRESH_CONCURRENCY = 4;
|
||||
|
||||
export function QuotaPage() {
|
||||
const { locale, t } = useI18n();
|
||||
const { askConfirmation, confirmationDialog } = useConfirmation();
|
||||
const [files, setFiles] = useState<AuthFile[]>([]);
|
||||
const quotas = useQuotaCache();
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -85,9 +87,7 @@ export function QuotaPage() {
|
||||
if (getQuotaCacheSnapshot()[key]?.status === 'loading') return;
|
||||
const cacheGeneration = captureQuotaCacheGeneration();
|
||||
updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } }));
|
||||
const result = await loadQuota(file, {
|
||||
confirmXaiPaidProbe: () => confirm(t('quota.xaiProbeConfirm'), { title: t('quota.title'), kind: 'warning' }),
|
||||
});
|
||||
const result = await loadQuota(file);
|
||||
commitQuotaCacheIfCurrent(cacheGeneration, () => {
|
||||
updateQuotaCache((current) => ({ ...current, [key]: result }));
|
||||
});
|
||||
@@ -96,19 +96,35 @@ export function QuotaPage() {
|
||||
const resetCodexQuota = useCallback(async (file: AuthFile, quota: QuotaState) => {
|
||||
setError('');
|
||||
try {
|
||||
await resetCodexQuotaWithConfirmation(file, () => confirm([
|
||||
t('quota.confirm.title', { name: fileName(file) }),
|
||||
'',
|
||||
t('quota.confirm.cost'),
|
||||
t('quota.confirm.available', { count: quota.resetCredits ?? '—' }),
|
||||
t('quota.confirm.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }),
|
||||
'',
|
||||
t('quota.confirm.warning'),
|
||||
].join('\n'), { title: t('quota.reset'), kind: 'warning' }));
|
||||
await resetCodexQuotaWithConfirmation(file, () => askConfirmation({
|
||||
title: t('quota.reset'),
|
||||
message: t('quota.confirm.title', { name: fileName(file) }),
|
||||
confirmText: t('quota.confirm.button'),
|
||||
details: [
|
||||
{ label: t('quota.resetCredits'), value: String(quota.resetCredits ?? '—') },
|
||||
{ label: t('quota.confirm.applicableLabel'), value: String(quota.resetCreditsApplicable ?? '—') },
|
||||
{ label: t('quota.earliestExpiry'), value: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) },
|
||||
],
|
||||
warning: t('quota.confirm.warning'),
|
||||
}));
|
||||
} catch (requestError) {
|
||||
setError(requestError instanceof Error ? requestError.message : String(requestError));
|
||||
}
|
||||
}, [locale, t]);
|
||||
}, [askConfirmation, locale, t]);
|
||||
|
||||
const probeXai = useCallback(async (file: AuthFile) => {
|
||||
setError('');
|
||||
try {
|
||||
await probeXaiWithConfirmation(file, () => askConfirmation({
|
||||
title: t('quota.probe.title'),
|
||||
message: t('quota.probe.confirm', { name: fileName(file) }),
|
||||
warning: t('quota.xaiProbeConfirm'),
|
||||
confirmText: t('quota.probe.confirmButton'),
|
||||
}));
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
}
|
||||
}, [askConfirmation, t]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (Object.values(getQuotaCacheSnapshot()).some((quota) => quota.status === 'loading')) return;
|
||||
@@ -125,11 +141,7 @@ export function QuotaPage() {
|
||||
for (let index = 0; index < files.length; index += REFRESH_CONCURRENCY) {
|
||||
const batch = files.slice(index, index + REFRESH_CONCURRENCY);
|
||||
await Promise.all(batch.map(async (file) => {
|
||||
const result = await loadQuota(file, {
|
||||
confirmXaiPaidProbe: () => confirm(`${fileName(file)}\n\n${t('quota.xaiProbeConfirm')}`, {
|
||||
title: t('quota.title'), kind: 'warning',
|
||||
}),
|
||||
});
|
||||
const result = await loadQuota(file);
|
||||
commitQuotaCacheIfCurrent(cacheGeneration, () => {
|
||||
updateQuotaCache((current) => ({ ...current, [quotaKey(file)]: result }));
|
||||
});
|
||||
@@ -157,6 +169,7 @@ export function QuotaPage() {
|
||||
|
||||
return (
|
||||
<section className="page management-page quota-page">
|
||||
{confirmationDialog}
|
||||
<header className="management-header">
|
||||
<div><h1>{t('quota.title')}</h1></div>
|
||||
<div className="management-heading-actions">
|
||||
@@ -179,7 +192,7 @@ export function QuotaPage() {
|
||||
{grouped.map(([provider, items]) => (
|
||||
<section className="quota-provider-group" key={provider}>
|
||||
<div className="quota-group-heading"><div><img src={providerMeta[provider].icon} alt="" className="provider-logo" /><h2>{providerMeta[provider].label}</h2></div><span>{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}</span></div>
|
||||
<div className="real-quota-grid">{items.map(({ file, quota }) => <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />)}</div>
|
||||
<div className="real-quota-grid">{items.map(({ file, quota }) => <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} onProbe={provider === 'xai' ? () => void probeXai(file) : undefined} />)}</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
@@ -188,7 +201,7 @@ export function QuotaPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; quota: QuotaState; onRefresh: () => void; onReset?: () => void }) {
|
||||
export function QuotaCard({ file, quota, onRefresh, onReset, onProbe }: { file: AuthFile; quota: QuotaState; onRefresh: () => void; onReset?: () => void; onProbe?: () => void }) {
|
||||
const { locale, t } = useI18n();
|
||||
const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0);
|
||||
const provider = providerForFile(file);
|
||||
@@ -196,9 +209,17 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
|
||||
const disabled = readBoolean(file, 'disabled');
|
||||
return (
|
||||
<article className="panel real-quota-card">
|
||||
<div className="real-quota-card-header"><div><strong title={name}>{name}</strong><span>{provider ? providerMeta[provider].label : t('quota.unknownProvider')}{quota.plan ? ` · ${quota.plan}` : ''}</span></div><div className="quota-card-actions">{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={disabled || quota.status === 'loading'}>{t('quota.reset')}</button> : null}<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} /></button></div></div>
|
||||
<div className="real-quota-card-header">
|
||||
<div><strong title={name}>{name}</strong><span>{provider ? providerMeta[provider].label : t('quota.unknownProvider')}{quota.plan ? ' · ' + quota.plan : ''}</span></div>
|
||||
<div className="quota-card-actions">
|
||||
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={!canResetCodexQuota(file, quota)} title={quota.resetCreditsApplicable === 0 ? t('quota.resetNotApplicable') : quota.actionResult?.action === 'reset' && quota.actionResult.status !== 'success' ? t('quota.resetCheckFirst') : t('quota.reset')}>{t('quota.reset')}</button> : null}
|
||||
{onProbe ? <button type="button" className="secondary-button compact-button" onClick={onProbe} disabled={disabled || quota.status === 'loading'}>{t('quota.probe.button')}</button> : null}
|
||||
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<QuotaActionFeedback quota={quota} />
|
||||
{quota.status === 'idle' ? <div className="quota-card-message"><span>{disabled ? t('quota.fileDisabled') : t('quota.notFetched')}</span><button type="button" className="secondary-button compact-button" onClick={onRefresh} disabled={disabled}>{disabled ? t('quota.disabled') : t('quota.fetch')}</button></div> : null}
|
||||
{quota.status === 'loading' ? <div className="quota-card-message"><LoaderCircle size={18} className="spin" />{t('quota.querying')}</div> : null}
|
||||
{quota.status === 'loading' ? <div className="quota-card-message"><LoaderCircle size={18} className="spin" />{t(quota.pendingAction === 'reset' ? 'quota.resetting' : quota.pendingAction === 'probe' ? 'quota.probe.running' : 'quota.querying')}</div> : null}
|
||||
{quota.status === 'error' ? <div className="quota-card-error"><AlertCircle size={18} />{quota.error}</div> : null}
|
||||
{quota.status === 'success' && provider === 'codex' ? <div className="quota-reset-credit-summary">
|
||||
<span>{t('quota.resetCredits')} <strong>{quota.resetCredits ?? '—'}</strong></span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
@@ -174,6 +175,7 @@ const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => (
|
||||
);
|
||||
|
||||
export function ThinkingAliasesPage() {
|
||||
const { askConfirmation, confirmationDialog } = useConfirmation();
|
||||
const { t } = useI18n();
|
||||
const [thinkingEntries, setThinkingEntries] = useState<ThinkingAliasEntry[]>([]);
|
||||
const [speedEntries, setSpeedEntries] = useState<SpeedAliasEntry[]>([]);
|
||||
@@ -419,9 +421,7 @@ export function ThinkingAliasesPage() {
|
||||
};
|
||||
|
||||
const deleteAlias = async (entry: AliasListEntry) => {
|
||||
if (!window.confirm(
|
||||
t('aliases.deleteConfirm', { alias: entry.alias }),
|
||||
)) return;
|
||||
if (!await askConfirmation({ title: t('common.delete'), message: t('aliases.deleteConfirm', { alias: entry.alias }), confirmText: t('common.delete'), variant: 'danger' })) return;
|
||||
setBusyAlias(entry.alias);
|
||||
setBusyAction('delete');
|
||||
setError('');
|
||||
@@ -450,6 +450,7 @@ export function ThinkingAliasesPage() {
|
||||
|
||||
return (
|
||||
<section className="page management-page thinking-alias-page">
|
||||
{confirmationDialog}
|
||||
<div className="thinking-alias-feedback" aria-live="polite">
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
@@ -724,13 +725,14 @@ export function UsageRecordsPage() {
|
||||
}
|
||||
|
||||
function UsageDataManagementView() {
|
||||
const { askConfirmation, confirmationDialog } = useConfirmation();
|
||||
const { t } = useI18n();
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<UsageRepairResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const repair = async () => {
|
||||
if (!window.confirm(t('usage.dataManagement.confirm'))) return;
|
||||
if (!await askConfirmation({ title: t('usage.dataManagement.title'), message: t('usage.dataManagement.confirm') })) return;
|
||||
setRunning(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
@@ -746,6 +748,7 @@ function UsageDataManagementView() {
|
||||
|
||||
return (
|
||||
<section className="panel usage-data-management-panel">
|
||||
{confirmationDialog}
|
||||
<div className="usage-data-management-heading">
|
||||
<div>
|
||||
<Wrench size={20} aria-hidden="true" />
|
||||
@@ -1850,6 +1853,7 @@ function PricingView({
|
||||
query: UsageQuery;
|
||||
onChanged: () => void | Promise<void>;
|
||||
}) {
|
||||
const { askConfirmation, confirmationDialog } = useConfirmation();
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const [draft, setDraft] = useState<PriceDraft | null>(null);
|
||||
@@ -1898,7 +1902,7 @@ function PricingView({
|
||||
};
|
||||
|
||||
const deletePrice = async (model: string) => {
|
||||
if (!window.confirm(t('usage.pricing.deleteConfirm', { model }))) return;
|
||||
if (!await askConfirmation({ title: t('common.delete'), message: t('usage.pricing.deleteConfirm', { model }), confirmText: t('common.delete'), variant: 'danger' })) return;
|
||||
try {
|
||||
await invoke('delete_usage_model_price', { model });
|
||||
setMessage(t('usage.pricing.deleted'));
|
||||
@@ -1930,6 +1934,7 @@ function PricingView({
|
||||
|
||||
return (
|
||||
<section className="panel usage-pricing-panel">
|
||||
{confirmationDialog}
|
||||
<div className="usage-pricing-toolbar">
|
||||
<div className="usage-pricing-summary">
|
||||
<strong>{formatUsd(pricing.totalCost)}</strong>
|
||||
|
||||
@@ -4,41 +4,69 @@ import {
|
||||
getQuotaCacheSnapshot,
|
||||
updateQuotaCache,
|
||||
} from './quotaCache';
|
||||
import { consumeCodexResetCredit, idleQuota, quotaKey, type AuthFile, type QuotaState } from './quotaService';
|
||||
import { readBoolean } from './managementApi';
|
||||
import {
|
||||
consumeCodexResetCredit, idleQuota, probeXaiAvailability, providerForFile, quotaKey,
|
||||
type AuthFile, type QuotaState,
|
||||
} from './quotaService';
|
||||
|
||||
/** Reserve the credential before opening the asynchronous native confirmation dialog. */
|
||||
export async function resetCodexQuotaWithConfirmation(
|
||||
const pendingActions = new Set<string>();
|
||||
type QuotaActionOutcome = 'cancelled' | 'success' | 'refresh-error' | 'error';
|
||||
|
||||
export const canResetCodexQuota = (file: AuthFile, quota: QuotaState): boolean =>
|
||||
providerForFile(file) === 'codex'
|
||||
&& !readBoolean(file, 'disabled')
|
||||
&& quota.status === 'success'
|
||||
&& (quota.resetCredits ?? 0) > 0
|
||||
&& quota.resetCreditsApplicable !== 0
|
||||
&& !(quota.actionResult?.action === 'reset' && quota.actionResult.status !== 'success');
|
||||
|
||||
async function runConfirmedQuotaAction(
|
||||
file: AuthFile,
|
||||
confirmReset: () => Promise<boolean>,
|
||||
): Promise<void> {
|
||||
action: 'reset' | 'probe',
|
||||
confirmAction: () => Promise<boolean>,
|
||||
execute: (file: AuthFile) => Promise<QuotaState>,
|
||||
): Promise<QuotaActionOutcome> {
|
||||
const key = quotaKey(file);
|
||||
const previous = getQuotaCacheSnapshot()[key] ?? idleQuota();
|
||||
if (previous.status === 'loading') return;
|
||||
const original = getQuotaCacheSnapshot()[key];
|
||||
const previous = original ?? idleQuota();
|
||||
if (pendingActions.has(key) || previous.status === 'loading' || readBoolean(file, 'disabled')) return 'cancelled';
|
||||
const generation = captureQuotaCacheGeneration();
|
||||
const pending: QuotaState = { ...previous, status: 'loading' };
|
||||
updateQuotaCache((current) => ({ ...current, [key]: pending }));
|
||||
|
||||
const isCurrent = () => captureQuotaCacheGeneration() === generation
|
||||
&& getQuotaCacheSnapshot()[key] === pending;
|
||||
pendingActions.add(key);
|
||||
let pending: QuotaState | undefined;
|
||||
const commit = (quota: QuotaState) => {
|
||||
commitQuotaCacheIfCurrent(generation, () => {
|
||||
updateQuotaCache((current) => current[key] === pending ? { ...current, [key]: quota } : current);
|
||||
});
|
||||
};
|
||||
|
||||
let started = false;
|
||||
try {
|
||||
// Never treat a pending promise, dismissal, or unexpected result as consent.
|
||||
if (await confirmReset() !== true || !isCurrent()) return;
|
||||
started = true;
|
||||
commit(await consumeCodexResetCredit(file));
|
||||
if (await confirmAction() !== true || captureQuotaCacheGeneration() !== generation
|
||||
|| getQuotaCacheSnapshot()[key] !== original) return 'cancelled';
|
||||
pending = { ...previous, status: 'loading', pendingAction: action, actionResult: undefined };
|
||||
updateQuotaCache((current) => ({ ...current, [key]: pending! }));
|
||||
const result = await execute(file);
|
||||
if (result.status === 'error') {
|
||||
const status = action === 'reset' ? 'refresh-error' : 'error';
|
||||
commit({ ...previous, actionResult: { action, status, error: result.error } });
|
||||
return status;
|
||||
}
|
||||
commit({ ...result, actionResult: { action, status: 'success' } });
|
||||
return 'success';
|
||||
} catch (error) {
|
||||
if (started) commit({
|
||||
status: 'error', rows: [], error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
if (!pending) throw error;
|
||||
commit({ ...previous, actionResult: { action, status: 'error', error: error instanceof Error ? error.message : String(error) } });
|
||||
return 'error';
|
||||
} finally {
|
||||
// Cancellation and dialog failures preserve the displayed quota and release the lock.
|
||||
if (!started) commit(previous);
|
||||
pendingActions.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetCodexQuotaWithConfirmation(file: AuthFile, confirmReset: () => Promise<boolean>): Promise<QuotaActionOutcome> {
|
||||
if (!canResetCodexQuota(file, getQuotaCacheSnapshot()[quotaKey(file)] ?? idleQuota())) return Promise.resolve('cancelled');
|
||||
return runConfirmedQuotaAction(file, 'reset', confirmReset, consumeCodexResetCredit);
|
||||
}
|
||||
|
||||
export function probeXaiWithConfirmation(file: AuthFile, confirmProbe: () => Promise<boolean>): Promise<QuotaActionOutcome> {
|
||||
if (providerForFile(file) !== 'xai') return Promise.resolve('cancelled');
|
||||
return runConfirmedQuotaAction(file, 'probe', confirmProbe, probeXaiAvailability);
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export type QuotaState = {
|
||||
subscriptionActiveUntil?: string;
|
||||
serverTimeOffsetMs?: number;
|
||||
fetchedAt?: number;
|
||||
pendingAction?: 'reset' | 'probe';
|
||||
actionResult?: { action: 'reset' | 'probe'; status: 'success' | 'refresh-error' | 'error'; error?: string };
|
||||
};
|
||||
|
||||
export const idleQuota = (): QuotaState => ({ status: 'idle', rows: [] });
|
||||
@@ -464,6 +466,11 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
}
|
||||
|
||||
if (provider === 'xai') {
|
||||
if (value.mode === 'paid-info') return [{
|
||||
label: quotaText('quota.service.xaiPaidAccount'),
|
||||
remainingPercent: null,
|
||||
detail: quotaText('quota.service.xaiPaidQuotaUnavailable'),
|
||||
}];
|
||||
if (value.mode === 'paid-health') return [{
|
||||
label: quotaText('quota.service.xaiPaidHealth'),
|
||||
remainingPercent: null,
|
||||
@@ -654,13 +661,7 @@ const requestQuotaPayload = async (
|
||||
return parseBody(response.body ?? response.bodyText);
|
||||
};
|
||||
|
||||
export type QuotaLoadOptions = {
|
||||
// Paid health checks send a real (potentially billable) completion. Never do this silently.
|
||||
confirmXaiPaidProbe?: () => boolean | Promise<boolean>;
|
||||
};
|
||||
|
||||
const callXaiPaidHealth = async (authIndex: string, options: QuotaLoadOptions): Promise<unknown> => {
|
||||
if (!await options.confirmXaiPaidProbe?.()) throw new Error(quotaText('quota.service.error.xaiProbeRequired'));
|
||||
const callXaiPaidHealth = async (authIndex: string): Promise<unknown> => {
|
||||
const header = { Authorization: 'Bearer $TOKEN$', accept: 'application/json' };
|
||||
const [profile, chat] = await Promise.allSettled([
|
||||
requestQuotaPayload(authIndex, 'https://api.x.ai/v1/me', header, 'GET', undefined, 15_000),
|
||||
@@ -674,10 +675,10 @@ const callXaiPaidHealth = async (authIndex: string, options: QuotaLoadOptions):
|
||||
return { mode: 'paid-health', plan_type: 'Paid', profile: profile.status === 'fulfilled' ? profile.value : null };
|
||||
};
|
||||
|
||||
const callXaiQuota = async (file: AuthFile, options: QuotaLoadOptions): Promise<unknown> => {
|
||||
const callXaiQuota = async (file: AuthFile): Promise<unknown> => {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex'));
|
||||
if (isPaidXaiFile(file)) return callXaiPaidHealth(authIndex, options);
|
||||
if (isPaidXaiFile(file)) return { mode: 'paid-info', plan_type: 'Paid' };
|
||||
const header = { ...headersByProvider.xai };
|
||||
const userId = await resolveXaiUserId(file);
|
||||
if (userId) header['x-userid'] = userId;
|
||||
@@ -692,12 +693,7 @@ const callXaiQuota = async (file: AuthFile, options: QuotaLoadOptions): Promise<
|
||||
if (quotaRowsFor('xai', payload).length > 0) return payload;
|
||||
const billingError = weekly.status === 'rejected' && monthly.status === 'rejected'
|
||||
? weekly.reason : new Error(quotaText('quota.service.error.unrecognized'));
|
||||
try {
|
||||
return await callXaiPaidHealth(authIndex, options);
|
||||
} catch {
|
||||
// Keep the useful billing error if the alternative account mode also fails or is declined.
|
||||
throw billingError;
|
||||
}
|
||||
throw billingError;
|
||||
};
|
||||
|
||||
const booleanValue = (value: unknown): boolean | null => {
|
||||
@@ -851,7 +847,7 @@ const callCodexResetCredits = async (
|
||||
return codexResetCreditDetailsFor(payload);
|
||||
};
|
||||
|
||||
async function loadQuotaSnapshot(file: AuthFile, options: QuotaLoadOptions = {}): Promise<QuotaState> {
|
||||
async function loadQuotaSnapshot(file: AuthFile): Promise<QuotaState> {
|
||||
const provider = providerForFile(file);
|
||||
if (!provider) {
|
||||
return {
|
||||
@@ -866,7 +862,7 @@ async function loadQuotaSnapshot(file: AuthFile, options: QuotaLoadOptions = {})
|
||||
const codexAccountId = codexMetadata?.accountId || '';
|
||||
const responseClock: { serverTimeOffsetMs?: number } = {};
|
||||
const payloadPromise = provider === 'xai'
|
||||
? callXaiQuota(file, options)
|
||||
? callXaiQuota(file)
|
||||
: callUpstreamQuota(file, provider, codexAccountId, responseClock);
|
||||
const planPromise = provider === 'claude'
|
||||
? loadClaudePlan(file)
|
||||
@@ -953,35 +949,49 @@ async function consumeCodexResetCreditSnapshot(file: AuthFile): Promise<QuotaSta
|
||||
return loadQuotaSnapshot(file);
|
||||
}
|
||||
|
||||
// Shared by both pages: deduplicate refreshes and serialize reset mutations per credential.
|
||||
const quotaRequests = new Map<string, Promise<QuotaState>>();
|
||||
const resetRequests = new Map<string, Promise<QuotaState>>();
|
||||
const quotaMutationRequests = new Map<string, Promise<QuotaState>>();
|
||||
|
||||
export function loadQuota(file: AuthFile, options: QuotaLoadOptions = {}): Promise<QuotaState> {
|
||||
export function loadQuota(file: AuthFile): Promise<QuotaState> {
|
||||
const key = quotaKey(file);
|
||||
const reset = resetRequests.get(key);
|
||||
const reset = quotaMutationRequests.get(key);
|
||||
if (reset) return reset.catch((error): QuotaState => ({
|
||||
status: 'error', rows: [], error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
const existing = quotaRequests.get(key);
|
||||
if (existing) return existing;
|
||||
const request = loadQuotaSnapshot(file, options).finally(() => {
|
||||
const request = loadQuotaSnapshot(file).finally(() => {
|
||||
if (quotaRequests.get(key) === request) quotaRequests.delete(key);
|
||||
});
|
||||
quotaRequests.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function consumeCodexResetCredit(file: AuthFile): Promise<QuotaState> {
|
||||
function runQuotaMutation(file: AuthFile, mutate: () => Promise<QuotaState>): Promise<QuotaState> {
|
||||
const key = quotaKey(file);
|
||||
const existing = resetRequests.get(key);
|
||||
const existing = quotaMutationRequests.get(key);
|
||||
if (existing) return existing;
|
||||
const request = (async () => {
|
||||
await quotaRequests.get(key);
|
||||
return consumeCodexResetCreditSnapshot(file);
|
||||
return mutate();
|
||||
})().finally(() => {
|
||||
if (resetRequests.get(key) === request) resetRequests.delete(key);
|
||||
if (quotaMutationRequests.get(key) === request) quotaMutationRequests.delete(key);
|
||||
});
|
||||
resetRequests.set(key, request);
|
||||
quotaMutationRequests.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function consumeCodexResetCredit(file: AuthFile): Promise<QuotaState> {
|
||||
return runQuotaMutation(file, () => consumeCodexResetCreditSnapshot(file));
|
||||
}
|
||||
|
||||
export function probeXaiAvailability(file: AuthFile): Promise<QuotaState> {
|
||||
return runQuotaMutation(file, async () => {
|
||||
if (providerForFile(file) !== 'xai') throw new Error(quotaText('quota.service.error.xaiProbeOnly'));
|
||||
if (booleanValue(file.disabled) === true) throw new Error(quotaText('quota.fileDisabled'));
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex'));
|
||||
const payload = await callXaiPaidHealth(authIndex);
|
||||
return { status: 'success', rows: quotaRowsFor('xai', payload), plan: 'Paid', fetchedAt: Date.now() };
|
||||
});
|
||||
}
|
||||
@@ -9064,6 +9064,79 @@ body.table-col-resizing * {
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.app-confirm-backdrop {
|
||||
z-index: 160;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.config-dialog.app-confirm-dialog {
|
||||
width: min(460px, 100%);
|
||||
height: auto;
|
||||
grid-template-rows: none;
|
||||
gap: 18px;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-confirm-dialog > p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.app-confirm-details {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 7px;
|
||||
background: var(--theme-f4f2ec);
|
||||
}
|
||||
|
||||
.app-confirm-details > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.app-confirm-details dt {
|
||||
color: var(--theme-7a746c);
|
||||
}
|
||||
|
||||
.app-confirm-details dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.app-confirm-warning {
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.quota-action-feedback {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: flex-start;
|
||||
padding: 9px 11px;
|
||||
border-radius: 6px;
|
||||
background: var(--theme-f4f2ec);
|
||||
font-size: var(--font-size-label);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.quota-action-feedback svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.quota-action-feedback.error {
|
||||
color: var(--theme-9f2d20);
|
||||
background: var(--theme-fff1ee);
|
||||
}
|
||||
|
||||
.quota-reset-credit-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { ConfirmationDialog } from '../src/components/ConfirmationDialog';
|
||||
import { QuotaActionFeedback } from '../src/components/QuotaActionFeedback';
|
||||
import { I18nProvider } from '../src/i18n';
|
||||
|
||||
describe('application confirmation and quota feedback', () => {
|
||||
it('renders an accessible in-app confirmation with explicit reset and cancel actions', () => {
|
||||
const html = renderToStaticMarkup(<I18nProvider><ConfirmationDialog title="重置额度" message="确认重置测试账号?" confirmText="确认重置" warning="消耗 1 次重置机会" onDecision={() => {}} /></I18nProvider>);
|
||||
expect(html).toContain('role="alertdialog"');
|
||||
expect(html).toContain('aria-modal="true"');
|
||||
expect(html).toContain('aria-describedby=');
|
||||
expect(html).toContain('确认重置');
|
||||
expect(html).toContain('取消');
|
||||
expect(html).toContain('消耗 1 次重置机会');
|
||||
});
|
||||
|
||||
it('distinguishes a submitted reset whose follow-up query failed', () => {
|
||||
const html = renderToStaticMarkup(<I18nProvider><QuotaActionFeedback quota={{ status: 'success', rows: [], actionResult: { action: 'reset', status: 'refresh-error', error: 'query failed' } }} /></I18nProvider>);
|
||||
expect(html).toContain('重置请求已提交');
|
||||
expect(html).toContain('额度刷新失败');
|
||||
expect(html).toContain('避免重复消耗');
|
||||
expect(html).toContain('query failed');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,41 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { resetCodexQuotaWithConfirmation } from '../src/services/quotaActions';
|
||||
import { canResetCodexQuota, probeXaiWithConfirmation, resetCodexQuotaWithConfirmation } from '../src/services/quotaActions';
|
||||
import { getQuotaCacheSnapshot, pruneQuotaCache, updateQuotaCache } from '../src/services/quotaCache';
|
||||
import { quotaKey, type QuotaState } from '../src/services/quotaService';
|
||||
|
||||
const file = { name: 'confirm-test.json', provider: 'codex', auth_index: 'confirm-test' };
|
||||
const key = quotaKey(file);
|
||||
const previous: QuotaState = {
|
||||
status: 'success', rows: [{ label: '5h', remainingPercent: 0 }], resetCredits: 2,
|
||||
status: 'success', rows: [{ label: '5h', remainingPercent: 0 }], resetCredits: 2, resetCreditsApplicable: 1,
|
||||
};
|
||||
const ask = () => confirm('Consume one Codex reset credit?', { title: 'Reset quota', kind: 'warning' });
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
let originalCache: ReturnType<typeof getQuotaCacheSnapshot>;
|
||||
let answer: () => Promise<unknown>;
|
||||
let dialogCalls: unknown[];
|
||||
let upstreamCalls: { url: string; method: string }[];
|
||||
let consumeError: boolean;
|
||||
let refreshError: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
|
||||
originalCache = getQuotaCacheSnapshot();
|
||||
updateQuotaCache({ [key]: previous });
|
||||
answer = async () => 'Cancel';
|
||||
dialogCalls = [];
|
||||
upstreamCalls = [];
|
||||
consumeError = false;
|
||||
refreshError = false;
|
||||
mockIPC((command, payload) => {
|
||||
if (command === 'plugin:dialog|message') {
|
||||
dialogCalls.push(payload);
|
||||
return answer();
|
||||
}
|
||||
if (command === 'management_request') {
|
||||
const request = (payload as { request: { path: string; body: { url: string; method: string } } }).request;
|
||||
expect(request.path).toBe('/api-call');
|
||||
upstreamCalls.push(request.body);
|
||||
if (request.body.url.endsWith('/consume') && consumeError) return { status_code: 409, body: 'reset denied' };
|
||||
return {
|
||||
status_code: 200,
|
||||
body: request.body.url.endsWith('/usage')
|
||||
? { rate_limit: { primary_window: { used_percent: 0 } } }
|
||||
: { available_count: 1 },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
if (command !== 'management_request') throw new Error('Unexpected IPC command: ' + command);
|
||||
const request = (payload as { request: { path: string; body: { url: string; method: string } } }).request;
|
||||
expect(request.path).toBe('/api-call');
|
||||
upstreamCalls.push(request.body);
|
||||
if (request.body.url.endsWith('/consume') && consumeError) return { status_code: 409, body: 'reset denied' };
|
||||
if (request.body.url.endsWith('/usage') && refreshError) return { status_code: 503, body: 'usage unavailable' };
|
||||
return {
|
||||
status_code: 200,
|
||||
body: request.body.url.endsWith('/usage')
|
||||
? { rate_limit: { primary_window: { used_percent: 0 } } }
|
||||
: { available_count: 1 },
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,92 +46,113 @@ afterEach(() => {
|
||||
updateQuotaCache(originalCache);
|
||||
});
|
||||
|
||||
describe('native quota reset confirmation', () => {
|
||||
it('uses the native dialog IPC and has its required Tauri capability', async () => {
|
||||
const capabilities = await Bun.file(new URL('../src-tauri/capabilities/default.json', import.meta.url)).json();
|
||||
expect(capabilities.permissions).toContain('dialog:allow-message');
|
||||
await resetCodexQuotaWithConfirmation(file, ask);
|
||||
expect(dialogCalls).toEqual([{
|
||||
message: 'Consume one Codex reset credit?', title: 'Reset quota', kind: 'warning', buttons: 'OkCancel',
|
||||
}]);
|
||||
describe('quota action confirmation with fully mocked IPC', () => {
|
||||
it('preserves displayed quota and sends nothing until explicit confirmation', async () => {
|
||||
let decide!: (confirmed: boolean) => void;
|
||||
const resetting = resetCodexQuotaWithConfirmation(file, () => new Promise((resolve) => { decide = resolve; }));
|
||||
expect(getQuotaCacheSnapshot()[key]).toBe(previous);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('waits for explicit confirmation before sending any request', async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
answer = () => new Promise((done) => { resolve = done; });
|
||||
const resetting = resetCodexQuotaWithConfirmation(file, ask);
|
||||
expect(getQuotaCacheSnapshot()[key].status).toBe('loading');
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
resolve('Ok');
|
||||
await resetting;
|
||||
decide(true);
|
||||
expect(await resetting).toBe('success');
|
||||
expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1);
|
||||
expect(upstreamCalls[0].method).toBe('POST');
|
||||
expect(getQuotaCacheSnapshot()[key]).toMatchObject({ status: 'success', resetCredits: 1 });
|
||||
expect(getQuotaCacheSnapshot()[key]).toMatchObject({ status: 'success', resetCredits: 1, actionResult: { action: 'reset', status: 'success' } });
|
||||
});
|
||||
|
||||
it.each(['Cancel', null, undefined, 'unexpected'])('does not consume a credit when dismissed with %s', async (result) => {
|
||||
answer = async () => result;
|
||||
await resetCodexQuotaWithConfirmation(file, ask);
|
||||
it.each([false, null, undefined, 'unexpected', 'true'])('never consumes on cancellation or an invalid answer: %s', async (answer) => {
|
||||
expect(await resetCodexQuotaWithConfirmation(file, async () => answer as boolean)).toBe('cancelled');
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
expect(getQuotaCacheSnapshot()[key]).toBe(previous);
|
||||
});
|
||||
|
||||
it('restores the previous quota when opening the dialog fails, and allows retry', async () => {
|
||||
answer = async () => { throw new Error('dialog permission denied'); };
|
||||
await expect(resetCodexQuotaWithConfirmation(file, ask)).rejects.toThrow('dialog permission denied');
|
||||
it('releases the reservation if opening the confirmation fails', async () => {
|
||||
await expect(resetCodexQuotaWithConfirmation(file, async () => { throw new Error('confirmation unavailable'); }))
|
||||
.rejects.toThrow('confirmation unavailable');
|
||||
expect(getQuotaCacheSnapshot()[key]).toBe(previous);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
answer = async () => 'Ok';
|
||||
await resetCodexQuotaWithConfirmation(file, ask);
|
||||
await resetCodexQuotaWithConfirmation(file, async () => true);
|
||||
expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not open another dialog or consume twice on repeated clicks', async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
answer = () => new Promise((done) => { resolve = done; });
|
||||
it('reserves the account while awaiting a decision and prevents duplicate confirmations', async () => {
|
||||
let decide!: (confirmed: boolean) => void;
|
||||
let confirmations = 0;
|
||||
const ask = () => { confirmations++; return new Promise<boolean>((resolve) => { decide = resolve; }); };
|
||||
const first = resetCodexQuotaWithConfirmation(file, ask);
|
||||
await resetCodexQuotaWithConfirmation(file, ask);
|
||||
expect(dialogCalls).toHaveLength(1);
|
||||
expect(await resetCodexQuotaWithConfirmation(file, ask)).toBe('cancelled');
|
||||
expect(confirmations).toBe(1);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
resolve('Ok');
|
||||
decide(true);
|
||||
await first;
|
||||
expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores confirmation if the credential was removed while the dialog was open', async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
answer = () => new Promise((done) => { resolve = done; });
|
||||
const resetting = resetCodexQuotaWithConfirmation(file, ask);
|
||||
it('ignores approval after the credential is removed', async () => {
|
||||
let decide!: (confirmed: boolean) => void;
|
||||
const resetting = resetCodexQuotaWithConfirmation(file, () => new Promise((resolve) => { decide = resolve; }));
|
||||
pruneQuotaCache(new Set());
|
||||
resolve('Ok');
|
||||
await resetting;
|
||||
decide(true);
|
||||
expect(await resetting).toBe('cancelled');
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
expect(getQuotaCacheSnapshot()[key]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not overwrite newer quota state after a pending dialog is cancelled', async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
answer = () => new Promise((done) => { resolve = done; });
|
||||
const resetting = resetCodexQuotaWithConfirmation(file, ask);
|
||||
it('does not act on a stale quota snapshot or overwrite newer data', async () => {
|
||||
let decide!: (confirmed: boolean) => void;
|
||||
const resetting = resetCodexQuotaWithConfirmation(file, () => new Promise((resolve) => { decide = resolve; }));
|
||||
const newer: QuotaState = { status: 'success', rows: [], resetCredits: 3 };
|
||||
updateQuotaCache({ [key]: newer });
|
||||
resolve('Cancel');
|
||||
decide(true);
|
||||
await resetting;
|
||||
expect(getQuotaCacheSnapshot()[key]).toBe(newer);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reports reset failures and releases the pending state', async () => {
|
||||
answer = async () => 'Ok';
|
||||
consumeError = true;
|
||||
await expect(resetCodexQuotaWithConfirmation(file, ask)).rejects.toThrow('reset denied');
|
||||
expect(getQuotaCacheSnapshot()[key]).toMatchObject({ status: 'error', error: 'reset denied' });
|
||||
it('does not offer or execute a reset when no credits currently apply', async () => {
|
||||
updateQuotaCache({ [key]: { ...previous, resetCreditsApplicable: 0 } });
|
||||
let asked = false;
|
||||
await resetCodexQuotaWithConfirmation(file, async () => { asked = true; return true; });
|
||||
expect(asked).toBe(false);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(false);
|
||||
expect(canResetCodexQuota({ ...file, disabled: true }, previous)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not reintroduce browser confirmation in quota actions', async () => {
|
||||
const page = await Bun.file(new URL('../src/pages/QuotaPage.tsx', import.meta.url)).text();
|
||||
expect(page).not.toContain('window.confirm');
|
||||
expect(page).toContain('await resetCodexQuotaWithConfirmation(file, () => confirm(');
|
||||
it('retains quota and reports a failed reset on the account, requiring refresh before retry', async () => {
|
||||
consumeError = true;
|
||||
expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('error');
|
||||
expect(getQuotaCacheSnapshot()[key]).toMatchObject({
|
||||
rows: previous.rows, actionResult: { action: 'reset', status: 'error', error: 'reset denied' },
|
||||
});
|
||||
expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(false);
|
||||
expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('cancelled');
|
||||
expect(upstreamCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('distinguishes an accepted reset from a failed refresh and prevents a second consume', async () => {
|
||||
refreshError = true;
|
||||
expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('refresh-error');
|
||||
expect(getQuotaCacheSnapshot()[key]).toMatchObject({
|
||||
rows: previous.rows, actionResult: { action: 'reset', status: 'refresh-error', error: 'usage unavailable' },
|
||||
});
|
||||
await resetCodexQuotaWithConfirmation(file, async () => true);
|
||||
expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('only runs a paid availability test after separate explicit confirmation', async () => {
|
||||
const xai = { name: 'xai-test.json', provider: 'xai', auth_index: 'test-xai' };
|
||||
await probeXaiWithConfirmation(xai, async () => false);
|
||||
expect(upstreamCalls).toHaveLength(0);
|
||||
expect(await probeXaiWithConfirmation(xai, async () => true)).toBe('success');
|
||||
expect(upstreamCalls.filter((request) => request.url.endsWith('/chat/completions'))).toHaveLength(1);
|
||||
expect(upstreamCalls.some((request) => request.url.endsWith('/consume'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps application confirmations out of native and browser dialog APIs', async () => {
|
||||
for (const name of ['QuotaPage', 'AuthFileManagementPage', 'ApiAccessPage', 'ThinkingAliasesPage', 'UsageRecordsPage']) {
|
||||
const source = await Bun.file(new URL('../src/pages/' + name + '.tsx', import.meta.url)).text();
|
||||
expect(source).not.toContain('window.confirm');
|
||||
expect(source).not.toContain('@tauri-apps/plugin-dialog');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,12 @@ const render = (quota: QuotaState, provider = 'codex') => renderToStaticMarkup(
|
||||
);
|
||||
|
||||
describe('quota card rendering', () => {
|
||||
it('即使当前适用次数为零,仍显示可用重置按钮、详情错误和订阅时间', () => {
|
||||
it('当前适用次数为零时禁用重置按钮,并保留额度详情', () => {
|
||||
const html = render({
|
||||
status: 'success', rows: [], resetCredits: 2, resetCreditsApplicable: 0,
|
||||
resetCreditsError: 'temporary failure', subscriptionActiveUntil: '2030-01-01T00:00:00Z',
|
||||
});
|
||||
expect(html).toContain('重置额度');
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>重置额度<\/button>/);
|
||||
expect(html).toContain('当前适用:0 次');
|
||||
expect(html).toContain('temporary failure');
|
||||
expect(html).toContain('订阅到期');
|
||||
|
||||
+13
-11
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import { managementApi } from '../src/services/managementApi';
|
||||
import { consumeCodexResetCredit, loadQuota } from '../src/services/quotaService';
|
||||
import { consumeCodexResetCredit, loadQuota, probeXaiAvailability } from '../src/services/quotaService';
|
||||
|
||||
const success = (body: unknown) => ({ status_code: 200, body });
|
||||
const codexUsage = {
|
||||
@@ -122,7 +122,7 @@ describe('quota API compatibility', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('xAI billing and paid API fallback', () => {
|
||||
describe('xAI quota queries and explicit availability tests', () => {
|
||||
const file = { name: 'xai.json', provider: 'x-ai', auth_index: 'x' };
|
||||
|
||||
it('单个账单接口失败仍展示另一接口的数据,不触发收费探测', async () => {
|
||||
@@ -136,16 +136,17 @@ describe('xAI billing and paid API fallback', () => {
|
||||
calls.forEach((request) => expect(request.header['x-userid']).toBe('u'));
|
||||
});
|
||||
|
||||
it('识别付费账户后先确认,默认不发送计费请求', async () => {
|
||||
it('查询付费账号只说明额度不可用,不要求确认也不发送计费请求', async () => {
|
||||
const result = await loadQuota({ ...file, using_api: true, prefix: 'paid' });
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.error).toContain('需要确认');
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.rows[0].remainingPercent).toBeNull();
|
||||
expect(result.rows[0].detail).toContain('不提供剩余额度');
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('确认后执行极短对话探测,profile 失败不影响结果,也不伪造剩余额度', async () => {
|
||||
handler = (request) => request.url.endsWith('/me') ? { status_code: 403 } : success({ choices: [] });
|
||||
const result = await loadQuota({ ...file, using_api: true, prefix: 'paid' }, { confirmXaiPaidProbe: () => true });
|
||||
const result = await probeXaiAvailability({ ...file, using_api: true, prefix: 'paid' });
|
||||
expect(result).toMatchObject({ status: 'success', plan: 'Paid', rows: [{ remainingPercent: null }] });
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls.every((request) => request.url.startsWith('https://api.x.ai/'))).toBe(true);
|
||||
@@ -155,18 +156,19 @@ describe('xAI billing and paid API fallback', () => {
|
||||
post.mock.calls.forEach((call: unknown[]) => expect(call[2]).toEqual({ timeoutMs: 15000 }));
|
||||
});
|
||||
|
||||
it('两个账单均无数据时可以确认回退;两种模式均失败时保留原账单错误', async () => {
|
||||
it('两个账单均无数据时返回查询错误,不自动发起付费测试', async () => {
|
||||
handler = (request) => request.url.includes('cli-chat-proxy') ? { status_code: 403, body: 'billing denied' }
|
||||
: { status_code: 429, body: 'paid denied' };
|
||||
const result = await loadQuota(file, { confirmXaiPaidProbe: () => true });
|
||||
const result = await loadQuota(file);
|
||||
expect(result).toMatchObject({ status: 'error', error: 'billing denied' });
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls.every((request) => request.method === 'GET')).toBe(true);
|
||||
});
|
||||
|
||||
it('不允许仅凭 profile 成功把对话失败的付费账户标记为可用', async () => {
|
||||
handler = (request) => request.url.endsWith('/me') ? success({ user_id: 'u' }) : { status_code: 429, body: 'chat denied' };
|
||||
expect(await loadQuota({ ...file, using_api: true, prefix: 'paid' }, { confirmXaiPaidProbe: () => true }))
|
||||
.toMatchObject({ status: 'error', error: 'chat denied' });
|
||||
await expect(probeXaiAvailability({ ...file, using_api: true, prefix: 'paid' }))
|
||||
.rejects.toThrow('chat denied');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user