'use client' /** * Chat runtime context — single seam for embedding the chat panel in a * different host (e.g. user1.openframe.ai reverse-proxying API calls * under /api/mingo-guide/* to hub.openframe.ai/api/*). * * Three concerns, one context: * 1. API endpoints: chatStreamUrl / approvalToolUrl / commandsUrl / * buildListUrl + attachment endpoints + chat-identity. The chat * reads them from runtime; hub vs embedded app supply different * strings via different providers. * 2. Navigation mode + callbacks: 'host' or 'embed' mode. Host wires * its own router/docNav via the optional `navigate` callback * (plain function, NOT a hook); embed forces new-tab via * `defaultContentOrigin` + lib's `resolveExternalNavigation`. * 3. Identity context: only `source` (required for localStorage * namespacing). The display identity (greeting first-name etc.) * comes from the server via `useChatIdentity()` — never injected * client-side, so it always matches the server-resolved auth. * * Sibling of EndpointsRuntimeContext (announcement bar, contact form, * access codes). Each runtime stays an independent React context so * embedders can opt into either feature without forcing the other. * * IMPORTANT for embedders: memoize the value passed to * `` (e.g. via React.useMemo). * Every change to its reference identity invalidates downstream * `useMemo` consumers (the chat input's slash-commands binding, * useNavLink's embed-resolution memo, useDocChat's streamFn factory). * The hub's `` already memoizes correctly with * stable deps. Embedded apps that build the value inline on each render * will pay an avoidable re-render cost across the entire chat tree. */ import { createContext, useContext, type ReactNode } from 'react' import type { ComposeContentUrl } from '../utils/content-href' /** * Runtime config consumed by the chat panel. */ export interface ChatRuntime { endpoints: { /** POST streaming chat. Hub: '/api/docs/chat'. */ chatStreamUrl: string /** POST agent approve/reject. Hub: '/api/chat/agent/confirm-tool'. */ approvalToolUrl: string /** Customer-ticket agent endpoints (Help Center). OPTIONAL — when unset, * the ticket hooks fall back to the bare hub paths * (`/api/chat/agent/{find-ticket,ticket-action,list-engagements}`). * Embedders behind a reverse proxy set these to their proxied paths * (e.g. `/content/api/chat/agent/...`) so tickets route through the SAME * endpoint config + proxy as every other endpoint. */ findTicketUrl?: string ticketActionUrl?: string listEngagementsUrl?: string /** Ticket live-stream + read-receipt endpoints (Help Center * realtime). OPTIONAL — unset → bare hub paths under the DEDICATED * ticket surface (`/api/tickets/{stream,read}` — deliberately NOT * the chat agent's `/api/chat/agent/*` prefix). Reverse-proxy * embedders set their proxied paths. Consumed by * `TicketLiveProvider`. The unread summary has NO endpoint — it * arrives as `ticket-summary` frames on the stream and in * `ticket-read` responses. */ ticketStreamUrl?: string ticketReadUrl?: string /** GET slash-command catalog. Hub: '/api/docs/commands'. */ commandsUrl: string /** GET server-side conversation history (`?conversationId=`) — the * chat panel's mount-time hydration read against the server transcript * store (localStorage keeps only the server-issued conversation id). * OPTIONAL — defaults to `/history`, which is correct for * same-origin hosts AND for reverse-proxy embedders (the proxied chat * prefix covers it). Set explicitly only when the history route lives * elsewhere. */ chatHistoryUrl?: string /** GET RAG-search endpoint behind `` (the in-source search * bar mounted by `` / `` when `showAIChat` is on). * Hub: '/api/docs/search'. OPTIONAL — falls back to the hub path so * same-origin Next.js hosts don't need to set it. Cross-origin embedders * set their proxied path so the search bar routes through the same * reverse proxy as everything else. Same pattern as `findTicketUrl`. */ docsSearchUrl?: string /** POST internal-link resolver. The in-source markdown renderer (lib or * custom) calls ``'s `handlers.onResolveLink(href, currentPath)` * for relative hrefs like `./getting-started/intro.md` — that callback * posts to this URL with `{ link, currentPath, source }` and expects a * `ResolveLinkResult` back. Hub: '/api/docs/resolve-link'. OPTIONAL — same * fall-back chain as `docsSearchUrl`: prop override → runtime → default. */ docsResolveLinkUrl?: string /** GET per-platform empty-state config (admin-edited in * `/admin/chat-config`): `{ greeting, enabledRagTableIds, suggestedQueries }`. * Hub: '/api/docs/empty-state'. OPTIONAL — the in-app (host-mode) chat * injects these values as SSR props instead, so it leaves this unset. * Cross-origin EMBEDDERS (no server hop) set it to their proxied path * (e.g. '/content/api/docs/empty-state') so `` can fetch * the greeting / quick-action chips / RAG-source filter at runtime. When * unset, the chat falls back to the explicit `emptyStateGreeting` / * `suggestedQueries` / `enabledRagTableIds` props (or in-code defaults). */ emptyStateUrl?: string /** Build the per-agent display-config URL for an OpenFrame AI agent * (Fae/Mingo). OPTIONAL. The returned endpoint MUST be byte-compatible * with the empty-state wire shape ({ greeting, suggestedQueries, * enabledRagTableIds }). When set AND `EmbeddableChat` receives an * `activeAgentSlug` in embed mode, the chat fetches THIS url instead of * `emptyStateUrl` to render the selected agent's greeting + suggested * prompts (the "agent mode" URL override). Hub: `(slug) => * '/api/ai-agents/' + encodeURIComponent(slug)`. */ aiAgentConfigUrl?: (slug: string) => string /** Build entity-card list URL for a content type + ids. Hub delegates * to the rag-table-config registry; embedded app provides its own * per-type URL builder against the reverse proxy. Returns null when * the type has no list endpoint (caller skips rendering). */ buildListUrl: (type: string, ids: string[]) => string | null /** Chat-attachment endpoints — added for the v2 attachment feature. * * Three concerns: * - `attachmentUploadUrl` — POSTed by the chat-attachment hook * to mint a Supabase signed-upload-URL + HMAC view token. * - `attachmentViewUrlPrefix` — embedded in markdown URLs the * chat hosts in user message bubbles (`![]()` / `[Attached]`). * Stored in chat history; chosen at SEND time. In host mode the * relative `/api/storage/view/chat-attachments/` is sufficient * (same-origin); embedders supply an absolute hub URL so the * browser can fetch cross-origin. * - `identityUrl` — GET endpoint the `useChatIdentity` hook * hits to learn the `{authTier, source, attachmentsEnabled}` * capability bag for the current session. Used beyond chat * (tickets / contact form / any embedded surface that needs * to identify the proxied customer), so the name has no * "chat" prefix even though the consuming hook still does. */ attachmentUploadUrl: string attachmentViewUrlPrefix: string identityUrl: string /** Optional URL prefix for the image proxy (`?url=`). * When unset, lib's `getProxiedImageUrl` returns the original URL * unchanged. Hub default: '/api/image-proxy'. Embedders that don't * host an image-proxy route leave this undefined → images load * directly cross-origin (CORS-permitting). */ imageProxyUrlPrefix?: string /** Optional list of hostnames that should bypass the image proxy * (rendered direct). Hub uses ['openmsp.ai']; embedders typically * leave it unset. Matches the `skipDomains` parameter of * `getProxiedImageUrl`. */ imageProxySkipDomains?: string[] /** Optional base URL for the branded og-placeholder image route — the * DEFAULT cover-image fallback for entity cards with no image. The lib * appends `?title=…` (+ `w`/`h` for square slots) itself, so this is * the base, NOT a full URL: relative (`/api/og-placeholder`) for same- * origin hosts, or the proxied path (`/content/api/og-placeholder`) for * cross-origin embedders. May carry baked-in query params (preserved when * the lib layers `title`/dimensions on top) — but per-platform brand * colors are NO LONGER baked here; the `/api/og-placeholder` route * resolves them server-side from the platform. Most hosts leave this unset * and let the lib derive the base from `imageProxyUrlPrefix`. * * OPTIONAL — when unset the lib derives the base from the sibling * `imageProxyUrlPrefix` (same API base, route name swapped), then falls * back to the relative `/api/og-placeholder`. So an embedder that already * proxies images needs NO og-placeholder wiring. See * `resolveOgPlaceholderBase` / `buildOgPlaceholderUrl` in `../utils`. */ ogPlaceholderUrl?: string /** Base URL prefix for the captions route (`/api/captions` — the native * `` VTT endpoint every video surface uses since captions stopped * being burned into video pixels). Plain path base, no query params. * Embedders point it at their proxied route (e.g. `/content/api/captions`); * unset ⇒ the same-origin relative `/api/captions` (the hub). Consumed via * `getEntityCaptionUrls` / `rebaseCaptionsUrl` in * `components/features/captions-url.ts`. */ captionsUrlPrefix?: string /** Supabase storage origin (e.g. `https://xyz.supabase.co`) — used * by `useVideoWarmup` to scope the `` * hint to MP4s the deployment actually hosts. Hub wires it via * `getSupabaseStorageOrigin()`; embedders without a Supabase * storage origin leave it unset (preload is then skipped; Mux/ * YouTube preconnect still fires). */ supabaseStorageOrigin?: string } navigation: { /** ONE knob, two behaviors: * - 'host' = use the host page's existing click-routing untouched. * The chat panel calls `navigate?.()` for in-app routing. * - 'embed' = guest inside another app: short-circuit at the top * of click handlers to force new-tab + absolutize via * resolveExternalNavigation. */ mode: 'host' | 'embed' /** Embed-only fallback origin for relative URLs whose target platform * can't be inferred. Used by resolveExternalNavigation when * `targetPlatform` is null — without this, a relative `/foo` href would * window.open against the embedder's origin, which is WRONG. * Set to your content host (e.g. 'https://hub.openframe.ai'). * Required by the embedded app whenever mode='embed'. */ defaultContentOrigin?: string /** Override for opening external URLs. MUST BE SYNCHRONOUS — * Safari/Firefox block popups opened outside a direct user gesture. * Default: window.open(href, '_blank', 'noopener,noreferrer'). */ openExternal?: (href: string) => void /** Optional in-app navigation callback (host-mode only). * Returns `true` if the host handled the click in-app * (router.push + docNav.navigate); returns `false`, `undefined`, * or `void` → lib falls back to window.location.assign(href). * Hub wires this via HubRuntimeProvider's HubNavigationWiring; * embedders not in Next.js leave it undefined. */ navigate?: (input: { href: string; path?: string | null; targetPlatform?: string | null }) => boolean | void /** Optional new-tab decision callback. Returns true → lib opens in * new tab; false → same tab via `navigate`. Hub wires the existing * `decideNewTab` logic from use-nav-link.tsx (re-imports the pure * helper from lib). Embedders may omit; lib defaults to: * same-origin/same-platform → same tab, else new tab. */ decideNewTab?: (args: { href: string; targetPlatform?: string | null }) => boolean } /** Optional content-URL composer. Returns the platform-aware href + * target-platform tuple for a content entity. Hub wires this to its * `buildContentURL(type, slug, extractPrimaryPlatform(platforms))` * pipeline so the lib catalog/detail views can derive cross- * platform hrefs without knowing the hub's platform topology * (openmsp.ai / openframe.app / flamingo.run / tmcg). * * THE single content-href authority for every embeddable surface — page * views (onboarding catalog/detail, releases) AND chat cards / chips / * search results all resolve content links through this one seam, so a * given type lands in the SAME place regardless of where it's rendered. * Embedders wire `makeComposeContentUrl({ hostedTypes, contentOrigin })`; * omit it and lib views fall back to a same-origin relative path * (`buildDefaultHref`). * * Takes a single `ComposeContentUrlInput`: `type` + `identifier` (page * views pass the slug; chat rows pass the id + `externalUrl`, whose path * yields the slug for in-app routing) + optional `platforms` / * `externalUrl` / `targetPlatform`. */ composeContentUrl?: ComposeContentUrl /** Per-`documentType` doc-viewer targets — the UNIFIED, DYNAMIC replacement for * the single `chipBasePlatform` prop. Maps a doc-table documentType * (`'markdown'`, `'data_room_doc'`, …) → `{ platform, basePath }` for the PUBLIC * doc viewer that hosts it. Doc chips with no `externalUrl` resolve PER ROW to * `getBaseUrl(platform)//`, so a chat mixing several doc sources * sends EACH to its own home (markdown→flamingo/knowledge-base, * data_room_doc→company-hub/data-room) instead of one static fallback. The hub * may keep using `chipBasePlatform` (one doc source per platform); embedders that * surface multiple doc sources wire this. Threaded into `resolveSourceRowCTA`. */ docPlatformTargets?: Record /** Chat source / platform identifier — OPTIONAL. The hub sets it from * `currentPlatform()`; EMBEDDERS leave it unset and stay platform-agnostic. * * It is NOT required for chat to work. The wire resolves source server-side * (`/docs/chat|search|commands` reject any client `source`); the * same-tab-vs-new-tab link decision falls back to an origin comparison when * it's absent (`decideNewTab` → `isCrossOriginUrl`); and the localStorage * history namespace falls back to a stable constant. Set it only where the * client legitimately needs to know its platform a priori — i.e. the hub, * where several platforms share related origins so "same platform" can't be * inferred from a URL alone. */ source?: string // NOTE: No `user` field. The chat's display identity (greeting // first-name, etc.) comes from the SERVER-resolved auth via // `useChatIdentity()` — the same identity the server uses to // authorize requests. Letting embedders pass a client-side `user` // would let it desync from the actual auth tier, causing greetings // like "Hey Bob" while the server treats the session as // alice@example.com. Single source of truth: the server. } export const ChatRuntimeContext = createContext(null) /** * Returns the active runtime, or null when no provider is mounted. * NULL is a first-class value — it signals "no chat runtime configured." * Optional consumers fall back to no-op behavior; strict consumers * use `useRequiredChatRuntime` (below). */ export function useChatRuntime(): ChatRuntime | null { return useContext(ChatRuntimeContext) } /** * Strict variant used INSIDE the chat panel. Throws if no provider. * The hub guarantees one exists by mounting `` at * root; the embedded app mounts its own `` * at the tree root. In Jest / Storybook tests that render chat * internals directly, wrap with `` (hub defaults) * or supply ``. */ export function useRequiredChatRuntime(): ChatRuntime { const v = useContext(ChatRuntimeContext) if (!v) { throw new Error( '[chat-runtime] hook called outside a . ' + 'The hub mounts at root — this only fires when ' + 'chat internals are rendered above the provider tree. ' + 'Fix: ensure the rendering subtree descends from the runtime provider. ' + 'In tests/Storybook: wrap with or supply ' + 'a .', ) } return v }