瀏覽代碼

统一聊天错误提示并折叠重复技术报错

Samien 1 月之前
父節點
當前提交
53b0d51

+ 21 - 0
docs/verification/2026-09-21-message-failure/README.md

@@ -0,0 +1,21 @@
+# 聊天错误提示统一设计
+
+iOS 原生 Bot 时间线与桌面两套聊天时间线使用相同的展示规则:
+
+- 默认显示中性色的信息图标、中文状态(响应超时/回复暂时中断)和详情入口,最小点击高度 44。
+- 原始错误仅在展开后显示,可选择复制;提供稍后重试或切换模型的建议。
+- 纯错误正文收进详情;正常的部分回复、用户消息和附件保留。
+- 仅合并紧邻且错误相同的 assistant → system 失败提示;优先匹配 runId,旧记录无 runId 时仅合并两秒内的重复。
+- 只改变展示,保留存储中的完整消息。模型服务的超时原因未改变。
+
+## 验证
+
+- Web:`npm run typecheck` 通过;messageFailure、toolTimeline、workspaceTimelineIdentity、workspacePresentation、chatLifecycle 共 39 项测试通过。
+- iOS:Debug Simulator 编译通过;WorkspaceFailurePresentationTests 2 项通过;已有附件/音频并发等编译警告仍在。
+- 浏览器人工检查:桌面浅色、375px 暗色、横屏;默认折叠与展开/收起正常,长文本换行无横向溢出。
+- iOS Simulator 人工检查:浅色折叠与展开、暗色、最大辅助功能字号。错误标题可换行,详情入口可用。
+
+Web 预览:运行 Vite 后访问 `/tests/fixtures/message-failure/index.html`,可加 `?mobile&dark`。
+iOS 预览:Debug 启动参数 `-ui-testing-scenario workspace-failure`,暗色另加 `-ui-testing-color-scheme dark`。
+
+附图为独立测试数据,不连接真实模型服务。`ios-collapsed.jpg`、`ios-expanded.jpg`、`ios-dark.jpg`。

二進制
docs/verification/2026-09-21-message-failure/ios-collapsed.jpg


二進制
docs/verification/2026-09-21-message-failure/ios-dark.jpg


二進制
docs/verification/2026-09-21-message-failure/ios-expanded.jpg


+ 39 - 0
src/client/components/messages/MessageFailureNotice.vue

@@ -0,0 +1,39 @@
+<script setup lang="ts">
+import AppIcon from '@/components/common/AppIcon.vue'
+import type { MessageFailure } from '@/utils/messageFailure'
+defineProps<{ failure: MessageFailure }>()
+</script>
+
+<template>
+  <div class="failure-notice">
+    <details v-if="failure.detail">
+      <summary :aria-label="`${failure.title},详情`">
+        <AppIcon name="info" :size="16" aria-hidden="true" />
+        <span class="failure-notice__label" role="status">{{ failure.title }}</span>
+        <span class="failure-notice__action"><span class="failure-notice__closed">详情</span><span class="failure-notice__open">收起</span><AppIcon name="chevron-down" :size="13" aria-hidden="true" /></span>
+      </summary>
+      <div class="failure-notice__details">
+        <p>{{ failure.hint }}</p>
+        <pre>{{ failure.detail }}</pre>
+      </div>
+    </details>
+    <div v-else class="failure-notice__summary" role="status"><AppIcon name="info" :size="16" aria-hidden="true" /><span>{{ failure.title }} · {{ failure.hint }}</span></div>
+  </div>
+</template>
+
+<style scoped>
+.failure-notice { width: min(100%, 460px); margin-top: 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); color: var(--text-secondary); text-align: left; font-size: 13px; line-height: 1.5; }
+summary, .failure-notice__summary { display: flex; min-height: 44px; align-items: center; gap: 8px; padding: 8px 12px; list-style: none; box-sizing: border-box; }
+summary { cursor: pointer; border-radius: 11px; }
+summary::-webkit-details-marker { display: none; }
+summary:hover, summary:active { background: var(--surface-hover); }
+summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
+.failure-notice__label { flex: 1; min-width: 0; font-weight: 500; }
+.failure-notice__action { display: flex; align-items: center; gap: 5px; margin-left: auto; white-space: nowrap; font-size: 12px; }
+.failure-notice__open, details[open] .failure-notice__closed { display: none; }
+details[open] .failure-notice__open { display: inline; }
+details[open] .failure-notice__action :deep(svg) { transform: rotate(180deg); }
+.failure-notice__details { padding: 0 12px 12px; }
+p { margin: 0 0 8px; }
+pre { max-height: 240px; overflow: auto; margin: 0; padding-top: 10px; border-top: 1px solid var(--line); white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 var(--font-code); }
+</style>

