From cda534a75d4bf6648f052f45f5bacc166c48dd23 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Fri, 11 Sep 2026 13:00:34 +0800 Subject: [PATCH] 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 --- web/src/api/chatWindow.ts | 3 +- web/src/components/chat/InputArea.tsx | 214 ++++++++++++++---- web/src/hooks/useChatWindow.ts | 43 +++- web/src/hooks/useUnreadMarks.ts | 79 +++++-- web/src/tests/urs-private-shared.test.ts | 112 +++++++++ .../tests/workflow-chat-chronology.test.ts | 43 ++++ web/src/types/workbench.ts | 17 ++ 7 files changed, 440 insertions(+), 71 deletions(-) diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index 8fb0e5ba..991eb254 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -59,8 +59,9 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage { : new Date().toTimeString().substring(0, 5); const meta = r.metadata || {}; + const msgId = meta.client_id || (r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`); return { - id: r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`, + id: msgId, serverId: r.id, sessionId: r.session_id, role: r.role, diff --git a/web/src/components/chat/InputArea.tsx b/web/src/components/chat/InputArea.tsx index 498d6822..327dfe21 100644 --- a/web/src/components/chat/InputArea.tsx +++ b/web/src/components/chat/InputArea.tsx @@ -3,10 +3,19 @@ import { Send, Paperclip, Square, X, Loader2, Sparkles, Lock } from 'lucide-reac 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?: { name: string; size: string; type: string }[], + attachments?: InputAttachmentItem[], quotes?: QuotedMessage[] ) => void; isThinking: boolean; @@ -18,6 +27,8 @@ interface InputAreaProps { disabledMessage?: string; } +const MAX_ATTACHMENTS = 5; + export const InputArea: React.FC = ({ onSendMessage, isThinking, @@ -29,7 +40,8 @@ export const InputArea: React.FC = ({ disabledMessage, }) => { const [text, setText] = useState(''); - const [attachments, setAttachments] = useState<{ name: string; size: string; type: string }[]>([]); + const [attachments, setAttachments] = useState([]); + const [uploadWarning, setUploadWarning] = useState(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); @@ -56,6 +68,7 @@ export const InputArea: React.FC = ({ onSendMessage(text.trim(), attachments, quotes); setText(''); setAttachments([]); + setUploadWarning(null); if (textareaRef.current) { textareaRef.current.style.height = '42px'; } @@ -69,23 +82,65 @@ export const InputArea: React.FC = ({ }; const handleFileChange = (e: React.ChangeEvent) => { - 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', - }, - ]); + 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) => prev.filter((_, i) => i !== index)); + 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) { @@ -137,15 +192,50 @@ export const InputArea: React.FC = ({ position: 'relative', }} > - {/* 隐藏的原生文件上传 */} + {/* 隐藏的原生文件上传(支持单次多选,单轮累计最多 5 个) */} + {/* 超出限制或操作提示条 */} + {uploadWarning && ( +
+ ⚠️ {uploadWarning} + +
+ )} + {/* 已挂载引用卡片 */} {quotes.length > 0 && (
@@ -157,31 +247,51 @@ export const InputArea: React.FC = ({ {/* 已挂载文件芯片 */} {attachments.length > 0 && ( -
- {attachments.map((att, i) => ( -
- 📎 {att.name} - -
- ))} + 📎 {att.name} ({att.size}) + +
+ ))} +
)} @@ -216,8 +326,19 @@ export const InputArea: React.FC = ({ > {/* 附件按钮 */}