mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-30 22:18:46 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eac554014a | |||
| fef62b12b4 | |||
| 6a10b6185b |
@@ -719,6 +719,8 @@ async function* streamAIResponse(
|
|||||||
input: params.input,
|
input: params.input,
|
||||||
output: params.output,
|
output: params.output,
|
||||||
model: params.model,
|
model: params.model,
|
||||||
|
instructions: params.instructions,
|
||||||
|
previousResponseId: params.previousResponseId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...noCacheFetchOptions,
|
...noCacheFetchOptions,
|
||||||
|
|||||||
@@ -189,5 +189,7 @@ export interface GitBookDataFetcher {
|
|||||||
input: api.AIMessageInput[];
|
input: api.AIMessageInput[];
|
||||||
output: api.AIOutputFormat;
|
output: api.AIOutputFormat;
|
||||||
model: api.AIModel;
|
model: api.AIModel;
|
||||||
|
instructions?: string;
|
||||||
|
previousResponseId?: string;
|
||||||
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
|
}): 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',
|
'group/aside',
|
||||||
'hidden',
|
'hidden',
|
||||||
'xl:flex',
|
'xl:flex',
|
||||||
|
'ask-open:hidden',
|
||||||
// 'page-no-toc:lg:flex',
|
// 'page-no-toc:lg:flex',
|
||||||
'flex-col',
|
'flex-col',
|
||||||
'basis-56',
|
'basis-56',
|
||||||
|
|||||||
@@ -156,7 +156,9 @@ export async function CustomizationRootLayout(props: {
|
|||||||
'theme-muted:bg-tint-subtle',
|
'theme-muted:bg-tint-subtle',
|
||||||
|
|
||||||
'theme-gradient:bg-gradient-primary',
|
'theme-gradient:bg-gradient-primary',
|
||||||
'theme-gradient-tint:bg-gradient-tint'
|
'theme-gradient-tint:bg-gradient-tint',
|
||||||
|
|
||||||
|
'ask-open:mr-[500px]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<IconsProvider
|
<IconsProvider
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { useAskController } from '../Ask';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { useSearch } from './useSearch';
|
import { useSearch } from './useSearch';
|
||||||
|
|
||||||
@@ -17,14 +18,19 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
|||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const [, setSearchState] = useSearch();
|
const [, setSearchState] = useSearch();
|
||||||
|
const askController = useAskController();
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
setSearchState({
|
if (1) {
|
||||||
ask: false,
|
askController.open();
|
||||||
global: false,
|
} else {
|
||||||
query: '',
|
setSearchState({
|
||||||
});
|
ask: false,
|
||||||
|
global: false,
|
||||||
|
query: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
trackEvent({
|
trackEvent({
|
||||||
type: 'search_open',
|
type: 'search_open',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
|||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { useAskController } from '../Ask';
|
||||||
import { LoadingPane } from '../primitives/LoadingPane';
|
import { LoadingPane } from '../primitives/LoadingPane';
|
||||||
import { SearchAskAnswer } from './SearchAskAnswer';
|
import { SearchAskAnswer } from './SearchAskAnswer';
|
||||||
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
||||||
@@ -146,6 +147,7 @@ function SearchModalBody(
|
|||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const resultsRef = React.useRef<SearchResultsRef>(null);
|
const resultsRef = React.useRef<SearchResultsRef>(null);
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const askController = useAskController();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -186,7 +188,9 @@ function SearchModalBody(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSwitchToAsk = () => {
|
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.
|
// 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',
|
block: 'nearest',
|
||||||
inline: 'nearest',
|
inline: 'nearest',
|
||||||
});
|
});
|
||||||
}, [cursor, refs]);
|
}, [cursor]);
|
||||||
|
|
||||||
const moveBy = React.useCallback(
|
const moveBy = React.useCallback(
|
||||||
(delta: number) => {
|
(delta: number) => {
|
||||||
@@ -198,7 +198,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
}
|
}
|
||||||
|
|
||||||
refs.current[cursor]?.click();
|
refs.current[cursor]?.click();
|
||||||
}, [cursor, refs]);
|
}, [cursor]);
|
||||||
|
|
||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { tcls } from '@/lib/tailwind';
|
|||||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
|
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
|
||||||
import { Announcement } from '../Announcement';
|
import { Announcement } from '../Announcement';
|
||||||
|
import { AskDialog, AskStateProvider } from '../Ask';
|
||||||
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
||||||
import { InsightsProvider } from '../Insights';
|
import { InsightsProvider } from '../Insights';
|
||||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||||
@@ -49,110 +50,116 @@ export function SpaceLayout(props: {
|
|||||||
customization.footer.groups?.length;
|
customization.footer.groups?.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
|
<AskStateProvider>
|
||||||
<InsightsProvider
|
<SpaceLayoutContextProvider basePath={context.linker.toPathInSpace('')}>
|
||||||
enabled={withTracking}
|
<InsightsProvider
|
||||||
appURL={GITBOOK_APP_URL}
|
enabled={withTracking}
|
||||||
apiHost={GITBOOK_API_PUBLIC_URL}
|
appURL={GITBOOK_APP_URL}
|
||||||
organizationId={context.organizationId}
|
apiHost={GITBOOK_API_PUBLIC_URL}
|
||||||
siteId={context.site.id}
|
organizationId={context.organizationId}
|
||||||
siteSectionId={context.sections?.current?.id ?? null}
|
siteId={context.site.id}
|
||||||
siteSpaceId={context.siteSpace.id}
|
siteSectionId={context.sections?.current?.id ?? null}
|
||||||
siteShareKey={context.shareKey ?? null}
|
siteSpaceId={context.siteSpace.id}
|
||||||
revisionId={context.revisionId}
|
siteShareKey={context.shareKey ?? null}
|
||||||
spaceId={context.space.id}
|
revisionId={context.revisionId}
|
||||||
visitorAuthClaims={visitorAuthClaims}
|
spaceId={context.space.id}
|
||||||
visitorCookieTrackingEnabled={context.customization.insights?.trackingCookie}
|
visitorAuthClaims={visitorAuthClaims}
|
||||||
>
|
visitorCookieTrackingEnabled={context.customization.insights?.trackingCookie}
|
||||||
<Announcement context={context} />
|
>
|
||||||
<Header withTopHeader={withTopHeader} context={context} />
|
<Announcement context={context} />
|
||||||
<div className="scroll-nojump">
|
<Header withTopHeader={withTopHeader} context={context} />
|
||||||
<div
|
<div className="scroll-nojump">
|
||||||
className={tcls(
|
<div
|
||||||
'flex',
|
className={tcls(
|
||||||
'flex-col',
|
'flex',
|
||||||
'lg:flex-row',
|
'flex-col',
|
||||||
CONTAINER_STYLE,
|
'lg:flex-row',
|
||||||
|
CONTAINER_STYLE,
|
||||||
|
|
||||||
// Ensure the footer is display below the viewport even if the content is not enough
|
// Ensure the footer is display below the viewport even if the content is not enough
|
||||||
withFooter && 'min-h-[calc(100vh-64px)]',
|
withFooter && 'min-h-[calc(100vh-64px)]',
|
||||||
withTopHeader ? null : 'lg:min-h-screen'
|
withTopHeader ? null : 'lg:min-h-screen'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TableOfContents
|
<TableOfContents
|
||||||
context={context}
|
context={context}
|
||||||
header={
|
header={
|
||||||
withTopHeader ? null : (
|
withTopHeader ? null : (
|
||||||
<div
|
<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}
|
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'w-full',
|
'hidden',
|
||||||
'page-no-toc:hidden',
|
'pr-4',
|
||||||
'site-header-none:page-no-toc:flex'
|
'lg:flex',
|
||||||
|
'grow-0',
|
||||||
|
'flex-wrap',
|
||||||
|
'dark:shadow-light/1',
|
||||||
|
'text-base/tight'
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
)}
|
<HeaderLogo context={context} />
|
||||||
</>
|
</div>
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">{children}</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{withFooter ? <Footer context={context} /> : null}
|
{withFooter ? <Footer context={context} /> : null}
|
||||||
|
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<SearchModal
|
<SearchModal
|
||||||
spaceTitle={siteSpace.title}
|
spaceTitle={siteSpace.title}
|
||||||
withAsk={customization.aiSearch.enabled}
|
withAsk={customization.aiSearch.enabled}
|
||||||
isMultiVariants={isMultiVariants}
|
isMultiVariants={isMultiVariants}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
<AskDialog />
|
||||||
</InsightsProvider>
|
</React.Suspense>
|
||||||
</SpaceLayoutContextProvider>
|
</InsightsProvider>
|
||||||
|
</SpaceLayoutContextProvider>
|
||||||
|
</AskStateProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,6 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
|
|||||||
import { Icon, type IconName } from '@gitbook/icons';
|
import { Icon, type IconName } from '@gitbook/icons';
|
||||||
import { Link, type LinkInsightsProps } from './Link';
|
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 = {
|
const variantClasses = {
|
||||||
primary: [
|
primary: [
|
||||||
'bg-primary-solid',
|
'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({
|
export function Button({
|
||||||
href,
|
href,
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
@@ -62,8 +62,14 @@ export function Button({
|
|||||||
...rest
|
...rest
|
||||||
}: ButtonProps & { target?: HTMLAttributeAnchorTarget }) {
|
}: ButtonProps & { target?: HTMLAttributeAnchorTarget }) {
|
||||||
const sizes = {
|
const sizes = {
|
||||||
default: ['text-base', 'font-semibold', 'px-5', 'py-2', 'circular-corners:px-6'],
|
default: [
|
||||||
medium: ['text-sm', 'px-3.5', 'py-1.5', 'circular-corners:px-4'],
|
'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'],
|
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.
|
* Variant when the page is displayed in print mode.
|
||||||
*/
|
*/
|
||||||
addVariant('print-mode', 'body:has(.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 }) => {
|
plugin(({ matchUtilities }) => {
|
||||||
matchUtilities({
|
matchUtilities({
|
||||||
|
|||||||
Reference in New Issue
Block a user