+ 13 - 5
src/client/components/messages/MessageTimeline.vue

@@ -1,6 +1,8 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import MessageFailureNotice from '@/components/messages/MessageFailureNotice.vue'
+import { messageFailure } from '@/utils/messageFailure'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import TypingIndicator from '@/components/common/TypingIndicator.vue'
@@ -258,6 +260,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
           class="message"
           :class="[`message--${message.role}`, {
             'message--failed': message.status === 'failed',
+            'message--failure-notice': messageFailure(message)?.replacesContent,
             'message--tool-only': message.role === 'assistant' && !message.content && !message.reasoning && Boolean(message.tools?.length),
             'message--assistant-internal': message.role === 'assistant' && !message.content.trim(),
             'message--assistant-anonymous': message.role === 'assistant' && !showAssistantIdentity,
@@ -265,7 +268,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
         >
           <AgentAvatar v-if="message.role !== 'user' && (message.role !== 'assistant' || showAssistantIdentity)" class="message__avatar" :name="message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')" :avatar="message.profile ? agentAvatars[message.profile] || '' : ''" :size="27" :state="avatarState(message)" />
           <div class="message__body">
-            <template v-if="message.timelineKind === 'delegation-complete'">
+            <MessageFailureNotice v-if="messageFailure(message)?.replacesContent && !message.attachments?.length" :failure="messageFailure(message)!" />
+            <template v-else-if="message.timelineKind === 'delegation-complete'">
               <details class="delegation-event">
                 <summary><AppIcon name="groups" :size="14" /><span><strong>子任务已完成</strong><small>{{ delegationSummary(message.timelineMetadata) }}</small></span></summary>
                 <MarkdownContent process-content v-if="message.content" :content="message.content" />
@@ -294,7 +298,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? '机器人' : '系统') }}</strong>
               <span v-if="message.metadata" class="message__execution">{{ message.metadata }}</span>
               <time>{{ formatTime(message.createdAt) }}</time>
-              <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: '发送失败', 'unknown-receipt': '回执未知' }[message.status] }}</span>
+              <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: message.role === 'user' ? '发送失败' : '回复中断', 'unknown-receipt': '回执未知' }[message.status] }}</span>
             </div>
 
             <details v-if="message.reasoning" class="message__reasoning">
@@ -310,7 +314,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               </button>
             </div>
 
-            <div class="message__content">
+            <div v-if="!messageFailure(message)?.replacesContent" class="message__content">
               <MarkdownContent
                 :file-profile="message.profile"
                 :content="displayContentForMessage(message.role, message.content)"
@@ -349,7 +353,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <ToolTrace v-for="tool in message.tools" :key="tool.id" :tool="tool" />
             </div>
 
-            <p v-if="message.error" class="message__error"><AppIcon name="alert" :size="13" />{{ message.error }}</p>
+            <MessageFailureNotice v-if="messageFailure(message)" :failure="messageFailure(message)!" />
 
             <div v-if="hasConversationActions(message)" class="message__actions">
               <time v-if="message.role === 'assistant' && !showAssistantIdentity && message.createdAt" class="message__action-time">{{ formatTime(message.createdAt) }}</time>
