mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-13 06:09:21 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eac554014a | |||
| fef62b12b4 | |||
| 6a10b6185b |
@@ -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'],
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user