feat(web): render chat wait-state and structured error actions in bubbles

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-09 16:36:49 +08:00
co-authored by Cursor
parent 6d32fc68f3
commit 5aa812943c
+166 -8
View File
@@ -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<MessageBubbleProps> = ({
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 (
<div
@@ -68,7 +104,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
{/* 发送人与意图标签 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'var(--text-faint)' }}>
<span style={{ fontWeight: 600, color: 'var(--text-muted)' }}>
{isUser ? message.authorName || '我' : '水木东方协同参谋'}
{isUser ? message.authorName || '我' : '项目助手'}
</span>
<span>{message.timestamp}</span>
{message.intentTag && (
@@ -116,20 +152,142 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
{/* 主体卡片 */}
<div
className={isUser ? 'chat-bubble chat-bubble-user' : 'chat-bubble chat-bubble-assistant'}
style={{
padding: '12px 14px',
borderRadius: isUser ? '14px 4px 14px 14px' : '4px 14px 14px 14px',
background: isUser ? 'var(--brand-500)' : 'var(--bg-panel)',
background: isUser ? 'var(--brand-500)' : isFailed ? 'var(--danger-bg)' : 'var(--bg-panel)',
color: isUser ? '#ffffff' : 'var(--text-strong)',
border: isUser ? 'none' : '1px solid var(--border)',
border: isUser ? 'none' : isFailed ? '1px solid var(--danger)' : '1px solid var(--border)',
boxShadow: isUser ? '0 2px 8px rgba(79, 107, 255, 0.25)' : 'var(--shadow-xs)',
fontSize: '13.5px',
lineHeight: 1.6,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
whiteSpace: 'normal',
maxWidth: '100%',
overflowX: 'auto',
}}
>
{message.content}
{isFailed && message.errorInfo ? (
/* 失败态:白话标题 + 一行诊断 + 复制诊断 + 分型恢复动作 */
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '6px',
color: 'var(--danger)',
fontWeight: 700,
fontSize: '13px',
}}
>
<AlertTriangle size={15} style={{ flexShrink: 0, marginTop: '1px' }} />
<span>{message.errorInfo.userMessage}</span>
</div>
{buildDiagLine(message.errorInfo) && (
<div
style={{
fontSize: '11px',
color: 'var(--text-faint)',
fontFamily: 'var(--font-mono)',
wordBreak: 'break-all',
}}
>
{buildDiagLine(message.errorInfo)}
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '2px' }}>
<button
onClick={handleCopyDiag}
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
padding: '5px 10px',
borderRadius: 'var(--r-sm)',
fontSize: '11.5px',
fontWeight: 500,
background: 'var(--bg-subtle)',
color: 'var(--text-muted)',
border: '1px solid var(--border)',
cursor: 'pointer',
}}
>
{diagCopied ? <Check size={12} /> : <Copy size={12} />}
<span>{diagCopied ? '已复制' : '复制诊断信息'}</span>
</button>
{message.errorInfo.actions?.map((action) => (
<button
key={action}
onClick={() => onErrorAction?.(message.id, action)}
style={{
padding: '5px 12px',
borderRadius: 'var(--r-sm)',
fontSize: '11.5px',
fontWeight: 600,
background: 'var(--brand-500)',
color: '#ffffff',
border: 'none',
boxShadow: '0 2px 6px rgba(79, 107, 255, 0.3)',
cursor: 'pointer',
}}
>
{ERROR_ACTION_LABEL[action]}
</button>
))}
</div>
</div>
) : isCancelled ? (
/* 已停止态:静音提示 + 可选重新发送 */
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' }}>
<span style={{ color: 'var(--text-faint)', fontSize: '13px' }}>已停止生成</span>
{onErrorAction && (
<button
onClick={() => onErrorAction(message.id, 'retry')}
style={{
padding: '4px 10px',
borderRadius: 'var(--r-sm)',
fontSize: '11.5px',
fontWeight: 500,
background: 'var(--bg-subtle)',
color: 'var(--brand-600)',
border: '1px solid var(--brand-100)',
cursor: 'pointer',
}}
>
重新发送
</button>
)}
</div>
) : (
<>
{isWaiting && (
/* 等待态:脉冲小图标 + 当前阶段文案,禁止把空正文当成成功渲染 */
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
color: 'var(--brand-500)',
fontSize: '13px',
marginBottom: hasContent ? '8px' : 0,
}}
>
<Loader2 size={14} className="animate-spin-fast" />
<span>{message.livePhaseLabel || '助手正在处理…'}</span>
</div>
)}
{(!isWaiting || hasContent) && (
<div
className="chat-md-body"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
)}
</>
)}
{/* 时序进度流条 */}
{message.phaseEvents && <PhaseEventStrip events={message.phaseEvents} />}