Throttle realtime refresh bursts
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { RealtimeRefresh } from "@/components/app-runtime";
|
||||
import { AppShell, ForwardComposer, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getProject, readState } from "@/lib/boss-data";
|
||||
@@ -21,6 +22,10 @@ export default async function ForwardPage({
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<RealtimeRefresh
|
||||
projectIds={[projectId, ...targets.map((target) => target.id)]}
|
||||
events={["conversation.updated", "project.messages.updated"]}
|
||||
/>
|
||||
<StatusBar />
|
||||
<PageNav title="转发到项目" backHref={`/conversations/${projectId}`} />
|
||||
<div className="space-y-3 px-[18px] pb-6">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
102
src/lib/realtime-refresh.ts
Normal file
102
src/lib/realtime-refresh.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export interface RealtimeRefreshScope {
|
||||
projectId?: string;
|
||||
projectIds?: string[];
|
||||
conversationUpdatedNotes?: string[];
|
||||
}
|
||||
|
||||
export interface RefreshThrottleState {
|
||||
lastRefreshAt: number | null;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
export type RefreshThrottleDecision =
|
||||
| { type: "refresh_now" }
|
||||
| { type: "schedule_refresh"; delayMs: number }
|
||||
| { type: "skip" };
|
||||
|
||||
interface RealtimeEventPayload {
|
||||
projectId?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
function parseRealtimeEventPayload(eventData: unknown): RealtimeEventPayload | null {
|
||||
if (typeof eventData !== "string" || !eventData.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(eventData) as RealtimeEventPayload;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRefreshRealtimeEvent(input: {
|
||||
eventType: string;
|
||||
eventData?: unknown;
|
||||
} & RealtimeRefreshScope) {
|
||||
const payload = parseRealtimeEventPayload(input.eventData);
|
||||
const projectScopeIds = new Set(
|
||||
[input.projectId, ...(input.projectIds ?? [])]
|
||||
.filter((value): value is string => Boolean(value?.trim()))
|
||||
.map((value) => value.trim()),
|
||||
);
|
||||
|
||||
if (projectScopeIds.size > 0) {
|
||||
if (!payload || typeof payload.projectId !== "string" || !payload.projectId.trim()) {
|
||||
return true;
|
||||
}
|
||||
if (!projectScopeIds.has(payload.projectId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.eventType === "conversation.updated" && input.conversationUpdatedNotes?.length) {
|
||||
if (!payload || typeof payload.note !== "string" || !payload.note.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (!input.conversationUpdatedNotes.includes(payload.note)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createRefreshThrottleState(): RefreshThrottleState {
|
||||
return {
|
||||
lastRefreshAt: null,
|
||||
pending: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function planThrottledRefresh(
|
||||
state: RefreshThrottleState,
|
||||
now: number,
|
||||
throttleMs: number,
|
||||
): RefreshThrottleDecision {
|
||||
if (state.pending) {
|
||||
return { type: "skip" };
|
||||
}
|
||||
if (state.lastRefreshAt === null || now - state.lastRefreshAt >= throttleMs) {
|
||||
state.lastRefreshAt = now;
|
||||
return { type: "refresh_now" };
|
||||
}
|
||||
state.pending = true;
|
||||
return {
|
||||
type: "schedule_refresh",
|
||||
delayMs: Math.max(0, throttleMs - (now - state.lastRefreshAt)),
|
||||
};
|
||||
}
|
||||
|
||||
export function markScheduledRefreshExecuted(state: RefreshThrottleState, now: number) {
|
||||
state.lastRefreshAt = now;
|
||||
state.pending = false;
|
||||
}
|
||||
|
||||
export function cancelScheduledRefresh(state: RefreshThrottleState) {
|
||||
state.pending = false;
|
||||
}
|
||||
Reference in New Issue
Block a user