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 = []