Ctrl K
ComunidadGuías…

lib/assistant-chat-context.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ReactNode,
  type Dispatch,
  type SetStateAction,
} from "react";
import { useLocale } from "next-intl";
import { QueryClientContext } from "@tanstack/react-query";
import { HIDDEN_PROJECTS_KEY } from "@/lib/use-hidden-projects";
import { useRouter } from "next/navigation";
import { useTheme } from "@/components/theme-provider";
import { useAssistantChat } from "@/lib/use-assistant-chat";
import { useAssistantPanel, type OpenAssistantOptions } from "@/lib/assistant-panel-context";
import { resolveAssistantScope } from "@/lib/assistant-scope";
import { hasResumableConversation } from "@/lib/assistant-resumable";
import {
  fetchActiveConversation,
  setActiveConversation,
  updateConversation,
} from "@/lib/assistant-api";
import { useAuth } from "@/lib/auth-context";
import { setLocaleCookie } from "@/lib/set-locale";
import { isAccountTheme } from "@/lib/account-theme";
import { resolveThemeAppearance } from "@/lib/theme-presets";
import { applyDocumentAppearance } from "@/lib/theme-appearance";

import type { AssistantPinnedContext } from "@/lib/assistant-types";

type AssistantChatApi = ReturnType<typeof useAssistantChat>;

export interface AssistantChatContextValue extends AssistantChatApi {
  /** Ambient project for the next message, independent of conversation identity. */
  scopeProjectId: string | null;
  restoring: boolean;
  /** Start the one-time active-conversation restore for any mounted surface. */
  requestRestore: () => void;
  isBusy: boolean;
  /** Acknowledge a completed response once its conversation is displayed. */
  markResponseRead: (conversationId: string) => void;
  pinned: AssistantPinnedContext[];
  setPinned: Dispatch<SetStateAction<AssistantPinnedContext[]>>;
}

const AssistantChatContext = createContext<AssistantChatContextValue | null>(
  null,
);

/** The keys of the old localStorage pointer, one per scope. Purged at the
 * first restart: the pointer lives in the base from now on, and leaving them lying around
 * would one day make one believe in a source of truth which is no longer one. */
const LEGACY_STORAGE_PREFIX = "assistant-active-conv-";

function purgeLegacyStorage(): void {
  try {
    const stale: string[] = [];
    for (let i = 0; i < window.localStorage.length; i++) {
      const key = window.localStorage.key(i);
      if (key?.startsWith(LEGACY_STORAGE_PREFIX)) stale.push(key);
    }
    for (const key of stale) window.localStorage.removeItem(key);
  } catch {
    // localStorage unavailable (private browsing, quota) — nothing to purge.
  }
}

/**
 * Keep the conversation and pinned context alive across panel and route changes.
 *
 * `standalone` gives a surface its own conversation, apart from the Numo
 * panel's (Home): it never restores the account's active conversation and
 * never writes that pointer, so neither side takes over the other's thread.
 */
