From a527428b2d20ed73195e394bfc1374c9b329d644 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Tue, 21 Apr 2026 13:43:38 +0800 Subject: [PATCH] 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 --- libs/rpc/admin/mod.rs | 0 src/components/room/DiscordChatPanel.tsx | 61 ++++++++++++------- src/components/room/RoomSettingsPanel.tsx | 16 +++-- src/components/room/message/MessageBubble.tsx | 15 +++-- src/components/room/message/MessageInput.tsx | 17 +++--- .../room/message/editor/IMEditor.tsx | 3 +- src/components/room/sender.ts | 6 +- src/contexts/room-context.tsx | 51 +++++++++------- 8 files changed, 104 insertions(+), 65 deletions(-) create mode 100644 libs/rpc/admin/mod.rs diff --git a/libs/rpc/admin/mod.rs b/libs/rpc/admin/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/components/room/DiscordChatPanel.tsx b/src/components/room/DiscordChatPanel.tsx index 2ce286f..3fa6826 100644 --- a/src/components/room/DiscordChatPanel.tsx +++ b/src/components/room/DiscordChatPanel.tsx @@ -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} /> @@ -321,12 +351,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha { - 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 > setShowMentions(false)} - onSelectNotification={(mention) => { - toast.info(`Navigate to message in ${mention.room_name}`); - setShowMentions(false); - }} + onSelectNotification={handleMentionSelect} /> )} @@ -372,12 +394,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha > { - // 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)} /> diff --git a/src/components/room/RoomSettingsPanel.tsx b/src/components/room/RoomSettingsPanel.tsx index 3c3d47e..5112075 100644 --- a/src/components/room/RoomSettingsPanel.tsx +++ b/src/components/room/RoomSettingsPanel.tsx @@ -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([]); 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(''); diff --git a/src/components/room/message/MessageBubble.tsx b/src/components/room/message/MessageBubble.tsx index b0bc997..5f7174c 100644 --- a/src/components/room/message/MessageBubble.tsx +++ b/src/components/room/message/MessageBubble.tsx @@ -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( () => diff --git a/src/components/room/message/MessageInput.tsx b/src/components/room/message/MessageInput.tsx index 82f6c8b..6c5a567 100644 --- a/src/components/room/message/MessageInput.tsx +++ b/src/components/room/message/MessageInput.tsx @@ -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(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(); }; diff --git a/src/components/room/message/editor/IMEditor.tsx b/src/components/room/message/editor/IMEditor.tsx index 5a21053..f6b8c68 100644 --- a/src/components/room/message/editor/IMEditor.tsx +++ b/src/components/room/message/editor/IMEditor.tsx @@ -365,7 +365,8 @@ export const IMEditor = forwardRef(function IMEdi status: 'done' } }).insertContent(' ').run(); - } catch { /* ignore */ + } catch (err) { + console.warn('[IMEditor] Upload failed:', err); } }; diff --git a/src/components/room/sender.ts b/src/components/room/sender.ts index c4b1302..573e108 100644 --- a/src/components/room/sender.ts +++ b/src/components/room/sender.ts @@ -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; } diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index dd0eae8..3e39852 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -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; }); } },