Ctrl K
ComunidadGuías…

hooks/use-timeline.ts

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"use client";

import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import type { MetisRuntimeEvent } from "@/lib/runtime/events";
import type { TimelineState, TimelineItem } from "@/lib/timeline/reducer";
import { initialTimelineState, reduceTimelineBatch } from "@/lib/timeline/reducer";

interface UseTimelineOptions {
  chatId: string;
  initialState?: TimelineState;
  batchWindowMs?: number;
  maxItems?: number;
}

interface UseTimelineReturn {
  state: TimelineState;
  items: TimelineItem[];
  subscribe: (events: Array<{ event: MetisRuntimeEvent; sequence: number }>) => void;
  replay: (afterSequence: number) => void;
  clear: () => void;
}

function createTimelineStore(initialState: TimelineState) {
  let state = initialState;
  const listeners = new Set<() => void>();
  const emit = () => listeners.forEach((listener) => listener());
  return {
    getSnapshot: () => state,
    getServerSnapshot: () => initialTimelineState,
    subscribe: (callback: () => void) => {
      listeners.add(callback);
      return () => listeners.delete(callback);
    },
    addEvents(events: Array<{ event: MetisRuntimeEvent; sequence: number }>) {
      if (!events.length) return;
      state = reduceTimelineBatch(state, events);
      emit();
    },
    clear() {
      state = { ...initialTimelineState, pendingDeltas: new Map(), pendingTools: new Map() };
      emit();
    },
  };
}

export function useTimeline({ chatId, initialState, maxItems = 500 }: UseTimelineOptions): UseTimelineReturn {
  const holder = useRef<{ chatId: string; store: ReturnType<typeof createTimelineStore> } | null>(null);
  if (!holder.current || holder.current.chatId !== chatId) {
    holder.current = { chatId, store: createTimelineStore(initialState || initialTimelineState) };
  }
  const store = holder.current.store;
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);

  useEffect(() => {
    if (!chatId) return;
    let source: EventSource | null = null;
    let stopped = false;
    const open = () => {
      if (stopped) return;
      const after = store.getSnapshot().lastEventSequence;
      source = new EventSource(`/api/runtime/events?chatId=${encodeURIComponent(chatId)}&after=${after}`);
      source.addEventListener("runtime", (raw) => {
        const message = raw as MessageEvent<string>;
        try {
          const event = JSON.parse(message.data) as MetisRuntimeEvent;
          const sequence = Number(message.lastEventId || 0);
          if (!Number.isFinite(sequence) || sequence <= store.getSnapshot().lastEventSequence) return;
          store.addEvents([{ event, sequence }]);
        } catch {
          // A malformed transport event must not poison the timeline store.
        }
      });
    };
    open();
    return () => {
      stopped = true;
      source?.close();
      source = null;
    };
  }, [chatId, store]);

  const subscribe = useCallback(
    (events: Array<{ event: MetisRuntimeEvent; sequence: number }>) => store.addEvents(events),
    [store],
  );
  const replay = useCallback((afterSequence: number) => {
    // Reconnect semantics are cursor-based. Consumers can move the local cursor
    // backward explicitly by clearing and letting SSE replay durable run_events.
    if (afterSequence <= 0) store.clear();
  }, [store]);
  const clear = useCallback(() => store.clear(), [store]);

  return { state, items: state.items.slice(-maxItems), subscribe, replay, clear };
}

export function useTimelineSelector<T>(state: TimelineState, selector: (state: TimelineState) => T): T {
  return selector(state);
}