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
*/
import type { RoomResponse, RoomThreadResponse } from '@/client';
import type { RoomMemberResponse, RoomMessageResponse, RoomResponse, RoomThreadResponse } from '@/client';
import type { MessageWithMeta } from '@/contexts';
import { cn } from '@/lib/utils';
import {
@ -89,6 +89,28 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
[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) => {
setEditingMessage(message);
setEditDialogOpen(true);
@ -136,6 +158,17 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
}
}, [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(
async (name: string, isPublic: boolean) => {
setIsUpdatingRoom(true);
@ -300,10 +333,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
onRevoke={handleRevoke}
onReply={setReplyingTo}
onMention={undefined}
onOpenUserCard={({ userId, username }) => {
messageInputRef.current?.insertMention('user', userId, username);
messageInputRef.current?.focus();
}}
onOpenUserCard={handleOpenUserCard}
onOpenThread={handleOpenThread}
onCreateThread={handleCreateThread}
/>
@ -313,7 +343,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
roomName={room.room_name ?? 'room'}
onSend={handleSend}
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
onCancelReply={() => setReplyingTo(null)}
onCancelReply={handleCancelReply}
/>
</div>
@ -321,12 +351,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
<DiscordMemberList
members={members}
membersLoading={membersLoading}
onMemberClick={({ user, user_info, role }) => {
const label = user_info?.username ?? user;
const type = role === 'ai' ? 'ai' : 'user';
messageInputRef.current?.insertMention(type, user, label);
messageInputRef.current?.focus();
}}
onMemberClick={handleMemberClick}
aiConfigs={roomAiConfigs}
/>
)}
@ -357,10 +382,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
>
<RoomMentionPanel
onClose={() => setShowMentions(false)}
onSelectNotification={(mention) => {
toast.info(`Navigate to message in ${mention.room_name}`);
setShowMentions(false);
}}
onSelectNotification={handleMentionSelect}
/>
</aside>
)}
@ -372,12 +394,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
>
<RoomMessageSearch
roomId={room.id}
onSelectMessage={(message) => {
// 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);
}}
onSelectMessage={handleSearchSelect}
onClose={() => setShowSearch(false)}
/>
</aside>

View File

@ -32,6 +32,12 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
const [name, setName] = useState(room.room_name ?? '');
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
const [aiConfigs, setAiConfigs] = useState<RoomAiResponse[]>([]);
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
@ -76,11 +82,6 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
}
}, [room.id]);
useEffect(() => {
loadAiConfigs();
loadModels();
}, [loadAiConfigs]);
// Load available models
const loadModels = useCallback(async () => {
setModelsLoading(true);
@ -95,6 +96,11 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
}
}, []);
useEffect(() => {
loadAiConfigs();
loadModels();
}, [loadAiConfigs, loadModels]);
const openAddDialog = () => {
setSelectedModelId('');
setTemperature('');

View File

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

View File

@ -5,7 +5,7 @@
* 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 { useRoom } from '@/contexts';
import type { MessageAST } from './editor/types';
@ -45,25 +45,26 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
}), []);
// Transform room data into MentionItems
const mentionItems = {
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation
const mentionItems = useMemo(() => ({
users: members.map((m) => ({
id: m.user,
label: m.user_info?.username ?? m.user,
type: 'user' as const,
avatar: m.user_info?.avatar_url ?? undefined,
})),
channels: [], // TODO: add channel mentions
ai: [], // TODO: add AI mention configs
commands: [], // TODO: add slash commands
};
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
ai: [] as { id: string; label: string; type: 'ai'; avatar?: string }[],
commands: [] as { id: string; label: string; type: 'command'; avatar?: string }[],
}), [members]);
// File upload handler — POST to /rooms/{room_id}/upload
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
if (!activeRoomId) throw new Error('No active room');
const formData = new FormData();
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');
return res.json();
};

View File

@ -365,7 +365,8 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
status: 'done'
}
}).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;
}
/** 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 {
return undefined;
}

View File

@ -88,6 +88,7 @@ function wsMessageToUiMessage(wsMsg: RoomMessagePayload): MessageWithMeta {
send_at: wsMsg.send_at,
display_content: wsMsg.content,
is_streaming: false,
reactions: wsMsg.reactions,
};
}
@ -453,9 +454,10 @@ export function RoomProvider({
}
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(
(m) => m.isOptimistic && m.seq === payload.seq && m.seq !== 0,
(m) => m.isOptimistic && m.id === payload.id,
);
if (optimisticIdx !== -1) {
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 }) => {
if (chunk.done) {
// When done: clear streaming content, set is_streaming=false, and
// update seq so the subsequent RoomMessage event deduplicates correctly.
setStreamingContent((prev) => {
prev.delete(chunk.message_id);
return new Map(prev);
@ -497,43 +501,44 @@ export function RoomProvider({
),
);
} 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) => {
const next = new Map(prev);
next.set(chunk.message_id, (next.get(chunk.message_id) ?? '') + chunk.content);
return next;
});
// Update or insert the AI message with current accumulated content
// Use streamingContent map as source of truth for display during streaming
setStreamingContent((current) => {
const accumulated = current.get(chunk.message_id) ?? '';
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === chunk.message_id);
const prevContent = next.get(chunk.message_id) ?? '';
// Only append the delta (the part of chunk.content that is NEW).
// This prevents double-accumulation since backend already sends cumulative text.
const newContent =
prevContent === '' || !chunk.content.startsWith(prevContent)
? chunk.content // First chunk or content diverged — use as-is
: prevContent + chunk.content.slice(prevContent.length); // Append delta
next.set(chunk.message_id, newContent);
setMessages((msgs) => {
const idx = msgs.findIndex((m) => m.id === chunk.message_id);
if (idx !== -1) {
const m = prev[idx];
// Skip render if content hasn't changed (dedup protection)
if (m.content === accumulated && m.is_streaming === true) return prev;
const updated = [...prev];
updated[idx] = { ...m, content: accumulated, display_content: accumulated };
const m = msgs[idx];
if (m.content === newContent && m.is_streaming === true) return msgs;
const updated = [...msgs];
updated[idx] = { ...m, content: newContent, display_content: newContent };
return updated;
}
// New message — avoid adding empty content blocks
if (!accumulated) return prev;
if (!newContent) return msgs;
const newMsg: MessageWithMeta = {
id: chunk.message_id,
room: chunk.room_id,
seq: 0,
sender_type: 'ai',
display_name: chunk.display_name,
content: accumulated,
display_content: accumulated,
content: newContent,
display_content: newContent,
content_type: 'text',
send_at: new Date().toISOString(),
is_streaming: true,
};
return [...prev, newMsg];
return [...msgs, newMsg];
});
return current;
return next;
});
}
},