mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
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:
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 发送队列 */
|
||||
|
||||
Reference in New Issue
Block a user