From d0dd29a132eeacad8f400dc21a47d594072e9979 Mon Sep 17 00:00:00 2001 From: zzj3720 Date: Sun, 10 May 2026 21:52:20 +0800 Subject: [PATCH] Add automatic auth profile switch option --- src/admin-ui/admin-legacy.js | 49 ++++++++- src/admin-ui/admin.css | 2 +- src/admin-ui/auth-profile-display.ts | 18 ++-- src/admin-ui/session-view.tsx | 23 +++-- src/auth-profile-quota.ts | 128 ++++++++++++++++++++++++ src/http/admin-routes.ts | 8 +- src/services/admin-service.ts | 46 ++++++--- test/admin-auth-profile-display.test.ts | 46 +++++++-- test/admin-control-plane.e2e.test.ts | 77 ++++++++++++++ test/admin-routes.test.ts | 44 +++++++- test/admin-session-view.test.ts | 2 +- test/admin-token-usage.e2e.test.ts | 2 +- 12 files changed, 394 insertions(+), 51 deletions(-) diff --git a/src/admin-ui/admin-legacy.js b/src/admin-ui/admin-legacy.js index 123860b..ef08da8 100644 --- a/src/admin-ui/admin-legacy.js +++ b/src/admin-ui/admin-legacy.js @@ -185,14 +185,53 @@ export function initAdminPage(options = {}) { if (!Number.isFinite(seconds)) return null; return Math.max(((seconds * 1000) - Date.now()) / 86400000, 1 / (24 * 60)); } + function msUntilReset(resetsAt) { + const seconds = Number(resetsAt); + if (!Number.isFinite(seconds)) return null; + return Math.max((seconds * 1000) - Date.now(), 60000); + } function weightedWeeklyQuotaScore(remaining, refreshDays) { if (remaining == null) return null; return (remaining / 100) / ((refreshDays || 7) / 7); } + function weightedQuotaWindowScore(remaining, limit, fallbackWindowMins) { + if (remaining == null) return null; + const windowMins = normalizeWindowDurationMins(limit?.windowDurationMins, fallbackWindowMins); + const resetMs = msUntilReset(limit?.resetsAt) || windowMins * 60000; + return (remaining / 100) / (resetMs / (windowMins * 60000)); + } function formatWeightedWeeklyQuotaScore(score) { if (!Number.isFinite(Number(score))) return "0"; return Number(score).toFixed(2).replace(/\.00$/, "").replace(/(\.\d)0$/, "$1"); } + function normalizeWindowDurationMins(value, fallback) { + const mins = Number(value); + return Number.isFinite(mins) && mins > 0 ? mins : fallback; + } + function formatWindowDuration(windowMins) { + if (windowMins % 1440 === 0) return (windowMins / 1440) + "d"; + if (windowMins % 60 === 0) return (windowMins / 60) + "h"; + return windowMins + "m"; + } + function quotaWindowDisplay(limit, fallbackWindowMins) { + const remaining = remainingPercent(limit?.usedPercent); + if (remaining == null) return null; + const windowMins = normalizeWindowDurationMins(limit?.windowDurationMins, fallbackWindowMins); + const score = weightedQuotaWindowScore(remaining, limit, windowMins); + return formatWindowDuration(windowMins) + " " + Math.round(remaining) + "% / " + formatWeightedWeeklyQuotaScore(score); + } + function authQuotaDisplay(rateLimits) { + const primary = rateLimits?.primary; + const primaryRemaining = remainingPercent(primary?.usedPercent); + const primaryScore = weightedQuotaWindowScore(primaryRemaining, primary, 300); + const shortLabel = Number.isFinite(Number(primaryScore)) && Number(primaryScore) < 0.5 + ? quotaWindowDisplay(primary, 300) + : null; + return [ + quotaWindowDisplay(rateLimits?.secondary, 10080), + shortLabel + ].filter(Boolean).join(" | ") || null; + } function weeklyQuotaDisplay(limit) { const remaining = remainingPercent(limit?.usedPercent); if (remaining == null) return null; @@ -219,11 +258,11 @@ export function initAdminPage(options = {}) { if (plan === "chatgpt") return "ChatGPT"; return plan; } - function profileTooltip(profile, weeklyLabel, secondary) { + function profileTooltip(profile, quotaLabel, secondary) { return [ profileAccountLabel(profile), profilePlanLabel(profile), - weeklyLabel ? "周额度 " + weeklyLabel : "", + quotaLabel ? "额度 " + quotaLabel : "", secondary ? "周重置 " + formatResetTime(secondary.resetsAt) : "", profile.name ? "内部标识 " + profile.name : "" ].filter(Boolean).join(" · "); @@ -234,7 +273,7 @@ export function initAdminPage(options = {}) { const rateLimits = profile.rateLimits || {}; if (rateLimits.ok === false) return null; const secondary = rateLimits.rateLimits?.secondary; - const label = weeklyQuotaDisplay(secondary); + const label = authQuotaDisplay(rateLimits.rateLimits); if (!label) return null; const remaining = remainingPercent(secondary?.usedPercent); const score = weeklyQuotaScore(secondary); @@ -504,9 +543,9 @@ export function initAdminPage(options = {}) { if (!rateLimits || !rateLimits.ok) return '
' + esc(rateLimits?.error || "额度不可用") + '
'; const snapshot = rateLimits.rateLimits || {}; const secondary = snapshot.secondary; - const weeklyLabel = weeklyQuotaDisplay(secondary); + const quotaLabel = authQuotaDisplay(snapshot); return '
' + - '
周额度' + esc(weeklyLabel || "--") + '' + esc(secondary ? formatResetTime(secondary.resetsAt) : "不可用") + '
' + + '
额度' + esc(quotaLabel || "--") + '' + esc(secondary ? "周 " + formatResetTime(secondary.resetsAt) : "不可用") + '
' + '
'; } function renderAuthProfiles(data) { diff --git a/src/admin-ui/admin.css b/src/admin-ui/admin.css index 8c894f4..e36e583 100644 --- a/src/admin-ui/admin.css +++ b/src/admin-ui/admin.css @@ -142,7 +142,7 @@ .auth-profile-label { color: var(--muted); font-size: 10px; white-space: nowrap; } .session-timeline-panel .mini-body { flex: 1; min-height: 0; overflow: hidden; padding: 0; } - .timeline { height: 100%; display: grid; gap: 0; overflow: auto; border: 0; } + .timeline { height: 100%; display: grid; grid-auto-rows: max-content; align-content: start; gap: 0; overflow: auto; border: 0; } .timeline-event { display: grid; grid-template-columns: 72px 90px minmax(0, 1fr); gap: 6px; align-items: start; padding: 4px 6px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 10px; } .timeline-event:last-child { border-bottom: 0; } .timeline-main { min-width: 0; } diff --git a/src/admin-ui/auth-profile-display.ts b/src/admin-ui/auth-profile-display.ts index d526b8b..20af283 100644 --- a/src/admin-ui/auth-profile-display.ts +++ b/src/admin-ui/auth-profile-display.ts @@ -1,4 +1,4 @@ -import { formatWeeklyQuotaDisplay } from "../auth-profile-quota.js"; +import { formatAuthQuotaDisplay } from "../auth-profile-quota.js"; type AuthProfileRecord = Record; interface QuotaLabelOptions { @@ -66,12 +66,12 @@ export function profileQuotaLabel(profile: AuthProfileRecord, options: QuotaLabe } const limits = rateLimits.rateLimits || {}; - const label = formatWeeklyQuotaDisplay({ - usedPercent: limits.secondary?.usedPercent, - resetsAt: limits.secondary?.resetsAt, + const label = formatAuthQuotaDisplay({ + primary: limits.primary, + secondary: limits.secondary, now: options.now }); - return label ?? "周额度未知"; + return label ?? "额度未知"; } export function profileWeeklyQuotaLabel(profile: AuthProfileRecord, options: QuotaLabelOptions = {}): string { @@ -81,11 +81,11 @@ export function profileWeeklyQuotaLabel(profile: AuthProfileRecord, options: Quo } const limits = rateLimits.rateLimits || {}; - return formatWeeklyQuotaDisplay({ - usedPercent: limits.secondary?.usedPercent, - resetsAt: limits.secondary?.resetsAt, + return formatAuthQuotaDisplay({ + primary: limits.primary, + secondary: limits.secondary, now: options.now - }) ?? "周额度未知"; + }) ?? "额度未知"; } function readString(value: unknown): string | null { diff --git a/src/admin-ui/session-view.tsx b/src/admin-ui/session-view.tsx index be1e4e8..f971f0d 100644 --- a/src/admin-ui/session-view.tsx +++ b/src/admin-ui/session-view.tsx @@ -40,6 +40,7 @@ type TimelinePayload = { } | TimelineEvent[]; const sessionFilters = ["ongoing", "all", "active", "inbound", "jobs", "issues", "usage"]; +const AUTO_AUTH_PROFILE_VALUE = "__auto_auth_profile__"; export function AdminSessionsView(): React.JSX.Element { const permalinkSessionKey = readPermalinkSessionKey(); @@ -577,7 +578,7 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr readonly currentProfile?: SessionRecord | undefined; }): React.JSX.Element { const dialogRef = useRef(null); - const [selected, setSelected] = useState(String(session.authProfileName || "")); + const [selected, setSelected] = useState(() => initialAuthProfileSelection(session)); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); const currentProfile = providedCurrentProfile ?? profiles.find((profile) => profile.name === session.authProfileName); @@ -588,7 +589,7 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr const compactLabel = currentProfile ? profileQuotaLabel(currentProfile) : (blocked ? "账号不可用" : "账号"); useEffect(() => { - setSelected(String(session.authProfileName || "")); + setSelected(initialAuthProfileSelection(session)); setMessage(null); }, [session.key, session.authProfileName, session.authBlockedAt]); @@ -613,7 +614,8 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr } async function switchProfile(): Promise { - if (!selected || selected === session.authProfileName) { + const autoSelected = selected === AUTO_AUTH_PROFILE_VALUE; + if (!autoSelected && (!selected || selected === session.authProfileName)) { return; } @@ -623,11 +625,11 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr await requestJson("/admin/api/sessions/" + encodeURIComponent(String(session.key || "")) + "/auth-profile", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: selected }) + body: JSON.stringify(autoSelected ? { mode: "auto" } : { name: selected }) }); const timelinePayload = await requestJson(sessionTimelineApiPath(String(session.key || ""))); publishTimelinePayload(String(session.key || ""), timelinePayload as TimelinePayload); - setMessage("已切换,正在恢复待处理消息"); + setMessage(autoSelected ? "已自动分配,正在恢复待处理消息" : "已切换,正在恢复待处理消息"); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { @@ -653,7 +655,7 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr {currentProfile ? (
- 周额度 + 额度 {profileQuotaLabel(currentProfile)}
) : null} @@ -671,6 +673,7 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr onChange={(event) => setSelected(event.target.value)} > + {profiles.map((profile) => (