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>
437 lines
13 KiB
TypeScript
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>
|
|
);
|
|
};
|