diff --git a/src/locales/modules/assistant.js b/src/locales/modules/assistant.js index 2d7b8ec..4e66aa0 100644 --- a/src/locales/modules/assistant.js +++ b/src/locales/modules/assistant.js @@ -81,6 +81,32 @@ export default { 'Bitte fügen Sie das Bild selbst ein oder ziehen Sie es, keinen lokalen Pfad', ), localPathSanitized: _('[本地文件(已忽略,请直接上传图片)]', '[local file (ignored — please upload the image)]', '[本地檔案(已忽略,請直接上傳圖片)]'), + retryCircuitHint: _( + '连续多次相同错误,已暂停自动重试。请检查 API 配置或网络(修改配置后自动恢复)', + 'Same error repeated, auto-retry paused. Check API config or network (fixed by saving new config)', + '連續多次相同錯誤,已暫停自動重試。請檢查 API 設定或網路(修改設定後自動恢復)', + '同じエラーが連続発生、自動再試行を一時停止。API 設定またはネットワークを確認してください(設定を保存すると自動復旧)', + '동일한 오류가 반복되어 자동 재시도를 일시 중지했습니다. API 설정 또는 네트워크를 확인하세요 (설정 저장 시 자동 복구)', + 'Lỗi giống nhau lặp lại, đã tạm dừng tự động thử lại. Kiểm tra cấu hình API hoặc mạng (tự khôi phục sau khi lưu cấu hình)', + 'Mismo error repetido, reintento automático pausado. Revise la configuración de la API o la red', + 'Mesmo erro repetido, repetição automática pausada. Verifique a configuração da API ou a rede', + 'Повторяющаяся ошибка, автоповтор приостановлен. Проверьте настройки API или сеть', + 'Même erreur répétée, nouvelle tentative automatique suspendue. Vérifiez la configuration API ou le réseau', + 'Gleicher Fehler wiederholt, automatischer Wiederholungsversuch pausiert. API-Konfiguration oder Netzwerk prüfen', + ), + retryCircuitBlocked: _( + '连续错误已暂停重试,请先检查配置', + 'Auto-retry paused due to repeated errors — please check config first', + '連續錯誤已暫停重試,請先檢查設定', + '連続エラーにより再試行を一時停止しました。先に設定を確認してください', + '연속 오류로 재시도가 일시 중지되었습니다. 먼저 설정을 확인하세요', + 'Đã tạm dừng thử lại do lỗi lặp lại — vui lòng kiểm tra cấu hình trước', + 'Reintentos pausados por errores repetidos — revise la configuración primero', + 'Tentativas pausadas devido a erros repetidos — verifique a configuração primeiro', + 'Повторы приостановлены из-за повторяющихся ошибок — сначала проверьте настройки', + 'Nouvelles tentatives suspendues en raison d\'erreurs répétées — vérifiez d\'abord la configuration', + 'Wiederholungen aufgrund wiederholter Fehler pausiert — bitte zuerst Konfiguration prüfen', + ), newSession: _('新建会话', 'New Session', '新建對話', '新しいセッション', '새 세션', 'Phiên mới', 'Nueva sesión', 'Nova sessão', 'Новая сессия', 'Nouvelle session', 'Neue Sitzung'), deleteSession: _('删除会话', 'Delete Session', '刪除對話', 'セッション削除', '세션 삭제', 'Xóa phiên', 'Eliminar sesión', 'Excluir sessão', 'Удалить сессию', 'Supprimer la session', 'Sitzung löschen'), noSessions: _('暂无会话', 'No sessions', '暫無對話', 'セッションなし', '세션 없음', 'Không có phiên', 'Sin sesiones', 'Sem sessões', 'Нет сессий', 'Aucune session', 'Keine Sitzungen'), diff --git a/src/pages/assistant.js b/src/pages/assistant.js index d9119bf..20cb12f 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -1377,6 +1377,79 @@ function sanitizeUserTextForApi(text) { return text.replace(LOCAL_PATH_MD_IMG_RE, t('assistant.localPathSanitized')) } +// ── 连续错误熔断(Fix #226)── +// 同一错误连续出现 N 次时暂停自动重试,引导用户检查配置,避免无限循环 +const CIRCUIT_WINDOW_MS = 2 * 60 * 1000 // 2 分钟滑动窗口 +const CIRCUIT_THRESHOLD = 3 // 同错误指纹出现 ≥3 次视为熔断打开 +let _recentFailures = [] // [{ fp: string, ts: number }] + +function _errorFingerprint(err) { + const msg = String(err?.message || err || '').trim() + if (!msg) return '' + // 归一化:去掉变化的数字(时间戳/ID)、URL、多余空白,保留错误核心 + return msg + .replace(/\d{3,}/g, 'N') + .replace(/\bhttps?:\/\/\S+/gi, 'URL') + .replace(/\s+/g, ' ') + .slice(0, 180) +} + +function recordRequestFailure(err) { + const fp = _errorFingerprint(err) + if (!fp) return + const now = Date.now() + _recentFailures.push({ fp, ts: now }) + _recentFailures = _recentFailures.filter(f => now - f.ts < CIRCUIT_WINDOW_MS) +} + +function isCircuitOpenFor(err) { + const fp = _errorFingerprint(err) + if (!fp) return false + const now = Date.now() + const matching = _recentFailures.filter(f => f.fp === fp && now - f.ts < CIRCUIT_WINDOW_MS) + return matching.length >= CIRCUIT_THRESHOLD +} + +function resetCircuit() { + _recentFailures = [] +} + +// 创建错误重试栏(sendMessageDirect 和 retryAIResponse 共用) +// circuitOpen 为 true 时:禁用重试按钮、改用警告色 hint、点击重试时 toast 提示 +function createRetryBar(session, circuitOpen) { + const retryBar = document.createElement('div') + retryBar.className = 'ast-retry-bar' + (circuitOpen ? ' ast-retry-bar-circuit' : '') + const retrySvg = '' + const continueSvg = '' + const warnSvg = '' + const hintText = circuitOpen ? t('assistant.retryCircuitHint') : t('assistant.retryHint') + const hintIcon = circuitOpen ? warnSvg + ' ' : '' + retryBar.innerHTML = ` + + + ${hintIcon}${hintText} + ` + retryBar.querySelector('.ast-btn-retry').addEventListener('click', (e) => { + if (circuitOpen) { + e.preventDefault() + toast(t('assistant.retryCircuitBlocked'), 'warn') + return + } + retryBar.remove() + session.messages.pop() + saveSessions() + setSessionStatus(session.id, 'idle') + retryAIResponse(session) + }) + retryBar.querySelector('.ast-btn-continue').addEventListener('click', () => { + retryBar.remove() + setSessionStatus(session.id, 'idle') + renderSessionList() + _textarea?.focus() + }) + return retryBar +} + // ── 图片附件 ── const MAX_IMAGE_SIZE = 4 * 1024 * 1024 // 4MB const MAX_IMAGE_DIM = 2048 // 最大边长 @@ -1519,6 +1592,8 @@ function loadConfig() { function saveConfig() { localStorage.setItem(STORAGE_KEY, JSON.stringify(_config)) + // Fix #226: 配置变更后重置熔断状态,让用户改了 API/模型/key 后能立刻重试 + resetCircuit() } // ── 会话管理 ── @@ -1566,6 +1641,8 @@ function createSession() { _sessions.push(session) _currentSessionId = session.id saveSessions() + // Fix #226: 新会话重置熔断状态,与旧会话的错误历史隔离 + resetCircuit() return session } @@ -3964,42 +4041,22 @@ async function sendMessageDirect(text) { aiMsg.content += aiMsg.content ? '\n\n*[' + t('assistant.stopped') + ']*' : '*[' + t('assistant.stopped') + ']*' } else { setSessionStatus(session.id, 'error') + // Fix #226: 记录错误用于熔断判断 + recordRequestFailure(err) // 保留已有内容,追加错误信息和重试按钮 const errInfo = aiMsg.content ? `\n\n---\n**${t('assistant.requestInterrupted')}**: ${err.message}` : err.message aiMsg.content += errInfo aiMsg._canRetry = true + aiMsg._circuitOpen = isCircuitOpenFor(err) } renderMessages() - // 错误后插入重试按钮 + // 错误后插入重试按钮(circuit 打开时禁用并提示) if (aiMsg._canRetry && _messagesEl) { - const retryBar = document.createElement('div') - retryBar.className = 'ast-retry-bar' - const retrySvg = '' - const continueSvg = '' - retryBar.innerHTML = ` - - - ${t('assistant.retryHint')} - ` - _messagesEl.appendChild(retryBar) + _messagesEl.appendChild(createRetryBar(session, !!aiMsg._circuitOpen)) _messagesEl.scrollTop = _messagesEl.scrollHeight - - retryBar.querySelector('.ast-btn-retry').addEventListener('click', () => { - retryBar.remove() - session.messages.pop() - saveSessions() - setSessionStatus(session.id, 'idle') - retryAIResponse(session) - }) - retryBar.querySelector('.ast-btn-continue').addEventListener('click', () => { - retryBar.remove() - setSessionStatus(session.id, 'idle') - renderSessionList() - _textarea?.focus() - }) } } finally { _isStreaming = false @@ -4104,39 +4161,19 @@ async function retryAIResponse(session) { aiMsg.content += aiMsg.content ? '\n\n*[' + t('assistant.stopped') + ']*' : '*[' + t('assistant.stopped') + ']*' } else { setSessionStatus(session.id, 'error') + // Fix #226: 记录错误用于熔断判断 + recordRequestFailure(err) aiMsg.content += aiMsg.content ? `\n\n---\n**${t('assistant.requestInterrupted')}**: ${err.message}` : err.message aiMsg._canRetry = true + aiMsg._circuitOpen = isCircuitOpenFor(err) } renderMessages() if (aiMsg._canRetry && _messagesEl) { - const retryBar = document.createElement('div') - retryBar.className = 'ast-retry-bar' - const retrySvg = '' - const continueSvg = '' - retryBar.innerHTML = ` - - - ${t('assistant.retryHint')} - ` - _messagesEl.appendChild(retryBar) + _messagesEl.appendChild(createRetryBar(session, !!aiMsg._circuitOpen)) _messagesEl.scrollTop = _messagesEl.scrollHeight - - retryBar.querySelector('.ast-btn-retry').addEventListener('click', () => { - retryBar.remove() - session.messages.pop() - saveSessions() - setSessionStatus(session.id, 'idle') - retryAIResponse(session) - }) - retryBar.querySelector('.ast-btn-continue').addEventListener('click', () => { - retryBar.remove() - setSessionStatus(session.id, 'idle') - renderSessionList() - _textarea?.focus() - }) } } finally { _isStreaming = false diff --git a/src/style/assistant.css b/src/style/assistant.css index 8a9dc50..e66ead9 100644 --- a/src/style/assistant.css +++ b/src/style/assistant.css @@ -1180,6 +1180,25 @@ font-size: 11px; color: var(--text-tertiary); margin-left: auto; + display: inline-flex; + align-items: center; + gap: 4px; +} +/* 熔断状态:连续错误时的警告变体(Fix #226) */ +.ast-retry-bar-circuit { + background: color-mix(in srgb, var(--warning, #f59e0b) 10%, var(--bg-secondary)); + border-color: var(--warning, #f59e0b); +} +.ast-retry-bar-circuit .ast-retry-hint { + color: var(--warning, #f59e0b); + font-weight: 500; +} +.ast-retry-bar-circuit .ast-btn-retry[disabled] { + opacity: 0.5; + cursor: not-allowed; +} +.ast-retry-bar-circuit .ast-btn-retry[disabled]:hover { + filter: none; } /* 发送队列 */