From 5aa812943c9e493aa472f2b323b290f9f45d6d65 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 9 Sep 2026 16:36:49 +0800 Subject: [PATCH] feat(web): render chat wait-state and structured error actions in bubbles Co-authored-by: Cursor --- web/src/components/chat/MessageBubble.tsx | 174 +++++++++++++++++++++- 1 file changed, 166 insertions(+), 8 deletions(-) diff --git a/web/src/components/chat/MessageBubble.tsx b/web/src/components/chat/MessageBubble.tsx index 166f2249..e4e2e5c0 100644 --- a/web/src/components/chat/MessageBubble.tsx +++ b/web/src/components/chat/MessageBubble.tsx @@ -1,22 +1,58 @@ -import React from 'react'; -import { ChatMessage } from '../../types/workbench'; +import React, { useMemo, useState } from 'react'; +import { ChatErrorInfo, ChatMessage } from '../../types/workbench'; import { PhaseEventStrip } from './PhaseEventStrip'; import { RecommendedCaseCard } from './RecommendedCaseCard'; import { InteractiveCard } from './InteractiveCard'; -import { FileText, Bot, User } from 'lucide-react'; +import { FileText, Bot, User, Loader2, AlertTriangle, Copy, Check } from 'lucide-react'; +import { renderInlineMarkdown } from '../../engine/renderMarkdown'; export interface MessageBubbleProps { message: ChatMessage; onOpenCase?: (docId: string) => void; onCardAction?: (cardId: string, actionId: string, payload?: any) => void; + /** 结构化错误 / 已停止会话上的手动恢复动作:重试 / 继续任务 / 重新开始 / 重新发送 */ + onErrorAction?: (messageId: string, action: 'retry' | 'continue' | 'restart') => void; +} + +const ERROR_ACTION_LABEL: Record<'retry' | 'continue' | 'restart', string> = { + retry: '重试', + continue: '继续任务', + restart: '重新开始', +}; + +/** 拼接一行紧凑诊断信息:code · phaseId · upstream · requestId,自动省略空字段 */ +function buildDiagLine(errorInfo: ChatErrorInfo): string { + return [errorInfo.code, errorInfo.phaseId, errorInfo.upstream, errorInfo.requestId] + .filter((part): part is string => !!part && part.trim().length > 0) + .join(' · '); } export const MessageBubble: React.FC = ({ message, onOpenCase, onCardAction, + onErrorAction, }) => { const isUser = message.role === 'user'; + const status = message.status; + const isWaiting = + !isUser && (status === 'pending' || status === 'streaming_status' || status === 'auto_retrying'); + const isFailed = !isUser && status === 'failed' && !!message.errorInfo; + const isCancelled = !isUser && status === 'cancelled'; + const hasContent = !!message.content && message.content.trim().length > 0; + const [diagCopied, setDiagCopied] = useState(false); + + const renderedHtml = useMemo( + () => renderInlineMarkdown(message.content || '', { preserveNewlines: true }), + [message.content] + ); + + const handleCopyDiag = () => { + if (!message.errorInfo) return; + navigator.clipboard.writeText(JSON.stringify(message.errorInfo)); + setDiagCopied(true); + setTimeout(() => setDiagCopied(false), 1500); + }; return (
= ({ {/* 发送人与意图标签 */}
- {isUser ? message.authorName || '我' : '水木东方协同参谋'} + {isUser ? message.authorName || '我' : '项目助手'} {message.timestamp} {message.intentTag && ( @@ -116,20 +152,142 @@ export const MessageBubble: React.FC = ({ {/* 主体卡片 */}
- {message.content} + {isFailed && message.errorInfo ? ( + /* 失败态:白话标题 + 一行诊断 + 复制诊断 + 分型恢复动作 */ +
+
+ + {message.errorInfo.userMessage} +
+ + {buildDiagLine(message.errorInfo) && ( +
+ {buildDiagLine(message.errorInfo)} +
+ )} + +
+ + + {message.errorInfo.actions?.map((action) => ( + + ))} +
+
+ ) : isCancelled ? ( + /* 已停止态:静音提示 + 可选重新发送 */ +
+ 已停止生成 + {onErrorAction && ( + + )} +
+ ) : ( + <> + {isWaiting && ( + /* 等待态:脉冲小图标 + 当前阶段文案,禁止把空正文当成成功渲染 */ +
+ + {message.livePhaseLabel || '助手正在处理…'} +
+ )} + {(!isWaiting || hasContent) && ( +
+ )} + + )} {/* 时序进度流条 */} {message.phaseEvents && }