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 React, { useMemo, useState } from 'react';
|
||||||
import { ChatMessage } from '../../types/workbench';
|
import { ChatErrorInfo, ChatMessage } from '../../types/workbench';
|
||||||
import { PhaseEventStrip } from './PhaseEventStrip';
|
import { PhaseEventStrip } from './PhaseEventStrip';
|
||||||
import { RecommendedCaseCard } from './RecommendedCaseCard';
|
import { RecommendedCaseCard } from './RecommendedCaseCard';
|
||||||
import { InteractiveCard } from './InteractiveCard';
|
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 {
|
export interface MessageBubbleProps {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
onOpenCase?: (docId: string) => void;
|
onOpenCase?: (docId: string) => void;
|
||||||
onCardAction?: (cardId: string, actionId: string, payload?: any) => 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> = ({
|
export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||||
message,
|
message,
|
||||||
onOpenCase,
|
onOpenCase,
|
||||||
onCardAction,
|
onCardAction,
|
||||||
|
onErrorAction,
|
||||||
}) => {
|
}) => {
|
||||||
const isUser = message.role === 'user';
|
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 (
|
return (
|
||||||
<div
|
<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)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'var(--text-faint)' }}>
|
||||||
<span style={{ fontWeight: 600, color: 'var(--text-muted)' }}>
|
<span style={{ fontWeight: 600, color: 'var(--text-muted)' }}>
|
||||||
{isUser ? message.authorName || '我' : '水木东方协同参谋'}
|
{isUser ? message.authorName || '我' : '项目助手'}
|
||||||
</span>
|
</span>
|
||||||
<span>{message.timestamp}</span>
|
<span>{message.timestamp}</span>
|
||||||
{message.intentTag && (
|
{message.intentTag && (
|
||||||
@@ -116,20 +152,142 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
|
|
||||||
{/* 主体卡片 */}
|
{/* 主体卡片 */}
|
||||||
<div
|
<div
|
||||||
|
className={isUser ? 'chat-bubble chat-bubble-user' : 'chat-bubble chat-bubble-assistant'}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 14px',
|
padding: '12px 14px',
|
||||||
borderRadius: isUser ? '14px 4px 14px 14px' : '4px 14px 14px 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)',
|
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)',
|
boxShadow: isUser ? '0 2px 8px rgba(79, 107, 255, 0.25)' : 'var(--shadow-xs)',
|
||||||
fontSize: '13.5px',
|
fontSize: '13.5px',
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
wordBreak: 'break-word',
|
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} />}
|
{message.phaseEvents && <PhaseEventStrip events={message.phaseEvents} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user