export function AssistantChatProvider({
  children,
  standalone = false,
}: {
  children: ReactNode;
  standalone?: boolean;
}) {
  const { pendingOptions, routeProjectId } = useAssistantPanel();
  const { refreshUser } = useAuth();
  const currentLocale = useLocale();
  const router = useRouter();

  // Numo can modify account settings on the server side (via the admin API),
  // which does not trigger any client auth event — we therefore reload the
  // account, and if it changed the language (a cookie, not user_metadata),
  // apply that change too.
  const { setTheme } = useTheme();
  // Read without throwing: a tree without a QueryClientProvider has no
  // hidden-projects cache to refresh.
  const queryClient = useContext(QueryClientContext);
  const handleToolResult = useCallback(
    (name: string, success: boolean, result: unknown) => {
      if (!success) return;
      if (name !== "update_account_settings") return;
      void refreshUser();
      // Hidden projects (MIN-692) are a table, not the account metadata.
      void queryClient?.invalidateQueries({ queryKey: HIDDEN_PROJECTS_KEY });
      const nextLocale = (result as { settings?: { locale?: string } })
        ?.settings?.locale;
      if (nextLocale && nextLocale !== currentLocale) {
        void setLocaleCookie(nextLocale).then(() => router.refresh());
      }
      // The theme is saved on the account: adopt it immediately (the provider
      // write also refreshes the localStorage cache the pre-paint script and
      // the ThemeProvider read back).
      const nextTheme = (result as { settings?: { theme?: unknown } })
        ?.settings?.theme;
      if (isAccountTheme(nextTheme)) setTheme(nextTheme);
      // Palettes and glass (MIN-696): the settings echo the saved values, so
      // the screen follows without waiting for the next document.
      const settings = (result as { settings?: Record<string, unknown> })?.settings;
      if (settings && "theme_light" in settings) {
        applyDocumentAppearance(document.documentElement, resolveThemeAppearance(settings));
      }
    },
    [refreshUser, currentLocale, router, setTheme, queryClient],
  );

  const {
    state,
    sendMessage: sendMessageRaw,
    loadConversation: loadConversationRaw,
    updateConversationConfig,
    reset: resetRaw,
    retry,
    reconnect,
    resend,
    abort,
  } = useAssistantChat({ onToolResult: handleToolResult });

  /**
 * Has the user ALREADY chosen the conversation they want to see?
 *
 * An INTENTION, not a state reached — and that's the difference. Resume
 * reads this flag to not overwrite a choice made while its GET
 * was still flying. `state.conversationId` cannot play this role: it is worth
 * `null` both when nothing has happened and just after a “new
 * conversation”, and it remains `null` throughout the `loadConversation` of a
 * selection (the dispatch only arrives after reading the messages). In the
 * three cases the recovery believed the field was clear.
 */
  const userPickedRef = useRef(false);
  const [pinned, setPinned] = useState<AssistantPinnedContext[]>([]);

  const loadConversation = useCallback(
    (conversationId: string, projectId: string | null) => {
      userPickedRef.current = true;
      setPinned([]);
      return loadConversationRaw(conversationId, projectId);
    },
    [loadConversationRaw],
  );

  const reset = useCallback((options?: { silent?: boolean }) => {
    userPickedRef.current = true;
    setPinned([]);
    resetRaw(options);
  }, [resetRaw]);

  // Sending also means choosing: the message opens a conversation, and the
  // recovery no longer has to impose another on top.
  const sendMessage = useCallback(
    (...args: Parameters<typeof sendMessageRaw>) => {
      userPickedRef.current = true;
      return sendMessageRaw(...args);
    },
    [sendMessageRaw],
  );

  const isBusy =
    state.status === "streaming" ||
    state.status === "executing_tool" ||
    state.status === "generating_server";

  const [unreadResponses, setUnreadResponses] = useState<
    ReadonlyMap<string, { messageId: string; awaitsAnswer: boolean }>
  >(() => new Map());
  const observedResponses = useRef(new Set<string>());
  useEffect(() => {
    const response = state.completedResponse;
    if (!response) return;
    const key = `${response.conversationId}:${response.messageId}`;
    if (observedResponses.current.has(key)) return;
    observedResponses.current.add(key);
    setUnreadResponses((previous) =>
      new Map(previous).set(response.conversationId, {
        messageId: response.messageId,
        awaitsAnswer: response.awaitsAnswer === true,
      }),
    );
  }, [state.completedResponse]);
  const unreadConversationId = unreadResponses.keys().next().value ?? null;
  const unreadAwaitsAnswer = unreadConversationId
    ? unreadResponses.get(unreadConversationId)?.awaitsAnswer === true
    : false;
  const markResponseRead = useCallback((conversationId: string) => {
    if (!unreadResponses.has(conversationId)) return;
    setUnreadResponses((previous) => {
      const next = new Map(previous);
      next.delete(conversationId);
      return next;
    });
    void updateConversation(conversationId, { read: true }).catch(() => {});
  }, [unreadResponses]);

  // A contextual opening supplies the next message's project until its options
  // are consumed. Navigation supplies ambient context independently of history.
  const overrideProjectId = useMemo<string | null | undefined>(
    () =>
      pendingOptions && "projectId" in pendingOptions
        ? (pendingOptions.projectId ?? null)
        : undefined,
    [pendingOptions],
  );

  const { scopeProjectId: scope } = resolveAssistantScope({
    conversationId: state.conversationId,
    conversationProjectId: state.conversationProjectId,
    routeProjectId,
    overrideProjectId,
  });

  // Restore only once per app session, when either the page or panel first asks
  // for Numo. The active conversation is independent of the current route, so
  // ordinary navigation must not replay the restore.
  const [restoring, setRestoring] = useState(false);
  const [restored, setRestored] = useState(false);
  const [restoreRequested, setRestoreRequested] = useState(false);
  const requestRestore = useCallback(() => {
    if (!standalone) setRestoreRequested(true);
  }, [standalone]);
  /**
  * What the server carries, as far as we know. `undefined` = we don't know
 * yet. It is HE who avoids the two parasitic writes of the recovery:
 * re-write the pointer that we have just read, and above all DELETE the pointer at
 * first rendering, when `conversationId` is still `null`.
 */
  const serverPointerRef = useRef<string | null | undefined>(undefined);
  // Read at lookup completion too: a contextual opening can arrive while the
  // pointer GET is flying. Its prompt, draft or conversation owns the next
  // turn — loading the remembered thread first would only flash it.
  const pendingOptionsRef = useRef<OpenAssistantOptions | null>(null);
  pendingOptionsRef.current = pendingOptions;

  // The restore depends on the request alone. `loadConversationRaw` changes
  // identity with its translator (a locale change Numo itself can make): as
  // a dependency it would replay a finished restore and block the panel again.
  const loadConversationRawRef = useRef(loadConversationRaw);
  loadConversationRawRef.current = loadConversationRaw;

  useEffect(() => {
    if (!restoreRequested) return;
    purgeLegacyStorage();

    let cancelled = false;
    setRestoring(true);
    void (async () => {
      try {
        const { conversationId, projectId } = await fetchActiveConversation();
        serverPointerRef.current = conversationId;
        // The user was able to choose while this GET was flying — selection in
        // history, “new conversation”, immediate sending. His choice
        // wins: we never recover it. `loadConversationRaw`, otherwise the
        // resume would declare itself as a choice. A pending prompt or draft
        // does NOT supersede: it continues the remembered thread, so the
        // resume loads it first and the panel dispatches once restored.
        const pending = pendingOptionsRef.current;
        if (!cancelled && conversationId && !userPickedRef.current) {
          if (pending?.conversationId) {
            // The opening loads its own conversation: loading the remembered
            // one too would only flash the wrong thread.
            serverPointerRef.current = null;
          } else {
            void updateConversation(conversationId, { read: true }).catch(() => {});
            await loadConversationRawRef.current(conversationId, projectId);
          }
        }
      } catch {
        // Unreadable pointer (network): we start with an empty screen, the thread remains
        // reachable via history. The pointer will rewrite itself on the next send.
        serverPointerRef.current = null;
      }
      if (!cancelled) {
        setRestoring(false);
        setRestored(true);
      }
    })();

    return () => {
      cancelled = true;
    };
  }, [restoreRequested]);

  // Mirror the pointer to the server: open a conversation in writing,
  // starting a new one erases it. `restored` is in the dependencies so that
  // the mirror catches up with an open conversation DURING the restart.
  useEffect(() => {
    if (!restored) return;
    const conversationId = state.routineOccurrence
      ? null
      : state.conversationId;
    if (serverPointerRef.current === conversationId) return;
    serverPointerRef.current = conversationId;
    void setActiveConversation(conversationId);
  }, [restored, state.conversationId, state.routineOccurrence]);

  /**
 * The pointer read again WITHOUT opening the panel, for surfaces which must
 * know if there is a thread left to resume — the reception, which only displays its FAB
 * under this condition. The recovery above cannot tell them: it
 * only triggers on the first opening, and on the reception there was
 * none. One reading per session, on demand: who does not ask the question
 * does not pay for the request.
 */
  const [probedConversationId, setProbedConversationId] = useState<
    string | null
  >(null);
  const probeStartedRef = useRef(false);
  const probeActiveConversation = useCallback(() => {
    if (probeStartedRef.current) return;
    probeStartedRef.current = true;
    void fetchActiveConversation()
      .then(({ conversationId }) => setProbedConversationId(conversationId))
      .catch(() => {
        // Unreadable pointer (network): we stick to the live state, like the
        // reprise. Au pire un FAB en moins, jamais un fil perdu.
      });
  }, []);

  const resumable = hasResumableConversation({
    conversationId: state.conversationId,
    messageCount: state.messages.length,
    busy: isBusy,
    probedConversationId,
    restored,
  });

  const resumeValue = useMemo<AssistantResumeValue>(
    () => ({ hasConversation: resumable, probe: probeActiveConversation }),
    [resumable, probeActiveConversation],
  );

  const value = useMemo<AssistantChatContextValue>(
    () => ({
      state,
      sendMessage,
      loadConversation,
      updateConversationConfig,
      reset,
      retry,
      reconnect,
      resend,
      abort,
      scopeProjectId: scope,
      restoring: standalone ? false : restoring || !restored,
      requestRestore,
      isBusy,
      markResponseRead,
      pinned,
      setPinned,
    }),
    [
      state,
      sendMessage,
      loadConversation,
      updateConversationConfig,
      reset,
      retry,
      reconnect,
      resend,
      abort,
      scope,
      standalone,
      restored,
      restoring,
      requestRestore,
      isBusy,
      markResponseRead,
      pinned,
    ],
  );

  return (
    <AssistantChatContext.Provider value={value}>
      <AssistantBusyContext.Provider value={isBusy}>
        <AssistantUnreadResponseContext.Provider value={unreadConversationId}>
          <AssistantUnreadQuestionContext.Provider value={unreadAwaitsAnswer}>
            <AssistantResumeContext.Provider value={resumeValue}>
              {children}
            </AssistantResumeContext.Provider>
          </AssistantUnreadQuestionContext.Provider>
        </AssistantUnreadResponseContext.Provider>
      </AssistantBusyContext.Provider>
    </AssistantChatContext.Provider>
  );
}

