fix(assistant): 连续错误自动熔断,避免无限重试循环 (#226)

在路径拦截 + sanitize 的基础上再加一道保险:用户粘贴的不只是
markdown 本地图片、而是其他 API 不接受的内容时(如意外内容格式、
鉴权失败、quota 耗尽),单纯清洗无法解决,用户反复点"重试"会陷
入同样的错误循环。

熔断机制:
- 2 分钟滑动窗口内,同一错误指纹累计 ≥3 次触发熔断
- 错误指纹归一化:去掉数字(时间戳/请求 ID)、URL、多余空白,
  只对比核心语义
- 熔断状态下重试按钮禁用 + 警告色样式,hint 文案改为"请先检查
  API 配置或网络",点击后 toast 提示而非触发重试
- 自动恢复:修改配置(saveConfig)或新建会话(createSession)时
  调用 resetCircuit() 清空失败历史

UI 共用:抽出 createRetryBar(session, circuitOpen) 供 sendMessageDirect
和 retryAIResponse 两处复用,消除原本重复的 30 行错误处理代码。
新增 .ast-retry-bar-circuit CSS 变体(warning 色 + 禁用态)。

翻译键(11 语言):
- retryCircuitHint — 熔断状态下的重试栏 hint
- retryCircuitBlocked — 点击已禁用重试按钮时的 toast

Refs: #226
This commit is contained in:
晴天
2026-04-20 03:25:40 +08:00
parent 3a4566d26a
commit 58f5525445
3 changed files with 131 additions and 49 deletions

View File

@@ -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'),

View File

@@ -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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>'
const continueSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
const warnSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align:-2px"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
const hintText = circuitOpen ? t('assistant.retryCircuitHint') : t('assistant.retryHint')
const hintIcon = circuitOpen ? warnSvg + ' ' : ''
retryBar.innerHTML = `
<button class="btn btn-sm btn-primary ast-btn-retry"${circuitOpen ? ' disabled aria-disabled="true"' : ''}>${retrySvg} ${t('assistant.retry')}</button>
<button class="btn btn-sm btn-secondary ast-btn-continue">${continueSvg} ${t('assistant.continueInput')}</button>
<span class="ast-retry-hint">${hintIcon}${hintText}</span>
`
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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>'
const continueSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
retryBar.innerHTML = `
<button class="btn btn-sm btn-primary ast-btn-retry">${retrySvg} ${t('assistant.retry')}</button>
<button class="btn btn-sm btn-secondary ast-btn-continue">${continueSvg} ${t('assistant.continueInput')}</button>
<span class="ast-retry-hint">${t('assistant.retryHint')}</span>
`
_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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>'
const continueSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
retryBar.innerHTML = `
<button class="btn btn-sm btn-primary ast-btn-retry">${retrySvg} ${t('assistant.retry')}</button>
<button class="btn btn-sm btn-secondary ast-btn-continue">${continueSvg} ${t('assistant.continueInput')}</button>
<span class="ast-retry-hint">${t('assistant.retryHint')}</span>
`
_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

View File

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