File size: 7,681 Bytes
5f40163 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | import { create } from "zustand";
import { OpenHandsEvent } from "#/types/agent-server/core";
import {
handleEventForUI,
isSameStreamingSender,
mergeStreamingDeltaEvent,
} from "#/utils/handle-event-for-ui";
import { isStreamingDeltaEvent } from "#/types/agent-server/type-guards";
export type OHEvent = OpenHandsEvent & {
isFromPlanningAgent?: boolean;
};
const getEventId = (event: OHEvent): string | number | undefined =>
"id" in event ? event.id : undefined;
const getEventTimestamp = (event: OHEvent): string | undefined =>
"timestamp" in event ? event.timestamp : undefined;
/**
* Compare two events by timestamp for sorting.
* Events without timestamps are placed at the end.
*/
const compareEventsByTimestamp = (a: OHEvent, b: OHEvent): number => {
const timestampA = getEventTimestamp(a);
const timestampB = getEventTimestamp(b);
// Events without timestamps go to the end
if (!timestampA && !timestampB) return 0;
if (!timestampA) return 1;
if (!timestampB) return -1;
// Compare ISO timestamp strings (lexicographic comparison works for ISO format)
return timestampA.localeCompare(timestampB);
};
/**
* Check if the new event needs sorting (i.e., it's out of order).
* Returns true if the new event's timestamp is earlier than the last event's timestamp.
*/
const needsSorting = (events: OHEvent[], newEvent: OHEvent): boolean => {
if (events.length === 0) return false;
const lastEvent = events[events.length - 1];
const lastTimestamp = getEventTimestamp(lastEvent);
const newTimestamp = getEventTimestamp(newEvent);
// If either event doesn't have a timestamp, don't sort
if (!lastTimestamp || !newTimestamp) return false;
// Sort needed if new event's timestamp is earlier than last event's timestamp
return newTimestamp < lastTimestamp;
};
export interface EventState {
events: OHEvent[];
eventIds: Set<string | number>;
uiEvents: OHEvent[];
/**
* The conversation whose events currently populate the store. The store is
* global (not keyed by conversation), so the conversation route uses this to
* tell a genuine conversation switch apart from a remount of the *same*
* conversation (e.g. navigating to Settings and back) — only the former
* should clear the accumulated events.
*/
loadedConversationId: string | null;
addEvent: (event: OHEvent) => void;
/**
* Bulk-insert events. Used for the initial REST history load and for
* "scroll up to load older" pagination. Newly-added events are de-duped
* against the existing store and the combined list is re-sorted by
* timestamp so older pages drop into the correct position.
*/
addEvents: (events: OHEvent[]) => void;
/**
* Clear all events. Also resets `loadedConversationId` to `null` so the
* store never claims to hold a conversation whose events have been wiped —
* the invariant (`loadedConversationId` reflects the conversation whose
* events are in the arrays) holds even for a standalone clear.
*/
clearEvents: () => void;
/**
* Atomically clear all events and record which conversation is now loaded.
* Collapsing the reset and the bookkeeping into a single `set` keeps the
* store invariant enforced at the boundary, rather than relying on every
* call-site to invoke a clear and a `loadedConversationId` setter in the
* right order.
*/
clearEventsForConversation: (conversationId: string | null) => void;
}
const appendEvent = (state: EventState, event: OHEvent): EventState => {
const eventId = getEventId(event);
// Transient deltas merge by position and are never persisted/resent, so skip
// id tracking for them — copying the growing `eventIds` Set per token would
// otherwise be O(n^2).
const isDelta = isStreamingDeltaEvent(event);
// Deduplicate: skip if event with same id already exists (O(1) lookup)
if (!isDelta && eventId !== undefined && state.eventIds.has(eventId)) {
return state;
}
const newEventIds =
!isDelta && eventId !== undefined
? new Set(state.eventIds).add(eventId)
: state.eventIds;
const lastEventIndex = state.events.length - 1;
const lastEvent = state.events[lastEventIndex];
const shouldMergeStreamingDelta =
lastEvent &&
isDelta &&
isStreamingDeltaEvent(lastEvent) &&
isSameStreamingSender(event, lastEvent);
const events = [...state.events];
if (shouldMergeStreamingDelta) {
events[lastEventIndex] = mergeStreamingDeltaEvent(event, lastEvent);
} else {
events.push(event);
}
return {
...state,
events,
eventIds: newEventIds,
uiEvents: handleEventForUI(event, state.uiEvents),
};
};
const sortEventState = (state: EventState): EventState => ({
...state,
events: [...state.events].sort(compareEventsByTimestamp),
uiEvents: [...state.uiEvents].sort(compareEventsByTimestamp),
});
const applyAddEvent = (state: EventState, event: OHEvent): EventState => {
const next = appendEvent(state, event);
if (next === state) {
return state;
}
if (
!needsSorting(state.events, event) &&
!needsSorting(state.uiEvents, event)
) {
return next;
}
return sortEventState(next);
};
export const useEventStore = create<EventState>()((set) => ({
events: [],
eventIds: new Set(),
uiEvents: [],
loadedConversationId: null,
addEvent: (event: OHEvent) => set((state) => applyAddEvent(state, event)),
addEvents: (incoming: OHEvent[]) =>
set((state) => {
if (incoming.length === 0) return state;
const eventIds = new Set(state.eventIds);
const events = [...state.events];
let uiEvents = [...state.uiEvents];
let added = false;
for (const event of incoming) {
const eventId = getEventId(event);
// See `appendEvent`: transient deltas are not tracked in `eventIds`.
const isDelta = isStreamingDeltaEvent(event);
const isDuplicate =
!isDelta && eventId !== undefined && eventIds.has(eventId);
if (!isDuplicate) {
added = true;
if (!isDelta && eventId !== undefined) {
eventIds.add(eventId);
}
const lastEventIndex = events.length - 1;
const lastEvent = events[lastEventIndex];
if (
lastEvent &&
isStreamingDeltaEvent(event) &&
isStreamingDeltaEvent(lastEvent) &&
isSameStreamingSender(event, lastEvent)
) {
events[lastEventIndex] = mergeStreamingDeltaEvent(event, lastEvent);
} else {
events.push(event);
}
uiEvents = handleEventForUI(event, uiEvents);
}
}
if (!added) {
return state;
}
return sortEventState({
...state,
events,
eventIds,
uiEvents,
});
}),
clearEvents: () =>
set(() => ({
events: [],
eventIds: new Set(),
uiEvents: [],
loadedConversationId: null,
})),
clearEventsForConversation: (conversationId: string | null) =>
set(() => ({
events: [],
eventIds: new Set(),
uiEvents: [],
loadedConversationId: conversationId,
})),
}));
// In dev builds, expose the store on `window` so that fixture/preview
// scripts (e.g. .pr/issue-132 demo capture) can inject synthetic events
// without round-tripping through the agent-server. Tree-shaken in
// production builds via `import.meta.env.DEV`.
if (
typeof window !== "undefined" &&
typeof import.meta !== "undefined" &&
(import.meta as { env?: { DEV?: boolean } }).env?.DEV
) {
(
window as unknown as { __OH_EVENT_STORE__?: typeof useEventStore }
).__OH_EVENT_STORE__ = useEventStore;
}
|