fix(frontend): room streaming, dedup, reactions, uploads, and render perf

- room-context: dedup by id not seq (streaming seq=0); single atomic
  setStreamingContent with delta detection; preserve reactions from WS
- MessageBubble: fix avatar lookup (members before IIFE); handleReaction
  deps (no message.reactions); add reactions to wsMessageToUiMessage
- MessageInput: memoize mentionItems; fix upload path with VITE_API_BASE_URL
- IMEditor: warn on upload failure instead of silent swallow
- RoomSettingsPanel: sync form on room switch; loadModels before useEffect
- DiscordChatPanel: extract inline callbacks to useCallback stable refs
This commit is contained in:
ZhenYi 2026-04-21 13:43:38 +08:00
parent a7e31d5649
commit a527428b2d
8 changed files with 104 additions and 65 deletions

0
libs/rpc/admin/mod.rs Normal file
View File

View File

@ -5,7 +5,7 @@
* Layout: header + message list + input + member sidebar * Layout: header + message list + input + member sidebar
*/ */
import type { RoomResponse, RoomThreadResponse } from '@/client'; import type { RoomMemberResponse, RoomMessageResponse, RoomResponse, RoomThreadResponse } from '@/client';
import type { MessageWithMeta } from '@/contexts'; import type { MessageWithMeta } from '@/contexts';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@ -89,6 +89,28 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
[sendMessage, replyingTo], [sendMessage, replyingTo],
); );
const handleCancelReply = useCallback(() => {
setReplyingTo(null);
}, []);
const handleOpenUserCard = useCallback(
(payload: { username: string; displayName?: string | null; avatarUrl?: string | null; userId: string; point: { x: number; y: number } }) => {
messageInputRef.current?.insertMention('user', payload.userId, payload.username);
messageInputRef.current?.focus();
},
[],
);
const handleMemberClick = useCallback(
(member: RoomMemberResponse) => {
const label = member.user_info?.username ?? member.user;
const type = member.role === 'ai' ? 'ai' : 'user';
messageInputRef.current?.insertMention(type, member.user, label);
messageInputRef.current?.focus();
},
[],
);
const handleEdit = useCallback((message: MessageWithMeta) => { const handleEdit = useCallback((message: MessageWithMeta) => {
setEditingMessage(message); setEditingMessage(message);
setEditDialogOpen(true); setEditDialogOpen(true);
@ -136,6 +158,17 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
} }
}, [wsClient, room.id, refreshThreads]); }, [wsClient, room.id, refreshThreads]);
const handleMentionSelect = useCallback((mention: { room_name: string }) => {
toast.info(`Navigate to message in ${mention.room_name}`);
setShowMentions(false);
}, []);
const handleSearchSelect = useCallback((message: RoomMessageResponse) => {
const el = document.getElementById(`msg-${message.id}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setShowSearch(false);
}, []);
const handleUpdateRoom = useCallback( const handleUpdateRoom = useCallback(
async (name: string, isPublic: boolean) => { async (name: string, isPublic: boolean) => {
setIsUpdatingRoom(true); setIsUpdatingRoom(true);
@ -300,10 +333,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
onRevoke={handleRevoke} onRevoke={handleRevoke}
onReply={setReplyingTo} onReply={setReplyingTo}
onMention={undefined} onMention={undefined}
onOpenUserCard={({ userId, username }) => { onOpenUserCard={handleOpenUserCard}
messageInputRef.current?.insertMention('user', userId, username);
messageInputRef.current?.focus();
}}
onOpenThread={handleOpenThread} onOpenThread={handleOpenThread}
onCreateThread={handleCreateThread} onCreateThread={handleCreateThread}
/> />
@ -313,7 +343,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
roomName={room.room_name ?? 'room'} roomName={room.room_name ?? 'room'}
onSend={handleSend} onSend={handleSend}
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null} replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
onCancelReply={() => setReplyingTo(null)} onCancelReply={handleCancelReply}
/> />
</div> </div>
@ -321,12 +351,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
<DiscordMemberList <DiscordMemberList
members={members} members={members}
membersLoading={membersLoading} membersLoading={membersLoading}
onMemberClick={({ user, user_info, role }) => { onMemberClick={handleMemberClick}
const label = user_info?.username ?? user;
const type = role === 'ai' ? 'ai' : 'user';
messageInputRef.current?.insertMention(type, user, label);
messageInputRef.current?.focus();
}}
aiConfigs={roomAiConfigs} aiConfigs={roomAiConfigs}
/> />
)} )}
@ -357,10 +382,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
> >
<RoomMentionPanel <RoomMentionPanel
onClose={() => setShowMentions(false)} onClose={() => setShowMentions(false)}
onSelectNotification={(mention) => { onSelectNotification={handleMentionSelect}
toast.info(`Navigate to message in ${mention.room_name}`);
setShowMentions(false);
}}
/> />
</aside> </aside>
)} )}
@ -372,12 +394,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
> >
<RoomMessageSearch <RoomMessageSearch
roomId={room.id} roomId={room.id}
onSelectMessage={(message) => { onSelectMessage={handleSearchSelect}
// Scroll to the selected message and close search
const el = document.getElementById(`msg-${message.id}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setShowSearch(false);
}}
onClose={() => setShowSearch(false)} onClose={() => setShowSearch(false)}
/> />
</aside> </aside>

View File

@ -32,6 +32,12 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
const [name, setName] = useState(room.room_name ?? ''); const [name, setName] = useState(room.room_name ?? '');
const [isPublic, setIsPublic] = useState(!!room.public); const [isPublic, setIsPublic] = useState(!!room.public);
// Sync form when room prop changes (e.g., user switched to a different room)
useEffect(() => {
setName(room.room_name ?? '');
setIsPublic(!!room.public);
}, [room.id, room.room_name, room.public]);
// AI section state // AI section state
const [aiConfigs, setAiConfigs] = useState<RoomAiResponse[]>([]); const [aiConfigs, setAiConfigs] = useState<RoomAiResponse[]>([]);
const [aiConfigsLoading, setAiConfigsLoading] = useState(false); const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
@ -76,11 +82,6 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
} }
}, [room.id]); }, [room.id]);
useEffect(() => {
loadAiConfigs();
loadModels();
}, [loadAiConfigs]);
// Load available models // Load available models
const loadModels = useCallback(async () => { const loadModels = useCallback(async () => {
setModelsLoading(true); setModelsLoading(true);
@ -95,6 +96,11 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
} }
}, []); }, []);
useEffect(() => {
loadAiConfigs();
loadModels();
}, [loadAiConfigs, loadModels]);
const openAddDialog = () => { const openAddDialog = () => {
setSelectedModelId(''); setSelectedModelId('');
setTemperature(''); setTemperature('');

View File

@ -17,7 +17,7 @@ import { ModelIcon } from '../icon-match';
import { FunctionCallBadge } from '../FunctionCallBadge'; import { FunctionCallBadge } from '../FunctionCallBadge';
import { MessageContent } from './MessageContent'; import { MessageContent } from './MessageContent';
import { ThreadIndicator } from '../RoomThreadPanel'; import { ThreadIndicator } from '../RoomThreadPanel';
import { getSenderDisplayName, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender'; import { getSenderDisplayName, getSenderUserUid, isUserSender } from '../sender';
import { MessageReactions } from './MessageReactions'; import { MessageReactions } from './MessageReactions';
import { ReactionPicker } from './ReactionPicker'; import { ReactionPicker } from './ReactionPicker';
@ -78,13 +78,17 @@ export const MessageBubble = memo(function MessageBubble({
const isAi = ['ai', 'system', 'tool'].includes(message.sender_type); const isAi = ['ai', 'system', 'tool'].includes(message.sender_type);
const isSystem = message.sender_type === 'system'; const isSystem = message.sender_type === 'system';
const displayName = getSenderDisplayName(message); const displayName = getSenderDisplayName(message);
const avatarUrl = getAvatarFromUiMessage(message);
const initial = (displayName?.charAt(0) ?? '?').toUpperCase(); const initial = (displayName?.charAt(0) ?? '?').toUpperCase();
const isStreaming = !!message.is_streaming; const isStreaming = !!message.is_streaming;
const isEdited = !!message.edited_at; const isEdited = !!message.edited_at;
useTheme(); useTheme();
const { user } = useUser(); const { user } = useUser();
const { wsClient, streamingMessages, members } = useRoom(); const { wsClient, streamingMessages, members } = useRoom();
const avatarUrl = (() => {
if (message.sender_type === 'ai') return undefined;
const member = members.find(m => m.user === message.sender_id);
return member?.user_info?.avatar_url ?? undefined;
})();
const isOwner = user?.uid === getSenderUserUid(message); const isOwner = user?.uid === getSenderUserUid(message);
const isRevoked = !!message.revoked; const isRevoked = !!message.revoked;
const isFailed = message.isOptimisticError === true; const isFailed = message.isOptimisticError === true;
@ -117,8 +121,9 @@ export const MessageBubble = memo(function MessageBubble({
const handleReaction = useCallback(async (emoji: string) => { const handleReaction = useCallback(async (emoji: string) => {
if (!wsClient) return; if (!wsClient) return;
try { try {
const existing = message.reactions?.find(r => r.emoji === emoji); // Read reactions from message.reactions at call time via getSnapshot
if (existing?.reacted_by_me) { const reactedByMe = message.reactions?.find(r => r.emoji === emoji)?.reacted_by_me ?? false;
if (reactedByMe) {
await wsClient.reactionRemove(roomId, message.id, emoji); await wsClient.reactionRemove(roomId, message.id, emoji);
} else { } else {
await wsClient.reactionAdd(roomId, message.id, emoji); await wsClient.reactionAdd(roomId, message.id, emoji);
@ -126,7 +131,7 @@ export const MessageBubble = memo(function MessageBubble({
} catch (err) { } catch (err) {
console.warn('[RoomMessage] Failed to update reaction:', err); console.warn('[RoomMessage] Failed to update reaction:', err);
} }
}, [roomId, message.id, message.reactions, wsClient]); }, [roomId, message.id, wsClient]);
const functionCalls = useMemo<FunctionCall[]>( const functionCalls = useMemo<FunctionCall[]>(
() => () =>

View File

@ -5,7 +5,7 @@
* Supports @mentions, file uploads, emoji picker, and rich message AST. * Supports @mentions, file uploads, emoji picker, and rich message AST.
*/ */
import { forwardRef, useImperativeHandle, useRef } from 'react'; import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
import { IMEditor } from './editor/IMEditor'; import { IMEditor } from './editor/IMEditor';
import { useRoom } from '@/contexts'; import { useRoom } from '@/contexts';
import type { MessageAST } from './editor/types'; import type { MessageAST } from './editor/types';
@ -45,25 +45,26 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [], getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
}), []); }), []);
// Transform room data into MentionItems // Transform room data into MentionItems — memoized to prevent IMEditor re-creation
const mentionItems = { const mentionItems = useMemo(() => ({
users: members.map((m) => ({ users: members.map((m) => ({
id: m.user, id: m.user,
label: m.user_info?.username ?? m.user, label: m.user_info?.username ?? m.user,
type: 'user' as const, type: 'user' as const,
avatar: m.user_info?.avatar_url ?? undefined, avatar: m.user_info?.avatar_url ?? undefined,
})), })),
channels: [], // TODO: add channel mentions channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
ai: [], // TODO: add AI mention configs ai: [] as { id: string; label: string; type: 'ai'; avatar?: string }[],
commands: [], // TODO: add slash commands commands: [] as { id: string; label: string; type: 'command'; avatar?: string }[],
}; }), [members]);
// File upload handler — POST to /rooms/{room_id}/upload // File upload handler — POST to /rooms/{room_id}/upload
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => { const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
if (!activeRoomId) throw new Error('No active room'); if (!activeRoomId) throw new Error('No active room');
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const res = await fetch(`/rooms/${activeRoomId}/upload`, { method: 'POST', body: formData }); const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const res = await fetch(`${baseUrl}/rooms/${activeRoomId}/upload`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed'); if (!res.ok) throw new Error('Upload failed');
return res.json(); return res.json();
}; };

View File

@ -365,7 +365,8 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
status: 'done' status: 'done'
} }
}).insertContent(' ').run(); }).insertContent(' ').run();
} catch { /* ignore */ } catch (err) {
console.warn('[IMEditor] Upload failed:', err);
} }
}; };

