From 4e22b85557714c7b4293f7775258a158b0f73a6b Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 28 Apr 2026 00:16:14 +0800 Subject: [PATCH] fix(chat): surface SSE error in retry card and stop poll from wiping local-only failed turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SSE setup fails (e.g. workspace permission denied for shared channel conversations opened from the web console), the failed turn is never persisted on the backend. Two issues made the failure invisible to the user: - The fallback errorInfo dropped data.message, so the inline retry card fell back to the generic "请求过程中遇到了意外问题" template instead of the actual reason. Carry rawMessage through, and lower the MessageBubble display threshold from >8 to >3 chars so short-but-informative messages (7-char Chinese / "Forbidden") aren't filtered out. - The status-poll loop in useChat overwrote the local-only failed turn with the server's "no message" view, erasing the inline retry card. Skip the merge for turns that exist only locally and are in error state, so the user can still see the failure and retry. --- .../src/components/chat/MessageBubble.vue | 4 +- mateclaw-ui/src/composables/chat/useChat.ts | 11 +++- mateclaw-ui/src/views/ChatConsole.vue | 52 +++++++++++++++++-- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 6933b12c..10b3419e 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -342,8 +342,10 @@ const errorDescription = computed(() => { // 优先展示后端 extractUserFriendlyError 生成的具体消息(比泛化模板更有指向性, // 比如"当前模型不支持工具调用,请切换到 qwen3 / qwen2.5 ...")。 // 仅当 rawMessage 为空/过短时才回退到分类模板。 + // 阈值用 3:可过滤 "OK"/"fail" 之类无意义短串,又能放行 "无权操作该会话" + // 这类 7 字中文 / "Forbidden" 这类英文短消息,避免被泛模板覆盖。 const raw = errorInfo.value.rawMessage?.trim() || '' - if (raw.length > 8) { + if (raw.length > 3) { // 去掉后端冗余前缀,错误卡标题已经表达了类别 return raw .replace(/^Bad request:\s*/i, '') diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 358ec752..e3bc44e5 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -454,8 +454,14 @@ export function useChat(options: UseChatOptions): UseChatReturn { let errorFired = false stream.on('error', (data) => { if (isStaleEvent(data)) return + // Always carry data.message as rawMessage, so the inline error card can + // surface the actual reason ("无权操作该会话" etc.) instead of the generic + // unknown.description template. classifyBackendError already does this + // when errorType is present; the fallback path used to drop it. const errorInfo: ChatErrorInfo = data.errorInfo - || (data.errorType ? classifyBackendError(data) : { category: 'unknown', retryable: true, timestamp: Date.now() }) + || (data.errorType + ? classifyBackendError(data) + : { category: 'unknown', rawMessage: data.message, retryable: true, timestamp: Date.now() }) if (currentAssistantId.value) { const msg = getMessage(currentAssistantId.value) if (msg) { @@ -469,7 +475,8 @@ export function useChat(options: UseChatOptions): UseChatReturn { } currentAssistantId.value = null } - error.value = new Error(data.message || '请求失败') + const errorMessage = data.message || '请求失败' + error.value = new Error(errorMessage) streamPhase.value = 'idle' phaseInfo.value = null // Clear queue on error to avoid stale state diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 2438133f..32c96f30 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -565,6 +565,11 @@ const { startObserving: startECharts, dispose: disposeECharts } = useEChartsRend const { startObserving: startKatex, dispose: disposeKatex } = useKatexRenderer(echartsContainerRef) const { startObserving: startMermaid, dispose: disposeMermaid } = useMermaidRenderer(echartsContainerRef) +// Last-attempt draft, restored into the input box when the SSE error event +// arrives async (sendChatMessage resolves on connect, the error fires later, +// so the catch in handleSendMessage cannot recover input by itself). +const pendingSendDraft = ref<{ input: string; attachments: any[] } | null>(null) + // 使用 useChat composable const { messages, @@ -584,11 +589,29 @@ const { baseUrl: '', thinkingLevel, onStreamEnd: async (meta) => { + // Restore the input/attachments if the turn ended in an error and the + // user hasn't typed something else in the meantime. + if (meta.reason === 'error' && pendingSendDraft.value) { + const draft = pendingSendDraft.value + if (!inputText.value) inputText.value = draft.input + if (pendingAttachments.value.length === 0) pendingAttachments.value = draft.attachments + } + if (meta.reason !== 'error') { + pendingSendDraft.value = null + } // 流结束后刷新会话列表(更新 lastActiveTime / 标题等) await loadConversations() if (meta.conversationId && meta.conversationId === currentConversationId.value) { - // 审批挂起或中断续跑时不从 DB 刷新消息,避免覆盖本地状态或破坏消息顺序 - if (meta.reason !== 'awaiting_approval' && meta.reason !== 'interrupted') { + // Skip DB refresh for awaiting_approval / interrupted / error: + // - awaiting_approval / interrupted: avoids overwriting local-only state + // or breaking message ordering. + // - error: the failed turn (e.g. SSE setup failure like "无权操作该会话") + // was never persisted, so refreshing would wipe the user's just-sent + // bubble and the failed assistant placeholder, leaving no trace of + // the attempt in the chat window. + if (meta.reason !== 'awaiting_approval' + && meta.reason !== 'interrupted' + && meta.reason !== 'error') { await refreshCurrentConversationMessages(meta.conversationId) } } @@ -736,6 +759,22 @@ function handleKeyboardShortcuts(e: KeyboardEvent) { let activityPollTimer: number | null = null const ACTIVITY_POLL_MS = 4000 +/** + * 判断当前消息列表的末尾是不是一条"本地仅有的失败气泡"。 + * 典型场景:SSE setup 阶段就抛错(如"无权操作该会话"), + * 这次 turn 的 user / assistant 消息从未持久化进 DB。 + * 数据库快照不知道它们存在,pollActivity 的对齐会把它们冲掉。 + * + * 识别条件:末尾 assistant 状态为 failed、带 errorInfo、且 id 不是 DB 数值 id(client uuid)。 + */ +function hasLocalOnlyFailedTail(): boolean { + const last = messages.value[messages.value.length - 1] as any + if (!last || last.role !== 'assistant') return false + if (last.status !== 'failed') return false + if (!last.errorInfo) return false + return !/^\d+$/.test(String(last.id)) +} + async function pollActivity() { // 页面不可见时不轮询,避免切到别的标签还在空耗 if (typeof document !== 'undefined' && document.hidden) return @@ -759,8 +798,11 @@ async function pollActivity() { await refreshCurrentConversationMessages(cid) if (currentConversationId.value !== cid || isGenerating.value) return await reconnectStream(cid) - } else { - // 不在跑:从 DB 对齐消息(新 user 消息 / 刚落库 assistant 会合并进来) + } else if (!hasLocalOnlyFailedTail()) { + // 不在跑:从 DB 对齐消息(新 user 消息 / 刚落库 assistant 会合并进来)。 + // 但若末尾是本地失败气泡(SSE setup 失败一类,后端从未持久化过), + // 就跳过对齐 —— 不然这次的 user/失败 assistant 会被 DB 快照覆盖掉, + // 用户除了上面的 toast 看不到任何痕迹。 await refreshCurrentConversationMessages(cid) } } catch { @@ -1214,6 +1256,8 @@ async function handleSendMessage(content: string) { // 先暂存,发送成功后再清空(失败时恢复) const savedInput = inputText.value const savedAttachments = [...pendingAttachments.value] + // Stash for async-error recovery in onStreamEnd (sync catch can't reach this). + pendingSendDraft.value = { input: savedInput, attachments: savedAttachments } inputText.value = '' chatInputRef.value?.clear?.() pendingAttachments.value = []