Skip to content

Commit 5312345

Browse files
committed
feat(ai): AI 配额常驻显示与 429 全局提示(标题栏胶囊/用量卡增强/quotaStore)
1 parent 4505ae9 commit 5312345

9 files changed

Lines changed: 274 additions & 74 deletions

File tree

‎client/src/components/layout/AppLayout.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { FirstDiveGate } from '@/features/onboarding/firstDive/FirstDiveGate';
2828
import { usePerformanceModeStore } from '@/lib/performance/usePerformanceMode';
2929
import { PERFORMANCE_MODE_CONFIG } from '@/lib/performance/performanceMode';
3030
import { cn } from '@/lib/utils';
31+
// AI 配额耗尽全局提示(监听 429 双路径:渲染直连 CustomEvent + 主进程代理 IPC)
32+
import { QuotaNotice } from '@/features/beta/QuotaNotice';
3133

3234
/**
3335
* 路由层级映射:子路由 -> 父路由
@@ -288,6 +290,7 @@ export default function AppLayout() {
288290
<BottomNav />
289291

290292
{/* 全局组件 */}
293+
<QuotaNotice />
291294
<FirstDiveGate />
292295
<OnboardingOverlay />
293296
<ModuleTourToast moduleId={currentModule} />

‎client/src/components/layout/CustomTitlebar.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect, useCallback } from 'react';
22
import { Minus, Square, Maximize2, X } from 'lucide-react';
33
import { cn } from '@/lib/utils';
4+
import { QuotaBadge } from './QuotaBadge';
45

56
/**
67
* 自定义标题栏组件属性
@@ -71,8 +72,9 @@ export function CustomTitlebar({ className }: CustomTitlebarProps) {
7172
{/* 中间弹性占位 — 双击最大化 */}
7273
<div className="flex-1" onDoubleClick={handleMaximize} />
7374

74-
{/* 右侧:窗口控制按钮(禁止拖拽) */}
75-
<div className="no-drag flex h-full">
75+
{/* 右侧:AI 配额胶囊(全页面常驻入口)+ 窗口控制按钮(禁止拖拽) */}
76+
<div className="no-drag flex h-full items-center">
77+
<QuotaBadge />
7678
<button
7779
onClick={handleMinimize}
7880
className="w-11 h-9 flex items-center justify-center text-text-tertiary hover:text-brand-500 hover:bg-black/5 dark:hover:bg-white/5 transition-all duration-200 active:scale-90"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* 标题栏 AI 配额胶囊 — 全页面常驻的配额可见入口
3+
*
4+
* @ai-context: 市场惯例(Kimi/DeepSeek/豆包等)的"常驻可见剩余额度":
5+
* 标题栏展示剩余次数并随阈值变色(≥80% 琥珀预警、100% 红色耗尽),
6+
* 点击跳转设置页查看完整用量;未登录/网关不可达时隐藏;开发者账户
7+
* (服务端返回 total_calls=-1)显示 ∞ 品牌色。数据源为 useQuotaStore
8+
* 全局配额状态(30s 去抖拉取,429 事件强制刷新)。
9+
* @ai-context: Persistent AI quota pill in the titlebar — threshold-tinted
10+
* (amber ≥80%, red exhausted), navigates to settings on click.
11+
*/
12+
import { useEffect } from 'react';
13+
import { useNavigate } from 'react-router-dom';
14+
import { Sparkles } from 'lucide-react';
15+
import { useQuotaStore } from '@/features/beta/useQuotaStore';
16+
import { useAuth } from '@/lib/auth/AuthContext';
17+
import { cn } from '@/lib/utils';
18+
19+
export function QuotaBadge() {
20+
const navigate = useNavigate();
21+
const { getAccessToken } = useAuth();
22+
const quota = useQuotaStore((s) => s.quota);
23+
const refresh = useQuotaStore((s) => s.refresh);
24+
25+
// 首次挂载拉取一次(后续由 429 事件 / 设置页用量卡驱动刷新)
26+
useEffect(() => {
27+
getAccessToken()
28+
.then((token) => refresh(token ?? undefined))
29+
.catch(() => {
30+
// 未登录静默(本地模式不展示配额)
31+
});
32+
}, [getAccessToken, refresh]);
33+
34+
// 未登录 / 网关不可达 / 数据异常(total_calls=0)→ 隐藏(增强信息不阻塞);
35+
// 注意:total_calls=-1(开发者无限配额)是合法值,不得在此过滤
36+
if (!quota || quota.totalCalls === 0) return null;
37+
38+
const isUnlimited = quota.totalCalls === -1;
39+
const remaining = Math.max(0, quota.totalCalls - quota.usedCalls);
40+
const exhausted = !isUnlimited && remaining <= 0;
41+
const nearlyDone = !isUnlimited && !exhausted && quota.usedCalls / quota.totalCalls >= 0.8;
42+
43+
return (
44+
<button
45+
onClick={() => navigate('/settings')}
46+
className={cn(
47+
'no-drag flex items-center gap-1.5 h-6 px-2.5 mr-1 rounded-kb-full text-c1 font-medium',
48+
'border transition-colors',
49+
exhausted
50+
? 'border-error/40 bg-error/10 text-error'
51+
: nearlyDone
52+
? 'border-semantic-warning/40 bg-semantic-warning/10 text-semantic-warning'
53+
: 'border-border-default/60 bg-bg-elevated/60 text-text-secondary hover:text-brand-500 hover:border-brand-500/40',
54+
)}
55+
aria-label="AI 配额"
56+
title="今日 AI 配额,点击查看详情"
57+
>
58+
<Sparkles className="w-3 h-3" strokeWidth={1.5} />
59+
<span>{isUnlimited ? 'AI ∞' : exhausted ? 'AI 已用完' : `AI ${remaining} 次`}</span>
60+
</button>
61+
);
62+
}