@@ -431,7 +435,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .fork-divider { display: flex; align-items: center; gap: 12px; margin: 8px 0 28px; color: var(--text-muted); font-size: 11px; }.fork-divider::before, .fork-divider::after { height: 1px; flex: 1; background: var(--line); content: ''; }.fork-divider > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); white-space: nowrap; }.fork-divider strong { max-width: 180px; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }
 .message__attachments { display: flex; margin-top: 8px; flex-wrap: wrap; gap: 7px; }.message__attachments button { display: flex; min-width: 90px; max-width: 210px; min-height: 42px; align-items: center; gap: 7px; padding: 5px 8px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-secondary); cursor: pointer; }.message__attachments button:hover { border-color: var(--line-strong); }.message__attachments img { width: 48px; height: 48px; margin: -5px 0 -5px -8px; object-fit: cover; }.message__attachments button > span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 7px; background: var(--surface-soft); }.message__attachments strong { min-width: 0; overflow: hidden; font-size: 11px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
 .message--user .message__attachments { margin: 0 0 9px; }.message--user .message__attachments button { width: 100%; max-width: none; min-height: 50px; padding: 7px 9px; border: 0; border-radius: 11px; background: rgba(255,255,255,.48); color: var(--text-primary); }.message--user .message__attachments button > span { width: 28px; height: 28px; background: rgba(255,255,255,.65); }.message--user .message__attachments strong { font-size: 12px; font-weight: 560; }.dark .message--user .message__attachments button { background: rgba(255,255,255,.08); }.message--user .message__attachments button.message__attachment--image { display: block; width: auto; min-width: 0; max-width: min(360px, 100%); min-height: 0; padding: 0; border: 0; border-radius: 8px; background: transparent; }.message--user .message__attachments button.message__attachment--image img { display: block; width: auto; height: auto; max-width: min(360px, 100%); max-height: 420px; margin: 0; border-radius: inherit; object-fit: contain; }
-.message__tools { margin-top: 9px; }.message__error { display: flex; align-items: center; gap: 5px; margin: 7px 0 0; color: var(--danger); font-size: 10px; }
+.message__tools { margin-top: 9px; }
 .message__actions { display: flex; position: absolute; left: -3px; top: 100%; gap: 2px; padding-top: 2px; opacity: 0; transition: opacity 120ms ease; }.message:hover .message__actions, .message:focus-within .message__actions { opacity: 1; }.message--user .message__actions { right: 0; left: auto; }.message__actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.message__actions button:hover { background: var(--surface-soft); color: var(--text-primary); }.message__actions .message-action--copied { color: var(--success); }.message__actions .message-action--copy-failed { color: var(--danger); }
 .message--assistant-anonymous .message__actions { align-items: center; }.message__action-time { min-width: 36px; padding: 0 4px 0 3px; color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
 .message--failed .message__body { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); }
@@ -444,6 +448,10 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message-skeletons { width: min(760px, 100%); margin: 0 auto; padding: 35px 24px; }.message-skeletons div { display: flex; gap: 10px; margin: 0 0 25px; }.message-skeletons div.user { justify-content: flex-end; }.message-skeletons i { width: 27px; height: 27px; border-radius: 9px; background: var(--surface-hover); }.message-skeletons span { width: min(480px, 70%); height: 64px; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }.message-skeletons .user i { display: none; }.message-skeletons .user span { width: 44%; height: 46px; }
 @keyframes shimmer { to { background-position: -200% 0; } }
 
+.message--failure-notice { justify-content: flex-start; }
+.message--failure-notice .message__avatar { display: none; }
+.message--failure-notice .message__body { width: min(460px, 100%); max-width: 100%; padding: 0; border: 0; background: transparent; text-align: left; }
+
 @media (max-width: 768px) {
   .timeline-header { min-height: 54px; padding: 8px 12px; }.timeline-state { font-size: 0; }.timeline-state .status-dot { width: 7px; height: 7px; }
   .new-chat-empty { gap: 15px; padding-block: 9vh; }.new-chat-empty__logo { width: min(150px, 42vw); }.new-chat-empty p { font-size: 16px; }

+ 1 - 0
src/client/components/messages/types.ts

@@ -32,6 +32,7 @@ export type UiMessage = {
   createdAt?: string | number | Date
   status?: 'preparing' | 'attached' | 'pending' | 'accepted' | 'streaming' | 'settled' | 'failed' | 'unknown-receipt'
   error?: string
+  runId?: string
   attachments?: UiMessageAttachment[]
   tools?: UiToolCall[]
   profile?: string

+ 13 - 5
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -1,6 +1,8 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import MessageFailureNotice from '@/components/messages/MessageFailureNotice.vue'
+import { messageFailure } from '@/utils/messageFailure'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
@@ -352,6 +354,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
           class="message"
           :class="[`message--${message.role}`, {
             'message--failed': message.status === 'failed',
+            'message--failure-notice': messageFailure(message)?.replacesContent,
             'message--communication': !!message.communication,
             'message--tool-only': message.role === 'assistant' && !message.content && !message.reasoning && Boolean(message.tools?.length),
             'message--assistant-internal': message.role === 'assistant' && !message.content.trim(),
@@ -365,7 +368,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
             <span class="communication-caption__name">{{ message.communication.peerName }}</span>
           </div>
           <div class="message__body">
-            <TaskResultNotice v-if="message.role === 'system' && message.taskReference" :content="message.content" :task-reference="message.taskReference" />
+            <MessageFailureNotice v-if="messageFailure(message)?.replacesContent && !message.attachments?.length" :failure="messageFailure(message)!" />
+            <TaskResultNotice v-else-if="message.role === 'system' && message.taskReference" :content="message.content" :task-reference="message.taskReference" />
             <template v-else-if="message.timelineKind === 'delegation-complete'">
               <details class="delegation-event">
                 <summary><AppIcon name="groups" :size="14" /><span><strong>子任务已完成</strong><small>{{ delegationSummary(message.timelineMetadata) }}</small></span></summary>
@@ -395,7 +399,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? '机器人' : '系统') }}</strong>
               <span v-if="message.metadata" class="message__execution">{{ message.metadata }}</span>
               <time>{{ formatTime(message.createdAt) }}</time>
