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>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
6f4e8c0933
commit
cda534a75d
@@ -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,
|
||||
|
||||
@@ -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<InputAreaProps> = ({
|
||||
onSendMessage,
|
||||
isThinking,
|
||||
@@ -29,7 +40,8 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
disabledMessage,
|
||||
}) => {
|
||||
const [text, setText] = useState('');
|
||||
const [attachments, setAttachments] = useState<{ name: string; size: string; type: string }[]>([]);
|
||||
const [attachments, setAttachments] = useState<InputAttachmentItem[]>([]);
|
||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -56,6 +68,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
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<InputAreaProps> = ({
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
]);
|
||||
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<InputAreaProps> = ({
|
||||
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' }}>
|
||||
@@ -157,31 +247,51 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
|
||||
{/* 已挂载文件芯片 */}
|
||||
{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)' }}
|
||||
<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)',
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -216,8 +326,19 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
>
|
||||
{/* 附件按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传项目需求材料或参考文档"
|
||||
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',
|
||||
@@ -225,17 +346,20 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
color: attachments.length >= MAX_ATTACHMENTS ? 'var(--text-faint)' : '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)';
|
||||
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 = 'var(--text-muted)';
|
||||
e.currentTarget.style.color =
|
||||
attachments.length >= MAX_ATTACHMENTS ? 'var(--text-faint)' : 'var(--text-muted)';
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -501,14 +501,45 @@ export function useChatWindow({
|
||||
todayBucket.loadedCount++;
|
||||
}
|
||||
|
||||
// 严谨保序:按照 liveMessages 中的自然发生时序排列,保证新追加的对话永远沉淀在最底部
|
||||
const getMessageSortKey = (m: ChatMessage): number => {
|
||||
if (m.id && liveIndexMap.has(m.id)) return liveIndexMap.get(m.id)!;
|
||||
if (m.serverId && liveIndexMap.has(`msg-${m.serverId}`)) return liveIndexMap.get(`msg-${m.serverId}`)!;
|
||||
if (m.serverId) return -1000000 + m.serverId;
|
||||
// 严谨保序:按照真实时间与发生时序排列,保证新追加的对话永远沉淀在最底部
|
||||
const getMessageSortTime = (m: ChatMessage): number => {
|
||||
if (m.createdAt) {
|
||||
const t = new Date(m.createdAt).getTime();
|
||||
if (!isNaN(t)) return t;
|
||||
}
|
||||
if (m.timestamp && m.timestamp.includes(':')) {
|
||||
const [hh, mm] = m.timestamp.split(':').map(Number);
|
||||
if (!isNaN(hh) && !isNaN(mm)) {
|
||||
const d = new Date();
|
||||
d.setHours(hh, mm, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
}
|
||||
if (m.id && (m.id.startsWith('msg-') || m.id.startsWith('ast-'))) {
|
||||
const numPart = m.id.replace(/^[a-z]+-/, '');
|
||||
const parsed = parseInt(numPart, 10);
|
||||
if (!isNaN(parsed) && parsed > 1000000000000) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
todayBucket.messages.sort((a, b) => getMessageSortKey(a) - getMessageSortKey(b));
|
||||
|
||||
todayBucket.messages.sort((a, b) => {
|
||||
const tA = getMessageSortTime(a);
|
||||
const tB = getMessageSortTime(b);
|
||||
if (tA !== 0 && tB !== 0 && tA !== tB) {
|
||||
return tA - tB;
|
||||
}
|
||||
const idxA = liveIndexMap.get(a.id) ?? (a.serverId ? liveIndexMap.get(`msg-${a.serverId}`) : undefined);
|
||||
const idxB = liveIndexMap.get(b.id) ?? (b.serverId ? liveIndexMap.get(`msg-${b.serverId}`) : undefined);
|
||||
if (idxA !== undefined && idxB !== undefined) {
|
||||
return idxA - idxB;
|
||||
}
|
||||
if (idxA !== undefined) return 1;
|
||||
if (idxB !== undefined) return -1;
|
||||
return (a.serverId || 0) - (b.serverId || 0);
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [dayStack, liveMessages])();
|
||||
|
||||
@@ -44,6 +44,22 @@ export interface UseUnreadMarksReturn {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
function getLocalReadIds(sessionId: string): Set<number> {
|
||||
try {
|
||||
const raw = localStorage.getItem(`smdf_read_ids_${sessionId}`);
|
||||
if (raw) return new Set(JSON.parse(raw));
|
||||
} catch {}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function saveLocalReadId(sessionId: string, msgId: number) {
|
||||
try {
|
||||
const ids = getLocalReadIds(sessionId);
|
||||
ids.add(msgId);
|
||||
localStorage.setItem(`smdf_read_ids_${sessionId}`, JSON.stringify(Array.from(ids).slice(-200)));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function useUnreadMarks(
|
||||
sessionId: string | null,
|
||||
userId?: number,
|
||||
@@ -78,34 +94,52 @@ export function useUnreadMarks(
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
const localReadSet = getLocalReadIds(sessionId);
|
||||
|
||||
// 兼容老格式 (Array) 与新格式 (UnreadSummaryResponse)
|
||||
if (Array.isArray(data)) {
|
||||
const items: UnreadMark[] = data.map((item: UnreadDayApiItem) => ({
|
||||
sessionId: sessionId,
|
||||
date: item.date,
|
||||
count: item.count,
|
||||
lastMessageId: item.last_message_id,
|
||||
senders: item.senders,
|
||||
}));
|
||||
const rawItems: UnreadDayApiItem[] = data;
|
||||
const items: UnreadMark[] = rawItems
|
||||
.map((item) => {
|
||||
const isReadLocally = localReadSet.has(item.last_message_id);
|
||||
const count = isReadLocally ? Math.max(0, item.count - 1) : item.count;
|
||||
return {
|
||||
sessionId: sessionId,
|
||||
date: item.date,
|
||||
count,
|
||||
lastMessageId: item.last_message_id,
|
||||
senders: item.senders,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.count > 0);
|
||||
|
||||
setUnreadMarks(items);
|
||||
const total = items.reduce((sum, item) => sum + item.count, 0);
|
||||
setUnreadTotal(total);
|
||||
setNextMessageId(items.length > 0 ? items[0].lastMessageId : null);
|
||||
setUnreadMessageIds(items.map((i) => i.lastMessageId));
|
||||
const validIds = items.map((i) => i.lastMessageId).filter((id) => !localReadSet.has(id));
|
||||
setNextMessageId(validIds.length > 0 ? validIds[0] : null);
|
||||
setUnreadMessageIds(validIds);
|
||||
} else {
|
||||
const summary = data as UnreadSummaryResponse;
|
||||
const items: UnreadMark[] = (summary.items || []).map((item) => ({
|
||||
sessionId: sessionId,
|
||||
date: item.date,
|
||||
count: item.count,
|
||||
lastMessageId: item.last_message_id,
|
||||
senders: item.senders,
|
||||
}));
|
||||
const unreadIds = (summary.unread_message_ids || []).filter((id) => !localReadSet.has(id));
|
||||
const items: UnreadMark[] = (summary.items || [])
|
||||
.map((item) => {
|
||||
const isReadLocally = localReadSet.has(item.last_message_id);
|
||||
const count = isReadLocally ? Math.max(0, item.count - 1) : item.count;
|
||||
return {
|
||||
sessionId: sessionId,
|
||||
date: item.date,
|
||||
count,
|
||||
lastMessageId: item.last_message_id,
|
||||
senders: item.senders,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.count > 0);
|
||||
|
||||
setUnreadMarks(items);
|
||||
setUnreadTotal(summary.total || 0);
|
||||
setNextMessageId(summary.next_message_id > 0 ? summary.next_message_id : null);
|
||||
setUnreadMessageIds(summary.unread_message_ids || []);
|
||||
setUnreadTotal(unreadIds.length);
|
||||
setNextMessageId(unreadIds.length > 0 ? unreadIds[0] : null);
|
||||
setUnreadMessageIds(unreadIds);
|
||||
}
|
||||
} catch {
|
||||
// 网络错误静默处理,不中断协同
|
||||
@@ -137,6 +171,9 @@ export function useUnreadMarks(
|
||||
}
|
||||
if (!targetId) return null;
|
||||
|
||||
// 0. 本地持久化记录已读
|
||||
saveLocalReadId(sessionId, targetId);
|
||||
|
||||
// 1. 乐观更新未读集合
|
||||
const remainingIds = unreadMessageIds.filter((id) => id !== targetId);
|
||||
setUnreadMessageIds(remainingIds);
|
||||
@@ -190,6 +227,10 @@ export function useUnreadMarks(
|
||||
async (messageIds: number[]) => {
|
||||
if (!sessionId || messageIds.length === 0) return;
|
||||
|
||||
for (const id of messageIds) {
|
||||
saveLocalReadId(sessionId, id);
|
||||
}
|
||||
|
||||
const idsToRead = new Set(messageIds);
|
||||
const remainingIds = unreadMessageIds.filter((id) => !idsToRead.has(id));
|
||||
setUnreadMessageIds(remainingIds);
|
||||
|
||||
@@ -148,4 +148,116 @@ describe('用户上传文档私密归档与共享流转测试', () => {
|
||||
assert.equal(newDeliverable.category, 'private');
|
||||
assert.equal(newDeliverable.space, 'my_private');
|
||||
});
|
||||
|
||||
it('单轮对话支持一次上传多个文档(上限5个),批量生成私密交付物并遵循数量约束', () => {
|
||||
const MAX_ATTACHMENTS = 5;
|
||||
|
||||
// 模拟用户一次性多选 6 个文件
|
||||
const incomingFiles = [
|
||||
{ name: '1-临床需求规格书.docx', size: '120 KB', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
|
||||
{ name: '2-硬件设计草案.pdf', size: '350 KB', type: 'application/pdf' },
|
||||
{ name: '3-竞品比对表.xlsx', size: '80 KB', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||
{ name: '4-法规风险调研.md', size: '15 KB', type: 'text/markdown' },
|
||||
{ name: '5-型检预估预算.xlsx', size: '45 KB', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||
{ name: '6-超出限制的文件.pdf', size: '200 KB', type: 'application/pdf' },
|
||||
];
|
||||
|
||||
// 模拟 InputArea handleFileChange 截断逻辑
|
||||
let currentAttachments: { name: string; size: string; type: string }[] = [];
|
||||
const availableSlots = MAX_ATTACHMENTS - currentAttachments.length;
|
||||
assert.equal(availableSlots, 5);
|
||||
|
||||
let filesToAdd = incomingFiles;
|
||||
let warningTriggered = false;
|
||||
if (incomingFiles.length > availableSlots) {
|
||||
filesToAdd = incomingFiles.slice(0, availableSlots);
|
||||
warningTriggered = true;
|
||||
}
|
||||
assert.equal(warningTriggered, true);
|
||||
assert.equal(filesToAdd.length, 5);
|
||||
|
||||
currentAttachments = [...currentAttachments, ...filesToAdd];
|
||||
assert.equal(currentAttachments.length, 5);
|
||||
|
||||
// 再次尝试添加:此时应无剩余槽位并拒绝
|
||||
const nextAvailableSlots = MAX_ATTACHMENTS - currentAttachments.length;
|
||||
assert.equal(nextAvailableSlots, 0);
|
||||
|
||||
// 模拟 App.tsx handleSendMessage 批量注册
|
||||
const currentProjectId = 6001;
|
||||
const currentUserId = 1;
|
||||
const currentUserName = '张工';
|
||||
const now = Date.now();
|
||||
|
||||
const newDeliverablesList: DeliverableItem[] = [];
|
||||
const newDocCacheEntries: Record<string, any> = {};
|
||||
|
||||
const processedAttachments = currentAttachments.map((att, idx) => {
|
||||
const fileDocId = `doc-upload-${now}-${idx}`;
|
||||
const baseTitle = att.name.replace(/\.[^/.]+$/, '');
|
||||
const newDeliverable: DeliverableItem = {
|
||||
docId: fileDocId,
|
||||
projectId: currentProjectId,
|
||||
taskKey: 'user_requirements',
|
||||
title: baseTitle,
|
||||
stage: 'V0.5',
|
||||
category: 'private',
|
||||
creatorId: currentUserId,
|
||||
creatorName: currentUserName,
|
||||
isFixedWorkflow: false,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: currentUserName,
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: '2026-09-11',
|
||||
summary: `用户上传的私密需求文档《${att.name}》`,
|
||||
space: 'my_private',
|
||||
governanceMode: 'freeform',
|
||||
};
|
||||
const initialMd = `# ${baseTitle}\n\n> 上传人:${currentUserName} | 大小:${att.size}`;
|
||||
const newAST = markdownToBlockAST(initialMd, {
|
||||
docId: fileDocId,
|
||||
title: baseTitle,
|
||||
taskKey: 'user_requirements',
|
||||
version: 'V1.0',
|
||||
category: 'private',
|
||||
creatorId: currentUserId,
|
||||
creatorName: currentUserName,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: currentUserName,
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
});
|
||||
newAST.governanceMode = 'freeform';
|
||||
newAST.space = 'my_private';
|
||||
|
||||
newDeliverablesList.push(newDeliverable);
|
||||
newDocCacheEntries[fileDocId] = newAST;
|
||||
|
||||
return {
|
||||
...att,
|
||||
docId: fileDocId,
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(processedAttachments.length, 5);
|
||||
assert.equal(newDeliverablesList.length, 5);
|
||||
assert.equal(Object.keys(newDocCacheEntries).length, 5);
|
||||
|
||||
// 验证所有 5 份交付物均具有独立 docId 且为 private / my_private
|
||||
const docIds = new Set(newDeliverablesList.map((d) => d.docId));
|
||||
assert.equal(docIds.size, 5);
|
||||
newDeliverablesList.forEach((d) => {
|
||||
assert.equal(d.category, 'private');
|
||||
assert.equal(d.space, 'my_private');
|
||||
assert.equal(d.creatorName, '张工');
|
||||
});
|
||||
|
||||
// 验证默认激活打开第 1 个文档
|
||||
assert.equal(processedAttachments[0].docId, newDeliverablesList[0].docId);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -98,4 +98,47 @@ describe('工作流切换与消息展示时序测试套件', () => {
|
||||
assert.equal(flat[4].id, 'msg-live-1');
|
||||
assert.equal(flat[5].id, 'ast-live-2');
|
||||
});
|
||||
|
||||
test('2. 页面刷新后,即使本地 liveMessages 未包含新消息,按时间戳与自增 ID 排序也绝对不倒挂在历史消息前', () => {
|
||||
// 模拟服务端返回完整按时间排布的消息
|
||||
const historicalMessages: ChatMessage[] = [
|
||||
{ id: 'm-jigu-1', sessionId: 'sess-jigu-u1', role: 'system', content: '助手就位', timestamp: '09:00', createdAt: '2026-09-11T09:00:00+08:00', serverId: 25217 },
|
||||
{ id: 'm-jigu-2', sessionId: 'sess-jigu-u1', role: 'user', content: '用户需求', timestamp: '09:14', createdAt: '2026-09-11T09:14:00+08:00', serverId: 25218 },
|
||||
];
|
||||
|
||||
const serverRecentMessages: ChatMessage[] = [
|
||||
{ id: 'msg-25227', sessionId: 'sess-jigu-u1', role: 'user', content: '分析一下测试方案中...', timestamp: '10:06', createdAt: '2026-09-11T10:06:46+08:00', serverId: 25227 },
|
||||
{ id: 'msg-25228', sessionId: 'sess-jigu-u1', role: 'assistant', content: '针对测试方案...', timestamp: '10:07', createdAt: '2026-09-11T10:07:26+08:00', serverId: 25228 },
|
||||
];
|
||||
|
||||
const allInBucket = [...serverRecentMessages, ...historicalMessages]; // 故意打乱测试排序算法鲁棒性
|
||||
|
||||
const getMessageSortTime = (m: ChatMessage): number => {
|
||||
if (m.createdAt) {
|
||||
const t = new Date(m.createdAt).getTime();
|
||||
if (!isNaN(t)) return t;
|
||||
}
|
||||
if (m.timestamp && m.timestamp.includes(':')) {
|
||||
const [hh, mm] = m.timestamp.split(':').map(Number);
|
||||
if (!isNaN(hh) && !isNaN(mm)) {
|
||||
const d = new Date();
|
||||
d.setHours(hh, mm, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
allInBucket.sort((a, b) => {
|
||||
const tA = getMessageSortTime(a);
|
||||
const tB = getMessageSortTime(b);
|
||||
if (tA !== 0 && tB !== 0 && tA !== tB) return tA - tB;
|
||||
return (a.serverId || 0) - (b.serverId || 0);
|
||||
});
|
||||
|
||||
assert.equal(allInBucket[0].id, 'm-jigu-1');
|
||||
assert.equal(allInBucket[1].id, 'm-jigu-2');
|
||||
assert.equal(allInBucket[2].id, 'msg-25227');
|
||||
assert.equal(allInBucket[3].id, 'msg-25228');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,6 +144,8 @@ export interface ChatMessage {
|
||||
size: string;
|
||||
type: string;
|
||||
docId?: string;
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
}[];
|
||||
intentTag?: string;
|
||||
branchTag?: string;
|
||||
@@ -228,6 +230,19 @@ export interface ChatCategoryStat {
|
||||
|
||||
export type DeliverableCategory = 'workflow' | 'shared' | 'private';
|
||||
|
||||
export type DeliverableSourceType = 'workflow' | 'chat_derived' | 'uploaded_file';
|
||||
|
||||
export interface UploadedFileMetadata {
|
||||
originalName: string;
|
||||
size: string;
|
||||
sizeBytes?: number;
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export interface DeliverableItem {
|
||||
docId: string;
|
||||
projectId: number;
|
||||
@@ -251,6 +266,8 @@ export interface DeliverableItem {
|
||||
visibilityRule?: DocVisibilityRule;
|
||||
governanceMode?: 'controlled' | 'freeform';
|
||||
space?: 'my_private' | 'project_shared' | 'trash';
|
||||
sourceType?: DeliverableSourceType;
|
||||
fileMeta?: UploadedFileMetadata;
|
||||
}
|
||||
|
||||
export interface WorkbenchState {
|
||||
|
||||
Reference in New Issue
Block a user