‎client/src/env.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ declare global {
118118
requestId?: string;
119119
source?: string;
120120
}>;
121+
/** AI 配额耗尽(429):主进程代理路径推送(gatewayHttp/gatewayStream) */
122+
onAIQuotaExhausted?: (callback: (detail: string) => void) => () => void;
121123
};
122124
}
123125
}

‎client/src/features/beta/AiQuotaCard.tsx‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
*
44
* @ai-context: 数据来自服务端权威计数(GET /api/v1/license/quota),
55
* 展示今日调用次数与费用双进度条;网关不可达时静默隐藏(增强信息不阻塞)。
6+
* 配额状态由全局 useQuotaStore 持有(与标题栏胶囊共用,30s 去抖拉取),
7+
* 挂载时兜底刷新一次(30s 窗口内不重复请求);开发者白名单账户
8+
* (total_calls=-1)展示 ∞ 不限量。
69
*/
10+
import { useEffect } from 'react';
711
import { Gauge, Coins } from 'lucide-react';
8-
import { useQuota } from '@/features/beta/hooks/useQuota';
12+
import { useQuotaStore } from '@/features/beta/useQuotaStore';
13+
import { useAuth } from '@/lib/auth/AuthContext';
914
import { TIER_LABELS } from '@/types/beta';
1015
import { cn } from '@/lib/utils';
1116

