Compare commits

...

3 Commits

Author SHA1 Message Date
Samy Pessé eac554014a Continue 2025-06-10 12:08:19 +02:00
Samy Pessé fef62b12b4 Continue 2025-06-09 00:45:31 +02:00
Samy Pessé 6a10b6185b Start 2025-06-09 00:24:27 +02:00
19 changed files with 747 additions and 121 deletions
+2
View File
@@ -719,6 +719,8 @@ async function* streamAIResponse(
input: params.input,
output: params.output,
model: params.model,
instructions: params.instructions,
previousResponseId: params.previousResponseId,
},
{
...noCacheFetchOptions,
@@ -189,5 +189,7 @@ export interface GitBookDataFetcher {
input: api.AIMessageInput[];
output: api.AIOutputFormat;
model: api.AIModel;
instructions?: string;
previousResponseId?: string;
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
}
@@ -0,0 +1,66 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { Button } from '../primitives';
import { AskInput } from './AskInput';
import { AskMessages } from './AskMessages';
import { useAskController, useAskState } from './state';
export function AskDialog() {
const state = useAskState();
const controller = useAskController();
if (!state.opened) {
return null;
}
return (
<div
role="dialog"
className={`ask-dialog ${tcls(
'z-40',
'fixed',
'top-3',
'right-3',
'bottom-3',
'w-[480px]',
'flex',
'flex-col',
'bg-tint-base',
'rounded-lg',
'straight-corners:rounded-sm',
'circular-corners:rounded-2xl',
'ring-1',
'ring-tint',
'shadow-2xl',
'depth-flat:shadow-none',
'overflow-hidden',
'dark:ring-inset',
'dark:ring-tint'
)})`}
>
<div className="flex flex-row">
<div className="flex-1"></div>
<div className="p-2">
<Button
variant="blank"
icon="close"
iconOnly
onClick={() => {
controller.close();
}}
/>
</div>
</div>
<div className="flex-1">
<AskMessages session={state.session} />
</div>
<div className="flex flex-row">
<AskInput />
</div>
{/* <div>
<h1>{state.session.title}</h1>
</div> */}
</div>
);
}
@@ -0,0 +1,28 @@
import { tcls } from '@/lib/tailwind';
import React from 'react';
import { useAskController } from './state';
export function AskInput() {
const controller = useAskController();
const [value, setValue] = React.useState('');
return (
<textarea
className={tcls('resize-none', 'bg-tint-base', 'rounded-lg', 'm-2', 'p-2', 'flex-1')}
value={value}
placeholder="Ask a question..."
onChange={(event) => {
setValue(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
controller.postMessage({
message: value,
});
setValue('');
}
}}
/>
);
}
@@ -0,0 +1,15 @@
import type { AskSession } from './state';
export function AskMessages(props: {
session: AskSession;
}) {
const { session } = props;
return (
<div className="flex flex-col gap-2">
{session.messages.map((message, index) => {
return <div key={index}>{message.content}</div>;
})}
</div>
);
}
@@ -0,0 +1,2 @@
export * from './AskDialog';
export * from './state';
@@ -0,0 +1,31 @@
import type { AIMessage } from '@gitbook/api';
import { DocumentView } from '../../DocumentView';
/**
* Render a message from the API backend.
*/
export function AIMessageView(props: {
message: AIMessage;
}) {
const { message } = props;
return (
<div className="flex flex-col gap-2">
{message.steps.map((step, index) => {
return (
<div key={index} className="flex flex-col gap-2">
<DocumentView
document={step.content}
context={{
mode: 'default',
contentContext: undefined,
wrapBlocksInSuspense: false,
}}
style={['space-y-5']}
/>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,214 @@
'use server';
import {
type AIMessage,
AIMessageRole,
type AIMessageStep,
type AIStreamResponse,
} from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import type { GitBookDataFetcher } from '@v2/lib/data';
import { EventIterator } from 'event-iterator';
import type { MaybePromise } from 'p-map';
import * as partialJson from 'partial-json';
import type { DeepPartial } from 'ts-essentials';
import type { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { AIMessageView } from './AIMessageView';
/**
* Get the latest value from a stream and the response id.
*/
export async function generate<T>(
promise: MaybePromise<{
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
}>
) {
const input = await promise;
let value: T | undefined;
for await (const event of input.stream) {
value = event;
}
const { responseId } = await input.response;
return {
responseId,
value,
};
}
/**
* Stream the generation of an object using the AI.
*/
export async function streamGenerateObject<T>(
context: GitBookBaseContext,
{
schema,
...input
}: Omit<Parameters<GitBookDataFetcher['streamAIResponse']>[0], 'output'> & {
schema: z.ZodSchema<T>;
}
) {
const rawStream = context.dataFetcher.streamAIResponse({
...input,
output: {
type: 'object',
schema: zodToJsonSchema(schema),
},
});
let json = '';
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
if (event.type === 'response_object') {
json += event.jsonChunk;
const parsed = partialJson.parse(json, partialJson.ALL);
return parsed;
}
});
}
/**
* Stream the generation of a document.
*/
export async function streamGenerateDocument(
context: GitBookBaseContext,
input: Omit<Parameters<GitBookDataFetcher['streamAIResponse']>[0], 'output'>
) {
const rawStream = context.dataFetcher.streamAIResponse({
...input,
output: {
type: 'document',
},
});
const message: AIMessage = {
id: '',
role: AIMessageRole.Assistant,
steps: [],
};
const updateProcessingMessageStep = (
stepIndex: number,
callback: (step: AIMessageStep) => void
) => {
if (stepIndex > message.steps.length) {
throw new Error(
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
);
}
if (message.steps[stepIndex]) {
message.steps = [...message.steps];
message.steps[stepIndex] = { ...message.steps[stepIndex] };
callback(message.steps[stepIndex]);
} else {
message.steps = [
...message.steps,
{
content: {
object: 'document',
data: {},
nodes: [],
},
},
];
callback(message.steps[stepIndex]);
}
};
return parseResponse<React.ReactNode>(rawStream, (event) => {
switch (event.type) {
/**
* The agent is processing a tool call in a new message.
*/
case 'response_tool_call': {
updateProcessingMessageStep(event.stepIndex, (step) => {
step.toolCalls ??= [];
step.toolCalls.push(event.toolCall);
});
break;
}
/**
* The agent is writing the content of a new message.
*/
case 'response_reasoning':
case 'response_document': {
updateProcessingMessageStep(event.stepIndex, (step) => {
const container = event.type === 'response_reasoning' ? 'reasoning' : 'content';
step[container] ??= {
object: 'document',
data: {},
nodes: [],
};
step[container] = {
...step[container],
nodes: [...step[container].nodes],
};
if (event.operation === 'insert') {
step[container].nodes.push(...event.blocks);
} else {
step[container].nodes.splice(
-event.blocks.length,
event.blocks.length,
...event.blocks
);
}
});
break;
}
}
return <AIMessageView message={message} />;
});
}
/**
* Parse a stream from the API to extract the responseId.
*/
function parseResponse<T>(
responseStream: EventIterator<AIStreamResponse>,
parse: (response: AIStreamResponse) => T | undefined
): {
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
} {
let resolveResponse: (value: { responseId: string }) => void;
const response = new Promise<{ responseId: string }>((resolve) => {
resolveResponse = resolve;
});
const stream = new EventIterator<T>((queue) => {
(async () => {
let foundResponse = false;
for await (const event of responseStream) {
if (event.type === 'response_finish') {
foundResponse = true;
resolveResponse({ responseId: event.responseId });
} else {
const parsed = parse(event);
if (parsed !== undefined) {
queue.push(parsed);
}
}
}
if (!foundResponse) {
throw new Error('No response found');
}
})().then(
() => {
queue.stop();
},
(error) => {
queue.fail(error);
}
);
});
return { stream, response };
}
@@ -0,0 +1,44 @@
'use server';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole, AIModel } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { getServerActionBaseContext } from '@v2/lib/server-actions';
import { streamGenerateDocument } from './api';
const PROMPT = `
You are a helpful assistant that can answer questions about the content of the site.
`;
export async function* streamAsk({
query,
previousResponseId,
}: {
query: string;
previousResponseId?: string;
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const { response, stream } = await streamGenerateDocument(baseContext, {
organizationId: siteURLData.organization,
siteId: siteURLData.site,
model: AIModel.Fast,
instructions: PROMPT,
previousResponseId,
input: [
{
role: AIMessageRole.User,
content: query,
},
],
});
for await (const output of stream) {
yield { output };
}
// Wait for the responseId to be available and yield one final time
const { responseId } = await response;
yield { responseId };
}
@@ -0,0 +1,2 @@
export * from './api';
export * from './ask';
@@ -0,0 +1,189 @@
'use client';
import { AIMessageRole } from '@gitbook/api';
import * as React from 'react';
import { streamAsk } from './server-actions';
export type AskMessage = {
role: AIMessageRole;
content: React.ReactNode;
};
export type AskSession = {
/**
* The title of the ask session.
* It can be auto-generated by the AI or an initial value.
*/
title: string;
/**
* ID of the latest AI response.
*/
responseId: string | null;
/**
* Messages in the session.
*/
messages: AskMessage[];
/**
* If true, the session is in progress.
*/
loading: boolean;
};
export type AskState = {
opened: boolean;
session: AskSession;
};
export type AskController = {
/** Open the dialog */
open: () => void;
/** Close the dialog */
close: () => void;
/** Post a message to the session */
postMessage: (input: {
/** If defined, the title of the session will be updated. */
title?: string;
/** The message to post to the session. */
message: string;
}) => void;
};
const AskStateContext = React.createContext<AskState | null>(null);
const AskControllerContext = React.createContext<AskController | null>(null);
export function AskStateProvider(props: React.PropsWithChildren) {
const { children } = props;
const [state, setState] = React.useState<AskState>({
opened: false,
session: {
title: '',
responseId: null,
messages: [],
loading: false,
},
});
const stateRef = React.useRef<AskState>(state);
stateRef.current = state;
const controller = React.useMemo(() => {
return {
open: () => {
setState((previous) => {
return {
...previous,
opened: true,
};
});
},
close: () => {
setState((previous) => {
return {
...previous,
opened: false,
};
});
},
postMessage: async (input: { title?: string; message: string }) => {
try {
const stream = await streamAsk({
query: input.message,
previousResponseId: stateRef.current.session.responseId ?? undefined,
});
setState((previous) => {
return {
...previous,
session: {
...previous.session,
messages: [
...previous.session.messages,
{
role: AIMessageRole.User,
content: input.message,
},
{
role: AIMessageRole.Assistant,
content: null,
},
],
},
};
});
for await (const data of stream) {
if (!data) continue;
if (data.responseId) {
setState((previous) => {
return {
...previous,
session: {
...previous.session,
responseId: data.responseId,
},
};
});
}
if (data.output) {
setState((previous) => {
return {
...previous,
session: {
...previous.session,
messages: [
...previous.session.messages.slice(0, -1),
{
role: AIMessageRole.Assistant,
content: data.output,
},
],
},
};
});
}
}
} catch (error) {
console.error('Error in summary stream:', error);
}
},
};
}, []);
return (
<AskStateContext.Provider value={state}>
<AskControllerContext.Provider value={controller}>
{children}
</AskControllerContext.Provider>
</AskStateContext.Provider>
);
}
/**
* Get the current state of the ask session.
*/
export function useAskState(): AskState {
const context = React.useContext(AskStateContext);
if (!context) {
throw new Error('useAskState must be used within a AskStateProvider');
}
return context;
}
/**
* Get the controller to interact with the ask session.
*/
export function useAskController(): AskController {
const context = React.useContext(AskControllerContext);
if (!context) {
throw new Error('useAskController must be used within a AskStateProvider');
}
return context;
}
@@ -48,6 +48,7 @@ export function PageAside(props: {
'group/aside',
'hidden',
'xl:flex',
'ask-open:hidden',
// 'page-no-toc:lg:flex',
'flex-col',
'basis-56',
@@ -156,7 +156,9 @@ export async function CustomizationRootLayout(props: {
'theme-muted:bg-tint-subtle',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint'
'theme-gradient-tint:bg-gradient-tint',
'ask-open:mr-[500px]'
)}
>
<IconsProvider
@@ -6,6 +6,7 @@ import { useEffect, useState } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { useAskController } from '../Ask';
import { useTrackEvent } from '../Insights';
import { useSearch } from './useSearch';
@@ -17,14 +18,19 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
const language = useLanguage();
const [, setSearchState] = useSearch();
const askController = useAskController();
const trackEvent = useTrackEvent();
const onClick = () => {
setSearchState({
ask: false,
global: false,
query: '',
});
if (1) {
askController.open();
} else {
setSearchState({
ask: false,
global: false,
query: '',
});
}
trackEvent({
type: 'search_open',
@@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useAskController } from '../Ask';
import { LoadingPane } from '../primitives/LoadingPane';
import { SearchAskAnswer } from './SearchAskAnswer';
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
@@ -146,6 +147,7 @@ function SearchModalBody(
const language = useLanguage();
const resultsRef = React.useRef<SearchResultsRef>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const askController = useAskController();
React.useEffect(() => {
inputRef.current?.focus();
@@ -186,7 +188,9 @@ function SearchModalBody(
};
const onSwitchToAsk = () => {
setSearchState((state) => (state ? { ...state, ask: true } : null));
alert('switch to ask');
askController.open();
// setSearchState((state) => (state ? { ...state, ask: true } : null));
};
// We trim the query to avoid invalidating the search when the user is typing between words.
@@ -177,7 +177,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
block: 'nearest',
inline: 'nearest',
});
}, [cursor, refs]);
}, [cursor]);
const moveBy = React.useCallback(
(delta: number) => {
@@ -198,7 +198,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
}
refs.current[cursor]?.click();
}, [cursor, refs]);
}, [cursor]);
React.useImperativeHandle(
ref,
@@ -14,6 +14,7 @@ import { tcls } from '@/lib/tailwind';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
import { Announcement } from '../Announcement';
import { AskDialog, AskStateProvider } from '../Ask';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
@@ -49,110 +50,116 @@ export function SpaceLayout(props: {
customization.footer.groups?.length;
return (
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
<InsightsProvider
enabled={withTracking}
appURL={GITBOOK_APP_URL}
apiHost={GITBOOK_API_PUBLIC_URL}
organizationId={context.organizationId}
siteId={context.site.id}
siteSectionId={context.sections?.current?.id ?? null}
siteSpaceId={context.siteSpace.id}
siteShareKey={context.shareKey ?? null}
revisionId={context.revisionId}
spaceId={context.space.id}
visitorAuthClaims={visitorAuthClaims}
visitorCookieTrackingEnabled={context.customization.insights?.trackingCookie}
>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} context={context} />
<div className="scroll-nojump">
<div
className={tcls(
'flex',
'flex-col',
'lg:flex-row',
CONTAINER_STYLE,
<AskStateProvider>
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
<InsightsProvider
enabled={withTracking}
appURL={GITBOOK_APP_URL}
apiHost={GITBOOK_API_PUBLIC_URL}
organizationId={context.organizationId}
siteId={context.site.id}
siteSectionId={context.sections?.current?.id ?? null}
siteSpaceId={context.siteSpace.id}
siteShareKey={context.shareKey ?? null}
revisionId={context.revisionId}
spaceId={context.space.id}
visitorAuthClaims={visitorAuthClaims}
visitorCookieTrackingEnabled={context.customization.insights?.trackingCookie}
>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} context={context} />
<div className="scroll-nojump">
<div
className={tcls(
'flex',
'flex-col',
'lg:flex-row',
CONTAINER_STYLE,
// Ensure the footer is display below the viewport even if the content is not enough
withFooter && 'min-h-[calc(100vh-64px)]',
withTopHeader ? null : 'lg:min-h-screen'
)}
>
<TableOfContents
context={context}
header={
withTopHeader ? null : (
<div
className={tcls(
'hidden',
'pr-4',
'lg:flex',
'grow-0',
'flex-wrap',
'dark:shadow-light/1',
'text-base/tight'
)}
>
<HeaderLogo context={context} />
</div>
)
}
innerHeader={
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<>
{!withTopHeader && (
<div className={tcls('hidden', 'lg:block')}>
<React.Suspense fallback={null}>
<SearchButton>
<span className={tcls('flex-1')}>
{t(
getSpaceLanguage(customization),
customization.aiSearch.enabled
? 'search_or_ask'
: 'search'
)}
...
</span>
</SearchButton>
</React.Suspense>
</div>
)}
{!withTopHeader && withSections && sections && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(context, sections)}
/>
)}
{isMultiVariants && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
// Ensure the footer is display below the viewport even if the content is not enough
withFooter && 'min-h-[calc(100vh-64px)]',
withTopHeader ? null : 'lg:min-h-screen'
)}
>
<TableOfContents
context={context}
header={
withTopHeader ? null : (
<div
className={tcls(
'w-full',
'page-no-toc:hidden',
'site-header-none:page-no-toc:flex'
'hidden',
'pr-4',
'lg:flex',
'grow-0',
'flex-wrap',
'dark:shadow-light/1',
'text-base/tight'
)}
/>
)}
</>
}
/>
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
>
<HeaderLogo context={context} />
</div>
)
}
innerHeader={
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<>
{!withTopHeader && (
<div className={tcls('hidden', 'lg:block')}>
<React.Suspense fallback={null}>
<SearchButton>
<span className={tcls('flex-1')}>
{t(
getSpaceLanguage(customization),
customization.aiSearch.enabled
? 'search_or_ask'
: 'search'
)}
...
</span>
</SearchButton>
</React.Suspense>
</div>
)}
{!withTopHeader && withSections && sections && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(
context,
sections
)}
/>
)}
{isMultiVariants && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className={tcls(
'w-full',
'page-no-toc:hidden',
'site-header-none:page-no-toc:flex'
)}
/>
)}
</>
}
/>
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
</div>
</div>
</div>
{withFooter ? <Footer context={context} /> : null}
{withFooter ? <Footer context={context} /> : null}
<React.Suspense fallback={null}>
<SearchModal
spaceTitle={siteSpace.title}
withAsk={customization.aiSearch.enabled}
isMultiVariants={isMultiVariants}
/>
</React.Suspense>
</InsightsProvider>
</SpaceLayoutContextProvider>
<React.Suspense fallback={null}>
<SearchModal
spaceTitle={siteSpace.title}
withAsk={customization.aiSearch.enabled}
isMultiVariants={isMultiVariants}
/>
<AskDialog />
</React.Suspense>
</InsightsProvider>
</SpaceLayoutContextProvider>
</AskStateProvider>
);
}
@@ -7,17 +7,6 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { Link, type LinkInsightsProps } from './Link';
type ButtonProps = {
href?: string;
variant?: 'primary' | 'secondary' | 'blank';
icon?: IconName;
iconOnly?: boolean;
size?: 'default' | 'medium' | 'small';
className?: ClassValue;
label?: string;
} & LinkInsightsProps &
HTMLAttributes<HTMLElement>;
const variantClasses = {
primary: [
'bg-primary-solid',
@@ -49,6 +38,17 @@ const variantClasses = {
],
};
type ButtonProps = {
href?: string;
variant?: keyof typeof variantClasses;
icon?: IconName;
iconOnly?: boolean;
size?: 'default' | 'medium' | 'small';
className?: ClassValue;
label?: string;
} & LinkInsightsProps &
HTMLAttributes<HTMLElement>;
export function Button({
href,
variant = 'primary',
@@ -62,8 +62,14 @@ export function Button({
...rest
}: ButtonProps & { target?: HTMLAttributeAnchorTarget }) {
const sizes = {
default: ['text-base', 'font-semibold', 'px-5', 'py-2', 'circular-corners:px-6'],
medium: ['text-sm', 'px-3.5', 'py-1.5', 'circular-corners:px-4'],
default: [
'text-base',
'font-semibold',
iconOnly ? 'px-2' : 'px-5',
'py-2',
'circular-corners:px-6',
],
medium: ['text-sm', iconOnly ? 'px-1.5' : 'px-3.5', 'py-1.5', 'circular-corners:px-4'],
small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'],
};
+5
View File
@@ -533,6 +533,11 @@ const config: Config = {
* Variant when the page is displayed in print mode.
*/
addVariant('print-mode', 'body:has(.print-mode) &');
/**
* Variant when the Ask dialog is open.
*/
addVariant('ask-open', 'html:has(.ask-dialog) &');
}),
plugin(({ matchUtilities }) => {
matchUtilities({