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
|
* 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>
|
||||||
|
|||||||
@ -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('');
|
||||||
|
|||||||
@ -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[]>(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user