@@ -27,14 +32,29 @@ function UsageBar({ used, total, tone }: { used: number; total: number; tone: 'b
2732

2833
/** AI 用量卡(设置页内嵌) */
2934
export function AiQuotaCard() {
30-
const { quota } = useQuota();
35+
const quota = useQuotaStore((s) => s.quota);
36+
const refresh = useQuotaStore((s) => s.refresh);
37+
const { getAccessToken } = useAuth();
3138

32-
// 网关不可达或未登录时不展示(增强信息)
33-
if (!quota || quota.totalCalls <= 0) return null;
39+
// 挂载时兜底刷新一次(30s 去抖窗口内与标题栏胶囊不重复请求)
40+
useEffect(() => {
41+
getAccessToken()
42+
.then((token) => refresh(token ?? undefined))
43+
.catch(() => {
44+
// 未登录静默(本地模式不展示配额)
45+
});
46+
}, [getAccessToken, refresh]);
3447

48+
// 网关不可达或未登录时不展示(增强信息);total_calls=0 视为数据异常;
49+
// -1(开发者无限配额)为合法值,不得在此过滤
50+
if (!quota || quota.totalCalls === 0) return null;
51+
52+
// 开发者白名单账户:服务端返回 total_calls=-1(完全豁免限流/费用)
53+
const isUnlimited = quota.totalCalls === -1;
3554
const callPct = quota.totalCalls > 0 ? quota.usedCalls / quota.totalCalls : 0;
3655
const costPct = quota.costLimit > 0 ? quota.usedCost / quota.costLimit : 0;
37-
const callsNearlyDone = callPct >= 0.8;
56+
const callsNearlyDone = isUnlimited ? false : callPct >= 0.8;
57+
const callsExhausted = !isUnlimited && quota.usedCalls >= quota.totalCalls;
3858

3959
return (
4060
<div className="flex flex-col gap-3 p-3 rounded-kb-md bg-bg-elevated border border-border-default">
@@ -52,24 +72,28 @@ export function AiQuotaCard() {
5272
<div className="flex items-center justify-between text-c1">
5373
<span className="text-text-tertiary">AI 调用</span>
5474
<span className={cn('font-medium', callsNearlyDone ? 'text-semantic-warning' : 'text-text-secondary')}>
55-
{quota.usedCalls}/{quota.totalCalls} 次
75+
{isUnlimited ? '∞ 次(开发者不限量)' : callsExhausted ? '今日额度已用完' : `${quota.usedCalls}/${quota.totalCalls} 次`}
5676
</span>
5777
</div>
58-
<UsageBar used={quota.usedCalls} total={quota.totalCalls} tone={callsNearlyDone ? 'warning' : 'brand'} />
78+
{isUnlimited ? (
79+
<p className="text-c1 text-text-tertiary">开发者白名单账户,完全豁免配额限制</p>
80+
) : (
81+
<UsageBar used={quota.usedCalls} total={quota.totalCalls} tone={callsNearlyDone ? 'warning' : 'brand'} />
82+
)}
5983
</div>
6084

61-
{/* 费用 */}
85+
{/* 费用(开发者豁免时同样展示 ∞) */}
6286
<div className="space-y-1">
6387
<div className="flex items-center justify-between text-c1">
6488
<span className="inline-flex items-center gap-1 text-text-tertiary">
6589
<Coins className="w-3 h-3" strokeWidth={1.5} />
6690
今日费用
6791
</span>
6892
<span className="font-medium text-text-secondary">
69-
¥{quota.usedCost.toFixed(2)}/¥{quota.costLimit.toFixed(1)}
93+
{isUnlimited ? '∞(开发者豁免)' : `¥${quota.usedCost.toFixed(2)}/¥${quota.costLimit.toFixed(1)}`}
7094
</span>
7195
</div>
72-
<UsageBar used={quota.usedCost} total={quota.costLimit} tone={costPct >= 0.8 ? 'warning' : 'brand'} />
96+
{!isUnlimited && <UsageBar used={quota.usedCost} total={quota.costLimit} tone={costPct >= 0.8 ? 'warning' : 'brand'} />}
7397
</div>
7498

7599
{/* 到期时间(订阅用户) */}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* 全局 AI 配额耗尽提示(监听 429 双路径,挂载于 AppLayout 全局组件区)
3+
*
4+
* @ai-context: 监听两条 429 感知路径——渲染进程直连网关(apiClient 派发
5+
* kb:ai-quota-exhausted CustomEvent)与 Electron 主进程代理(gatewayHttp /
6+
* gatewayStream 经 preload 推送 ai:quota-exhausted IPC),统一弹非阻断
7+
* Toast(带「查看额度」跳转)并强制刷新标题栏配额胶囊;同一用户 5 分钟内
8+
* 防重复提示,避免并发 AI 请求同时 429 刷屏。
9+
* @ai-context: Global listener for both 429 paths; shows a non-blocking
10+
* toast with a quota navigation action, deduped within 5 minutes.
11+
*/
12+
import { useCallback, useEffect, useRef } from 'react';
13+
import { useNavigate } from 'react-router-dom';
14+
import { useToast } from '@/components/ui/Toast';
15+
import { useQuotaStore } from '@/features/beta/useQuotaStore';
16+
import { useAuth } from '@/lib/auth/AuthContext';
17+
18+
// 同一用户 5 分钟内不重复提示(并发 AI 请求同时 429 防刷屏)
19+
const TOAST_COOLDOWN_MS = 5 * 60_000;
20+
21+
export function QuotaNotice() {
22+
const navigate = useNavigate();
23+
const { toast } = useToast();
24+
const { getAccessToken } = useAuth();
25+
const refresh = useQuotaStore((s) => s.refresh);
26+
const lastToastAt = useRef(0);
27+
28+
const handleQuotaExhausted = useCallback(
29+
(detail?: string) => {
30+
const now = Date.now();
31+
if (now - lastToastAt.current < TOAST_COOLDOWN_MS) return;
32+
lastToastAt.current = now;
33+
34+
// 429 后强制刷新配额(绕过 30s 去抖),标题栏胶囊即时反映耗尽状态
35+
getAccessToken()
36+
.then((token) => refresh(token ?? undefined, true))
37+
.catch(() => {
38+
// 未登录静默(本地模式无配额概念)
39+
});
40+
41+
const isCost = (detail ?? '').includes('费用');
42+
toast({
43+
type: 'warning',
44+
message: isCost
45+
? '今日 AI 费用已达上限,AI 功能暂不可用,明天自动恢复'
46+
: '今日 AI 配额已用完,AI 功能已切换为本地模式,明天自动恢复',
47+
action: {
48+
label: '查看额度',
49+
onClick: () => navigate('/settings'),
50+
},
51+
silent: true,
52+
});
53+
},
54+
[getAccessToken, refresh, navigate, toast],
55+
);
56+
57+
useEffect(() => {
58+
// 路径 1:渲染进程直连网关(apiClient)派发的窗口事件
59+
const onWindowEvent = () => handleQuotaExhausted();
60+
window.addEventListener('kb:ai-quota-exhausted', onWindowEvent);
61+
62+
// 路径 2:Electron 主进程代理(gatewayHttp/gatewayStream)IPC 推送
63+
const cleanupIpc = window.electronAPI?.onAIQuotaExhausted?.((detail) =>
64+
handleQuotaExhausted(detail),
65+
);
66+
67+
return () => {
68+
window.removeEventListener('kb:ai-quota-exhausted', onWindowEvent);
69+
cleanupIpc?.();
70+
};
71+
}, [handleQuotaExhausted]);
72+
73+
return null;
74+
}

‎client/src/features/beta/hooks/useQuota.ts‎

Lines changed: 0 additions & 62 deletions
This file was deleted.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* AI 配额全局状态(Zustand)
3+
*
4+
* @ai-context: 配额是跨页面信息(标题栏常驻胶囊 + 设置页用量卡共用),
5+
* 由本 store 统一持有与刷新,避免多组件各自请求 /api/v1/license/quota。
6+
* refresh 带 30 秒去抖窗口;429 配额耗尽事件用 force 绕过窗口立即刷新,
7+
* 让"已用完"状态在标题栏即时可见。
8+
* @ai-context: Global AI quota state shared by the titlebar badge and the
9+
* settings quota card; refresh is debounced, force bypasses on 429 events.
10+
*/
11+
import { create } from 'zustand';
12+
import type { QuotaInfo } from '@/types/beta';
13+
14+
interface QuotaState {
15+
quota: QuotaInfo | null;
16+
loading: boolean;
17+
/** 拉取服务端当日配额(token 由调用方从 AuthContext 获取;force 绕过去抖) */
18+
refresh: (token?: string, force?: boolean) => Promise<void>;
19+
}
20+
21+
/** 配额接口响应 → 类型字段名映射(snake_case → camelCase) */
22+
function normalizeQuota(raw: Record<string, unknown>): QuotaInfo {
23+
return {
24+
usedCalls: Number(raw.used_calls) || 0,
25+
totalCalls: Number(raw.total_calls) || 0,
26+
usedCost: Number(raw.used_cost) || 0,
27+
costLimit: Number(raw.cost_limit) || 0,
28+
tier: (raw.tier as QuotaInfo['tier']) ?? 'free',
29+
expiresAt: typeof raw.expires_at === 'string' ? raw.expires_at : undefined,
30+
};
31+
}
32+
33+
// 30 秒去抖窗口:并发组件(标题栏 + 用量卡)同时挂载不会重复请求
34+
const REFRESH_DEBOUNCE_MS = 30_000;
35+
36+
// 模块级去抖时间戳 + 请求中标记(独立于 store,避免 refresh 内部读写 store 的循环依赖)
37+
let lastFetchedAt = 0;
38+
let refreshing = false;
39+
40+
export const useQuotaStore = create<QuotaState>((set) => ({
41+
quota: null,
42+
loading: false,
43+
44+
refresh: async (token?: string, force = false) => {
45+
// 请求进行中直接跳过(并发 429 事件 / 多组件挂载时防止重复请求)
46+
if (refreshing) return;
47+
const now = Date.now();
48+
if (!force && now - lastFetchedAt < REFRESH_DEBOUNCE_MS) return;
49+
lastFetchedAt = now;
50+
refreshing = true;
51+
set({ loading: true });
52+
try {
53+
const headers: Record<string, string> = {};
54+
if (token) headers.Authorization = `Bearer ${token}`;
55+
const resp = await fetch('/api/v1/license/quota', { headers });
56+
if (!resp.ok) return;
57+
const data = (await resp.json()) as Record<string, unknown>;
58+
set({ quota: normalizeQuota(data) });
59+
} catch {
60+
// 网关不可达时静默降级(配额展示是增强信息,不阻塞页面)
61+
} finally {
62+
refreshing = false;
63+
set({ loading: false });
64+
}
65+
},
66+
}));

0 commit comments

Comments
 (0)