Throttle realtime refresh bursts

This commit is contained in:
kris
2026-04-07 17:50:30 +08:00
parent 4093c41949
commit 4c31dd7e98
5 changed files with 226 additions and 47 deletions

View File

@@ -4,6 +4,13 @@ import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import clsx from "clsx";
import type { BossEventName } from "@/lib/boss-events";
import {
cancelScheduledRefresh,
createRefreshThrottleState,
markScheduledRefreshExecuted,
planThrottledRefresh,
shouldRefreshRealtimeEvent,
} from "@/lib/realtime-refresh";
import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections";
import {
clearNativeSessionSnapshot,
@@ -263,63 +270,43 @@ export function RealtimeRefresh({
conversationUpdatedNotes?: string[];
}) {
const router = useRouter();
const throttleStateRef = useRef(createRefreshThrottleState());
const pendingTimerRef = useRef<number | null>(null);
useEffect(() => {
const source = new EventSource("/api/v1/events");
const projectScopeIds = new Set(
[projectId, ...(projectIds ?? [])]
.filter((value): value is string => Boolean(value?.trim()))
.map((value) => value.trim()),
);
const throttleState = throttleStateRef.current;
const listeners = Array.from(new Set([
"conversation.context_indicator.updated",
"project.context_risk.updated",
...events,
]));
const shouldRefresh = (event: Event) => {
let payload: { projectId?: string; note?: string } | null = null;
const eventData = "data" in event && typeof event.data === "string" ? event.data : "";
const hasPayloadData = Boolean(eventData.trim());
if (hasPayloadData) {
try {
const parsed = JSON.parse(eventData) as { projectId?: string; note?: string };
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
payload = parsed;
}
} catch {
payload = null;
}
}
if (projectScopeIds.size > 0) {
if (!payload || typeof payload.projectId !== "string" || !payload.projectId.trim()) {
return true;
}
if (!projectScopeIds.has(payload.projectId)) {
return false;
}
}
if (event.type === "conversation.updated" && conversationUpdatedNotes?.length) {
if (!payload || typeof payload.note !== "string" || !payload.note.trim()) {
return false;
}
if ((payload.note) && !conversationUpdatedNotes.includes(payload.note)) {
return false;
}
}
return true;
};
const listenerMap = new Map<string, (event: Event) => void>();
for (const event of listeners) {
const refresh = (nextEvent: Event) => {
if (!shouldRefresh(nextEvent)) {
const eventData = "data" in nextEvent ? nextEvent.data : undefined;
if (!shouldRefreshRealtimeEvent({
eventType: nextEvent.type,
eventData,
projectId,
projectIds,
conversationUpdatedNotes,
})) {
return;
}
router.refresh();
const decision = planThrottledRefresh(throttleState, Date.now(), 400);
if (decision.type === "refresh_now") {
router.refresh();
return;
}
if (decision.type === "schedule_refresh" && pendingTimerRef.current === null) {
pendingTimerRef.current = window.setTimeout(() => {
pendingTimerRef.current = null;
markScheduledRefreshExecuted(throttleState, Date.now());
router.refresh();
}, decision.delayMs);
}
};
listenerMap.set(event, refresh);
source.addEventListener(event, refresh);
@@ -332,6 +319,11 @@ export function RealtimeRefresh({
source.removeEventListener(event, refresh);
}
}
if (pendingTimerRef.current !== null) {
window.clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = null;
}
cancelScheduledRefresh(throttleState);
source.close();
};
}, [conversationUpdatedNotes, events, projectId, projectIds, router]);