Integrate Ask AI with upcoming insights by passing visitor session IDs (#4027)

This commit is contained in:
Samy Pessé
2026-02-19 14:36:23 +01:00
committed by GitHub
parent 2e495cb28a
commit ae30a576e8
9 changed files with 99 additions and 48 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Integrate Ask AI with upcoming insights by passing visitor session IDs.
+2 -2
View File
@@ -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"],
+1 -1
View File
@@ -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",
@@ -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,
}
);
@@ -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,
@@ -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 (
<ToolbarControlsWrapper value={toolbarControls}>
<AuthenticatedUserToolbar
@@ -8,7 +8,7 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import { getAllBrowserCookiesMap } from '@/lib/browser';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
import { getSession } from './sessions';
import { type SessionResponse, useVisitorSession } from './visitorId';
import { type VisitorResponse, useVisitor } from './visitorId';
export type InsightsEventName = api.SiteInsightsEvent['type'];
@@ -68,7 +68,7 @@ interface InsightsProviderProps {
export function InsightsProvider(props: InsightsProviderProps) {
const { enabled, children, eventUrl } = props;
const visitorSession = useVisitorSession();
const visitor = useVisitor();
const currentContent = useCurrentContent();
const eventsRef = React.useRef<{
[pathname: string]:
@@ -86,7 +86,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
*/
const flushEventsSync = useEventCallback(() => {
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<InsightsEventName>[];
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(),
@@ -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<SessionResponse> | null;
const visitorStore = createStore<{
visitor: VisitorResponse | null;
pendingVisitor: Promise<VisitorResponse> | 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<VisitorResponse> {
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<SessionResponse> {
}): Promise<VisitorResponse> {
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;
}
@@ -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}
>
<VisitorSessionProvider
<VisitorProvider
appURL={GITBOOK_APP_URL}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
@@ -87,7 +87,7 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
{children}
</AIChatProvider>
</InsightsProvider>
</VisitorSessionProvider>
</VisitorProvider>
</CurrentContentProvider>
</AdaptiveVisitorContextProvider>
</SpaceLayoutContextProvider>