feat(web): render chat wait-state and structured error actions in bubbles
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user