-              <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: '发送失败', 'unknown-receipt': '回执未知' }[message.status] }}</span>
+              <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: message.role === 'user' ? '发送失败' : '回复中断', 'unknown-receipt': '回执未知' }[message.status] }}</span>
             </div>
 
             <details v-if="message.reasoning" class="message__reasoning">
@@ -411,7 +415,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               </button>
             </div>
 
-            <div class="message__content">
+            <div v-if="!messageFailure(message)?.replacesContent" class="message__content">
               <MarkdownContent
                 :stream-interval-ms="0"
                 :file-profile="message.profile"
@@ -451,7 +455,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <ToolTrace v-for="tool in message.tools" :key="tool.id" :tool="tool" />
             </div>
 
-            <p v-if="message.error" class="message__error"><AppIcon name="alert" :size="13" />{{ message.error }}</p>
+            <MessageFailureNotice v-if="messageFailure(message)" :failure="messageFailure(message)!" />
 
             <div v-if="hasConversationActions(message)" class="message__actions">
               <time v-if="message.role === 'assistant' && !showAssistantIdentity && message.createdAt" class="message__action-time">{{ formatTime(message.createdAt) }}</time>
@@ -541,7 +545,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .fork-divider { display: flex; align-items: center; gap: 12px; margin: 8px 0 28px; color: var(--text-muted); font-size: 11px; }.fork-divider::before, .fork-divider::after { height: 1px; flex: 1; background: var(--line); content: ''; }.fork-divider > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); white-space: nowrap; }.fork-divider strong { max-width: 180px; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }
 .message__attachments { display: flex; margin-top: 8px; flex-wrap: wrap; gap: 7px; }.message__attachments button { display: flex; min-width: 90px; max-width: 210px; min-height: 42px; align-items: center; gap: 7px; padding: 5px 8px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-secondary); cursor: pointer; }.message__attachments button:hover { border-color: var(--line-strong); }.message__attachments img { width: 48px; height: 48px; margin: -5px 0 -5px -8px; object-fit: cover; }.message__attachments button > span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 7px; background: var(--surface-soft); }.message__attachments strong { min-width: 0; overflow: hidden; font-size: 11px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
 .message--user .message__attachments { margin: 0 0 9px; }.message--user .message__attachments button { width: 100%; max-width: none; min-height: 50px; padding: 7px 9px; border: 0; border-radius: 11px; background: rgba(255,255,255,.48); color: var(--text-primary); }.message--user .message__attachments button > span { width: 28px; height: 28px; background: rgba(255,255,255,.65); }.message--user .message__attachments strong { font-size: 12px; font-weight: 560; }.dark .message--user .message__attachments button { background: rgba(255,255,255,.08); }.message--user .message__attachments button.message__attachment--image { display: block; width: auto; min-width: 0; max-width: min(360px, 100%); min-height: 0; padding: 0; border: 0; border-radius: 8px; background: transparent; }.message--user .message__attachments button.message__attachment--image img { display: block; width: auto; height: auto; max-width: min(360px, 100%); max-height: 420px; margin: 0; border-radius: inherit; object-fit: contain; }
-.message__tools { margin-top: 9px; }.message__error { display: flex; align-items: center; gap: 5px; margin: 7px 0 0; color: var(--danger); font-size: 10px; }
+.message__tools { margin-top: 9px; }
 .message__actions { display: flex; position: absolute; left: -3px; top: 100%; gap: 2px; padding-top: 2px; opacity: 0; transition: opacity 120ms ease; }.message:hover .message__actions, .message:focus-within .message__actions { opacity: 1; }.message--user .message__actions { right: 0; left: auto; }.message__actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.message__actions button:hover { background: var(--surface-soft); color: var(--text-primary); }.message__actions .message-action--copied { color: var(--success); }.message__actions .message-action--copy-failed { color: var(--danger); }
 .message--assistant-anonymous .message__actions { align-items: center; }.message__action-time { min-width: 36px; padding: 0 4px 0 3px; color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
 .message--failed .message__body { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); }
@@ -554,6 +558,10 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message-skeletons { width: min(760px, 100%); margin: 0 auto; padding: 35px 24px; }.message-skeletons div { display: flex; gap: 10px; margin: 0 0 25px; }.message-skeletons div.user { justify-content: flex-end; }.message-skeletons i { width: 27px; height: 27px; border-radius: 9px; background: var(--surface-hover); }.message-skeletons span { width: min(480px, 70%); height: 64px; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }.message-skeletons .user i { display: none; }.message-skeletons .user span { width: 44%; height: 46px; }
 @keyframes shimmer { to { background-position: -200% 0; } }
 
+.message--failure-notice { justify-content: flex-start; }
+.message--failure-notice .message__avatar { display: none; }
+.message--failure-notice .message__body { width: min(460px, 100%); max-width: 100%; padding: 0; border: 0; background: transparent; text-align: left; }
+
 @media (max-width: 768px) {
   .timeline-header { min-height: 54px; padding: 8px 12px; }
   .new-chat-empty { gap: 15px; padding-block: 9vh; }.new-chat-empty__logo { width: min(150px, 42vw); }.new-chat-empty p { font-size: 16px; }

+ 1 - 0
src/client/components/workspace/viewModels.ts

@@ -386,6 +386,7 @@ export function workspaceMessagesToUi(messages: import('@shared/workspace').Work
     id: message.id, role: message.role, author: message.agentName,
     communication: message.communication,
     taskReference: message.taskReference,
+    error: message.error, runId: message.runId,
     profile: message.agentId, createdAt: message.createdAt,
     content: (message.communication?.content ?? (message.role === 'assistant' ? visibleMessageText(message.content) : message.content)).replace(/(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g, (whole, label: string, path: string) => {
       const file = message.attachments.find(file => file.sourcePath === path)

+ 38 - 0
src/client/utils/messageFailure.ts

@@ -0,0 +1,38 @@
+import type { UiMessage } from '@/components/messages/types'
+
+export type MessageFailure = { title: string; hint: string; detail: string; key: string; replacesContent: boolean }
+const failurePrefix = /^执行失败[::]\s*/
+
+/** Presentation only: retain the original transcript and diagnostics for inspection. */
+export function messageFailure(message: UiMessage): MessageFailure | undefined {
+  if (message.communication || message.taskReference || message.role === 'tool') return
+  if (message.status !== 'failed' && !message.error?.trim()) return
+  const content = message.content.trim()
+  const prefixed = message.role !== 'user' && message.status === 'failed' && failurePrefix.test(content)
+  const error = message.error?.trim() || (prefixed ? content.replace(failurePrefix, '') : '')
+  if (!error && message.status !== 'failed') return
+  const providerNotice = message.role === 'assistant' && message.status === 'failed'
+    && content.includes('Provider said:') && /didn[’']t respond in time on any of \d+ attempts/.test(content)
+  const replacesContent = message.role !== 'user' && (prefixed || providerNotice || (!!error && content === error) || !content)
+  const detail = [...new Set([replacesContent ? content.replace(failurePrefix, '') : '', error].filter(Boolean))]
+    .filter((value, index, values) => !values.some((other, otherIndex) => otherIndex !== index && other.includes(value))).join('\n\n')
+  const timeout = /timeout|timed?\s*out|no SSE events|didn[’']t respond in time|超时/i.test(error || detail)
+  return {
+    title: message.role === 'user' ? '消息未发送' : timeout ? '响应超时' : '回复暂时中断',
+    hint: message.role === 'user' ? '请检查连接后重新发送。' : '请稍后重试,或切换模型。',
+    detail, key: error || detail, replacesContent,
+  }
+}
+
+/** Fold only the scheduler's immediately repeated system failure, never a user's message. */
+export function collapseDuplicateFailures(messages: UiMessage[]): UiMessage[] {
+  return messages.filter((message, index) => {
+    const previous = messages[index - 1]
+    if (!previous || previous.role !== 'assistant' || message.role !== 'system' || !/^执行失败[::]/.test(message.content)) return true
+    const failure = messageFailure(message), prior = messageFailure(previous)
+    if (!failure?.key || failure.key !== prior?.key || message.attachments?.length || message.tools?.length || message.reasoning) return true
+    if (message.runId && previous.runId) return message.runId !== previous.runId
+    const at = new Date(message.createdAt ?? '').getTime(), before = new Date(previous.createdAt ?? '').getTime()
+    return !Number.isFinite(at) || !Number.isFinite(before) || at < before || at - before > 2000
+  })
+}

+ 3 - 2
src/client/utils/turnTrace.ts

@@ -1,4 +1,5 @@
 import type { UiMessage, UiToolCall } from '@/components/messages/types'
+import { collapseDuplicateFailures } from './messageFailure'
 
 export type TurnTraceEntry =
   | { id: string; type: 'reasoning'; messageId: string; content: string }
@@ -20,7 +21,7 @@ function assistantOwner(message: UiMessage): string {
 }
 
 function hasVisibleMessage(message: UiMessage): boolean {
-  return Boolean(message.content.trim() || message.attachments?.length || message.error)
+  return Boolean(message.content.trim() || message.attachments?.length || message.error || message.status === 'failed')
 }
 
 function traceStatus(messages: UiMessage[], entries: TurnTraceEntry[]): TurnTraceGroup['status'] {
@@ -62,7 +63,7 @@ export function buildMessageTimelineRows(messages: UiMessage[], bodies?: WeakMap
   let segment: UiMessage[] = []
   let owner = ''
   const flush = () => { appendAssistantSegment(rows, segment, bodies); segment = []; owner = '' }
-  for (const message of messages) {
+  for (const message of collapseDuplicateFailures(messages)) {
     if (message.role === 'assistant' && !message.timelineKind) {
       const nextOwner = assistantOwner(message)
       if (segment.length && nextOwner !== owner) flush()

+ 63 - 0
tests/client/messageFailure.test.ts

@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { collapseDuplicateFailures, messageFailure } from '@/utils/messageFailure'
+import MessageFailureNotice from '@/components/messages/MessageFailureNotice.vue'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import { workspaceMessagesToUi } from '@/components/workspace/viewModels'
+import type { UiMessage } from '@/components/messages/types'
+
+const error = 'Codex stream produced no SSE events for 120s after the first parsed event (threshold: 120s)'
+const failed: UiMessage = { id: 'answer', role: 'assistant', content: error, error, status: 'failed', runId: 'run', createdAt: 1000 }
+const duplicate: UiMessage = { id: 'system', role: 'system', content: `执行失败:${error}`, status: 'failed', runId: 'run', createdAt: 1200 }
+
+describe('conversation failure presentation', () => {
+  it('folds diagnostics while preserving partial replies and user content', () => {
+    expect(messageFailure(failed)).toMatchObject({ title: '响应超时', detail: error, replacesContent: true })
+    expect(messageFailure({ ...failed, content: '已完成第一步。' })).toMatchObject({ replacesContent: false, detail: error })
+    expect(messageFailure({ ...failed, role: 'user' })).toMatchObject({ title: '消息未发送', replacesContent: false })
+    expect(messageFailure({ ...failed, error: undefined, content: '已完成第一步。' })).toMatchObject({ title: '回复暂时中断', replacesContent: false })
+    expect(messageFailure({ ...failed, content: `Provider didn’t respond in time on any of 3 attempts — temporarily unavailable.\nProvider said: ${error}` })?.replacesContent).toBe(true)
+    expect(messageFailure({ ...failed, status: 'settled', error: undefined })).toBeUndefined()
+    expect(messageFailure({ ...failed, taskReference: { conversationId: 'c', taskId: 't' } })).toBeUndefined()
+  })
+
+  it('folds only a matching adjacent system failure from the same run', () => {
+    expect(collapseDuplicateFailures([failed, duplicate])).toEqual([failed])
+    for (const changed of [{ ...duplicate, runId: 'next' }, { ...duplicate, content: '执行失败:Other error' }, { ...duplicate, attachments: [{ id: 'f', name: '结果' }] }]) {
+      expect(collapseDuplicateFailures([failed, changed])).toHaveLength(2)
+    }
+    expect(collapseDuplicateFailures([failed, { id: 'user', role: 'user', content: '重试' }, duplicate])).toHaveLength(3)
+    expect(collapseDuplicateFailures([{ ...failed, runId: undefined }, { ...duplicate, runId: undefined }])).toHaveLength(1)
+    expect(collapseDuplicateFailures([{ ...failed, runId: undefined }, { ...duplicate, runId: undefined, createdAt: 9000 }])).toHaveLength(2)
+    expect(failed.content).toBe(error)
+  })
+
+  it('uses a native disclosure with the complete original diagnostic', () => {
+    const wrapper = mount(MessageFailureNotice, { props: { failure: messageFailure(failed)! } })
+    expect(wrapper.get('details').attributes('open')).toBeUndefined()
+    expect(wrapper.get('summary').text()).toContain('响应超时')
+    expect(wrapper.get('summary').text()).not.toContain('SSE')
+    expect(wrapper.get('pre').text()).toBe(error)
+    expect(wrapper.get('[role="status"]').text()).toBe('响应超时')
+    wrapper.unmount()
+  })
+
+  it.each([MessageTimeline, WorkspaceMessageTimeline])('uses the same disclosure and keeps partial content in each timeline', async component => {
+    vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
+    Element.prototype.scrollTo = vi.fn()
+    const wrapper = mount(component, { props: { messages: [failed, duplicate], identity: 'test' }, global: { stubs: { AgentAvatar: true, MarkdownContent: { props: ['content'], template: '<p class="answer">{{ content }}</p>' } } } })
+    expect(wrapper.findAll('.failure-notice')).toHaveLength(1)
+    expect(wrapper.findAll('.answer')).toHaveLength(0)
+    await wrapper.setProps({ messages: [{ ...failed, content: '已完成第一步。' }] })
+    expect(wrapper.get('.answer').text()).toBe('已完成第一步。')
+    expect(wrapper.findAll('.failure-notice')).toHaveLength(1)
+    wrapper.unmount()
+    vi.unstubAllGlobals()
+  })
+
+  it('keeps workspace diagnostics available to the shared timeline', () => {
+    const [message] = workspaceMessagesToUi([{ id: 'a', conversationId: 'c', seq: 1, role: 'assistant', content: '部分回复', reasoning: '', status: 'failed', error, runId: 'run', attachments: [], tools: [], createdAt: 1000 }])
+    expect(message).toMatchObject({ error, runId: 'run' })
+  })
+})

+ 1 - 0
tests/fixtures/message-failure/index.html

@@ -0,0 +1 @@
+<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聊天错误提示预览</title></head><body><div id="app"></div><script type="module" src="./main.ts"></script></body></html>

+ 18 - 0
tests/fixtures/message-failure/main.ts

@@ -0,0 +1,18 @@
+import { createApp, defineComponent, h } from 'vue'
+import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import '@/styles/tokens.css'
+import '@/styles/global.css'
+const query = new URLSearchParams(location.search)
+document.documentElement.classList.toggle('dark', query.has('dark'))
+const error = 'Codex stream produced no SSE events for 120s after the first parsed event (threshold: 120s)'
+createApp(defineComponent({ setup() {
+  return () => h('main', { style: `height:100dvh;max-width:${query.has('mobile') ? '375px' : '900px'};margin:auto;display:flex;flex-direction:column;border-inline:1px solid var(--line)` }, [
+    h(WorkspaceMessageTimeline, { identity: 'preview', title: '瑶儿', showAssistantIdentity: false, messages: [
+      { id: 'u', role: 'user', content: '帮我整理一下今天的内容' },
+      { id: 'a', role: 'assistant', content: '我已经整理了第一部分,正在继续处理剩余内容。', error, status: 'failed', runId: 'one' },
+      { id: 's', role: 'system', content: `执行失败:${error}`, status: 'failed', runId: 'one' },
+      { id: 'u2', role: 'user', content: '再试一次' },
+      { id: 'a2', role: 'assistant', content: `执行失败:${error}`, error, status: 'failed' },
+    ] }),
+  ])
+} })).mount('#app')