feat(web): implement agent chat stream with thinking pulse indicator and case cards

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 14:46:08 +08:00
co-authored by Cursor
parent 1ed455ab73
commit 171b42510e
5 changed files with 620 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, Square, X, Loader2, Sparkles } from 'lucide-react';
interface InputAreaProps {
onSendMessage: (text: string, attachments?: { name: string; size: string; type: string }[]) => void;
isThinking: boolean;
isStreaming: boolean;
onStopStreaming?: () => void;
}
export const InputArea: React.FC<InputAreaProps> = ({
onSendMessage,
isThinking,
isStreaming,
onStopStreaming,
}) => {
const [text, setText] = useState('');
const [attachments, setAttachments] = useState<{ name: string; size: string; type: string }[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 自动撑高
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(160, Math.max(42, textareaRef.current.scrollHeight))}px`;
}
}, [text]);
const handleSend = () => {
if (isThinking || isStreaming) {
if (isStreaming && onStopStreaming) {
onStopStreaming();
}
return;
}
if (!text.trim() && attachments.length === 0) {
return;
}
onSendMessage(text.trim(), attachments);
setText('');
setAttachments([]);
if (textareaRef.current) {
textareaRef.current.style.height = '42px';
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
const sizeStr = (file.size / 1024).toFixed(0) + ' KB';
setAttachments((prev) => [
...prev,
{
name: file.name,
size: sizeStr,
type: file.type || 'document',
},
]);
e.target.value = '';
}
};
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
return (
<div
style={{
padding: '12px 16px 16px',
background: 'var(--bg-panel)',
borderTop: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
position: 'relative',
}}
>
{/* 隐藏的原生文件上传 */}
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.md,.txt,.xlsx"
/>
{/* 已挂载文件芯片 */}
{attachments.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{attachments.map((att, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
padding: '3px 8px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--brand-100)',
fontSize: '11px',
color: 'var(--brand-600)',
}}
>
<span>📎 {att.name}</span>
<button
onClick={() => removeAttachment(i)}
style={{ display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
>
<X size={12} />
</button>
</div>
))}
</div>
)}
{/* 智能体状态提示 */}
{isThinking && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '11px',
color: 'var(--brand-500)',
fontWeight: 500,
}}
>
<Sparkles size={12} />
<span>智能体协同小队正在调取法规并推演回复...</span>
</div>
)}
{/* 输入框与按钮条 */}
<div
style={{
display: 'flex',
alignItems: 'flex-end',
gap: '8px',
background: 'var(--bg-subtle)',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
padding: '6px 10px',
}}
>
{/* 附件按钮 */}
<button
onClick={() => fileInputRef.current?.click()}
title="上传项目需求材料或参考文档"
style={{
width: '32px',
height: '32px',
borderRadius: 'var(--r-sm)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
flexShrink: 0,
marginBottom: '4px',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--brand-500)';
e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)';
e.currentTarget.style.background = 'transparent';
}}
>
<Paperclip size={18} />
</button>
{/* 多行输入框 */}
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="向参谋小队提问、下达立项指令,或要求编制/修改交付物 (Enter 发送,Shift+Enter 换行)..."
rows={1}
style={{
flex: 1,
border: 'none',
background: 'transparent',
resize: 'none',
fontSize: '13.5px',
lineHeight: 1.5,
color: 'var(--text-strong)',
minHeight: '42px',
maxHeight: '160px',
padding: '8px 4px',
}}
/>
{/* 智能发送按钮 */}
<button
onClick={handleSend}
disabled={!text.trim() && attachments.length === 0 && !isStreaming}
className={isThinking ? 'agent-pulse-active' : ''}
style={{
width: '36px',
height: '36px',
borderRadius: 'var(--r-md)',
background: isStreaming
? 'var(--danger)'
: isThinking
? 'var(--brand-600)'
: 'var(--brand-grad)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
marginBottom: '3px',
transition: 'all 0.2s ease',
opacity: !text.trim() && attachments.length === 0 && !isThinking && !isStreaming ? 0.45 : 1,
boxShadow: isThinking
? '0 0 12px rgba(107, 132, 255, 0.7)'
: '0 2px 8px rgba(79, 107, 255, 0.25)',
}}
title={
isStreaming
? '停止当前生成'
: isThinking
? '智能体正在推演中...'
: '发送 (Enter)'
}
>
{isStreaming ? (
<Square size={14} fill="#fff" />
) : isThinking ? (
<Loader2 size={16} className="animate-spin-fast" />
) : (
<Send size={15} />
)}
</button>
</div>
</div>
);
};
+140
View File
@@ -0,0 +1,140 @@
import React from 'react';
import { ChatMessage } from '../../types/workbench';
import { PhaseEventStrip } from './PhaseEventStrip';
import { RecommendedCaseCard } from './RecommendedCaseCard';
import { FileText, Bot, User } from 'lucide-react';
interface MessageBubbleProps {
message: ChatMessage;
onOpenCase?: (docId: string) => void;
}
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onOpenCase }) => {
const isUser = message.role === 'user';
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
marginBottom: '16px',
padding: '0 16px',
}}
>
<div
style={{
display: 'flex',
gap: '10px',
maxWidth: '85%',
flexDirection: isUser ? 'row-reverse' : 'row',
}}
>
{/* 头像 */}
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: isUser ? 'var(--brand-500)' : 'var(--brand-grad)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
fontWeight: 700,
flexShrink: 0,
boxShadow: 'var(--shadow-xs)',
}}
>
{isUser ? <User size={16} /> : <Bot size={16} />}
</div>
{/* 气泡正文 */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
gap: '4px',
}}
>
{/* 发送人与意图标签 */}
<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 || '我' : '水木东方协同参谋'}
</span>
<span>{message.timestamp}</span>
{message.intentTag && (
<span
style={{
fontSize: '10px',
fontWeight: 600,
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
background: 'var(--brand-50)',
color: 'var(--brand-600)',
border: '1px solid var(--brand-100)',
}}
>
{message.intentTag}
</span>
)}
</div>
{/* 附件展示 */}
{message.attachments && message.attachments.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '4px' }}>
{message.attachments.map((att, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 8px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
fontSize: '11px',
color: 'var(--text)',
}}
>
<FileText size={12} style={{ color: 'var(--brand-500)' }} />
<span>{att.name}</span>
<span style={{ color: 'var(--text-faint)' }}>({att.size})</span>
</div>
))}
</div>
)}
{/* 主体卡片 */}
<div
style={{
padding: '12px 14px',
borderRadius: isUser ? '14px 4px 14px 14px' : '4px 14px 14px 14px',
background: isUser ? 'var(--brand-500)' : 'var(--bg-panel)',
color: isUser ? '#ffffff' : 'var(--text-strong)',
border: isUser ? 'none' : '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',
}}
>
{message.content}
{/* 时序进度流条 */}
{message.phaseEvents && <PhaseEventStrip events={message.phaseEvents} />}
{/* 标杆案例卡片 */}
{message.recommendedCases && (
<RecommendedCaseCard cases={message.recommendedCases} onOpenCase={onOpenCase} />
)}
</div>
</div>
</div>
</div>
);
};
+50
View File
@@ -0,0 +1,50 @@
import React, { useRef, useEffect } from 'react';
import { ChatMessage } from '../../types/workbench';
import { MessageBubble } from './MessageBubble';
interface MessageStreamProps {
messages: ChatMessage[];
onOpenCase?: (docId: string) => void;
}
export const MessageStream: React.FC<MessageStreamProps> = ({ messages, onOpenCase }) => {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '16px 0',
display: 'flex',
flexDirection: 'column',
}}
>
{messages.length === 0 ? (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-faint)',
gap: '8px',
fontSize: '13px',
}}
>
<span>暂无对话记录,请在下方发送立项需求</span>
</div>
) : (
messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} onOpenCase={onOpenCase} />
))
)}
<div ref={bottomRef} style={{ height: '1px' }} />
</div>
);
};
@@ -0,0 +1,63 @@
import React from 'react';
import { PhaseEvent } from '../../types/workbench';
import { CheckCircle2, Loader2, Circle } from 'lucide-react';
interface PhaseEventStripProps {
events: PhaseEvent[];
}
export const PhaseEventStrip: React.FC<PhaseEventStripProps> = ({ events }) => {
if (!events || events.length === 0) {
return null;
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '6px',
padding: '8px 12px',
margin: '6px 0 10px',
borderRadius: 'var(--r-md)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
fontSize: '12px',
}}
>
{events.map((evt, idx) => {
const isRunning = evt.status === 'running';
const isDone = evt.status === 'completed';
return (
<React.Fragment key={evt.step + idx}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
color: isDone
? 'var(--success)'
: isRunning
? 'var(--brand-500)'
: 'var(--text-faint)',
fontWeight: isRunning ? 600 : 500,
}}
>
<span>{evt.icon}</span>
<span>{evt.label}</span>
{isDone && <CheckCircle2 size={13} style={{ color: 'var(--success)' }} />}
{isRunning && <Loader2 size={13} className="animate-spin-fast" style={{ color: 'var(--brand-500)' }} />}
{!isDone && !isRunning && <Circle size={10} style={{ color: 'var(--border-strong)' }} />}
</div>
{idx < events.length - 1 && (
<span style={{ color: 'var(--border-strong)', fontSize: '11px', margin: '0 2px' }}>➔</span>
)}
</React.Fragment>
);
})}
</div>
);
};
@@ -0,0 +1,115 @@
import React from 'react';
import { RecommendedCase } from '../../types/workbench';
import { Bookmark, Sparkles, ExternalLink } from 'lucide-react';
interface RecommendedCaseCardProps {
cases: RecommendedCase[];
onOpenCase?: (docId: string) => void;
}
export const RecommendedCaseCard: React.FC<RecommendedCaseCardProps> = ({ cases, onOpenCase }) => {
if (!cases || cases.length === 0) {
return null;
}
return (
<div
style={{
marginTop: '10px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', fontWeight: 600, color: 'var(--amber-500)' }}>
<Sparkles size={14} />
<span>往期标杆案例与历史报价经验自推荐(智能向量召回)</span>
</div>
{cases.map((c, idx) => (
<div
key={c.docId + idx}
style={{
padding: '10px 12px',
borderRadius: 'var(--r-md)',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-xs)',
display: 'flex',
flexDirection: 'column',
gap: '5px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Bookmark size={13} style={{ color: 'var(--brand-500)' }} />
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
{c.artifactTitle}
</span>
</div>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--success)',
background: 'var(--success-bg)',
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
}}
>
相似度 {(c.similarityScore * 100).toFixed(0)}%
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
<span>定位章节: </span>
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{c.sectionTitle}</span>
</div>
{c.keyRegulations && c.keyRegulations.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '4px', margin: '2px 0' }}>
{c.keyRegulations.map((reg) => (
<span
key={reg}
style={{
fontSize: '10px',
padding: '1px 5px',
borderRadius: 'var(--r-pill)',
background: 'var(--brand-50)',
color: 'var(--brand-600)',
border: '1px solid var(--brand-100)',
}}
>
{reg}
</span>
))}
</div>
)}
<div style={{ fontSize: '12px', color: 'var(--text)', lineHeight: 1.5, background: 'var(--bg-subtle)', padding: '6px 8px', borderRadius: 'var(--r-sm)' }}>
{c.summarySnippet}
</div>
{onOpenCase && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '2px' }}>
<button
onClick={() => onOpenCase(c.docId)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
color: 'var(--brand-500)',
fontWeight: 600,
}}
>
<span>在右侧受控栏定位此经验</span>
<ExternalLink size={11} />
</button>
</div>
)}
</div>
))}
</div>
);
};