Files
Agent-System/web/src/components/chat/InputArea.tsx
T
WeiCHENandCursor cda534a75d feat(web): improve chat attachments, unread marks, and session UX
Support richer attachment payloads in input area, local read-id cache for
unread badges, and expand private-shared and chronology test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 02:50:21 +00:00

437 lines
13 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, Square, X, Loader2, Sparkles, Lock } from 'lucide-react';
import { QuotedMessage } from '../../types/workbench';
import { QuoteChip } from './QuoteChip';
export interface InputAttachmentItem {
name: string;
size: string;
type: string;
docId?: string;
fileRaw?: File | Blob;
fileUrl?: string;
}
interface InputAreaProps {
onSendMessage: (
text: string,
attachments?: InputAttachmentItem[],
quotes?: QuotedMessage[]
) => void;
isThinking: boolean;
isStreaming: boolean;
onStopStreaming?: () => void;
quotes?: QuotedMessage[];
onRemoveQuote?: (index: number) => void;
disabled?: boolean;
disabledMessage?: string;
}
const MAX_ATTACHMENTS = 5;
export const InputArea: React.FC<InputAreaProps> = ({
onSendMessage,
isThinking,
isStreaming,
onStopStreaming,
quotes = [],
onRemoveQuote,
disabled = false,
disabledMessage,
}) => {
const [text, setText] = useState('');
const [attachments, setAttachments] = useState<InputAttachmentItem[]>([]);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
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, quotes);
setText('');
setAttachments([]);
setUploadWarning(null);
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) return;
const files = Array.from(e.target.files);
const currentCount = attachments.length;
const availableSlots = MAX_ATTACHMENTS - currentCount;
if (availableSlots <= 0) {
setUploadWarning(`单轮对话最多支持上传 ${MAX_ATTACHMENTS} 个文档`);
setTimeout(() => setUploadWarning(null), 3000);
e.target.value = '';
return;
}
let filesToAdd = files;
if (files.length > availableSlots) {
filesToAdd = files.slice(0, availableSlots);
setUploadWarning(
`单轮对话最多支持上传 ${MAX_ATTACHMENTS} 个文档,已为您选取前 ${availableSlots} 个`
);
setTimeout(() => setUploadWarning(null), 3500);
} else {
setUploadWarning(null);
}
const newAtts: InputAttachmentItem[] = filesToAdd.map((file) => {
let fileUrl = '';
try {
if (typeof window !== 'undefined' && window.URL && window.URL.createObjectURL) {
fileUrl = window.URL.createObjectURL(file);
}
} catch (err) {
console.warn('Failed to create object URL for file:', file.name, err);
}
return {
name: file.name,
size: (file.size / 1024).toFixed(0) + ' KB',
type: file.type || 'document',
fileRaw: file,
fileUrl,
};
});
setAttachments((prev) => [...prev, ...newAtts]);
e.target.value = '';
};
const removeAttachment = (index: number) => {
setAttachments((prev) => {
const target = prev[index];
if (target?.fileUrl && target.fileUrl.startsWith('blob:') && typeof window !== 'undefined' && window.URL?.revokeObjectURL) {
try {
window.URL.revokeObjectURL(target.fileUrl);
} catch (e) {
// ignore
}
}
return prev.filter((_, i) => i !== index);
});
if (uploadWarning) setUploadWarning(null);
};
if (disabled) {
return (
<div
style={{
padding: '16px 20px',
background: 'var(--bg-subtle, #f8fafc)',
borderTop: '1px solid var(--border-light, #e2e8f0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
color: 'var(--text-muted, #64748b)',
fontSize: '13px',
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'rgba(239, 68, 68, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#dc2626',
}}
>
<Lock size={14} />
</div>
<span>
{disabledMessage ||
'您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。'}
</span>
</div>
);
}
return (
<div
style={{
padding: '12px 16px 16px',
background: 'var(--bg-panel)',
borderTop: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
position: 'relative',
}}
>
{/* 隐藏的原生文件上传(支持单次多选,单轮累计最多 5 个) */}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.md,.txt,.xlsx"
/>
{/* 超出限制或操作提示条 */}
{uploadWarning && (
<div
style={{
padding: '6px 10px',
borderRadius: 'var(--r-sm, 6px)',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.3)',
color: '#d97706',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
animation: 'fadeIn 0.2s ease',
}}
>
<span>⚠️ {uploadWarning}</span>
<button
onClick={() => setUploadWarning(null)}
style={{
border: 'none',
background: 'transparent',
color: '#d97706',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '2px',
}}
>
<X size={12} />
</button>
</div>
)}
{/* 已挂载引用卡片 */}
{quotes.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{quotes.map((q, i) => (
<QuoteChip key={i} quote={q} onRemove={() => onRemoveQuote?.(i)} />
))}
</div>
)}
{/* 已挂载文件芯片 */}
{attachments.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-muted, #64748b)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span>待上传文档 ({attachments.length}/{MAX_ATTACHMENTS})</span>
{attachments.length < MAX_ATTACHMENTS && (
<span style={{ fontSize: '10px', color: 'var(--text-faint, #94a3b8)' }}>
还可添加 {MAX_ATTACHMENTS - attachments.length} 个
</span>
)}
</div>
<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} ({att.size})</span>
<button
onClick={() => removeAttachment(i)}
title="移除此文档"
style={{ display: 'flex', alignItems: 'center', color: 'var(--text-faint)', cursor: 'pointer' }}
>
<X size={12} />
</button>
</div>
))}
</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={() => {
if (attachments.length >= MAX_ATTACHMENTS) {
setUploadWarning(`单轮对话最多支持上传 ${MAX_ATTACHMENTS} 个文档(当前已达上限)`);
setTimeout(() => setUploadWarning(null), 3000);
return;
}
fileInputRef.current?.click();
}}
title={
attachments.length >= MAX_ATTACHMENTS
? `已达上限 (最多 ${MAX_ATTACHMENTS} 个文档)`
: `上传项目需求材料或参考文档 (最多 ${MAX_ATTACHMENTS} 个,支持多选)`
}
style={{
width: '32px',
height: '32px',
borderRadius: 'var(--r-sm)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: attachments.length >= MAX_ATTACHMENTS ? 'var(--text-faint)' : 'var(--text-muted)',
flexShrink: 0,
marginBottom: '4px',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (attachments.length < MAX_ATTACHMENTS) {
e.currentTarget.style.color = 'var(--brand-500)';
e.currentTarget.style.background = 'var(--bg-hover)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.color =
attachments.length >= MAX_ATTACHMENTS ? 'var(--text-faint)' : '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>
);
};