feat(messages): stream history one-message-per-WS-frame instead of 50-row batch
- backend: add streamMany generator (paginated, yields one record at a time) + messagesService.streamMessages + WS 'stream_messages' handler emitting 'message_snapshot' per message, 'message_snapshot_end' with nextCursor - frontend: useMessagesStream hook accumulates snapshots into SWR list, SSR getMessages seeds first paint, WsHook gains sendText - add stream-many.test.ts locking the one-at-a-time + cursor contract
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import { getConfig, getGuilds } from "@/lib/api/server";
|
||||
import { getConfig, getGuilds, getMessages } from "@/lib/api/server";
|
||||
import { MessagesView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -7,8 +7,16 @@ export const dynamic = "force-dynamic";
|
||||
export default async function MessagesPage() {
|
||||
let config: import("@/lib/types/guild").AppConfig | undefined;
|
||||
let guilds: import("@/lib/types").Guild[] | undefined;
|
||||
let initialMessages: {
|
||||
data: import("@/lib/types").MessageRecord[];
|
||||
nextCursor: string | null;
|
||||
} | null = null;
|
||||
try {
|
||||
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
|
||||
const gid = config?.monitorGuildId;
|
||||
if (gid) {
|
||||
initialMessages = await getMessages(gid, undefined, 50);
|
||||
}
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
@@ -17,6 +25,7 @@ export default async function MessagesPage() {
|
||||
<MessagesView
|
||||
initialGuilds={guilds}
|
||||
initialGuildId={config?.monitorGuildId ?? null}
|
||||
initialMessages={initialMessages}
|
||||
/>
|
||||
</PageTransition>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
useMessageSearch,
|
||||
useMessages,
|
||||
useMessagesHasMore,
|
||||
useMessagesStream,
|
||||
useMessagesWsSync,
|
||||
} from "@/hooks";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
@@ -49,9 +50,14 @@ import { useWebSocket } from "@/lib/ws/context";
|
||||
export function MessagesView({
|
||||
initialGuilds,
|
||||
initialGuildId,
|
||||
initialMessages,
|
||||
}: {
|
||||
initialGuilds?: Guild[];
|
||||
initialGuildId?: string | null;
|
||||
initialMessages?: {
|
||||
data: MessageRecord[];
|
||||
nextCursor: string | null;
|
||||
} | null;
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const [guildId, setGuildId] = useState<string | null>(
|
||||
@@ -69,7 +75,15 @@ export function MessagesView({
|
||||
data: messages,
|
||||
isLoading,
|
||||
error,
|
||||
} = useMessages(guildId ?? "", channelId ?? undefined);
|
||||
} = useMessages(
|
||||
guildId ?? "",
|
||||
channelId ?? undefined,
|
||||
initialMessages ?? undefined,
|
||||
);
|
||||
// Stream history one message per WS frame (replaces the 50-row batched fetch).
|
||||
// Drives snapshots into the SWR list above as they arrive; falls back to the
|
||||
// SSR `initialMessages` seed if WS is unavailable.
|
||||
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
|
||||
// Cursor to the next (older) page + whether more history exists.
|
||||
const { data: pageInfo } = useMessagesHasMore(
|
||||
guildId ?? "",
|
||||
|
||||
@@ -25,6 +25,7 @@ export {
|
||||
useMessageSearch,
|
||||
useMessages,
|
||||
useMessagesHasMore,
|
||||
useMessagesStream,
|
||||
useMessagesWsSync,
|
||||
useReview,
|
||||
useTextChannels,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useAction } from "@/hooks/use-action";
|
||||
import { messagesApi, voiceApi } from "@/lib/api";
|
||||
@@ -275,3 +275,82 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
||||
};
|
||||
}, [ws, guildId, mutate]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a channel/guild history ONE message per WS frame (no 50-row batch).
|
||||
* Calls the backend `stream_messages` handler and accumulates each incoming
|
||||
* `message_snapshot` into the SWR list as it arrives, so the UI renders
|
||||
* progressively. Falls back to the batched `messagesApi.list` if WS is down.
|
||||
*
|
||||
* Returns: { streaming, streamed, error }.
|
||||
*/
|
||||
export function useMessagesStream(
|
||||
ws: WsHook,
|
||||
guildId: string | null,
|
||||
channelId?: string | null,
|
||||
) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!guildId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const key = msgKeys.list(guildId, channelId ?? undefined);
|
||||
const unsubSnap = ws.on("message_snapshot", (data) => {
|
||||
if (cancelled) return;
|
||||
const msg = data as MessageRecord;
|
||||
if (channelId && msg.channel_id !== channelId) return;
|
||||
if (!channelId && msg.guild_id && msg.guild_id !== guildId) return;
|
||||
void mutate(
|
||||
key,
|
||||
(old: MessagePage | undefined): MessagePage => {
|
||||
const data2 = old?.data ?? [];
|
||||
if (data2.some((m) => m.id === msg.id))
|
||||
return old ?? { data: [], nextCursor: null };
|
||||
return { data: [msg, ...data2], nextCursor: old?.nextCursor ?? null };
|
||||
},
|
||||
{ revalidate: false },
|
||||
);
|
||||
});
|
||||
const unsubEnd = ws.on("message_snapshot_end", (data) => {
|
||||
if (cancelled) return;
|
||||
const end = data as {
|
||||
sent: number;
|
||||
nextCursor: string | null;
|
||||
error?: boolean;
|
||||
};
|
||||
setStreaming(false);
|
||||
setError(Boolean(end.error));
|
||||
// Persist the next-page cursor so "load older" still works after streaming.
|
||||
if (end.nextCursor) {
|
||||
void mutate(
|
||||
key,
|
||||
(old: MessagePage | undefined): MessagePage =>
|
||||
old
|
||||
? { ...old, nextCursor: end.nextCursor }
|
||||
: { data: [], nextCursor: end.nextCursor },
|
||||
{ revalidate: false },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setStreaming(true);
|
||||
setError(false);
|
||||
ws.sendText(
|
||||
JSON.stringify({
|
||||
type: "stream_messages",
|
||||
payload: { guildId, channelId: channelId ?? undefined, limit: 200 },
|
||||
}),
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubSnap();
|
||||
unsubEnd();
|
||||
};
|
||||
}, [ws, guildId, channelId, mutate]);
|
||||
|
||||
return { streaming, error };
|
||||
}
|
||||
|
||||
@@ -99,3 +99,26 @@ export async function getRecordings(limit = 50): Promise<PaginatedRecordings> {
|
||||
limit,
|
||||
}) as unknown as Promise<PaginatedRecordings>;
|
||||
}
|
||||
|
||||
// ---- Messages (SSR seed for the streaming view) ----
|
||||
// Used to seed the first paint so the feed isn't blank before the WS stream
|
||||
// arrives. The client then takes over and streams the rest one frame at a time.
|
||||
export async function getMessages(
|
||||
guildId: string,
|
||||
channelId?: string,
|
||||
limit = 50,
|
||||
cursor?: string,
|
||||
): Promise<{
|
||||
data: import("@/lib/types").MessageRecord[];
|
||||
nextCursor: string | null;
|
||||
}> {
|
||||
return serverOrpc().messages.list({
|
||||
guildId,
|
||||
channelId,
|
||||
limit,
|
||||
cursor,
|
||||
}) as unknown as Promise<{
|
||||
data: import("@/lib/types").MessageRecord[];
|
||||
nextCursor: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export type WsHook = {
|
||||
eventType: E,
|
||||
handler: (data: unknown) => void,
|
||||
) => () => void;
|
||||
sendText: (text: string) => void;
|
||||
};
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface WsEventMap {
|
||||
/** Gateway emits { id, deleted_at } — NOT a bare string */
|
||||
message_deleted: { id: string; deleted_at?: number };
|
||||
message_analyzed: MessageRecord;
|
||||
/**
|
||||
* Streamed history frame — one MessageRecord per WS message (replaces the old
|
||||
* 50-row batched `messages.list` fetch on the client). The view accumulates
|
||||
* these into the SWR list as they arrive. `message_snapshot_end` signals done.
|
||||
*/
|
||||
message_snapshot: MessageRecord;
|
||||
message_snapshot_end: { sent: number; error?: boolean };
|
||||
attachment_created: unknown;
|
||||
attachment_uploaded: unknown;
|
||||
voice_recording_started: unknown;
|
||||
|
||||
Reference in New Issue
Block a user