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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user