/**
 * “Is Numo working?” ", and nothing else (MIN-323).
 *
 * A separate context because its only consumer is the FAB, which only needs
 * ONLY this boolean: subscribed to the complete context, it goes back to each
 * SSE token — `state` changes with each fragment of answer, so the value too.
 * A boolean only changes twice per turn.
 *
 * It only bites CLOSED panel: open panel, the FAB returns `null`. This is
 * precisely the case where the user is looking at something else.
 */
const AssistantBusyContext = createContext(false);

export function useAssistantBusy(): boolean {
  return useContext(AssistantBusyContext);
}

/** Launcher consumers receive a stable unread-conversation ID rather than every streamed token. */
const AssistantUnreadResponseContext = createContext<string | null>(null);

/** Open the oldest conversation with a response that has not been displayed yet. */
export function useAssistantUnreadResponseConversationId(): string | null {
  return useContext(AssistantUnreadResponseContext);
}

const AssistantUnreadQuestionContext = createContext(false);

/** The unread response the launchers point to waits on the user (a question or
    a proposal), not a finished reply: their dot turns orange instead of blue. */
export function useAssistantUnreadAwaitsAnswer(): boolean {
  return useContext(AssistantUnreadQuestionContext);
}

/**
 * “Is there a thread left to pick up?” ", and nothing else — a separate context for
 * the same reason as the previous one: its consumer only needs the boolean,
 * which only changes on both ends of a conversation, not on each token.
 *
 * `probe` requests reading of the server pointer, once per session. It lives
 * here rather than at the caller so that the two surfaces that would ask the question make only one request, and the answer arrives at the same place as the living state with which it combines
 * ([assistant-resumable.ts](assistant-resumable.ts)).
 */
interface AssistantResumeValue {
  hasConversation: boolean;
  probe: () => void;
}

const AssistantResumeContext = createContext<AssistantResumeValue>({
  hasConversation: false,
  probe: () => {},
});

/**
 * Is there a Numo conversation to reopen? Calling this hook DECLARE the need:
 * it triggers reading of the pointer on mount, and returns `false` as long as we don't know — the home prefers a FAB that appears to a FAB that goes out.
 */
export function useResumableConversation(): boolean {
  const { hasConversation, probe } = useContext(AssistantResumeContext);
  useEffect(() => {
    probe();
  }, [probe]);
  return hasConversation;
}

export function useAssistantChatContext(): AssistantChatContextValue {
  const ctx = useContext(AssistantChatContext);
  if (!ctx) {
    throw new Error(
      "useAssistantChatContext must be used within an AssistantChatProvider",
    );
  }
  return ctx;
}