View File

@ -24,7 +24,11 @@ export function getSenderDisplayName(message: MessageWithMeta): string {
return message.sender_type; return message.sender_type;
} }
/** Avatar URL from a MessageWithMeta */ /** Avatar URL from a MessageWithMeta.
* Callers should pass members to resolve the avatar.
* This helper returns undefined for now avatar resolution is done in components
* using members.find() for consistency with the rest of the codebase.
*/
export function getAvatarFromUiMessage(_message: MessageWithMeta): string | undefined { export function getAvatarFromUiMessage(_message: MessageWithMeta): string | undefined {
return undefined; return undefined;
} }

View File

@ -88,6 +88,7 @@ function wsMessageToUiMessage(wsMsg: RoomMessagePayload): MessageWithMeta {
send_at: wsMsg.send_at, send_at: wsMsg.send_at,
display_content: wsMsg.content, display_content: wsMsg.content,
is_streaming: false, is_streaming: false,
reactions: wsMsg.reactions,
}; };
} }
@ -453,9 +454,10 @@ export function RoomProvider({
} }
return prev; return prev;
} }
// Replace optimistic message with server-confirmed one // Replace optimistic message with server-confirmed one.
// Streaming messages have seq=0 in optimistic state, so match by id instead.
const optimisticIdx = prev.findIndex( const optimisticIdx = prev.findIndex(
(m) => m.isOptimistic && m.seq === payload.seq && m.seq !== 0, (m) => m.isOptimistic && m.id === payload.id,
); );
if (optimisticIdx !== -1) { if (optimisticIdx !== -1) {
const confirmed: MessageWithMeta = { const confirmed: MessageWithMeta = {
@ -485,6 +487,8 @@ export function RoomProvider({
}, },
onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string }) => { onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string }) => {
if (chunk.done) { if (chunk.done) {
// When done: clear streaming content, set is_streaming=false, and
// update seq so the subsequent RoomMessage event deduplicates correctly.
setStreamingContent((prev) => { setStreamingContent((prev) => {
prev.delete(chunk.message_id); prev.delete(chunk.message_id);
return new Map(prev); return new Map(prev);
@ -497,43 +501,44 @@ export function RoomProvider({
), ),
); );
} else { } else {
// Accumulate streaming content in dedicated map // Single atomic update: accumulate in streamingContent AND update message.
// Backend sends CUMULATIVE content (text_accumulated.clone()), not delta.
// Use deduplication to only add the new delta portion.
setStreamingContent((prev) => { setStreamingContent((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(chunk.message_id, (next.get(chunk.message_id) ?? '') + chunk.content); const prevContent = next.get(chunk.message_id) ?? '';
return next; // Only append the delta (the part of chunk.content that is NEW).
}); // This prevents double-accumulation since backend already sends cumulative text.
// Update or insert the AI message with current accumulated content const newContent =
// Use streamingContent map as source of truth for display during streaming prevContent === '' || !chunk.content.startsWith(prevContent)
setStreamingContent((current) => { ? chunk.content // First chunk or content diverged — use as-is
const accumulated = current.get(chunk.message_id) ?? ''; : prevContent + chunk.content.slice(prevContent.length); // Append delta
setMessages((prev) => { next.set(chunk.message_id, newContent);
const idx = prev.findIndex((m) => m.id === chunk.message_id); setMessages((msgs) => {
const idx = msgs.findIndex((m) => m.id === chunk.message_id);
if (idx !== -1) { if (idx !== -1) {
const m = prev[idx]; const m = msgs[idx];
// Skip render if content hasn't changed (dedup protection) if (m.content === newContent && m.is_streaming === true) return msgs;
if (m.content === accumulated && m.is_streaming === true) return prev; const updated = [...msgs];
const updated = [...prev]; updated[idx] = { ...m, content: newContent, display_content: newContent };
updated[idx] = { ...m, content: accumulated, display_content: accumulated };
return updated; return updated;
} }
// New message — avoid adding empty content blocks if (!newContent) return msgs;
if (!accumulated) return prev;
const newMsg: MessageWithMeta = { const newMsg: MessageWithMeta = {
id: chunk.message_id, id: chunk.message_id,
room: chunk.room_id, room: chunk.room_id,
seq: 0, seq: 0,
sender_type: 'ai', sender_type: 'ai',
display_name: chunk.display_name, display_name: chunk.display_name,
content: accumulated, content: newContent,
display_content: accumulated, display_content: newContent,
content_type: 'text', content_type: 'text',
send_at: new Date().toISOString(), send_at: new Date().toISOString(),
is_streaming: true, is_streaming: true,
}; };
return [...prev, newMsg]; return [...msgs, newMsg];
}); });
return current; return next;
}); });
} }
}, },