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;
}
/* 发送队列 */