mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-29 21:49:26 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 078eacce45 | |||
| 9f168ff93b | |||
| df2841cf36 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Track which Ask AI entry point opened the chat in ask view analytics.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Keep links working when their target page is deleted and re-created at the same path, by falling back to the path recorded on the link.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the site auth login link sometimes redirecting back to an internal URL after login.
|
||||
@@ -7,7 +7,7 @@ import type { CustomizationAIMode } from '@gitbook/api';
|
||||
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
|
||||
import { type AskAITrigger, useAIChatController, useAIChatState } from '.';
|
||||
import { useAIChatController, useAIChatState } from '.';
|
||||
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
|
||||
import { useIntegrationAssistants } from '../Integrations';
|
||||
import { useSetSearchState } from '../Search/useSearch';
|
||||
@@ -26,8 +26,7 @@ export type AIConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
export type Assistant = Omit<GitBookAssistant, 'icon' | 'open'> & {
|
||||
open: (query?: string, trigger?: AskAITrigger) => void;
|
||||
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
|
||||
/**
|
||||
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
|
||||
* @example '123e4567-e89b-12d3-a456-426614174000'
|
||||
@@ -103,8 +102,8 @@ export function useAI(): AIContext {
|
||||
className="size-text-lg"
|
||||
/>
|
||||
),
|
||||
open: (query?: string, trigger?: AskAITrigger) => {
|
||||
chatController.open(trigger);
|
||||
open: (query?: string) => {
|
||||
chatController.open();
|
||||
if (query) {
|
||||
chatController.postMessage({ message: query });
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SiteInsightsDisplayContext,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { getInsightsSession, trackAskView, useTrackEvent } from '../Insights';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { type UpdateSearchState, useSetSearchState } from '../Search';
|
||||
import { addRecentSearchQuery } from '../Search/recent-queries';
|
||||
import type { AnyAIControl } from './controls';
|
||||
@@ -60,8 +60,6 @@ export type AIChatStatus =
|
||||
| 'error'
|
||||
| 'confirm';
|
||||
|
||||
export type AskAITrigger = 'hover' | 'selection' | 'site' | 'page' | 'embed';
|
||||
|
||||
export type AIChatState = {
|
||||
/**
|
||||
* If true, the chat is open.
|
||||
@@ -162,7 +160,7 @@ type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
|
||||
|
||||
export type AIChatController = {
|
||||
/** Open the dialog */
|
||||
open: (trigger?: AskAITrigger) => void;
|
||||
open: () => void;
|
||||
/** Close the dialog */
|
||||
close: () => void;
|
||||
/** Post a message to the session */
|
||||
@@ -304,28 +302,22 @@ export function AIChatProvider(props: {
|
||||
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
|
||||
|
||||
// Open AI chat and sync with search state
|
||||
const onOpen = React.useCallback(
|
||||
(trigger?: AskAITrigger) => {
|
||||
setIntercomLauncherHidden(true);
|
||||
if (!renderMessageOptions?.asEmbeddable) {
|
||||
trackAskView(trackEvent, trigger);
|
||||
}
|
||||
const onOpen = React.useCallback(() => {
|
||||
setIntercomLauncherHidden(true);
|
||||
|
||||
const { initialQuery } = globalState.getState();
|
||||
globalState.setState((state) => ({ ...state, opened: true }));
|
||||
const { initialQuery } = globalState.getState();
|
||||
globalState.setState((state) => ({ ...state, opened: true }));
|
||||
|
||||
// Update search state to show ask mode with first message or current ask value
|
||||
setSearchState((prev) => ({
|
||||
ask: prev?.ask ?? initialQuery ?? '',
|
||||
query: prev?.query ?? null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
// Update search state to show ask mode with first message or current ask value
|
||||
setSearchState((prev) => ({
|
||||
ask: prev?.ask ?? initialQuery ?? '',
|
||||
query: prev?.query ?? null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
|
||||
notify(eventsRef.current.get('open'), {});
|
||||
},
|
||||
[setSearchState, trackEvent, renderMessageOptions?.asEmbeddable]
|
||||
);
|
||||
notify(eventsRef.current.get('open'), {});
|
||||
}, [setSearchState]);
|
||||
|
||||
// Close AI chat and clear ask parameter
|
||||
const onClose = React.useCallback(() => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
EmbeddableFrameTitle,
|
||||
} from '../Embeddable/EmbeddableFrame';
|
||||
import { useIsMounted, useNow } from '../hooks';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Button } from '../primitives';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
@@ -67,6 +68,16 @@ export function AIChat() {
|
||||
}
|
||||
);
|
||||
|
||||
// Track the view of the AI chat
|
||||
const trackEvent = useTrackEvent();
|
||||
React.useEffect(() => {
|
||||
if (chat.opened) {
|
||||
trackEvent({
|
||||
type: 'ask_view',
|
||||
});
|
||||
}
|
||||
}, [chat.opened, trackEvent]);
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
side="right"
|
||||
|
||||
@@ -39,7 +39,7 @@ export function AIChatButton(props: {
|
||||
</div>
|
||||
}
|
||||
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
|
||||
onClick={() => assistant.open(undefined, 'site')}
|
||||
onClick={() => assistant.open()}
|
||||
>
|
||||
{showLabel ? t(language, 'ask') : null}
|
||||
</Button>
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
|
||||
id: `text-${fnv1a(text, { size: 32 })}`,
|
||||
content: text,
|
||||
});
|
||||
chatController.open('hover');
|
||||
chatController.open();
|
||||
chatController.setDraft(tString(language, 'ai_chat_paragraph_draft'));
|
||||
chatController.focus();
|
||||
};
|
||||
|
||||
@@ -84,7 +84,7 @@ export function AskAITextSelection() {
|
||||
id: `text-${fnv1a(content, { size: 32 })}`,
|
||||
content,
|
||||
});
|
||||
chatController.open('selection');
|
||||
chatController.open();
|
||||
chatController.focus();
|
||||
clear();
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from 'react';
|
||||
|
||||
import * as api from '@gitbook/api';
|
||||
|
||||
import { trackAskView, useTrackEvent } from '../Insights';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { LinkContext } from '../primitives';
|
||||
import { useIsVisible } from '../VisibilityContext';
|
||||
import {
|
||||
@@ -60,10 +60,15 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackAskView(trackEvent, 'embed', {
|
||||
pageId: null,
|
||||
displayContext: api.SiteInsightsDisplayContext.Embed,
|
||||
});
|
||||
trackEvent(
|
||||
{
|
||||
type: 'ask_view',
|
||||
},
|
||||
{
|
||||
pageId: null,
|
||||
displayContext: api.SiteInsightsDisplayContext.Embed,
|
||||
}
|
||||
);
|
||||
}, [trackEvent, isVisible]);
|
||||
|
||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
|
||||
import type * as api from '@gitbook/api';
|
||||
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
|
||||
|
||||
import type { AskAITrigger } from '../AI';
|
||||
import { type CurrentContentContext, useCurrentContent } from '../hooks';
|
||||
import { getSession } from './sessions';
|
||||
import { type VisitorResponse, useVisitor } from './visitorId';
|
||||
@@ -43,7 +42,7 @@ export type TrackEventInput<EventName extends InsightsEventName = InsightsEventN
|
||||
/**
|
||||
* Callback to track an event.
|
||||
*/
|
||||
export type TrackEventCallback = <EventName extends InsightsEventName>(
|
||||
type TrackEventCallback = <EventName extends InsightsEventName>(
|
||||
event: TrackEventInput<EventName>,
|
||||
ctx?: InsightsEventPageContext,
|
||||
options?: InsightsEventOptions
|
||||
@@ -215,16 +214,6 @@ export function useTrackEvent(): TrackEventCallback {
|
||||
return React.useContext(InsightsContext);
|
||||
}
|
||||
|
||||
/** Track an ask view while the frontend uses an API type that predates its trigger field. */
|
||||
export function trackAskView(
|
||||
trackEvent: TrackEventCallback,
|
||||
trigger: AskAITrigger | undefined,
|
||||
ctx?: InsightsEventPageContext
|
||||
) {
|
||||
// TODO: remove this cast once @gitbook/api includes SiteInsightsEventAskView.trigger.
|
||||
trackEvent({ type: 'ask_view', trigger } as unknown as TrackEventInput<'ask_view'>, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post the events to the server.
|
||||
*/
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ActionOpenAssistant(props: {
|
||||
href: page.href,
|
||||
});
|
||||
}
|
||||
assistant.open(undefined, 'page');
|
||||
assistant.open();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
import { useIsMounted } from '../hooks/useIsMounted';
|
||||
import { Button, type ButtonProps } from '../primitives/Button';
|
||||
import { DropdownMenuItem } from '../primitives/DropdownMenu';
|
||||
import { Link, type LinkInsightsProps, type LinkProps } from '../primitives/Link';
|
||||
@@ -16,9 +17,17 @@ function useSiteAuthLoginHrefWithLocation(href: string) {
|
||||
const rawPathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const currentSearch = searchParams?.toString();
|
||||
const pathname = rawPathname ?? '/';
|
||||
// On the server, usePathname() returns the internal rewritten route (/sites/…, which includes
|
||||
// the site API token), so the location is only added once mounted.
|
||||
// https://nextjs.org/docs/app/api-reference/functions/use-pathname#avoid-hydration-mismatch-with-rewrites
|
||||
const isMounted = useIsMounted();
|
||||
const pathname = isMounted ? (rawPathname ?? '/') : null;
|
||||
|
||||
return useMemo(() => {
|
||||
if (pathname === null) {
|
||||
return href;
|
||||
}
|
||||
|
||||
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
|
||||
const resolved = URL.canParse(href) ? new URL(href) : new URL(href, baseURL);
|
||||
const siteBasePath = removeTrailingSlash(
|
||||
|
||||
@@ -738,3 +738,131 @@ describe('resolveContentRef for direct space links', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContentRef for a page ref whose page was re-created', () => {
|
||||
function buildPage(id: string, title: string, path: string): RevisionPageDocument {
|
||||
return {
|
||||
object: 'page',
|
||||
id,
|
||||
type: 'document',
|
||||
kind: 'sheet',
|
||||
title,
|
||||
path,
|
||||
slug: path.split('/').pop(),
|
||||
pages: [],
|
||||
tags: [],
|
||||
layout: {},
|
||||
urls: { app: 'https://app.gitbook.com/page' },
|
||||
} as unknown as RevisionPageDocument;
|
||||
}
|
||||
|
||||
function buildRevision(id: string, pages: RevisionPageDocument[]): Revision {
|
||||
return {
|
||||
object: 'revision',
|
||||
id,
|
||||
type: 'edits',
|
||||
pages,
|
||||
files: [],
|
||||
reusableContents: [],
|
||||
tags: [],
|
||||
parents: [],
|
||||
createdAt: '',
|
||||
urls: { app: '' },
|
||||
} as unknown as Revision;
|
||||
}
|
||||
|
||||
function buildSpace(id: string): Space {
|
||||
return {
|
||||
object: 'space',
|
||||
id,
|
||||
title: id,
|
||||
organization: 'org',
|
||||
revision: `rev-${id}`,
|
||||
urls: {
|
||||
location: `https://api.gitbook.com/spaces/${id}`,
|
||||
app: `https://app.gitbook.com/o/org/s/${id}/`,
|
||||
published: `https://${id}.gitbook.io/`,
|
||||
},
|
||||
} as unknown as Space;
|
||||
}
|
||||
|
||||
const recreated = buildPage('page-new', 'Detector', 'alerts-by-name/detector');
|
||||
const other = buildPage('page-other', 'Other', 'alerts-by-name/other');
|
||||
const alertsRevision = buildRevision('rev-alerts', [recreated, other]);
|
||||
const alerts = buildSpace('alerts');
|
||||
const notes = buildSpace('notes');
|
||||
|
||||
const dataFetcher = {
|
||||
getSpace: async ({ spaceId }: { spaceId: string }) =>
|
||||
spaceId === 'alerts'
|
||||
? { data: alerts }
|
||||
: { error: { code: 404, message: 'Not found' } },
|
||||
getRevision: async ({ spaceId }: { spaceId: string }) =>
|
||||
spaceId === 'alerts'
|
||||
? { data: alertsRevision }
|
||||
: { error: { code: 404, message: 'Not found' } },
|
||||
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
|
||||
withToken: function () {
|
||||
return this;
|
||||
},
|
||||
} as unknown as GitBookDataFetcher;
|
||||
|
||||
function buildContext(space: Space, revision: Revision): GitBookAnyContext {
|
||||
return {
|
||||
dataFetcher,
|
||||
linker: createLinker({
|
||||
host: 'docs.example.com',
|
||||
spaceBasePath: '/',
|
||||
siteBasePath: '/',
|
||||
}),
|
||||
organizationId: 'org',
|
||||
space,
|
||||
revision,
|
||||
revisionId: revision.id,
|
||||
changeRequest: null,
|
||||
shareKey: undefined,
|
||||
} as unknown as GitBookAnyContext;
|
||||
}
|
||||
|
||||
// Refs only get a typed `path` once @gitbook/api ships it, so the tests add it through a cast.
|
||||
const withPath = (ref: object, path: string) => ({ ...ref, path }) as never;
|
||||
|
||||
it('finds the page at the recorded path when its ID no longer exists', async () => {
|
||||
const result = await resolveContentRef(
|
||||
withPath({ kind: 'page', page: 'page-deleted' }, 'alerts-by-name/detector'),
|
||||
buildContext(alerts, alertsRevision)
|
||||
);
|
||||
|
||||
expect(result?.text).toBe('Detector');
|
||||
});
|
||||
|
||||
it('finds the page at the recorded path in another space', async () => {
|
||||
const result = await resolveContentRef(
|
||||
withPath(
|
||||
{ kind: 'page', space: 'alerts', page: 'page-deleted' },
|
||||
'alerts-by-name/detector'
|
||||
),
|
||||
buildContext(notes, buildRevision('rev-notes', []))
|
||||
);
|
||||
|
||||
expect(result?.text).toBe('Detector');
|
||||
});
|
||||
|
||||
it('keeps resolving by ID while the page still exists', async () => {
|
||||
const result = await resolveContentRef(
|
||||
withPath({ kind: 'page', page: 'page-other' }, 'alerts-by-name/detector'),
|
||||
buildContext(alerts, alertsRevision)
|
||||
);
|
||||
|
||||
expect(result?.text).toBe('Other');
|
||||
});
|
||||
|
||||
it('does not resolve when neither the ID nor the recorded path exists', async () => {
|
||||
const result = await resolveContentRef(
|
||||
withPath({ kind: 'page', page: 'page-deleted' }, 'alerts-by-name/removed'),
|
||||
buildContext(alerts, alertsRevision)
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type React from 'react';
|
||||
import type {
|
||||
ContentRef,
|
||||
JSONDocument,
|
||||
Revision,
|
||||
RevisionFile,
|
||||
RevisionPageDocument,
|
||||
RevisionReusableContent,
|
||||
@@ -16,7 +17,7 @@ import type { Filesystem } from '@gitbook/openapi-parser';
|
||||
|
||||
import { getGitBookAppHref } from './app';
|
||||
import { getBlockById, getBlockTitle } from './document';
|
||||
import { resolvePageId } from './pages';
|
||||
import { resolvePageId, resolvePagePath } from './pages';
|
||||
import {
|
||||
findSiteSpaceBy,
|
||||
getFallbackSiteSpacePath,
|
||||
@@ -192,7 +193,8 @@ export async function resolveContentRef(
|
||||
? activePage
|
||||
? { page: activePage, ancestors: [] }
|
||||
: undefined
|
||||
: resolvePageId(revision.pages, contentRef.page);
|
||||
: (resolvePageId(revision.pages, contentRef.page) ??
|
||||
resolvePageAtRecordedPath(revision, contentRef));
|
||||
|
||||
const page = resolvePageResult?.page;
|
||||
const ancestors =
|
||||
@@ -430,6 +432,17 @@ export function resolveContentRefFallback(contentRef: ContentRef): ResolvedConte
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the page at the path recorded on a page or anchor ref, for when its page ID no longer
|
||||
* exists (the page was deleted and re-created at the same path).
|
||||
*/
|
||||
function resolvePageAtRecordedPath(revision: Revision, contentRef: ContentRef) {
|
||||
// TODO: read `contentRef.path` directly once @gitbook/api ships it on ContentRefPage and
|
||||
// ContentRefAnchor; until then the field is untyped.
|
||||
const { path } = contentRef as { path?: unknown };
|
||||
return typeof path === 'string' && path ? resolvePagePath(revision.pages, path) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to get the best possible target space while resolving a content ref.
|
||||
* It will try to return the space in the site context if it exists to avoid cross-site links.
|
||||
|
||||
Reference in New Issue
Block a user