diff --git a/src/auth/AuthSource.js b/src/auth/AuthSource.js index 56e860a..119a0c0 100644 --- a/src/auth/AuthSource.js +++ b/src/auth/AuthSource.js @@ -18,8 +18,16 @@ class AuthSource { this.logger = logger; this.authMode = "file"; this.availableIndices = []; + // Indices used for rotation/switching (deduplicated by email, keeping the latest index per account) + this.rotationIndices = []; + // Duplicate auth indices detected (valid JSON but skipped from rotation due to same email) + this.duplicateIndices = []; this.initialIndices = []; this.accountNameMap = new Map(); + // Map any valid index -> canonical (latest) index for the same account email + this.canonicalIndexMap = new Map(); + // Duplicate groups (email -> kept + duplicates) + this.duplicateGroups = []; this.lastScannedIndices = "[]"; // Cache to track changes this.logger.info('[Auth] Using files in "configs/auth/" directory for authentication.'); @@ -96,13 +104,19 @@ class AuthSource { _preValidateAndFilter() { if (this.initialIndices.length === 0) { this.availableIndices = []; + this.rotationIndices = []; + this.duplicateIndices = []; this.accountNameMap.clear(); + this.canonicalIndexMap.clear(); + this.duplicateGroups = []; return; } const validIndices = []; const invalidSourceDescriptions = []; this.accountNameMap.clear(); // Clear old names before re-validating + this.canonicalIndexMap.clear(); + this.duplicateGroups = []; for (const index of this.initialIndices) { // Iterate over initial to check all, not just previously available @@ -131,6 +145,73 @@ class AuthSource { } this.availableIndices = validIndices.sort((a, b) => a - b); + this._buildRotationIndices(); + } + + _normalizeEmailKey(accountName) { + if (typeof accountName !== "string") return null; + const trimmed = accountName.trim(); + if (!trimmed) return null; + // Conservative: only deduplicate when the name looks like an email address. + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailPattern.test(trimmed)) return null; + return trimmed.toLowerCase(); + } + + _buildRotationIndices() { + this.rotationIndices = []; + this.duplicateIndices = []; + this.duplicateGroups = []; + + const emailKeyToIndices = new Map(); + + for (const index of this.availableIndices) { + const accountName = this.accountNameMap.get(index); + const emailKey = this._normalizeEmailKey(accountName); + + if (!emailKey) { + this.rotationIndices.push(index); + this.canonicalIndexMap.set(index, index); + continue; + } + + const list = emailKeyToIndices.get(emailKey) || []; + list.push(index); + emailKeyToIndices.set(emailKey, list); + } + + for (const [emailKey, indices] of emailKeyToIndices.entries()) { + indices.sort((a, b) => a - b); + const keptIndex = indices[indices.length - 1]; + this.rotationIndices.push(keptIndex); + + const duplicateIndices = []; + for (const index of indices) { + this.canonicalIndexMap.set(index, keptIndex); + if (index !== keptIndex) { + duplicateIndices.push(index); + } + } + + if (duplicateIndices.length > 0) { + this.duplicateIndices.push(...duplicateIndices); + this.duplicateGroups.push({ + email: emailKey, + keptIndex, + removedIndices: duplicateIndices, + }); + } + } + + this.rotationIndices = [...new Set(this.rotationIndices)].sort((a, b) => a - b); + this.duplicateIndices = [...new Set(this.duplicateIndices)].sort((a, b) => a - b); + + if (this.duplicateIndices.length > 0) { + this.logger.warn( + `[Auth] Detected ${this.duplicateIndices.length} duplicate auth files (same email). ` + + `Rotation will only use latest index per account: [${this.rotationIndices.join(", ")}].` + ); + } } _getAuthContent(index) { @@ -162,6 +243,20 @@ class AuthSource { return null; } } + + getRotationIndices() { + return this.rotationIndices; + } + + getCanonicalIndex(index) { + if (!Number.isInteger(index)) return null; + if (!this.availableIndices.includes(index)) return null; + return this.canonicalIndexMap.get(index) ?? index; + } + + getDuplicateGroups() { + return this.duplicateGroups; + } } module.exports = AuthSource; diff --git a/src/auth/AuthSwitcher.js b/src/auth/AuthSwitcher.js index 2a0aa45..9780560 100644 --- a/src/auth/AuthSwitcher.js +++ b/src/auth/AuthSwitcher.js @@ -30,10 +30,14 @@ class AuthSwitcher { } getNextAuthIndex() { - const available = this.authSource.availableIndices; + const available = this.authSource.getRotationIndices(); if (available.length === 0) return null; - const currentIndexInArray = available.indexOf(this.currentAuthIndex); + const currentCanonicalIndex = + this.currentAuthIndex >= 0 + ? this.authSource.getCanonicalIndex(this.currentAuthIndex) + : this.currentAuthIndex; + const currentIndexInArray = available.indexOf(currentCanonicalIndex); if (currentIndexInArray === -1) { this.logger.warn( @@ -47,7 +51,7 @@ class AuthSwitcher { } async switchToNextAuth() { - const available = this.authSource.availableIndices; + const available = this.authSource.getRotationIndices(); if (available.length === 0) { throw new Error("No available authentication sources, cannot switch."); @@ -86,7 +90,11 @@ class AuthSwitcher { } // Multi-account mode - const currentIndexInArray = available.indexOf(this.currentAuthIndex); + const currentCanonicalIndex = + this.currentAuthIndex >= 0 + ? this.authSource.getCanonicalIndex(this.currentAuthIndex) + : this.currentAuthIndex; + const currentIndexInArray = available.indexOf(currentCanonicalIndex); const hasCurrentAccount = currentIndexInArray !== -1; const startIndex = hasCurrentAccount ? currentIndexInArray : 0; const originalStartAccount = hasCurrentAccount ? available[startIndex] : null; @@ -94,7 +102,9 @@ class AuthSwitcher { this.logger.info("=================================================="); this.logger.info(`🔄 [Auth] Multi-account mode: Starting intelligent account switching`); this.logger.info(` • Current account: #${this.currentAuthIndex}`); - this.logger.info(` • Available accounts: [${available.join(", ")}]`); + this.logger.info( + ` • Available accounts (dedup by email, keeping latest index): [${available.join(", ")}]` + ); if (hasCurrentAccount) { this.logger.info(` • Starting from: #${originalStartAccount}`); } else { @@ -191,13 +201,22 @@ class AuthSwitcher { this.logger.info("🔄 [Auth] Account switching in progress, skipping duplicate operation"); return { reason: "Switch already in progress.", success: false }; } - if (!this.authSource.availableIndices.includes(targetIndex)) { + + const canonicalIndex = this.authSource.getCanonicalIndex(targetIndex); + if (canonicalIndex === null) { return { reason: `Switch failed: Account #${targetIndex} invalid or does not exist.`, success: false, }; } + if (canonicalIndex !== targetIndex) { + this.logger.warn( + `[Auth] Requested account #${targetIndex} is a duplicate for the same email. Redirecting to latest auth index #${canonicalIndex}.` + ); + } + targetIndex = canonicalIndex; + this.isSystemBusy = true; try { this.logger.info(`🔄 [Auth] Starting switch to specified account #${targetIndex}...`); diff --git a/src/core/ProxyServerSystem.js b/src/core/ProxyServerSystem.js index a83a1ed..1316e4c 100644 --- a/src/core/ProxyServerSystem.js +++ b/src/core/ProxyServerSystem.js @@ -61,6 +61,7 @@ class ProxyServerSystem extends EventEmitter { this.logger.info(`[System] Proxy server system startup complete.`); const allAvailableIndices = this.authSource.availableIndices; + const allRotationIndices = this.authSource.getRotationIndices(); if (allAvailableIndices.length === 0) { this.logger.warn("[System] No available authentication source. Starting in account binding mode."); @@ -68,16 +69,27 @@ class ProxyServerSystem extends EventEmitter { return; // Exit early } - let startupOrder = [...allAvailableIndices]; - if (initialAuthIndex && allAvailableIndices.includes(initialAuthIndex)) { - this.logger.info(`[System] Detected specified startup index #${initialAuthIndex}, will try it first.`); - startupOrder = [initialAuthIndex, ...allAvailableIndices.filter(i => i !== initialAuthIndex)]; - } else { - if (initialAuthIndex) { + let startupOrder = allRotationIndices.length > 0 ? [...allRotationIndices] : [...allAvailableIndices]; + const hasInitialAuthIndex = Number.isInteger(initialAuthIndex); + if (hasInitialAuthIndex) { + const canonicalInitialIndex = this.authSource.getCanonicalIndex(initialAuthIndex); + if (canonicalInitialIndex !== null && startupOrder.includes(canonicalInitialIndex)) { + if (canonicalInitialIndex !== initialAuthIndex) { + this.logger.warn( + `[System] Specified startup index #${initialAuthIndex} is a duplicate for the same email, using latest auth index #${canonicalInitialIndex} instead.` + ); + } else { + this.logger.info( + `[System] Detected specified startup index #${initialAuthIndex}, will try it first.` + ); + } + startupOrder = [canonicalInitialIndex, ...startupOrder.filter(i => i !== canonicalInitialIndex)]; + } else { this.logger.warn( `[System] Specified startup index #${initialAuthIndex} is invalid or unavailable, will start in default order.` ); } + } else { this.logger.info( `[System] No valid startup index specified, will try in default order [${startupOrder.join(", ")}].` ); @@ -121,6 +133,7 @@ class ProxyServerSystem extends EventEmitter { "/health", "/api/status", "/api/accounts/current", + "/api/accounts/deduplicate", "/api/settings/streaming-mode", "/api/settings/force-thinking", "/api/settings/force-web-search", diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index cbccd5a..187e583 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -153,7 +153,7 @@ class RequestHandler { } catch (error) { this.logger.error(`❌ [System] Recovery failed: ${error.message}`); - if (wasDirectRecovery && this.authSource.availableIndices.length > 1) { + if (wasDirectRecovery && this.authSource.getRotationIndices().length > 1) { this.logger.warn("⚠️ [System] Attempting to switch to alternative account..."); try { const result = await this.authSwitcher.switchToNextAuth(); diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index b0b2645..6231048 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -150,7 +150,7 @@ class StatusRoutes { } } else { this.logger.info("[WebUI] Received manual request to switch to next account..."); - if (this.serverSystem.authSource.availableIndices.length <= 1) { + if (this.serverSystem.authSource.getRotationIndices().length <= 1) { return res.status(400).json({ message: "accountSwitchCancelledSingle" }); } const result = await this.serverSystem.requestHandler._switchToNextAuth(); @@ -167,6 +167,90 @@ class StatusRoutes { } }); + app.post("/api/accounts/deduplicate", isAuthenticated, async (req, res) => { + try { + const { authSource, requestHandler } = this.serverSystem; + + // Force refresh to ensure dedup metadata is up-to-date even if file list didn't change. + authSource.reloadAuthSources(true); + + const duplicateGroups = authSource.getDuplicateGroups() || []; + if (duplicateGroups.length === 0) { + return res.status(200).json({ + message: "accountDedupNoop", + removedIndices: [], + rotationIndices: authSource.getRotationIndices(), + }); + } + + this.logger.warn( + "[Auth] Dedup cleanup will keep the auth file with the highest index per email and delete the other duplicates. " + + "Assumption: for the same account, auth indices are created in chronological order (higher index = newer)." + ); + + const currentAuthIndex = requestHandler.currentAuthIndex; + if (Number.isInteger(currentAuthIndex) && currentAuthIndex >= 0) { + const canonicalCurrent = authSource.getCanonicalIndex(currentAuthIndex); + if (canonicalCurrent !== null && canonicalCurrent !== currentAuthIndex) { + this.logger.warn( + `[Auth] Current active auth #${currentAuthIndex} is a duplicate. Switching to the latest auth #${canonicalCurrent} before cleanup.` + ); + const switchResult = await requestHandler._switchToSpecificAuth(canonicalCurrent); + if (!switchResult.success) { + return res.status(409).json({ + message: "accountDedupSwitchFailed", + reason: switchResult.reason, + }); + } + } + } + + const removedIndices = []; + const failed = []; + + for (const group of duplicateGroups) { + const removed = Array.isArray(group.removedIndices) ? group.removedIndices : []; + if (removed.length === 0) continue; + + this.logger.info( + `[Auth] Dedup: email ${group.email} -> keep auth-${group.keptIndex}.json, delete [${removed + .map(i => `auth-${i}.json`) + .join(", ")}]` + ); + + for (const index of removed) { + try { + authSource.removeAuth(index); + removedIndices.push(index); + } catch (error) { + failed.push({ error: error.message, index }); + this.logger.error(`[Auth] Dedup delete failed for auth-${index}.json: ${error.message}`); + } + } + } + + authSource.reloadAuthSources(true); + + if (failed.length > 0) { + return res.status(500).json({ + failed, + message: "accountDedupPartialFailed", + removedIndices, + rotationIndices: authSource.getRotationIndices(), + }); + } + + return res.status(200).json({ + message: "accountDedupSuccess", + removedIndices, + rotationIndices: authSource.getRotationIndices(), + }); + } catch (error) { + this.logger.error(`[Auth] Dedup cleanup failed: ${error.message}`); + return res.status(500).json({ error: error.message, message: "accountDedupFailed" }); + } + }); + app.delete("/api/accounts/:index", isAuthenticated, (req, res) => { const rawIndex = req.params.index; const targetIndex = Number(rawIndex); @@ -340,12 +424,19 @@ class StatusRoutes { const { config, requestHandler, authSource, browserManager } = this.serverSystem; const initialIndices = authSource.initialIndices || []; const invalidIndices = initialIndices.filter(i => !authSource.availableIndices.includes(i)); + const rotationIndices = authSource.getRotationIndices(); + const duplicateIndices = authSource.duplicateIndices || []; const logs = this.logger.logBuffer || []; const accountNameMap = authSource.accountNameMap; const accountDetails = initialIndices.map(index => { const isInvalid = invalidIndices.includes(index); const name = isInvalid ? null : accountNameMap.get(index) || null; - return { index, isInvalid, name }; + + const canonicalIndex = isInvalid ? null : authSource.getCanonicalIndex(index); + const isDuplicate = canonicalIndex !== null && canonicalIndex !== index; + const isRotation = rotationIndices.includes(index); + + return { canonicalIndex, index, isDuplicate, isInvalid, isRotation, name }; }); const currentAuthIndex = requestHandler.currentAuthIndex; @@ -371,6 +462,7 @@ class StatusRoutes { currentAccountName, currentAuthIndex, debugMode: LoggingService.isDebugEnabled(), + duplicateIndicesRaw: duplicateIndices, failureCount, forceThinking: this.serverSystem.forceThinking, forceUrlContext: this.serverSystem.forceUrlContext, @@ -382,6 +474,7 @@ class StatusRoutes { initialIndicesRaw: initialIndices, invalidIndicesRaw: invalidIndices, isSystemBusy: requestHandler.isSystemBusy, + rotationIndicesRaw: rotationIndices, streamingMode: this.serverSystem.streamingMode, usageCount, }, diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 858fd02..71e85b6 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -111,7 +111,9 @@ {{ t('currentAccount') }}: #{{ state.currentAuthIndex }} ({{ currentAccountName }}) {{ t('usageCount') }}: {{ state.usageCount }} {{ t('consecutiveFailures') }}: {{ state.failureCount }} -{{ t('totalScanned') }}: {{ totalScannedAccountsText }} @@ -192,6 +194,26 @@ /> + { return `[${indices.join(", ")}] (${t("total")}: ${indices.length})`; }); +const duplicateAuthText = computed(() => { + const indices = state.duplicateIndicesRaw || []; + return `[${indices.join(", ")}] (${t("total")}: ${indices.length})`; +}); + const serviceConnectedClass = computed(() => (state.serviceConnected ? "status-ok" : "status-error")); const serviceConnectedText = computed(() => (state.serviceConnected ? t("running") : t("disconnected"))); const streamingModeText = computed(() => (state.streamingModeReal ? t("real") : t("fake"))); +const rotationAccountsText = computed(() => { + const indices = state.rotationIndicesRaw || []; + return `[${indices.join(", ")}] (${t("total")}: ${indices.length})`; +}); + const totalScannedAccountsText = computed(() => { const indices = state.initialIndicesRaw || []; return `[${indices.join(", ")}] (${t("total")}: ${indices.length})`; @@ -475,7 +509,11 @@ const getAccountDisplayName = account => { if (account.isInvalid) { return t("jsonFormatError"); } - return account.name || t("unnamedAccount"); + const name = account.name || t("unnamedAccount"); + if (account.isDuplicate && account.canonicalIndex !== null && account.canonicalIndex !== undefined) { + return `${name} (${t("duplicateAuthHint", { index: account.canonicalIndex })})`; + } + return name; }; const addUser = () => { @@ -554,6 +592,56 @@ const deleteUser = async () => { }); }; +const deduplicateAuth = () => { + ElMessageBox.confirm(t("accountDedupConfirm"), t("warningTitle"), { + cancelButtonText: t("cancel"), + confirmButtonText: t("ok"), + lockScroll: false, + type: "warning", + }) + .then(async () => { + const notification = ElNotification({ + duration: 0, + message: t("operationInProgress"), + title: t("warningTitle"), + type: "warning", + }); + state.isSwitchingAccount = true; + try { + const res = await fetch("/api/accounts/deduplicate", { method: "POST" }); + const data = await res.json(); + + const removedIndicesText = Array.isArray(data.removedIndices) + ? `[${data.removedIndices.join(", ")}]` + : "[]"; + const failedText = Array.isArray(data.failed) ? JSON.stringify(data.failed) : ""; + + const message = t(data.message, { + ...data, + failed: failedText, + removedIndices: removedIndicesText, + }); + + if (res.ok) { + ElMessage.success(message); + } else { + ElMessage.error(message); + } + } catch (err) { + ElMessage.error(t("accountDedupFailed", { error: err.message || err })); + } finally { + state.isSwitchingAccount = false; + notification.close(); + updateContent(); + } + }) + .catch(e => { + if (e !== "cancel") { + console.error(e); + } + }); +}; + const handleForceThinkingBeforeChange = () => handleSettingChange("/api/settings/force-thinking", "forceThinking"); const handleForceUrlContextBeforeChange = () => @@ -734,7 +822,9 @@ const updateStatus = data => { state.logCount = data.logCount || 0; state.logs = data.logs || ""; state.initialIndicesRaw = data.status.initialIndicesRaw; + state.rotationIndicesRaw = data.status.rotationIndicesRaw || []; state.invalidIndicesRaw = data.status.invalidIndicesRaw; + state.duplicateIndicesRaw = data.status.duplicateIndicesRaw || []; state.isSystemBusy = data.status.isSystemBusy; const isSelectedAccountValid = state.accountDetails.some(acc => acc.index === state.selectedAccount); @@ -1032,6 +1122,10 @@ pre { color: @error-color; } + &.btn-warning:hover:not(:disabled) { + color: @warning-color; + } + // Primary button uses primary color on hover (already default) &.btn-primary:hover:not(:disabled) { color: @primary-color; diff --git a/ui/locales/en.json b/ui/locales/en.json index 1c974e0..f92ef8a 100644 --- a/ui/locales/en.json +++ b/ui/locales/en.json @@ -1,5 +1,11 @@ { "account": "Account", + "accountDedupConfirm": "Deduplicate auth files by email, keeping the highest index and deleting other duplicates (assumes indices increase over time for the same account). Continue?", + "accountDedupFailed": "Deduplication failed: {error}", + "accountDedupNoop": "No duplicate auth files found to clean up.", + "accountDedupPartialFailed": "Partial deduplication failure. Deleted: {removedIndices}; Failed: {failed}", + "accountDedupSuccess": "Deduplication completed. Deleted: {removedIndices}", + "accountDedupSwitchFailed": "Failed to switch to latest auth before deduplication: {reason}", "accountDeleteSuccess": "Account #{index} deleted successfully.", "accountStatus": "Account Status", "accountSwitchCancelledSingle": "Switch cancelled: Only one available account.", @@ -51,6 +57,7 @@ "authVncNotConnected": "VNC session is not connected yet.", "browserConnection": "Browser Connection", "btnAddUser": "Add User", + "btnDeduplicateAuth": "Deduplicate Auth", "btnDeleteUser": "Delete User", "btnSwitchAccount": "Switch Account", "cancel": "Cancel", @@ -61,11 +68,14 @@ "currentAccount": "Current Active Account", "custom": "Custom", "debug": "Debug", + "dedupedAvailable": "Available Accounts (Deduped)", "default": "Default", "deleteFailed": "Delete failed: {message}", "disabled": "Disabled", "disconnected": "Disconnected", "download": "Download Auth", + "duplicateAuth": "Duplicate Auth", + "duplicateAuthHint": "Duplicate, keep #{index}", "enabled": "Enabled", "entries": "entries", "errorAccountNotFound": "Account #{index} not found or already removed.", diff --git a/ui/locales/zh.json b/ui/locales/zh.json index c1543c2..9028d55 100644 --- a/ui/locales/zh.json +++ b/ui/locales/zh.json @@ -1,5 +1,11 @@ { "account": "账号", + "accountDedupConfirm": "将按同一邮箱保留最大 index 的 auth 文件并删除其余重复文件(前提:同一账号的多个 auth 按 index 递增生成,index 越大越新)。是否继续?", + "accountDedupFailed": "去重清理失败:{error}", + "accountDedupNoop": "未发现需要清理的重复 auth。", + "accountDedupPartialFailed": "部分去重清理失败。已删除:{removedIndices};失败:{failed}", + "accountDedupSuccess": "去重清理完成,已删除:{removedIndices}", + "accountDedupSwitchFailed": "去重前切换到最新 auth 失败:{reason}", "accountDeleteSuccess": "账号 {index} 删除成功。", "accountStatus": "账号状态", "accountSwitchCancelledSingle": "切换取消:只有一个可用账号。", @@ -51,6 +57,7 @@ "authVncNotConnected": "VNC 会话尚未连接。", "browserConnection": "浏览器连接", "btnAddUser": "添加账号", + "btnDeduplicateAuth": "去重清理", "btnDeleteUser": "删除账号", "btnSwitchAccount": "切换账号", "cancel": "取消", @@ -61,11 +68,14 @@ "currentAccount": "当前活跃账号", "custom": "自定义", "debug": "调试", + "dedupedAvailable": "去重后可用账号", "default": "默认", "deleteFailed": "删除失败:{message}", "disabled": "已禁用", "disconnected": "连接中断", "download": "下载 Auth", + "duplicateAuth": "重复 Auth", + "duplicateAuthHint": "重复,保留 #{index}", "enabled": "已启用", "entries": "条", "errorAccountNotFound": "账号 {index} 未找到或已被移除。",