mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 23:25:16 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75e34fa299 | |||
| c8a2fd8351 | |||
| 9466913e18 | |||
| ef57c608a5 | |||
| d678a9162d | |||
| af7cadc8e9 | |||
| e2b2468475 | |||
| 11f4241709 | |||
| 5a410feda0 | |||
| 31bfe77f74 | |||
| 34dceb2f97 | |||
| cced4f0d78 |
@@ -26,7 +26,7 @@
|
||||
"name": "@gitbook/cache-tags",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/api": "^0.117.1",
|
||||
"assert-never": "^1.2.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -51,7 +51,7 @@
|
||||
"name": "gitbook",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/api": "^0.117.1",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
@@ -143,7 +143,7 @@
|
||||
"name": "gitbook-v2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/api": "^0.117.1",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@opennextjs/cloudflare": "1.1.0",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
@@ -202,7 +202,7 @@
|
||||
"name": "@gitbook/react-contentkit",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/api": "^0.117.1",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"classnames": "^2.5.1",
|
||||
},
|
||||
@@ -260,7 +260,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/api": "^0.117.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
},
|
||||
@@ -625,7 +625,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.115.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-Lyj+1WVNnE/Zuuqa/1ZdnUQfUiNE6es89RFK6CJ+Tb36TFwls6mbHKXCZsBwSYyoMYTVK39WQ3Nob6Nw6+TWCA=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.117.1", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-edyi4Y+Zs/9vdU6CvJfwtVSiEWrKzzlp+sIfsyw8WT+mJWnH9CN+o17nxvRWNmAznU+YZm+Qz7itVbG8k+vSkQ=="],
|
||||
|
||||
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"packageManager": "bun@1.2.11",
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/api": "^0.117.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/api": "^0.117.1",
|
||||
"assert-never": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/api": "^0.117.1",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@opennextjs/cloudflare": "1.1.0",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
|
||||
@@ -189,5 +189,7 @@ export interface GitBookDataFetcher {
|
||||
input: api.AIMessageInput[];
|
||||
output: api.AIOutputFormat;
|
||||
model: api.AIModel;
|
||||
tools?: api.AIToolCapabilities;
|
||||
previousResponseId?: string;
|
||||
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/api": "^0.117.1",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AIMessage } from '@gitbook/api';
|
||||
import { DocumentView } from '../DocumentView';
|
||||
|
||||
/**
|
||||
* Render a message from the API backend.
|
||||
*/
|
||||
export function MessageView(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>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
'use server';
|
||||
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
|
||||
import type { GitBookBaseContext } from '@v2/lib/context';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
{
|
||||
organizationId,
|
||||
siteId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
},
|
||||
{
|
||||
schema,
|
||||
messages,
|
||||
model = AIModel.Fast,
|
||||
}: {
|
||||
schema: z.ZodSchema<T>;
|
||||
messages: AIMessageInput[];
|
||||
model?: AIModel;
|
||||
previousResponseId?: string;
|
||||
}
|
||||
) {
|
||||
const rawStream = context.dataFetcher.streamAIResponse({
|
||||
organizationId,
|
||||
siteId,
|
||||
input: messages,
|
||||
output: {
|
||||
type: 'object',
|
||||
schema: zodToJsonSchema(schema),
|
||||
},
|
||||
model,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,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 { MessageView } from '../MessageView';
|
||||
|
||||
/**
|
||||
* 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 <MessageView 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 };
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './streamLinkPageSummary';
|
||||
export * from './api';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
import { getV1BaseContext } from '@/lib/v1';
|
||||
import { isV2 } from '@/lib/v2';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import { AIMessageRole, AIModel } from '@gitbook/api';
|
||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@v2/lib/server-actions';
|
||||
import { z } from 'zod';
|
||||
@@ -32,23 +32,18 @@ export async function* streamLinkPageSummary({
|
||||
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const { stream } = await streamGenerateObject(
|
||||
baseContext,
|
||||
{
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
highlight: z
|
||||
.string()
|
||||
.describe('The reason why the user should read the target page.'),
|
||||
// questions: z.array(z.string().describe('The questions to sea')).max(3),
|
||||
}),
|
||||
messages: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 1. Role
|
||||
const { stream } = await streamGenerateObject(baseContext, {
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
model: AIModel.Fast,
|
||||
schema: z.object({
|
||||
highlight: z.string().describe('The reason why the user should read the target page.'),
|
||||
// questions: z.array(z.string().describe('The questions to sea')).max(3),
|
||||
}),
|
||||
input: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 1. Role
|
||||
You are a contextual fact extractor. Your job is to find the exact fact from the linked page that directly answers the implied question in the current paragraph.
|
||||
|
||||
# 2. Task
|
||||
@@ -64,60 +59,60 @@ Extract a contextually-relevant fact that:
|
||||
3. Ensure the fact relates directly to the context of the paragraph containing the link
|
||||
4. Avoid ALL instructional language including words like "use", "click", "select", "create"
|
||||
5. Keep it under 30 words, factual and declarative about what EXISTS or IS TRUE`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 4. Current page
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 4. Current page
|
||||
The content of the current page is:`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId: currentSpaceId,
|
||||
pageId: currentPageId,
|
||||
},
|
||||
],
|
||||
},
|
||||
...(visitedPages
|
||||
? [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: '# 5. Previous pages',
|
||||
},
|
||||
...visitedPages.map(({ spaceId, pageId }) => ({
|
||||
role: AIMessageRole.Developer,
|
||||
content: `## Page ${pageId}`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId,
|
||||
pageId,
|
||||
},
|
||||
],
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 6. Target page
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId: currentSpaceId,
|
||||
pageId: currentPageId,
|
||||
},
|
||||
],
|
||||
},
|
||||
...(visitedPages
|
||||
? [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: '# 5. Previous pages',
|
||||
},
|
||||
...visitedPages.map(({ spaceId, pageId }) => ({
|
||||
role: AIMessageRole.Developer,
|
||||
content: `## Page ${pageId}`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId,
|
||||
pageId,
|
||||
},
|
||||
],
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 6. Target page
|
||||
The content of the target page is:`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId: targetSpaceId,
|
||||
pageId: targetPageId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 7. Link preview
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId: targetSpaceId,
|
||||
pageId: targetPageId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 7. Link preview
|
||||
The content of the link preview is:
|
||||
> ${linkPreview}
|
||||
> Page ID: ${targetPageId}`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 8. Guidelines & Examples
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 8. Guidelines & Examples
|
||||
ALWAYS:
|
||||
- ALWAYS choose facts that directly fulfill the contextual need where the link appears
|
||||
- ALWAYS connect target page information specifically to the current paragraph context
|
||||
@@ -146,14 +141,13 @@ Current paragraph: "Your team mentioned issues with conflicting edits. Need to c
|
||||
Preview: "Live Edit: Real-time collaborative editing."
|
||||
✓ "Teams with GitHub repositories (like yours) cannot use this feature due to sync limitations."
|
||||
✗ "Incompatible with GitHub/GitLab sync and requires specific visibility settings."`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: `I'm considering reading the link titled "${linkTitle}" pointing to page ${targetPageId}. Why should I read it? Relate it to the paragraph I'm currently reading.`,
|
||||
},
|
||||
].filter(filterOutNullable),
|
||||
}
|
||||
);
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: `I'm considering reading the link titled "${linkTitle}" pointing to page ${targetPageId}. Why should I read it? Relate it to the paragraph I'm currently reading.`,
|
||||
},
|
||||
].filter(filterOutNullable),
|
||||
});
|
||||
|
||||
for await (const value of stream) {
|
||||
const highlight = value.highlight;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
/* Light mode */
|
||||
::-webkit-scrollbar {
|
||||
@apply bg-tint-subtle;
|
||||
@apply bg-tint-subtle z-50;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ export function HighlightQuery(props: {
|
||||
'text-bold',
|
||||
'bg-primary',
|
||||
'text-contrast-primary',
|
||||
'px-0.5',
|
||||
'-mx-0.5',
|
||||
'px-1',
|
||||
'py-0.5',
|
||||
'rounded',
|
||||
'straight-corners:rounded-sm',
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import React from 'react';
|
||||
|
||||
import { Loading } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import React from 'react';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Link } from '../primitives';
|
||||
import { useSearchAskContext } from './SearchAskContext';
|
||||
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
|
||||
import { useSearch, useSearchLink } from './useSearch';
|
||||
|
||||
export type SearchAskState =
|
||||
| {
|
||||
type: 'answer';
|
||||
@@ -88,13 +86,22 @@ export function SearchAskAnswer(props: { query: string }) {
|
||||
}, [setAskState]);
|
||||
|
||||
const loading = (
|
||||
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
|
||||
<Loading className={tcls('w-6', 'py-8', 'text-primary-subtle')} />
|
||||
<div key="loading" className={tcls('flex', 'flex-wrap', 'gap-2')}>
|
||||
{[...Array(9)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 animate-[fadeIn_0.5s_ease-in-out_both,pulse_2s_ease-in-out_infinite] rounded straight-corners:rounded-none bg-tint-active"
|
||||
style={{
|
||||
animationDelay: `${index * 0.1}s,${0.5 + index * 0.1}s`,
|
||||
width: `${((index % 5) + 1) * 15}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={tcls('max-h-[60vh]', 'overflow-y-auto')}>
|
||||
<motion.div className={tcls('mx-auto w-full max-w-prose')} layout="position">
|
||||
{askState?.type === 'answer' ? (
|
||||
<React.Suspense fallback={loading}>
|
||||
<TransitionAnswerBody answer={askState.answer} placeholder={loading} />
|
||||
@@ -104,7 +111,7 @@ export function SearchAskAnswer(props: { query: string }) {
|
||||
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
|
||||
) : null}
|
||||
{askState?.type === 'loading' ? loading : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,10 +145,7 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="search-ask-answer"
|
||||
className={tcls('my-4', 'sm:mt-6', 'px-4', 'sm:px-12', 'text-tint-strong')}
|
||||
>
|
||||
<div data-testid="search-ask-answer" className={tcls('text-tint-strong')}>
|
||||
{answer.body ?? t(language, 'search_ask_no_answer')}
|
||||
{answer.followupQuestions.length > 0 ? (
|
||||
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
|
||||
@@ -182,7 +186,6 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
|
||||
)}
|
||||
{...getSearchLinkProps({
|
||||
query: question,
|
||||
ask: true,
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
|
||||
const onClick = () => {
|
||||
setSearchState({
|
||||
ask: false,
|
||||
mode: 'both',
|
||||
global: false,
|
||||
query: '',
|
||||
});
|
||||
@@ -99,7 +99,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcut() {
|
||||
export function Shortcut() {
|
||||
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useVisitedPages } from '../Insights/useVisitedPages';
|
||||
import { Button } from '../primitives';
|
||||
import { Shortcut } from './SearchButton';
|
||||
import { isQuestion } from './isQuestion';
|
||||
import { streamAISearchAnswer, streamAISearchSummary } from './server-actions';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
// Types
|
||||
type Message = {
|
||||
role: 'assistant' | 'user';
|
||||
content?: string;
|
||||
context?: string;
|
||||
fetching?: boolean;
|
||||
};
|
||||
|
||||
// Loading animation component
|
||||
function LoadingAnimation() {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{[...Array(9)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 animate-[fadeIn_0.5s_ease-in-out_both,pulse_2s_ease-in-out_infinite] rounded straight-corners:rounded-none bg-tint-active"
|
||||
style={{
|
||||
animationDelay: `${index * 0.1}s,${0.5 + index * 0.1}s`,
|
||||
width: `${((index % 5) + 1) * 15}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Followup questions component
|
||||
function FollowupQuestions({
|
||||
questions,
|
||||
onQuestionClick,
|
||||
}: {
|
||||
questions: string[];
|
||||
onQuestionClick: (question: string) => void;
|
||||
}) {
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-prose flex-col">
|
||||
{questions.map((question) => (
|
||||
<button
|
||||
type="button"
|
||||
key={question}
|
||||
className="-mx-4 flex items-center gap-4 rounded straight-corners:rounded-none px-4 py-2 text-tint hover:bg-tint-hover"
|
||||
onClick={() => onQuestionClick(question)}
|
||||
>
|
||||
<Icon icon="search" className="size-4" /> {question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual chat message component
|
||||
function ChatMessage({
|
||||
message,
|
||||
}: {
|
||||
message: Message;
|
||||
}) {
|
||||
const language = useLanguage();
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
<div className={tcls('flex-col gap-1', isUser && 'items-end gap-1 self-end')}>
|
||||
<h5 className="flex items-center gap-1 font-semibold text-tint-subtle text-xs">
|
||||
{isUser ? (
|
||||
(message.context ??
|
||||
`You asked ${isQuestion(message.content ?? '') ? '' : 'about'}`)
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="sparkle" className="mt-0.5 size-3" />
|
||||
{message.context ?? 'AI Answer'}
|
||||
</>
|
||||
)}
|
||||
</h5>
|
||||
|
||||
{message.fetching ? (
|
||||
<LoadingAnimation />
|
||||
) : !message.content ? (
|
||||
<div className="text-tint-subtle italic">{t(language, 'search_ask_no_answer')}</div>
|
||||
) : (
|
||||
<div className={tcls(isUser && 'rounded-lg bg-tint-active px-4 py-2')}>
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chat input component
|
||||
function ChatInput({
|
||||
onSendMessage,
|
||||
disabled,
|
||||
inputRef,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void;
|
||||
disabled: boolean;
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const handleSend = () => {
|
||||
if (!inputValue.trim()) return;
|
||||
onSendMessage(inputValue);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex grow">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Ask a follow-up question"
|
||||
className="grow rounded px-4 py-1 ring-1 ring-tint-subtle"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
disabled={disabled}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!disabled && (
|
||||
<div className="-translate-y-1/2 absolute top-1/2 right-2.5">
|
||||
<Shortcut />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
label="Send"
|
||||
iconOnly
|
||||
icon="arrow-up"
|
||||
size="medium"
|
||||
className="shrink-0"
|
||||
onClick={handleSend}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom hook for AI streaming
|
||||
function useAIStream({
|
||||
question,
|
||||
previousResponseId,
|
||||
}: {
|
||||
question?: string;
|
||||
previousResponseId?: string;
|
||||
}) {
|
||||
const [response, setResponse] = useState<{
|
||||
content?: React.ReactNode;
|
||||
responseId?: string;
|
||||
followupQuestions?: string[];
|
||||
fetching: boolean;
|
||||
}>({
|
||||
fetching: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!question) return;
|
||||
|
||||
let cancelled = false;
|
||||
setResponse({ fetching: true });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const stream = await streamAISearchAnswer({
|
||||
question,
|
||||
previousResponseId,
|
||||
});
|
||||
|
||||
for await (const data of stream) {
|
||||
if (cancelled) break;
|
||||
if (!data) continue;
|
||||
|
||||
setResponse((prev) => {
|
||||
const updated = { ...prev, fetching: false };
|
||||
|
||||
if (data.responseId) {
|
||||
updated.responseId = String(data.responseId);
|
||||
}
|
||||
|
||||
if (data.answer) {
|
||||
updated.content = data.answer;
|
||||
}
|
||||
|
||||
if (data.followupQuestions) {
|
||||
updated.followupQuestions = data.followupQuestions;
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in AI stream:', error);
|
||||
setResponse((prev) => ({ ...prev, fetching: false }));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [question, previousResponseId]);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Summary hook
|
||||
function useSummary(visitedPages: any[]) {
|
||||
const [summary, setSummary] = useState<React.ReactNode | undefined>(undefined);
|
||||
const [summaryResponseId, setSummaryResponseId] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const stream = await streamAISearchSummary({ visitedPages });
|
||||
|
||||
for await (const data of stream) {
|
||||
if (cancelled) break;
|
||||
if (!data) continue;
|
||||
|
||||
if (data.responseId) {
|
||||
setSummaryResponseId(String(data.responseId));
|
||||
}
|
||||
|
||||
if (data.output) {
|
||||
setSummary(data.output);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in summary stream:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visitedPages]);
|
||||
|
||||
return { summary, summaryResponseId };
|
||||
}
|
||||
|
||||
// Main component
|
||||
export function SearchChat(props: {
|
||||
query: string;
|
||||
chatInputRef?: React.RefObject<HTMLInputElement>;
|
||||
}) {
|
||||
const { query, chatInputRef } = props;
|
||||
const visitedPages = useVisitedPages((state) => state.pages);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [followupQuestions, setFollowupQuestions] = useState<string[]>([]);
|
||||
const [conversationResponseId, setConversationResponseId] = useState<string | undefined>();
|
||||
const [searchState, setSearchState] = useSearch();
|
||||
const latestMessageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isExpanded = searchState?.mode === 'chat';
|
||||
|
||||
// Get summary of visited pages
|
||||
const { summary, summaryResponseId } = useSummary(visitedPages);
|
||||
|
||||
// Handle initial query
|
||||
const initialResponse = useAIStream({
|
||||
question: query,
|
||||
previousResponseId: summaryResponseId,
|
||||
});
|
||||
|
||||
// Set up initial query effect
|
||||
useEffect(() => {
|
||||
if (!query) return;
|
||||
|
||||
// Add initial assistant message
|
||||
setMessages([
|
||||
{
|
||||
role: 'assistant',
|
||||
context: `You asked ${isQuestion(query) ? '' : 'about'} "${query}"`,
|
||||
fetching: true,
|
||||
},
|
||||
]);
|
||||
|
||||
setFollowupQuestions([]);
|
||||
setConversationResponseId(undefined);
|
||||
}, [query]);
|
||||
|
||||
// Update message when initial response changes
|
||||
useEffect(() => {
|
||||
if (!query || !initialResponse) return;
|
||||
|
||||
if (initialResponse.content !== undefined) {
|
||||
setMessages([
|
||||
{
|
||||
role: 'assistant',
|
||||
context: `You asked ${isQuestion(query) ? '' : 'about'} "${query}"`,
|
||||
content: initialResponse.content,
|
||||
fetching: initialResponse.fetching,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (initialResponse.followupQuestions) {
|
||||
setFollowupQuestions(initialResponse.followupQuestions);
|
||||
}
|
||||
|
||||
if (initialResponse.responseId) {
|
||||
setConversationResponseId(initialResponse.responseId);
|
||||
}
|
||||
}, [initialResponse, query]);
|
||||
|
||||
// Handle follow-up messages
|
||||
const handleSendMessage = (message: string) => {
|
||||
// Add user message
|
||||
const newMessages: Message[] = [
|
||||
...messages,
|
||||
{ role: 'user', content: message, fetching: false },
|
||||
{ role: 'assistant', fetching: true },
|
||||
];
|
||||
|
||||
setMessages(newMessages);
|
||||
setFollowupQuestions([]);
|
||||
if (!searchState?.manual) {
|
||||
setSearchState((state) => (state ? { ...state, mode: 'chat' } : null));
|
||||
}
|
||||
|
||||
// Get AI response
|
||||
const cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const stream = await streamAISearchAnswer({
|
||||
question: message,
|
||||
previousResponseId: conversationResponseId,
|
||||
});
|
||||
|
||||
for await (const rawData of stream) {
|
||||
if (cancelled) break;
|
||||
if (!rawData) continue;
|
||||
|
||||
// Use type assertion
|
||||
const data = rawData as any;
|
||||
|
||||
if (data.responseId) {
|
||||
setConversationResponseId(String(data.responseId));
|
||||
}
|
||||
|
||||
if (data.answer !== undefined) {
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{ role: 'assistant', content: data.answer, fetching: false },
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.followupQuestions && Array.isArray(data.followupQuestions)) {
|
||||
setFollowupQuestions(data.followupQuestions.filter(filterOutNullable));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in follow-up stream:', error);
|
||||
// Update the message to show an error state
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{ role: 'assistant', fetching: false },
|
||||
]);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// Handle followup question click
|
||||
const handleFollowupClick = (question: string) => {
|
||||
handleSendMessage(question);
|
||||
};
|
||||
|
||||
// Auto-scroll to latest message
|
||||
useEffect(() => {
|
||||
if (latestMessageRef.current) {
|
||||
latestMessageRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Toggle button for showing search results */}
|
||||
{searchState?.mode === 'chat' && (
|
||||
<div
|
||||
className="absolute top-2 animate-fadeIn max-md:right-4 md:top-4 md:left-4"
|
||||
style={{ animationDelay: '500ms' }}
|
||||
>
|
||||
<Button
|
||||
label="Show search results"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
iconOnly
|
||||
icon="arrow-down-from-line"
|
||||
className="md:hidden"
|
||||
onClick={() => {
|
||||
setSearchState((state) =>
|
||||
state ? { ...state, mode: 'both', manual: true } : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Show search results"
|
||||
iconOnly
|
||||
variant="blank"
|
||||
size="default"
|
||||
icon="sidebar"
|
||||
className="hidden px-2 md:block"
|
||||
onClick={() => {
|
||||
setSearchState((state) =>
|
||||
state ? { ...state, mode: 'both', manual: true } : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main chat area */}
|
||||
<div
|
||||
className={tcls(
|
||||
'mx-auto flex w-full grow scroll-pt-8 flex-col gap-4 overflow-y-auto p-8 transition-all delay-200 duration-500',
|
||||
isExpanded && 'md:px-16'
|
||||
)}
|
||||
ref={latestMessageRef}
|
||||
>
|
||||
{/* Summary section */}
|
||||
<div className="mx-auto w-full max-w-prose">
|
||||
<h5 className="mb-1 flex items-center gap-1 font-semibold text-tint-subtle text-xs">
|
||||
<Icon icon="glasses-round" className="mt-0.5 size-3" /> Summary of what
|
||||
you've read
|
||||
</h5>
|
||||
{summary ? summary : <LoadingAnimation />}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{messages.map((message, index) => {
|
||||
const isLast = index === messages.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
ref={isLast ? latestMessageRef : undefined}
|
||||
className={tcls(
|
||||
'mx-auto flex flex w-full max-w-prose flex-col gap-4',
|
||||
isLast && 'min-h-[calc(100%-2rem)]'
|
||||
)}
|
||||
>
|
||||
<ChatMessage message={message} />
|
||||
{isLast && followupQuestions && followupQuestions.length > 0 && (
|
||||
<FollowupQuestions
|
||||
questions={followupQuestions}
|
||||
onQuestionClick={handleFollowupClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
{query && (
|
||||
<div
|
||||
className={tcls(
|
||||
'border-tint-subtle border-t bg-tint-subtle px-8 py-4 transition-all delay-200 duration-500',
|
||||
isExpanded && 'md:px-16'
|
||||
)}
|
||||
>
|
||||
<div className={tcls('mx-auto flex w-full max-w-prose flex-col gap-2')}>
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
disabled={!conversationResponseId}
|
||||
inputRef={chatInputRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
@@ -8,10 +6,9 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { LoadingPane } from '../primitives/LoadingPane';
|
||||
import { SearchAskAnswer } from './SearchAskAnswer';
|
||||
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
||||
import { SearchChat } from './SearchChat';
|
||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||
import { SearchScopeToggle } from './SearchScopeToggle';
|
||||
import { type SearchState, type UpdateSearchState, useSearch } from './useSearch';
|
||||
@@ -30,14 +27,21 @@ export function SearchModal(props: SearchModalProps) {
|
||||
const searchAsk = useSearchAskState();
|
||||
const [askState] = searchAsk;
|
||||
const router = useRouter();
|
||||
const chatInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
useHotkeys(
|
||||
'mod+k',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setSearchState({ ask: false, query: '', global: false });
|
||||
if (state !== null) {
|
||||
// If search is already open, focus the chat input
|
||||
chatInputRef.current?.focus();
|
||||
} else {
|
||||
// Otherwise open the search modal
|
||||
setSearchState({ mode: 'both', query: '', global: false });
|
||||
}
|
||||
},
|
||||
[]
|
||||
[state]
|
||||
);
|
||||
|
||||
// Add a global class on the body when the search modal is open
|
||||
@@ -125,6 +129,7 @@ export function SearchModal(props: SearchModalProps) {
|
||||
state={state}
|
||||
setSearchState={setSearchState}
|
||||
onClose={onClose}
|
||||
chatInputRef={chatInputRef}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -139,9 +144,11 @@ function SearchModalBody(
|
||||
state: SearchState;
|
||||
setSearchState: UpdateSearchState;
|
||||
onClose: (to?: string) => void;
|
||||
chatInputRef: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
) {
|
||||
const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose } = props;
|
||||
const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose, chatInputRef } =
|
||||
props;
|
||||
|
||||
const language = useLanguage();
|
||||
const resultsRef = React.useRef<SearchResultsRef>(null);
|
||||
@@ -165,6 +172,12 @@ function SearchModalBody(
|
||||
}, [onClose]);
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Handle second Cmd+K
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
chatInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
resultsRef.current?.moveUp();
|
||||
@@ -179,7 +192,7 @@ function SearchModalBody(
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchState({
|
||||
ask: false, // When typing, we go back to the default search mode
|
||||
mode: 'both', // When typing, we go back to the default search mode
|
||||
query: event.target.value,
|
||||
global: state.global,
|
||||
});
|
||||
@@ -219,9 +232,10 @@ function SearchModalBody(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'bg-tint-base',
|
||||
'max-w-prose',
|
||||
'max-w-screen-lg',
|
||||
'mx-auto',
|
||||
'max-h-[70dvh]',
|
||||
// 'min-h-[50dvh]',
|
||||
'h-[70dvh]',
|
||||
'w-full',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-sm',
|
||||
@@ -242,12 +256,10 @@ function SearchModalBody(
|
||||
'flex-row',
|
||||
'items-start',
|
||||
state.query !== null ? 'border-b' : null,
|
||||
'border-tint-subtle'
|
||||
'border-tint-subtle',
|
||||
'col-span-full'
|
||||
)}
|
||||
>
|
||||
<div className={tcls('p-2', 'pl-4', 'pt-4')}>
|
||||
<Icon icon="magnifying-glass" className={tcls('size-4', 'text-tint-subtle')} />
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'w-full',
|
||||
@@ -270,8 +282,8 @@ function SearchModalBody(
|
||||
'flex',
|
||||
'resize-none',
|
||||
'flex-1',
|
||||
'h-12',
|
||||
'p-2',
|
||||
'py-4',
|
||||
'px-8',
|
||||
'focus:outline-none',
|
||||
'bg-transparent',
|
||||
'whitespace-pre-line'
|
||||
@@ -287,18 +299,38 @@ function SearchModalBody(
|
||||
{isMultiVariants ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{!state.ask || !withAsk ? (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
global={isMultiVariants && state.global}
|
||||
query={normalizedQuery}
|
||||
withAsk={withAsk}
|
||||
onSwitchToAsk={onSwitchToAsk}
|
||||
/>
|
||||
) : null}
|
||||
{normalizedQuery && state.ask && withAsk ? (
|
||||
<SearchAskAnswer query={normalizedQuery} />
|
||||
) : null}
|
||||
<div className={tcls('flex grow flex-col overflow-hidden md:flex-row')}>
|
||||
<div
|
||||
key="results"
|
||||
className={tcls(
|
||||
'h-full w-full flex-1 overflow-y-auto transition-all duration-500 ease-[cubic-bezier(0.85,0,0.15,1)] *:transition-opacity *:delay-200 *:duration-300',
|
||||
state.mode === 'chat' && 'flex-[0] delay-200 *:opacity-0 *:delay-0'
|
||||
)}
|
||||
aria-hidden={state.mode === 'chat' ? 'true' : undefined}
|
||||
>
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
global={isMultiVariants && state.global}
|
||||
query={normalizedQuery}
|
||||
withAsk={withAsk}
|
||||
onSwitchToAsk={onSwitchToAsk}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
key="chat"
|
||||
className={tcls(
|
||||
'relative h-full w-full flex-1 overflow-y-auto overflow-x-hidden bg-tint-subtle transition-colors duration-500 *:transition-opacity *:delay-200 *:duration-300 max-md:border-t md:border-l',
|
||||
state.mode === 'results' && 'flex-[0] *:opacity-0 *:delay-0',
|
||||
state.mode === 'both'
|
||||
? 'border-tint-subtle'
|
||||
: 'border-transparent delay-500'
|
||||
)}
|
||||
aria-hidden={state.mode === 'results' ? 'true' : undefined}
|
||||
>
|
||||
<SearchChat query={normalizedQuery} chatInputRef={chatInputRef} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { Button, Link } from '../primitives';
|
||||
import { HighlightQuery } from './HighlightQuery';
|
||||
import type { ComputedPageResult } from './server-actions';
|
||||
|
||||
@@ -14,6 +16,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
},
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
const language = useLanguage();
|
||||
const { query, item, active } = props;
|
||||
|
||||
const breadcrumbs =
|
||||
@@ -34,16 +37,19 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'p-4',
|
||||
'border-t',
|
||||
'border-tint-subtle',
|
||||
'first:border-none',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-none',
|
||||
'text-base',
|
||||
'font-medium',
|
||||
'text-tint-strong',
|
||||
'hover:bg-tint-hover',
|
||||
'group',
|
||||
active
|
||||
? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover']
|
||||
: null
|
||||
active && [
|
||||
'is-active',
|
||||
'bg-primary',
|
||||
'text-primary-strong',
|
||||
'hover:bg-primary-hover',
|
||||
]
|
||||
)}
|
||||
insights={{
|
||||
type: 'search_open_result',
|
||||
@@ -56,8 +62,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
>
|
||||
<div className="size-4">
|
||||
<Icon
|
||||
icon="file-lines"
|
||||
className={tcls('size-4', active ? 'text-primary' : 'text-tint-subtle')}
|
||||
icon="file"
|
||||
className={tcls('size-4', active ? 'text-primary-subtle' : 'text-tint-subtle')}
|
||||
/>
|
||||
</div>
|
||||
<div className={tcls('flex', 'flex-col', 'w-full')}>
|
||||
@@ -65,7 +71,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
<div
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
'opacity-6',
|
||||
active ? 'text-primary-subtle' : 'text-neutral-subtle',
|
||||
// 'opacity-6',
|
||||
'contrast-more:opacity-11',
|
||||
'font-normal',
|
||||
'uppercase',
|
||||
@@ -103,19 +110,15 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
) : null}
|
||||
<HighlightQuery query={query} text={item.title} />
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'p-2',
|
||||
'rounded',
|
||||
'straight-corners:rounded-none',
|
||||
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
|
||||
className={tcls('size-4')}
|
||||
{active ? (
|
||||
<Button
|
||||
icon="arrow-turn-down-left"
|
||||
size="small"
|
||||
label={tString(language, 'view')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { Button, Link } from '../primitives';
|
||||
import { useSearchLink } from './useSearch';
|
||||
|
||||
export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem(
|
||||
@@ -28,19 +28,20 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
className={tcls(
|
||||
'flex',
|
||||
'px-4',
|
||||
recommended ? ['py-2', 'text-tint'] : 'py-4',
|
||||
'py-2',
|
||||
'text-tint',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-none',
|
||||
'hover:bg-tint-hover',
|
||||
'first:mt-0',
|
||||
'last:pb-3',
|
||||
'gap-4',
|
||||
active && [
|
||||
'is-active',
|
||||
'bg-primary',
|
||||
'text-contrast-primary',
|
||||
'text-primary-strong',
|
||||
'hover:bg-primary-hover',
|
||||
]
|
||||
)}
|
||||
{...getLinkProp({
|
||||
ask: true,
|
||||
query: question,
|
||||
})}
|
||||
>
|
||||
@@ -50,7 +51,6 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
'size-4',
|
||||
'shrink-0',
|
||||
'mt-1.5',
|
||||
'mr-4',
|
||||
active ? ['text-primary'] : ['text-tint-subtle']
|
||||
)}
|
||||
/>
|
||||
@@ -66,19 +66,16 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'p-2',
|
||||
'rounded',
|
||||
'self-center',
|
||||
'straight-corners:rounded-none',
|
||||
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
|
||||
<div className="self-center">
|
||||
{active ? (
|
||||
<Button
|
||||
icon="arrow-turn-down-left"
|
||||
size="small"
|
||||
label={tString(language, 'search')}
|
||||
/>
|
||||
) : (
|
||||
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
|
||||
className={tcls('size-4')}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import React from 'react';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Loading } from '../primitives';
|
||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
searchSiteSpaceContent,
|
||||
streamRecommendedQuestions,
|
||||
} from './server-actions';
|
||||
import { type SearchState, useSearch } from './useSearch';
|
||||
|
||||
export interface SearchResultsRef {
|
||||
moveUp(): void;
|
||||
@@ -44,7 +46,6 @@ let cachedRecommendedQuestions: null | ResultType[] = null;
|
||||
*/
|
||||
export const SearchResults = React.forwardRef(function SearchResults(
|
||||
props: {
|
||||
children?: React.ReactNode;
|
||||
query: string;
|
||||
global: boolean;
|
||||
withAsk: boolean;
|
||||
@@ -52,7 +53,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>
|
||||
) {
|
||||
const { children, query, withAsk, global, onSwitchToAsk } = props;
|
||||
const { query, withAsk, global, onSwitchToAsk } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
@@ -62,6 +63,39 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
}>({ results: [], fetching: true });
|
||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
||||
const [searchState, setSearchState] = useSearch();
|
||||
const manualStateRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchState?.manual !== undefined) {
|
||||
manualStateRef.current = searchState.manual;
|
||||
}
|
||||
}, [searchState?.manual]);
|
||||
|
||||
const results: ResultType[] = React.useMemo(() => resultsState.results, [resultsState.results]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
// Reset the cursor when there's no query
|
||||
setCursor(null);
|
||||
} else if (!searchState?.manual && !resultsState.fetching && results.length === 0) {
|
||||
setSearchState((prev) => {
|
||||
const newState: SearchState | null = prev
|
||||
? { ...prev, mode: 'chat' as const }
|
||||
: null;
|
||||
return newState;
|
||||
});
|
||||
} else if (results.length > 0) {
|
||||
// Auto-focus the first result
|
||||
setSearchState((prev) => {
|
||||
const newState: SearchState | null = prev
|
||||
? { ...prev, mode: 'both' as const }
|
||||
: null;
|
||||
return newState;
|
||||
});
|
||||
setCursor(0);
|
||||
}
|
||||
}, [results, query, setSearchState, resultsState.fetching, searchState?.manual]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
@@ -78,7 +112,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
let cancelled = false;
|
||||
|
||||
// Silently fetch the recommended questions, instead of showing a spinner
|
||||
setResultsState({ results: [], fetching: false });
|
||||
// setResultsState({ results: [], fetching: false });
|
||||
|
||||
// We currently have a bug where the same question can be returned multiple times.
|
||||
// This is a workaround to avoid that.
|
||||
@@ -150,23 +184,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
};
|
||||
}, [query, global, withAsk, trackEvent]);
|
||||
|
||||
const results: ResultType[] = React.useMemo(() => {
|
||||
if (!withAsk) {
|
||||
return resultsState.results;
|
||||
}
|
||||
return withQuestionResult(resultsState.results, query);
|
||||
}, [resultsState.results, query, withAsk]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
// Reset the cursor when there's no query
|
||||
setCursor(null);
|
||||
} else if (results.length > 0) {
|
||||
// Auto-focus the first result
|
||||
setCursor(0);
|
||||
}
|
||||
}, [results, query]);
|
||||
|
||||
// Scroll to the active result.
|
||||
React.useEffect(() => {
|
||||
if (cursor === null || !refs.current[cursor]) {
|
||||
@@ -214,106 +231,93 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
[moveBy, select]
|
||||
);
|
||||
|
||||
if (resultsState.fetching) {
|
||||
return (
|
||||
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8')}>
|
||||
<Loading className={tcls('w-6', 'text-primary')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const loading = (
|
||||
<div className={tcls('flex', 'items-center', 'justify-center', 'p-8')}>
|
||||
<Loading className={tcls('w-6', 'text-primary-subtle')} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const noResults = (
|
||||
<div className={tcls('text', 'text-tint', 'p-8', 'text-center')}>
|
||||
{t(language, 'search_no_results', query)}
|
||||
<div className={tcls('text', 'text-tint', 'text-center', 'p-8')}>
|
||||
<div className="animate-fadeIn" style={{ animationDelay: '0.5s' }}>
|
||||
{t(language, 'search_no_results', query)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={tcls('overflow-auto')}>
|
||||
{children}
|
||||
{results.length === 0 ? (
|
||||
query ? (
|
||||
noResults
|
||||
) : null
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{resultsState.fetching ? (
|
||||
loading
|
||||
) : query && results.length === 0 ? (
|
||||
noResults
|
||||
) : (
|
||||
<>
|
||||
<div data-testid="search-results">
|
||||
{results.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'page': {
|
||||
return (
|
||||
<SearchPageResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={query}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'recommended-question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={item.question}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
recommended
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'section': {
|
||||
return (
|
||||
<SearchSectionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(item);
|
||||
<motion.div
|
||||
layout="position"
|
||||
className="flex flex-col gap-2 p-4"
|
||||
data-testid="search-results"
|
||||
>
|
||||
{results.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'page': {
|
||||
return (
|
||||
<SearchPageResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{!results.some((result) => result.type !== 'question') && noResults}
|
||||
</>
|
||||
case 'question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={query}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'recommended-question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={item.question}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
recommended
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'section': {
|
||||
return (
|
||||
<SearchSectionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(item);
|
||||
}
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Add a "Ask <question>" item at the top of the results list.
|
||||
*/
|
||||
function withQuestionResult(results: ResultType[], query: string): ResultType[] {
|
||||
const without = results.filter((result) => result.type !== 'question');
|
||||
|
||||
if (query.length === 0) {
|
||||
return without;
|
||||
}
|
||||
|
||||
return [{ type: 'question', id: 'question', query }, ...(without ?? [])];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { Button, Link } from '../primitives';
|
||||
import { HighlightQuery } from './HighlightQuery';
|
||||
import type { ComputedSectionResult } from './server-actions';
|
||||
|
||||
@@ -16,13 +17,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
const { query, item, active } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={item.href}
|
||||
className={tcls(
|
||||
'[&:has(+:not(&))]:mb-6',
|
||||
// '[&:has(+:not(&))]:mb-6',
|
||||
'-mt-2',
|
||||
'flex',
|
||||
'items-center',
|
||||
'pl-6',
|
||||
@@ -33,6 +36,8 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
||||
'font-normal',
|
||||
'py-2',
|
||||
'group',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-none',
|
||||
active && [
|
||||
'is-active',
|
||||
'bg-primary',
|
||||
@@ -72,20 +77,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'p-2',
|
||||
'rounded',
|
||||
'straight-corners:rounded-none',
|
||||
'bg-primary-solid',
|
||||
'text-contrast-primary-solid',
|
||||
'hidden',
|
||||
'sm:block',
|
||||
active ? ['opacity-11', 'block'] : ['opacity-0']
|
||||
)}
|
||||
>
|
||||
<Icon icon="arrow-turn-down-left" className={tcls('size-4')} />
|
||||
</div>
|
||||
{active ? (
|
||||
<Button
|
||||
icon="arrow-turn-down-left"
|
||||
size="small"
|
||||
label={tString(language, 'view')}
|
||||
/>
|
||||
) : (
|
||||
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const questionWords = new Set([
|
||||
* Return true if an input query looks like a question.
|
||||
*/
|
||||
export function isQuestion(query: string): boolean {
|
||||
if (query.length > 25 || query.includes('?') || query.includes(' ')) {
|
||||
if ((query.length > 25 && query.includes(' ')) || query.includes('?')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@ import { resolvePageId } from '@/lib/pages';
|
||||
import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
import { getV1BaseContext } from '@/lib/v1';
|
||||
import type {
|
||||
RevisionPage,
|
||||
SearchAIAnswer,
|
||||
SearchAIRecommendedQuestionStream,
|
||||
SearchPageResult,
|
||||
SearchSpaceResult,
|
||||
SiteSection,
|
||||
SiteSectionGroup,
|
||||
Space,
|
||||
import {
|
||||
AIMessageRole,
|
||||
AIModel,
|
||||
type RevisionPage,
|
||||
type SearchAIAnswer,
|
||||
type SearchAIRecommendedQuestionStream,
|
||||
type SearchPageResult,
|
||||
type SearchSpaceResult,
|
||||
type SiteSection,
|
||||
type SiteSectionGroup,
|
||||
type Space,
|
||||
} from '@gitbook/api';
|
||||
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
|
||||
@@ -24,6 +26,8 @@ import { isV2 } from '@/lib/v2';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { throwIfDataError } from '@v2/lib/data';
|
||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||
import { z } from 'zod';
|
||||
import { streamGenerateDocument, streamGenerateObject } from '../Adaptive/server-actions';
|
||||
import { DocumentView } from '../DocumentView';
|
||||
|
||||
export type OrderedComputedResult = ComputedPageResult | ComputedSectionResult;
|
||||
@@ -410,3 +414,119 @@ async function transformSitePageResult(
|
||||
|
||||
return [page, ...pageSections];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an AI-generated answer to a search query.
|
||||
*/
|
||||
export async function* streamAISearchSummary({
|
||||
visitedPages,
|
||||
}: {
|
||||
visitedPages: { spaceId: string; pageId: 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,
|
||||
input: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content:
|
||||
'Summarise the most important information the user has learned from the provided context. Be concise and focus on facts. Do not add commentary, adjectives or other empty descriptors.',
|
||||
attachments: visitedPages.map(({ spaceId, pageId }) => ({
|
||||
type: 'page' as const,
|
||||
spaceId,
|
||||
pageId,
|
||||
})),
|
||||
},
|
||||
].filter(filterOutNullable),
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an AI-generated answer to a search query.
|
||||
*/
|
||||
export async function* streamAISearchAnswer({
|
||||
question,
|
||||
previousResponseId,
|
||||
}: {
|
||||
question: string;
|
||||
previousResponseId?: string;
|
||||
}) {
|
||||
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const mainResponse = await streamGenerateDocument(baseContext, {
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
model: AIModel.Fast,
|
||||
tools: {
|
||||
search: true,
|
||||
getPageContent: true,
|
||||
},
|
||||
previousResponseId: previousResponseId,
|
||||
input: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content:
|
||||
'Answer the following question by using the provided documentation or by searching. Format the answer in Markdown.',
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: question,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for await (const answer of mainResponse.stream) {
|
||||
yield { answer };
|
||||
}
|
||||
|
||||
// Wait for the responseId to be available and yield one final time
|
||||
const { responseId } = await mainResponse.response;
|
||||
yield { responseId };
|
||||
|
||||
// Generate a list of follow-up questions
|
||||
const followupQuestionsResponse = await streamGenerateObject(baseContext, {
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
model: AIModel.Fast,
|
||||
tools: {
|
||||
search: true,
|
||||
getPageContent: true,
|
||||
},
|
||||
previousResponseId: responseId,
|
||||
schema: z.object({
|
||||
followupQuestions: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Follow-up questions to the question, based on the provided content only. Keep questions very short and use pronouns to refer to known concepts.'
|
||||
)
|
||||
.max(3),
|
||||
}),
|
||||
input: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `Based on the previously asked question and the answer, generate a list of follow-up questions. Keep questions very short and use pronouns to refer to known concepts. Check first that you can answer the question given the provided context before listing it as a follow-up question. If you can't answer a question, don't include it in the follow up questions.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for await (const partial of followupQuestionsResponse.stream) {
|
||||
yield {
|
||||
followupQuestions: (partial.followupQuestions ?? []).filter(
|
||||
filterOutNullable
|
||||
) as string[],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { parseAsBoolean, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs';
|
||||
import React from 'react';
|
||||
|
||||
import type { LinkProps } from '../primitives';
|
||||
|
||||
export interface SearchState {
|
||||
query: string;
|
||||
ask: boolean;
|
||||
global: boolean;
|
||||
mode: 'results' | 'chat' | 'both';
|
||||
manual?: boolean;
|
||||
}
|
||||
|
||||
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
|
||||
const keyMap = {
|
||||
q: parseAsString,
|
||||
ask: parseAsBoolean,
|
||||
mode: parseAsStringEnum(['both', 'results', 'chat']).withDefault('both'),
|
||||
global: parseAsBoolean,
|
||||
manual: parseAsBoolean,
|
||||
};
|
||||
|
||||
export type UpdateSearchState = (
|
||||
@@ -33,7 +35,12 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global };
|
||||
return {
|
||||
query: rawState.q,
|
||||
mode: rawState.mode,
|
||||
global: !!rawState.global,
|
||||
manual: !!rawState.manual,
|
||||
};
|
||||
}, [rawState]);
|
||||
|
||||
const stateRef = React.useRef(state);
|
||||
@@ -52,14 +59,16 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
|
||||
if (update === null) {
|
||||
return setRawState({
|
||||
q: null,
|
||||
ask: null,
|
||||
mode: null,
|
||||
global: null,
|
||||
manual: null,
|
||||
});
|
||||
}
|
||||
return setRawState({
|
||||
q: update.query,
|
||||
ask: update.ask ? true : null,
|
||||
mode: update.mode,
|
||||
global: update.global ? true : null,
|
||||
manual: update.manual ? true : null,
|
||||
});
|
||||
},
|
||||
[setRawState]
|
||||
@@ -78,8 +87,9 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
||||
(query) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('q', query.query ?? '');
|
||||
query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask');
|
||||
query.mode ? searchParams.set('mode', query.mode) : searchParams.delete('mode');
|
||||
query.global ? searchParams.set('global', 'on') : searchParams.delete('global');
|
||||
searchParams.delete('manual');
|
||||
return {
|
||||
href: `?${searchParams.toString()}`,
|
||||
prefetch: false,
|
||||
@@ -87,7 +97,7 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
||||
event.preventDefault();
|
||||
setSearch((prev) => ({
|
||||
query: '',
|
||||
ask: false,
|
||||
mode: 'both',
|
||||
global: false,
|
||||
...(prev ?? {}),
|
||||
...query,
|
||||
|
||||
@@ -6,6 +6,7 @@ export const de = {
|
||||
switch_to_light_theme: 'Zum hellen Modus wechseln',
|
||||
switch_to_system_theme: 'Zum Systemmodus wechseln',
|
||||
search: 'Suche',
|
||||
view: 'Anzeigen',
|
||||
search_or_ask: 'Fragen oder Suchen',
|
||||
search_input_placeholder: 'Inhalt durchsuchen',
|
||||
search_ask_input_placeholder: 'Inhalt durchsuchen oder eine Frage stellen',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const en = {
|
||||
switch_to_light_theme: 'Switch to light theme',
|
||||
switch_to_system_theme: 'Switch to system theme',
|
||||
search: 'Search',
|
||||
view: 'View',
|
||||
search_or_ask: 'Ask or search',
|
||||
search_input_placeholder: 'Search content',
|
||||
search_ask_input_placeholder: 'Search content or ask a question',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const es: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Cambiar a tema claro',
|
||||
switch_to_system_theme: 'Cambiar a tema del sistema',
|
||||
search: 'Buscar',
|
||||
view: 'Ver',
|
||||
search_or_ask: 'Preguntar o Buscar',
|
||||
search_input_placeholder: 'Buscar contenido',
|
||||
search_ask_input_placeholder: 'Buscar contenido o hacer una pregunta',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const fr: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Passer au thème clair',
|
||||
switch_to_system_theme: 'Passer au thème système',
|
||||
search: 'Rechercher',
|
||||
view: 'Voir',
|
||||
search_or_ask: 'Demander ou rechercher',
|
||||
search_input_placeholder: 'Rechercher le contenu',
|
||||
search_ask_input_placeholder: 'Rechercher du contenu ou poser une question',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ja: TranslationLanguage = {
|
||||
switch_to_light_theme: 'ライトテーマに切り替え',
|
||||
switch_to_system_theme: 'システムのテーマに切り替え',
|
||||
search: '検索',
|
||||
view: '表示',
|
||||
search_or_ask: '質問または検索',
|
||||
search_input_placeholder: 'コンテンツを検索',
|
||||
search_ask_input_placeholder: 'コンテンツを検索するか質問をする',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const nl: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Schakel over naar lichte modus',
|
||||
switch_to_system_theme: 'Schakel over naar systeemmodus',
|
||||
search: 'Zoeken',
|
||||
view: 'Bekijken',
|
||||
search_or_ask: 'Zoek of vraag',
|
||||
search_input_placeholder: 'Zoek inhoud',
|
||||
search_ask_input_placeholder: 'Zoek inhoud of stel een vraag',
|
||||
@@ -17,7 +18,7 @@ export const nl: TranslationLanguage = {
|
||||
search_ask: 'Vraag "${1}"',
|
||||
search_ask_description: 'Vind het antwoord met AI',
|
||||
search_ask_sources: 'Bronnen',
|
||||
search_ask_sources_no_answer: 'Gerelateerde pagina’s',
|
||||
search_ask_sources_no_answer: "Gerelateerde pagina's",
|
||||
search_ask_no_answer:
|
||||
'Er kon geen antwoord op je vraag worden gevonden. Probeer je vraag anders te formuleren of wees specifieker.',
|
||||
search_ask_error: 'Er is iets misgegaan. Probeer het later opnieuw.',
|
||||
@@ -54,9 +55,9 @@ export const nl: TranslationLanguage = {
|
||||
pdf_print: 'Print of opslaan als PDF',
|
||||
pdf_page_of: '${1} van ${2}',
|
||||
pdf_mode_only_page: 'Alleen deze pagina',
|
||||
pdf_mode_all: 'Alle pagina’s',
|
||||
pdf_mode_all: "Alle pagina's",
|
||||
pdf_limit_reached: "Kon de PDF niet genereren voor ${1} pagina's, generatie gestopt bij ${2}.",
|
||||
pdf_limit_reached_continue: 'Verleng met ${1} extra pagina’s.',
|
||||
pdf_limit_reached_continue: "Verleng met ${1} extra pagina's.",
|
||||
more: 'Meer',
|
||||
link_tooltip_external_link: 'Externe link naar',
|
||||
link_tooltip_page_anchor: 'Spring naar sectie',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const no: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Bytt til lyst tema',
|
||||
switch_to_system_theme: 'Bytt til systemtema',
|
||||
search: 'Søk',
|
||||
view: 'Vis',
|
||||
search_or_ask: 'Spør eller søk',
|
||||
search_input_placeholder: 'Søk i innhold',
|
||||
search_ask_input_placeholder: 'Søk i innhold eller still et spørsmål',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const pt_br = {
|
||||
switch_to_light_theme: 'Mudar para modo claro',
|
||||
switch_to_system_theme: 'Mudar para configuração do sistema',
|
||||
search: 'Busca',
|
||||
view: 'Ver',
|
||||
search_or_ask: 'Perguntar ou buscar',
|
||||
search_input_placeholder: 'Buscar conteúdo',
|
||||
search_ask_input_placeholder: 'Buscar conteúdo ou fazer uma pergunta',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const zh: TranslationLanguage = {
|
||||
switch_to_light_theme: '切换到浅色主题',
|
||||
switch_to_system_theme: '切换到系统主题',
|
||||
search: '搜索',
|
||||
view: '查看',
|
||||
search_or_ask: '询问或搜索',
|
||||
search_input_placeholder: '搜索内容',
|
||||
search_ask_input_placeholder: '搜索内容或提问',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/api": "^0.117.1",
|
||||
"@gitbook/icons": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user