fix(chat): surface SSE error in retry card and stop poll from wiping local-only failed turn

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.
This commit is contained in:
matevip 2026-04-28 00:16:14 +08:00
parent 5b24a599ca
commit 4e22b85557
3 changed files with 60 additions and 7 deletions

View File

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

View File

@ -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

View File

@ -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 数值 idclient 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 = []