From ae30a576e855007d714206ab5b2d649dc4c8bd9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Thu, 19 Feb 2026 14:36:23 +0100 Subject: [PATCH] Integrate Ask AI with upcoming insights by passing visitor session IDs (#4027) --- .changeset/metal-cloths-worry.md | 5 + bun.lock | 4 +- package.json | 2 +- .../src/components/AI/server-actions/chat.ts | 4 + .../gitbook/src/components/AI/useAIChat.tsx | 7 +- .../AdminToolbar/AdminToolbarClient.tsx | 6 +- .../components/Insights/InsightsProvider.tsx | 16 +-- .../src/components/Insights/visitorId.tsx | 97 +++++++++++++------ .../components/SpaceLayout/SpaceLayout.tsx | 6 +- 9 files changed, 99 insertions(+), 48 deletions(-) create mode 100644 .changeset/metal-cloths-worry.md diff --git a/.changeset/metal-cloths-worry.md b/.changeset/metal-cloths-worry.md new file mode 100644 index 000000000..bee9e0c9e --- /dev/null +++ b/.changeset/metal-cloths-worry.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Integrate Ask AI with upcoming insights by passing visitor session IDs. diff --git a/bun.lock b/bun.lock index 64cbc4b7a..2fd6c1efc 100644 --- a/bun.lock +++ b/bun.lock @@ -347,7 +347,7 @@ "react-dom": "catalog:", }, "catalog": { - "@gitbook/api": "0.161.0", + "@gitbook/api": "0.162.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -744,7 +744,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="], - "@gitbook/api": ["@gitbook/api@0.161.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-EVg5XVoyevO9FTGQbFoW+FjN2tg77eMyufXIDF00rCi2wXrxLNvAuWEfs605ETKFY+JHEbecHswMUG8luBcaNQ=="], + "@gitbook/api": ["@gitbook/api@0.162.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-fQtilVcAZJ832dE4dWNsxwDuLEJow0bCScyxFX/MGJszRXh6GQMSD3DFE821HiVsdlNmGLC8wu5fcu932RXR1g=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index a1b6c2bb1..c16d4bb8a 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "catalog": { "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", - "@gitbook/api": "0.161.0", + "@gitbook/api": "0.162.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/src/components/AI/server-actions/chat.ts b/packages/gitbook/src/components/AI/server-actions/chat.ts index 8a81ffc80..eaf4bcded 100644 --- a/packages/gitbook/src/components/AI/server-actions/chat.ts +++ b/packages/gitbook/src/components/AI/server-actions/chat.ts @@ -9,6 +9,7 @@ import { AIModel, type AIToolCallResult, type AIToolDefinition, + type SiteInsightsSession, } from '@gitbook/api'; import { streamRenderAIMessage } from './api'; import type { RenderAIMessageOptions } from './types'; @@ -22,6 +23,7 @@ export async function* streamAIChatResponse({ previousResponseId, toolCall, tools, + session, options, }: { message?: string; @@ -29,6 +31,7 @@ export async function* streamAIChatResponse({ previousResponseId?: string; toolCall?: AIToolCallResult; tools?: AIToolDefinition[]; + session: SiteInsightsSession; options?: RenderAIMessageOptions; }) { const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => { @@ -57,6 +60,7 @@ export async function* streamAIChatResponse({ previousResponseId, toolCall, tools, + session, } ); diff --git a/packages/gitbook/src/components/AI/useAIChat.tsx b/packages/gitbook/src/components/AI/useAIChat.tsx index 98f770e38..5d92dfc40 100644 --- a/packages/gitbook/src/components/AI/useAIChat.tsx +++ b/packages/gitbook/src/components/AI/useAIChat.tsx @@ -11,7 +11,8 @@ import { } from '@gitbook/api'; import type { IconName } from '@gitbook/icons'; import * as React from 'react'; -import { useTrackEvent } from '../Insights'; +import { getVisitor, useTrackEvent } from '../Insights'; +import { getSession } from '../Insights/sessions'; import { integrationsAssistantTools } from '../Integrations'; import { useSetSearchState } from '../Search'; import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions'; @@ -269,6 +270,10 @@ export function AIChatProvider(props: { toolCall: input.toolCall, messageContext: messageContextRef.current, previousResponseId: globalState.getState().responseId ?? undefined, + session: { + sessionId: getSession().id, + visitorId: (await getVisitor()).deviceId, + }, tools: integrationTools.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index d1a3b5a14..3b7a2636a 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -2,7 +2,7 @@ import { Icon } from '@gitbook/icons'; import { MotionConfig, motion } from 'motion/react'; import { useCheckForContentUpdate } from '../AutoRefreshContent'; -import { useVisitorSession } from '../Insights'; +import { useVisitor } from '../Insights'; import { useCurrentPagePath } from '../hooks'; import { HideToolbarButton } from './HideToolbarButton'; import { IframeWrapper } from './IframeWrapper'; @@ -40,7 +40,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) { onToggleMinify, }); - const visitorSession = useVisitorSession(); + const visitor = useVisitor(); const toolbarControls: ToolbarControlsContextValue = { minimize, @@ -80,7 +80,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) { } // If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar - if (visitorSession?.organizationId === context.organizationId) { + if (visitor?.organizationId === context.organizationId) { return ( { const session = getSession(); - if (!visitorSession) { + if (!visitor) { return; } @@ -107,7 +107,7 @@ export function InsightsProvider(props: InsightsProviderProps) { events: eventsForPathname.events, context: currentContent, pageContext: eventsForPathname.pageContext, - visitorSession, + visitor: visitor, sessionId: session.id, }) ); @@ -140,10 +140,10 @@ export function InsightsProvider(props: InsightsProviderProps) { // Flush pending events once the visitor session has been fetched React.useEffect(() => { - if (visitorSession) { + if (visitor) { flushEventsSync(); } - }, [visitorSession, flushEventsSync]); + }, [visitor, flushEventsSync]); const trackEvent: TrackEventCallback = useEventCallback( ( @@ -241,12 +241,12 @@ function transformEvents(input: { events: TrackEventInput[]; context: CurrentContentContext; pageContext: InsightsEventPageContext; - visitorSession: SessionResponse; + visitor: VisitorResponse; sessionId: string; }): api.SiteInsightsEvent[] { const session: api.SiteInsightsEventSession = { sessionId: input.sessionId, - visitorId: input.visitorSession.deviceId, + visitorId: input.visitor.deviceId, userAgent: window.navigator.userAgent, language: window.navigator.language, cookies: getAllBrowserCookiesMap(), diff --git a/packages/gitbook/src/components/Insights/visitorId.tsx b/packages/gitbook/src/components/Insights/visitorId.tsx index eeaaf17ec..9620da6dd 100644 --- a/packages/gitbook/src/components/Insights/visitorId.tsx +++ b/packages/gitbook/src/components/Insights/visitorId.tsx @@ -4,37 +4,47 @@ import { createStore, useStore } from 'zustand'; import { getBrowserCookie } from '@/lib/browser'; +import type { MaybePromise } from 'p-map'; import React from 'react'; import { isCookiesTrackingDisabled } from './cookies'; import { generateRandomId } from './utils'; const VISITORID_COOKIE = '__session'; -type SessionVisitorResponse = { +/** + * Visitor state when the visitor is not a signed-in GitBook user. + */ +type AnyVisitorResponse = { + /** + * A random identifier for the visitor. Resets if the user clears their cookies. + */ deviceId: string; userId?: undefined; organizationId?: undefined; }; -type SessionUserResponse = SessionVisitorResponse & { +/** + * Visitor state when the visitor is also a signed-in GitBook user. + */ +type VisitorUserResponse = AnyVisitorResponse & { userId: string; organizationId: string; }; -export type SessionResponse = SessionVisitorResponse | SessionUserResponse; +export type VisitorResponse = AnyVisitorResponse | VisitorUserResponse; -const visitorSessionStore = createStore<{ - session: SessionResponse | null; - pendingSession: Promise | null; +const visitorStore = createStore<{ + visitor: VisitorResponse | null; + pendingVisitor: Promise | null; }>(() => ({ - session: null, - pendingSession: null, + visitor: null, + pendingVisitor: null, })); /** - * Fetch, synchronize the visitor sesion with GitBook. + * Fetch, synchronize the visitor with GitBook. */ -export function VisitorSessionProvider( +export function VisitorProvider( props: React.PropsWithChildren<{ appURL: string; visitorCookieTrackingEnabled: boolean; @@ -43,15 +53,15 @@ export function VisitorSessionProvider( const { appURL, visitorCookieTrackingEnabled, children } = props; React.useEffect(() => { - const state = visitorSessionStore.getState(); - if (state.pendingSession || state.session) { + const state = visitorStore.getState(); + if (state.pendingVisitor || state.visitor) { return; } - const pendingSession = fetchSession({ appURL, visitorCookieTrackingEnabled }); - visitorSessionStore.setState({ pendingSession, session: null }); - pendingSession.then((session) => { - visitorSessionStore.setState({ pendingSession: null, session }); + const pendingVisitor = fetchGlobalVisitor({ appURL, visitorCookieTrackingEnabled }); + visitorStore.setState({ pendingVisitor, visitor: null }); + pendingVisitor.then((visitor) => { + visitorStore.setState({ pendingVisitor: null, visitor }); }); }, [appURL, visitorCookieTrackingEnabled]); @@ -61,29 +71,46 @@ export function VisitorSessionProvider( /** * Hook to get the current visitor session. */ -export function useVisitorSession() { - return useStore(visitorSessionStore, (state) => state.session); +export function useVisitor() { + return useStore(visitorStore, (state) => state.visitor); +} + +/** + * Get the current visitor ID. + */ +export function getVisitor(): MaybePromise { + const state = visitorStore.getState(); + if (state.visitor) { + return state.visitor; + } + if (state.pendingVisitor) { + return state.pendingVisitor; + } + + return { + deviceId: getProposedVisitorId(), + }; } /** * Propose a visitor identifier to the GitBook.com server and get the devideId back. */ -async function fetchSession({ +async function fetchGlobalVisitor({ appURL, visitorCookieTrackingEnabled, }: { appURL: string; visitorCookieTrackingEnabled: boolean; -}): Promise { +}): Promise { const withoutCookies = isCookiesTrackingDisabled(); if (withoutCookies || !visitorCookieTrackingEnabled) { return { - deviceId: generateRandomId(), + deviceId: getProposedVisitorId(), }; } - const { existing, proposedId } = getSessionCookie(); + const { existing, proposedId } = getVisitorFromCookies(); if (existing) { // If the cookie already exists, we'll just use that. Avoids a server request. @@ -100,10 +127,11 @@ async function fetchSession({ credentials: 'include', // Make sure to send/receive cookies. cache: 'no-cache', mode: 'cors', // Need to use cors as we are on a different domain. + signal: AbortSignal.timeout(500), }); - const session = (await resp.json()) as SessionResponse; - return session; + const visitor = (await resp.json()) as VisitorResponse; + return visitor; } catch (error) { console.error('Failed to fetch visitor session ID', error); return { @@ -112,19 +140,28 @@ async function fetchSession({ } } -function getSessionCookie(): { existing: SessionResponse | null; proposedId: string } { - const proposed = generateRandomId(); +/** + * Extract the current visitor from the cookies. + */ +function getVisitorFromCookies(): { existing: VisitorResponse | null; proposedId: string } { + const proposedId = getProposedVisitorId(); const value = getBrowserCookie(VISITORID_COOKIE); if (!(value && typeof value === 'string')) { - return { existing: null, proposedId: proposed }; + return { existing: null, proposedId }; } try { - const parsed = JSON.parse(value) as SessionResponse; - return { existing: parsed, proposedId: proposed }; + const parsed = JSON.parse(value) as VisitorResponse; + return { existing: parsed, proposedId }; } catch { // Ignore legacy __session cookie format // and we'll renew it with the server using the previous one as a proposed ID - return { existing: null, proposedId: proposed }; + return { existing: null, proposedId }; } } + +let proposedIdCache: string | null = null; +function getProposedVisitorId(): string { + proposedIdCache ??= generateRandomId(); + return proposedIdCache; +} diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 8ec08dfdc..0c6b5f8bb 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -19,7 +19,7 @@ import { AIChat } from '../AIChat'; import { AdaptiveVisitorContextProvider } from '../Adaptive'; import { Announcement } from '../Announcement'; import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown'; -import { InsightsProvider, VisitorSessionProvider } from '../Insights'; +import { InsightsProvider, VisitorProvider } from '../Insights'; import { SearchContainer } from '../Search'; import { SiteSectionList, encodeClientSiteSections } from '../SiteSections'; import { CurrentContentProvider } from '../hooks'; @@ -78,7 +78,7 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) { revisionId={context.revisionId} visitorAuthClaims={visitorAuthClaims} > - @@ -87,7 +87,7 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) { {children} - +