Skip to content

Commit 5b04708

Browse files
committed
feat(rate-limit): add frontend models, hooks, and i18n for rate limit rules
Include RateLimit model types, useAccountsRateLimitState hook, account runtime types, Codex integration, locales, and Wails bridge bindings.
1 parent 8347cb9 commit 5b04708

10 files changed

Lines changed: 530 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
import { GetAllRateLimitStatuses, ListRateLimitStrategies } from '../../../../wailsjs/go/main/App';
3+
import type { AccountRecord } from '../../../types';
4+
import { hasWailsAppBindings } from '../../../utils/previewMode';
5+
import { getAccountsPreviewRateLimitByID } from '../previewData';
6+
import {
7+
DEFAULT_RATE_LIMIT_STRATEGIES,
8+
buildRateLimitStatusMap,
9+
type RateLimitState,
10+
type RateLimitStrategyMeta,
11+
} from '../model/rateLimit';
12+
import type { TrackRequest } from '../model/types';
13+
14+
export default function useAccountsRateLimitState(trackRequest: TrackRequest) {
15+
const [accountRateLimitByID, setAccountRateLimitByID] = useState<Record<string, RateLimitState>>({});
16+
const [rateLimitStrategies, setRateLimitStrategies] = useState<RateLimitStrategyMeta[]>(DEFAULT_RATE_LIMIT_STRATEGIES);
17+
const latestAccountsRef = useRef<AccountRecord[]>([]);
18+
19+
const loadAccountRateLimits = useCallback(
20+
async (accounts: AccountRecord[]) => {
21+
latestAccountsRef.current = accounts;
22+
if (accounts.length === 0) {
23+
setAccountRateLimitByID({});
24+
return;
25+
}
26+
27+
if (!hasWailsAppBindings()) {
28+
setRateLimitStrategies(DEFAULT_RATE_LIMIT_STRATEGIES);
29+
setAccountRateLimitByID(getAccountsPreviewRateLimitByID(accounts));
30+
return;
31+
}
32+
33+
try {
34+
const [strategies, statuses] = await Promise.all([
35+
trackRequest<any>('ListRateLimitStrategies', { args: [] }, () => ListRateLimitStrategies()),
36+
trackRequest<any>('GetAllRateLimitStatuses', { args: [] }, () => GetAllRateLimitStatuses()),
37+
]);
38+
setRateLimitStrategies(
39+
Array.isArray(strategies) && strategies.length > 0 ? strategies : DEFAULT_RATE_LIMIT_STRATEGIES,
40+
);
41+
const statusMap = buildRateLimitStatusMap(statuses);
42+
const accountIDSet = new Set(accounts.map((account) => account.id));
43+
setAccountRateLimitByID(
44+
Object.fromEntries(Object.entries(statusMap).filter(([accountID]) => accountIDSet.has(accountID))),
45+
);
46+
} catch (error) {
47+
console.error(error);
48+
setAccountRateLimitByID({});
49+
}
50+
},
51+
[trackRequest],
52+
);
53+
54+
useEffect(() => {
55+
const timer = window.setInterval(() => {
56+
if (latestAccountsRef.current.length > 0) {
57+
void loadAccountRateLimits(latestAccountsRef.current);
58+
}
59+
}, 30000);
60+
return () => window.clearInterval(timer);
61+
}, [loadAccountRateLimits]);
62+
63+
return {
64+
accountRateLimitByID,
65+
rateLimitStrategies,
66+
loadAccountRateLimits,
67+
};
68+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { SidecarStatus } from '../../../types';
2+
3+
export function shouldLoadAccountsData(sidecarStatus: SidecarStatus, hasWailsBindings: boolean) {
4+
return !hasWailsBindings || sidecarStatus?.code === 'ready';
5+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
export type RateLimitTone = 'neutral' | 'warning' | 'critical';
2+
3+
export interface RateLimitStrategyMeta {
4+
id: string;
5+
name: string;
6+
supportedWindows: string[];
7+
}
8+
9+
export interface RateLimitRule {
10+
id?: string;
11+
accountKey: string;
12+
matchKey?: string;
13+
strategy: string;
14+
window: string;
15+
limitValue: number;
16+
action: 'block' | 'warn' | string;
17+
enabled: boolean;
18+
label?: string;
19+
createdAt?: number;
20+
updatedAt?: number;
21+
}
22+
23+
export interface RateLimitRuleState {
24+
rule: RateLimitRule;
25+
exceeded: boolean;
26+
reason?: string;
27+
usagePct: number;
28+
currentUsage: number;
29+
}
30+
31+
export interface RateLimitState {
32+
accountKey: string;
33+
matchKey?: string;
34+
blocked: boolean;
35+
blockReason?: string;
36+
rules: RateLimitRuleState[];
37+
updatedAt?: string;
38+
}
39+
40+
export interface RateLimitEvent {
41+
id: string;
42+
accountKey: string;
43+
matchKey?: string;
44+
ruleID: string;
45+
strategy: string;
46+
window: string;
47+
action: string;
48+
usageValue: number;
49+
limitValue: number;
50+
blocked: boolean;
51+
reason?: string;
52+
triggeredAt: number;
53+
}
54+
55+
export const DEFAULT_RATE_LIMIT_STRATEGIES: RateLimitStrategyMeta[] = [
56+
{ id: 'token-window', name: 'Token 窗口限流', supportedWindows: ['1h', '6h', '12h', '24h', '7d', '30d'] },
57+
{ id: 'request-window', name: '请求窗口限流', supportedWindows: ['1h', '6h', '12h', '24h', '7d', '30d'] },
58+
];
59+
60+
export function buildRateLimitStatusMap(items: RateLimitState[] | undefined) {
61+
return (items ?? []).reduce<Record<string, RateLimitState>>((result, item) => {
62+
const accountKey = String(item.accountKey || '').trim();
63+
if (accountKey) {
64+
result[accountKey] = normalizeRateLimitState(item);
65+
}
66+
return result;
67+
}, {});
68+
}
69+
70+
export function normalizeRateLimitState(input: RateLimitState): RateLimitState {
71+
return {
72+
accountKey: String(input.accountKey || '').trim(),
73+
matchKey: String(input.matchKey || '').trim(),
74+
blocked: Boolean(input.blocked),
75+
blockReason: String(input.blockReason || '').trim(),
76+
updatedAt: String(input.updatedAt || '').trim(),
77+
rules: (input.rules ?? []).map((ruleState) => ({
78+
exceeded: Boolean(ruleState.exceeded),
79+
reason: String(ruleState.reason || '').trim(),
80+
usagePct: Number.isFinite(Number(ruleState.usagePct)) ? Number(ruleState.usagePct) : 0,
81+
currentUsage: Number.isFinite(Number(ruleState.currentUsage)) ? Number(ruleState.currentUsage) : 0,
82+
rule: {
83+
id: String(ruleState.rule?.id || '').trim(),
84+
accountKey: String(ruleState.rule?.accountKey || input.accountKey || '').trim(),
85+
matchKey: String(ruleState.rule?.matchKey || '').trim(),
86+
strategy: String(ruleState.rule?.strategy || '').trim(),
87+
window: String(ruleState.rule?.window || '').trim(),
88+
limitValue: Number.isFinite(Number(ruleState.rule?.limitValue)) ? Number(ruleState.rule.limitValue) : 0,
89+
action: String(ruleState.rule?.action || 'block').trim(),
90+
enabled: Boolean(ruleState.rule?.enabled),
91+
label: String(ruleState.rule?.label || '').trim(),
92+
createdAt: Number(ruleState.rule?.createdAt || 0),
93+
updatedAt: Number(ruleState.rule?.updatedAt || 0),
94+
},
95+
})),
96+
};
97+
}
98+
99+
export function rateLimitStateTone(status?: RateLimitState): RateLimitTone {
100+
if (!status || status.rules.length === 0) return 'neutral';
101+
if (status.blocked) return 'critical';
102+
if (status.rules.some((item) => item.exceeded)) return 'warning';
103+
return 'neutral';
104+
}
105+
106+
export function rateLimitRuleLabel(rule: Pick<RateLimitRule, 'strategy' | 'window' | 'label'>) {
107+
const label = String(rule.label || '').trim();
108+
if (label) return label.toUpperCase();
109+
const window = String(rule.window || 'window').toUpperCase();
110+
return `${window} ${rateLimitStrategyShortLabel(rule.strategy)}`;
111+
}
112+
113+
export function rateLimitStrategyShortLabel(strategy: string) {
114+
switch (strategy) {
115+
case 'token-window':
116+
return 'TOKENS';
117+
case 'request-window':
118+
return 'REQ';
119+
default:
120+
return String(strategy || 'LIMIT').toUpperCase();
121+
}
122+
}
123+
124+
export function formatRateLimitMetric(value: number) {
125+
const normalized = Math.max(0, Number(value || 0));
126+
if (normalized >= 1000000) return `${trimDecimal(normalized / 1000000)}M`;
127+
if (normalized >= 10000) return `${trimDecimal(normalized / 10000)}W`;
128+
return new Intl.NumberFormat('zh-CN').format(Math.round(normalized));
129+
}
130+
131+
export function formatRateLimitLimitDraftValue(rule: Pick<RateLimitRule, 'strategy' | 'limitValue'>) {
132+
const normalized = Math.max(0, Number(rule.limitValue || 0));
133+
if (rule.strategy === 'token-window') {
134+
return trimDecimal(normalized / 1000000);
135+
}
136+
return String(Math.round(normalized));
137+
}
138+
139+
export function parseRateLimitLimitDraftValue(strategy: string, value: string) {
140+
const normalized = Math.max(0, Number(value || 0));
141+
if (strategy === 'token-window') {
142+
return Math.round(normalized * 1000000);
143+
}
144+
return Math.round(normalized);
145+
}
146+
147+
function trimDecimal(value: number) {
148+
const normalized = Math.round(value * 10) / 10;
149+
return Number.isInteger(normalized) ? String(normalized) : normalized.toFixed(1).replace(/\.0$/, '');
150+
}

frontend/src/features/codex/components/CodexAccountOrderRow.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { GripVertical } from 'lucide-react';
22
import { type DragEvent, type MouseEvent } from 'react';
33
import ToggleSwitch from '../../../components/ui/ToggleSwitch';
44
import type { AccountUsageSummary } from '../../accounts/model/accountUsage';
5+
import type { RateLimitState } from '../../accounts/model/rateLimit';
56
import type { CodexQuotaState } from '../../accounts/model/types';
67
import { buildQuotaDisplay } from '../../accounts/model/accountQuota';
78
import AttributionCard, { type AttributionCardBadge, type AttributionCardEvidenceRow } from '../../accounts/components/AttributionCard';
@@ -66,6 +67,7 @@ export function AccountOrderRow({
6667
routePolicyState,
6768
quotaState,
6869
usageSummary,
70+
rateLimitStatus,
6971
onPolicyModeChange,
7072
}: {
7173
row: CodexAccountRow;
@@ -85,6 +87,7 @@ export function AccountOrderRow({
8587
routePolicyState?: CodexRoutePolicyRowState;
8688
quotaState?: CodexQuotaState;
8789
usageSummary?: AccountUsageSummary;
90+
rateLimitStatus?: RateLimitState;
8891
onPolicyModeChange: (id: string, mode: Exclude<CodexRoutePolicyRowMode, 'blocked'>) => void;
8992
}) {
9093
const endpointLabel = buildEndpointLabel(row);
@@ -104,6 +107,9 @@ export function AccountOrderRow({
104107
} else if (!row.requestable) {
105108
badges.push({ label: routePolicyModeLabel(t, 'blocked'), tone: 'critical' });
106109
}
110+
if (rateLimitStatus?.blocked) {
111+
badges.push({ label: rateLimitStatus.blockReason || 'ROUTE GUARD', tone: 'critical' });
112+
}
107113
const evidenceRows: AttributionCardEvidenceRow[] = [
108114
{ label: t('accounts.card_asset'), value: row.id, title: row.id },
109115
{ label: t('codex.account_list_policy_title'), value: routePolicyState?.mode || 'default' },
@@ -140,6 +146,7 @@ export function AccountOrderRow({
140146
badges={badges}
141147
usageSummary={usageSummary}
142148
quotaDisplay={quotaDisplay}
149+
rateLimitStatus={rateLimitStatus}
143150
evidenceRows={evidenceRows}
144151
tone={cardTone}
145152
density={density}

frontend/src/features/codex/components/CodexAccountOrderSection.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type CodexRoutePolicyRowState,
77
} from '../model/codexAccountList';
88
import type { AccountUsageSummary } from '../../accounts/model/accountUsage';
9+
import type { RateLimitState } from '../../accounts/model/rateLimit';
910
import type { CodexQuotaState } from '../../accounts/model/types';
1011
import { AccountOrderRow } from './CodexAccountOrderRow';
1112
import { shouldUseCodexOrderSectionActionMenu } from '../model/codexAccountOrderSectionLayout';
@@ -34,6 +35,7 @@ export function CodexAccountOrderSection({
3435
routePolicyRowStates,
3536
codexQuotaByName,
3637
accountUsageByID,
38+
accountRateLimitByID,
3739
refreshLabel,
3840
loadingLabel,
3941
saveLabel,
@@ -68,6 +70,7 @@ export function CodexAccountOrderSection({
6870
routePolicyRowStates: Record<string, CodexRoutePolicyRowState>;
6971
codexQuotaByName: Record<string, CodexQuotaState>;
7072
accountUsageByID: Record<string, AccountUsageSummary>;
73+
accountRateLimitByID: Record<string, RateLimitState>;
7174
refreshLabel: string;
7275
loadingLabel: string;
7376
saveLabel: string;
@@ -183,6 +186,7 @@ export function CodexAccountOrderSection({
183186
routePolicyState={routePolicyRowStates[row.id]}
184187
quotaState={row.quotaKey ? codexQuotaByName[row.quotaKey] : undefined}
185188
usageSummary={accountUsageByID[row.id]}
189+
rateLimitStatus={accountRateLimitByID[row.id]}
186190
onPolicyModeChange={onPolicyModeChange}
187191
/>
188192
))}

frontend/src/locales/en.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,24 @@
825825
"ui_no_data_available": "No Data Available",
826826
"ui_loading_short": "Loading...",
827827
"ui_fetching_fs": "Fetching file...",
828+
"rate_limit_rules_title": "Route Guard Rules",
829+
"rate_limit_cache": "Cache",
830+
"rate_limit_add_rule": "+ Rule",
831+
"rate_limit_no_local_rule": "No Local Route Guard",
832+
"rate_limit_rule_required": "Rule, window, and limit are required.",
833+
"rate_limit_preview_only": "Browser preview only. Local draft updated.",
834+
"rate_limit_rule_legend": "Route guard rule configuration",
835+
"rate_limit_strategy": "Strategy",
836+
"rate_limit_window": "Window",
837+
"rate_limit_limit": "Limit",
838+
"rate_limit_action": "Action",
839+
"rate_limit_action_block": "Block",
840+
"rate_limit_action_warn": "Warn",
841+
"rate_limit_enabled": "Enable Rule",
842+
"rate_limit_enabled_short": "On",
843+
"rate_limit_label": "Rule Label",
844+
"rate_limit_count_unit": "req",
845+
"rate_limit_not_evaluated": "Not Evaluated",
828846
"reauth": "Reauth",
829847
"reauth_pending": "Logging In",
830848
"reauth_pending_global": "ChatGPT login is in progress. Finish authorization in the browser, then return here and wait for sync.",

frontend/src/locales/zh.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,24 @@
825825
"ui_no_data_available": "暂无数据",
826826
"ui_loading_short": "加载中...",
827827
"ui_fetching_fs": "读取文件中...",
828+
"rate_limit_rules_title": "路由守卫规则",
829+
"rate_limit_cache": "缓存",
830+
"rate_limit_add_rule": "+ 新增规则",
831+
"rate_limit_no_local_rule": "暂无本地路由守卫",
832+
"rate_limit_rule_required": "请填写规则、窗口与限额。",
833+
"rate_limit_preview_only": "仅浏览器预览,本地草稿已更新。",
834+
"rate_limit_rule_legend": "路由守卫规则配置",
835+
"rate_limit_strategy": "策略",
836+
"rate_limit_window": "窗口",
837+
"rate_limit_limit": "限额",
838+
"rate_limit_action": "行为",
839+
"rate_limit_action_block": "阻断",
840+
"rate_limit_action_warn": "告警",
841+
"rate_limit_enabled": "启用规则",
842+
"rate_limit_enabled_short": "启用",
843+
"rate_limit_label": "规则标签",
844+
"rate_limit_count_unit": "",
845+
"rate_limit_not_evaluated": "尚未评估",
828846
"reauth": "重新登录",
829847
"reauth_pending": "登录中",
830848
"reauth_pending_global": "CHATGPT 登录流程进行中,请在浏览器完成授权后返回此页面等待同步。",

0 commit comments

Comments
 (0)