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:
parent
a7e31d5649
commit
a527428b2d
0
libs/rpc/admin/mod.rs
Normal file
0
libs/rpc/admin/mod.rs
Normal 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>
|
||||
|
||||
@ -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('');
|
||||
|
||||
@ -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[]>(
|
||||
() =>
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user