mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Integrate Ask AI with upcoming insights by passing visitor session IDs (#4027)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Integrate Ask AI with upcoming insights by passing visitor session IDs.
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user