mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-27 15:37:05 +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",
|
"name": "@gitbook/cache-tags",
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "^0.115.0",
|
"@gitbook/api": "^0.117.1",
|
||||||
"assert-never": "^1.2.1",
|
"assert-never": "^1.2.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "^0.115.0",
|
"@gitbook/api": "^0.117.1",
|
||||||
"@gitbook/cache-do": "workspace:*",
|
"@gitbook/cache-do": "workspace:*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
"name": "gitbook-v2",
|
"name": "gitbook-v2",
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "^0.115.0",
|
"@gitbook/api": "^0.117.1",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@opennextjs/cloudflare": "1.1.0",
|
"@opennextjs/cloudflare": "1.1.0",
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
"name": "@gitbook/react-contentkit",
|
"name": "@gitbook/react-contentkit",
|
||||||
"version": "0.7.0",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "^0.115.0",
|
"@gitbook/api": "^0.117.1",
|
||||||
"@gitbook/icons": "workspace:*",
|
"@gitbook/icons": "workspace:*",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
},
|
},
|
||||||
@@ -260,7 +260,7 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
"@gitbook/api": "^0.115.0",
|
"@gitbook/api": "^0.117.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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=="],
|
"@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"],
|
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"packageManager": "bun@1.2.11",
|
"packageManager": "bun@1.2.11",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
"@gitbook/api": "^0.115.0",
|
"@gitbook/api": "^0.117.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "^0.115.0",
|
"@gitbook/api": "^0.117.1",
|
||||||
"assert-never": "^1.2.1"
|
"assert-never": "^1.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "^0.115.0",
|
"@gitbook/api": "^0.117.1",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@opennextjs/cloudflare": "1.1.0",
|
"@opennextjs/cloudflare": "1.1.0",
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
tools?: api.AIToolCapabilities;
|
||||||
|
previousResponseId?: string;
|
||||||
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
|
}): 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"
|
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "^0.115.0",
|
"@gitbook/api": "^0.117.1",
|
||||||
"@gitbook/cache-do": "workspace:*",
|
"@gitbook/cache-do": "workspace:*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "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 './streamLinkPageSummary';
|
||||||
|
export * from './api';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { filterOutNullable } from '@/lib/typescript';
|
import { filterOutNullable } from '@/lib/typescript';
|
||||||
import { getV1BaseContext } from '@/lib/v1';
|
import { getV1BaseContext } from '@/lib/v1';
|
||||||
import { isV2 } from '@/lib/v2';
|
import { isV2 } from '@/lib/v2';
|
||||||
import { AIMessageRole } from '@gitbook/api';
|
import { AIMessageRole, AIModel } from '@gitbook/api';
|
||||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||||
import { getServerActionBaseContext } from '@v2/lib/server-actions';
|
import { getServerActionBaseContext } from '@v2/lib/server-actions';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -32,20 +32,15 @@ export async function* streamLinkPageSummary({
|
|||||||
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
const { stream } = await streamGenerateObject(
|
const { stream } = await streamGenerateObject(baseContext, {
|
||||||
baseContext,
|
|
||||||
{
|
|
||||||
organizationId: siteURLData.organization,
|
organizationId: siteURLData.organization,
|
||||||
siteId: siteURLData.site,
|
siteId: siteURLData.site,
|
||||||
},
|
model: AIModel.Fast,
|
||||||
{
|
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
highlight: z
|
highlight: z.string().describe('The reason why the user should read the target page.'),
|
||||||
.string()
|
|
||||||
.describe('The reason why the user should read the target page.'),
|
|
||||||
// questions: z.array(z.string().describe('The questions to sea')).max(3),
|
// questions: z.array(z.string().describe('The questions to sea')).max(3),
|
||||||
}),
|
}),
|
||||||
messages: [
|
input: [
|
||||||
{
|
{
|
||||||
role: AIMessageRole.Developer,
|
role: AIMessageRole.Developer,
|
||||||
content: `# 1. Role
|
content: `# 1. Role
|
||||||
@@ -152,8 +147,7 @@ Preview: "Live Edit: Real-time collaborative editing."
|
|||||||
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.`,
|
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),
|
].filter(filterOutNullable),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
for await (const value of stream) {
|
for await (const value of stream) {
|
||||||
const highlight = value.highlight;
|
const highlight = value.highlight;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
/* Light mode */
|
/* Light mode */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@apply bg-tint-subtle;
|
@apply bg-tint-subtle z-50;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ export function HighlightQuery(props: {
|
|||||||
'text-bold',
|
'text-bold',
|
||||||
'bg-primary',
|
'bg-primary',
|
||||||
'text-contrast-primary',
|
'text-contrast-primary',
|
||||||
'px-0.5',
|
'px-1',
|
||||||
'-mx-0.5',
|
|
||||||
'py-0.5',
|
'py-0.5',
|
||||||
'rounded',
|
'rounded',
|
||||||
'straight-corners:rounded-sm',
|
'straight-corners:rounded-sm',
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
'use client';
|
'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 { useLanguage } from '@/intl/client';
|
||||||
import { t } from '@/intl/translate';
|
import { t } from '@/intl/translate';
|
||||||
import type { TranslationLanguage } from '@/intl/translations';
|
import type { TranslationLanguage } from '@/intl/translations';
|
||||||
import { tcls } from '@/lib/tailwind';
|
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 { useTrackEvent } from '../Insights';
|
||||||
import { Link } from '../primitives';
|
import { Link } from '../primitives';
|
||||||
import { useSearchAskContext } from './SearchAskContext';
|
import { useSearchAskContext } from './SearchAskContext';
|
||||||
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
|
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
|
||||||
import { useSearch, useSearchLink } from './useSearch';
|
import { useSearch, useSearchLink } from './useSearch';
|
||||||
|
|
||||||
export type SearchAskState =
|
export type SearchAskState =
|
||||||
| {
|
| {
|
||||||
type: 'answer';
|
type: 'answer';
|
||||||
@@ -88,13 +86,22 @@ export function SearchAskAnswer(props: { query: string }) {
|
|||||||
}, [setAskState]);
|
}, [setAskState]);
|
||||||
|
|
||||||
const loading = (
|
const loading = (
|
||||||
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
|
<div key="loading" className={tcls('flex', 'flex-wrap', 'gap-2')}>
|
||||||
<Loading className={tcls('w-6', 'py-8', 'text-primary-subtle')} />
|
{[...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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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' ? (
|
{askState?.type === 'answer' ? (
|
||||||
<React.Suspense fallback={loading}>
|
<React.Suspense fallback={loading}>
|
||||||
<TransitionAnswerBody answer={askState.answer} placeholder={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>
|
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{askState?.type === 'loading' ? loading : null}
|
{askState?.type === 'loading' ? loading : null}
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,10 +145,7 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div data-testid="search-ask-answer" className={tcls('text-tint-strong')}>
|
||||||
data-testid="search-ask-answer"
|
|
||||||
className={tcls('my-4', 'sm:mt-6', 'px-4', 'sm:px-12', 'text-tint-strong')}
|
|
||||||
>
|
|
||||||
{answer.body ?? t(language, 'search_ask_no_answer')}
|
{answer.body ?? t(language, 'search_ask_no_answer')}
|
||||||
{answer.followupQuestions.length > 0 ? (
|
{answer.followupQuestions.length > 0 ? (
|
||||||
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
|
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
|
||||||
@@ -182,7 +186,6 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
|
|||||||
)}
|
)}
|
||||||
{...getSearchLinkProps({
|
{...getSearchLinkProps({
|
||||||
query: question,
|
query: question,
|
||||||
ask: true,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
|||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
setSearchState({
|
setSearchState({
|
||||||
ask: false,
|
mode: 'both',
|
||||||
global: false,
|
global: false,
|
||||||
query: '',
|
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);
|
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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';
|
'use client';
|
||||||
|
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -8,10 +6,9 @@ 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 { LoadingPane } from '../primitives/LoadingPane';
|
import { LoadingPane } from '../primitives/LoadingPane';
|
||||||
import { SearchAskAnswer } from './SearchAskAnswer';
|
|
||||||
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
||||||
|
import { SearchChat } from './SearchChat';
|
||||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||||
import { SearchScopeToggle } from './SearchScopeToggle';
|
import { SearchScopeToggle } from './SearchScopeToggle';
|
||||||
import { type SearchState, type UpdateSearchState, useSearch } from './useSearch';
|
import { type SearchState, type UpdateSearchState, useSearch } from './useSearch';
|
||||||
@@ -30,14 +27,21 @@ export function SearchModal(props: SearchModalProps) {
|
|||||||
const searchAsk = useSearchAskState();
|
const searchAsk = useSearchAskState();
|
||||||
const [askState] = searchAsk;
|
const [askState] = searchAsk;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const chatInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'mod+k',
|
'mod+k',
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
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
|
// Add a global class on the body when the search modal is open
|
||||||
@@ -125,6 +129,7 @@ export function SearchModal(props: SearchModalProps) {
|
|||||||
state={state}
|
state={state}
|
||||||
setSearchState={setSearchState}
|
setSearchState={setSearchState}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
chatInputRef={chatInputRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -139,9 +144,11 @@ function SearchModalBody(
|
|||||||
state: SearchState;
|
state: SearchState;
|
||||||
setSearchState: UpdateSearchState;
|
setSearchState: UpdateSearchState;
|
||||||
onClose: (to?: string) => void;
|
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 language = useLanguage();
|
||||||
const resultsRef = React.useRef<SearchResultsRef>(null);
|
const resultsRef = React.useRef<SearchResultsRef>(null);
|
||||||
@@ -165,6 +172,12 @@ function SearchModalBody(
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
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') {
|
if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
resultsRef.current?.moveUp();
|
resultsRef.current?.moveUp();
|
||||||
@@ -179,7 +192,7 @@ function SearchModalBody(
|
|||||||
|
|
||||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchState({
|
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,
|
query: event.target.value,
|
||||||
global: state.global,
|
global: state.global,
|
||||||
});
|
});
|
||||||
@@ -219,9 +232,10 @@ function SearchModalBody(
|
|||||||
'flex',
|
'flex',
|
||||||
'flex-col',
|
'flex-col',
|
||||||
'bg-tint-base',
|
'bg-tint-base',
|
||||||
'max-w-prose',
|
'max-w-screen-lg',
|
||||||
'mx-auto',
|
'mx-auto',
|
||||||
'max-h-[70dvh]',
|
// 'min-h-[50dvh]',
|
||||||
|
'h-[70dvh]',
|
||||||
'w-full',
|
'w-full',
|
||||||
'rounded-lg',
|
'rounded-lg',
|
||||||
'straight-corners:rounded-sm',
|
'straight-corners:rounded-sm',
|
||||||
@@ -242,12 +256,10 @@ function SearchModalBody(
|
|||||||
'flex-row',
|
'flex-row',
|
||||||
'items-start',
|
'items-start',
|
||||||
state.query !== null ? 'border-b' : null,
|
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
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'w-full',
|
'w-full',
|
||||||
@@ -270,8 +282,8 @@ function SearchModalBody(
|
|||||||
'flex',
|
'flex',
|
||||||
'resize-none',
|
'resize-none',
|
||||||
'flex-1',
|
'flex-1',
|
||||||
'h-12',
|
'py-4',
|
||||||
'p-2',
|
'px-8',
|
||||||
'focus:outline-none',
|
'focus:outline-none',
|
||||||
'bg-transparent',
|
'bg-transparent',
|
||||||
'whitespace-pre-line'
|
'whitespace-pre-line'
|
||||||
@@ -287,7 +299,15 @@ function SearchModalBody(
|
|||||||
{isMultiVariants ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
|
{isMultiVariants ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!state.ask || !withAsk ? (
|
<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
|
<SearchResults
|
||||||
ref={resultsRef}
|
ref={resultsRef}
|
||||||
global={isMultiVariants && state.global}
|
global={isMultiVariants && state.global}
|
||||||
@@ -295,10 +315,22 @@ function SearchModalBody(
|
|||||||
withAsk={withAsk}
|
withAsk={withAsk}
|
||||||
onSwitchToAsk={onSwitchToAsk}
|
onSwitchToAsk={onSwitchToAsk}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
{normalizedQuery && state.ask && withAsk ? (
|
|
||||||
<SearchAskAnswer query={normalizedQuery} />
|
<div
|
||||||
) : null}
|
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>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { tcls } from '@/lib/tailwind';
|
|||||||
import { Icon, type IconName } from '@gitbook/icons';
|
import { Icon, type IconName } from '@gitbook/icons';
|
||||||
import React from 'react';
|
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 { HighlightQuery } from './HighlightQuery';
|
||||||
import type { ComputedPageResult } from './server-actions';
|
import type { ComputedPageResult } from './server-actions';
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
},
|
},
|
||||||
ref: React.Ref<HTMLAnchorElement>
|
ref: React.Ref<HTMLAnchorElement>
|
||||||
) {
|
) {
|
||||||
|
const language = useLanguage();
|
||||||
const { query, item, active } = props;
|
const { query, item, active } = props;
|
||||||
|
|
||||||
const breadcrumbs =
|
const breadcrumbs =
|
||||||
@@ -34,16 +37,19 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
'flex-row',
|
'flex-row',
|
||||||
'items-center',
|
'items-center',
|
||||||
'p-4',
|
'p-4',
|
||||||
'border-t',
|
'rounded-lg',
|
||||||
'border-tint-subtle',
|
'straight-corners:rounded-none',
|
||||||
'first:border-none',
|
|
||||||
'text-base',
|
'text-base',
|
||||||
'font-medium',
|
'font-medium',
|
||||||
|
'text-tint-strong',
|
||||||
'hover:bg-tint-hover',
|
'hover:bg-tint-hover',
|
||||||
'group',
|
'group',
|
||||||
active
|
active && [
|
||||||
? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover']
|
'is-active',
|
||||||
: null
|
'bg-primary',
|
||||||
|
'text-primary-strong',
|
||||||
|
'hover:bg-primary-hover',
|
||||||
|
]
|
||||||
)}
|
)}
|
||||||
insights={{
|
insights={{
|
||||||
type: 'search_open_result',
|
type: 'search_open_result',
|
||||||
@@ -56,8 +62,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
>
|
>
|
||||||
<div className="size-4">
|
<div className="size-4">
|
||||||
<Icon
|
<Icon
|
||||||
icon="file-lines"
|
icon="file"
|
||||||
className={tcls('size-4', active ? 'text-primary' : 'text-tint-subtle')}
|
className={tcls('size-4', active ? 'text-primary-subtle' : 'text-tint-subtle')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={tcls('flex', 'flex-col', 'w-full')}>
|
<div className={tcls('flex', 'flex-col', 'w-full')}>
|
||||||
@@ -65,7 +71,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'text-xs',
|
'text-xs',
|
||||||
'opacity-6',
|
active ? 'text-primary-subtle' : 'text-neutral-subtle',
|
||||||
|
// 'opacity-6',
|
||||||
'contrast-more:opacity-11',
|
'contrast-more:opacity-11',
|
||||||
'font-normal',
|
'font-normal',
|
||||||
'uppercase',
|
'uppercase',
|
||||||
@@ -103,19 +110,15 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
|||||||
) : null}
|
) : null}
|
||||||
<HighlightQuery query={query} text={item.title} />
|
<HighlightQuery query={query} text={item.title} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
{active ? (
|
||||||
className={tcls(
|
<Button
|
||||||
'p-2',
|
icon="arrow-turn-down-left"
|
||||||
'rounded',
|
size="small"
|
||||||
'straight-corners:rounded-none',
|
label={tString(language, 'view')}
|
||||||
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
|
|
||||||
className={tcls('size-4')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { t, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import { Link } from '../primitives';
|
import { Button, Link } from '../primitives';
|
||||||
import { useSearchLink } from './useSearch';
|
import { useSearchLink } from './useSearch';
|
||||||
|
|
||||||
export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem(
|
export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem(
|
||||||
@@ -28,19 +28,20 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
|||||||
className={tcls(
|
className={tcls(
|
||||||
'flex',
|
'flex',
|
||||||
'px-4',
|
'px-4',
|
||||||
recommended ? ['py-2', 'text-tint'] : 'py-4',
|
'py-2',
|
||||||
|
'text-tint',
|
||||||
|
'rounded-lg',
|
||||||
|
'straight-corners:rounded-none',
|
||||||
'hover:bg-tint-hover',
|
'hover:bg-tint-hover',
|
||||||
'first:mt-0',
|
'gap-4',
|
||||||
'last:pb-3',
|
|
||||||
active && [
|
active && [
|
||||||
'is-active',
|
'is-active',
|
||||||
'bg-primary',
|
'bg-primary',
|
||||||
'text-contrast-primary',
|
'text-primary-strong',
|
||||||
'hover:bg-primary-hover',
|
'hover:bg-primary-hover',
|
||||||
]
|
]
|
||||||
)}
|
)}
|
||||||
{...getLinkProp({
|
{...getLinkProp({
|
||||||
ask: true,
|
|
||||||
query: question,
|
query: question,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -50,7 +51,6 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
|||||||
'size-4',
|
'size-4',
|
||||||
'shrink-0',
|
'shrink-0',
|
||||||
'mt-1.5',
|
'mt-1.5',
|
||||||
'mr-4',
|
|
||||||
active ? ['text-primary'] : ['text-tint-subtle']
|
active ? ['text-primary'] : ['text-tint-subtle']
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -66,19 +66,16 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="self-center">
|
||||||
className={tcls(
|
{active ? (
|
||||||
'p-2',
|
<Button
|
||||||
'rounded',
|
icon="arrow-turn-down-left"
|
||||||
'self-center',
|
size="small"
|
||||||
'straight-corners:rounded-none',
|
label={tString(language, 'search')}
|
||||||
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
|
|
||||||
className={tcls('size-4')}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React from 'react';
|
|||||||
import { t, useLanguage } from '@/intl/client';
|
import { t, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { Loading } from '../primitives';
|
import { Loading } from '../primitives';
|
||||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
searchSiteSpaceContent,
|
searchSiteSpaceContent,
|
||||||
streamRecommendedQuestions,
|
streamRecommendedQuestions,
|
||||||
} from './server-actions';
|
} from './server-actions';
|
||||||
|
import { type SearchState, useSearch } from './useSearch';
|
||||||
|
|
||||||
export interface SearchResultsRef {
|
export interface SearchResultsRef {
|
||||||
moveUp(): void;
|
moveUp(): void;
|
||||||
@@ -44,7 +46,6 @@ let cachedRecommendedQuestions: null | ResultType[] = null;
|
|||||||
*/
|
*/
|
||||||
export const SearchResults = React.forwardRef(function SearchResults(
|
export const SearchResults = React.forwardRef(function SearchResults(
|
||||||
props: {
|
props: {
|
||||||
children?: React.ReactNode;
|
|
||||||
query: string;
|
query: string;
|
||||||
global: boolean;
|
global: boolean;
|
||||||
withAsk: boolean;
|
withAsk: boolean;
|
||||||
@@ -52,7 +53,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
},
|
},
|
||||||
ref: React.Ref<SearchResultsRef>
|
ref: React.Ref<SearchResultsRef>
|
||||||
) {
|
) {
|
||||||
const { children, query, withAsk, global, onSwitchToAsk } = props;
|
const { query, withAsk, global, onSwitchToAsk } = props;
|
||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
@@ -62,6 +63,39 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
}>({ results: [], fetching: true });
|
}>({ results: [], fetching: true });
|
||||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||||
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!query) {
|
if (!query) {
|
||||||
@@ -78,7 +112,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
// Silently fetch the recommended questions, instead of showing a spinner
|
// 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.
|
// We currently have a bug where the same question can be returned multiple times.
|
||||||
// This is a workaround to avoid that.
|
// This is a workaround to avoid that.
|
||||||
@@ -150,23 +184,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
};
|
};
|
||||||
}, [query, global, withAsk, trackEvent]);
|
}, [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.
|
// Scroll to the active result.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (cursor === null || !refs.current[cursor]) {
|
if (cursor === null || !refs.current[cursor]) {
|
||||||
@@ -214,30 +231,32 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
[moveBy, select]
|
[moveBy, select]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (resultsState.fetching) {
|
const loading = (
|
||||||
return (
|
<div className={tcls('flex', 'items-center', 'justify-center', 'p-8')}>
|
||||||
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8')}>
|
<Loading className={tcls('w-6', 'text-primary-subtle')} />
|
||||||
<Loading className={tcls('w-6', 'text-primary')} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const noResults = (
|
const noResults = (
|
||||||
<div className={tcls('text', 'text-tint', 'p-8', 'text-center')}>
|
<div className={tcls('text', 'text-tint', 'text-center', 'p-8')}>
|
||||||
|
<div className="animate-fadeIn" style={{ animationDelay: '0.5s' }}>
|
||||||
{t(language, 'search_no_results', query)}
|
{t(language, 'search_no_results', query)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={tcls('overflow-auto')}>
|
<AnimatePresence initial={false} mode="wait">
|
||||||
{children}
|
{resultsState.fetching ? (
|
||||||
{results.length === 0 ? (
|
loading
|
||||||
query ? (
|
) : query && results.length === 0 ? (
|
||||||
noResults
|
noResults
|
||||||
) : null
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<motion.div
|
||||||
<div data-testid="search-results">
|
layout="position"
|
||||||
|
className="flex flex-col gap-2 p-4"
|
||||||
|
data-testid="search-results"
|
||||||
|
>
|
||||||
{results.map((item, index) => {
|
{results.map((item, index) => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'page': {
|
case 'page': {
|
||||||
@@ -297,23 +316,8 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
assertNever(item);
|
assertNever(item);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</motion.div>
|
||||||
{!results.some((result) => result.type !== 'question') && noResults}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</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 { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import { Link } from '../primitives';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
|
import { Button, Link } from '../primitives';
|
||||||
import { HighlightQuery } from './HighlightQuery';
|
import { HighlightQuery } from './HighlightQuery';
|
||||||
import type { ComputedSectionResult } from './server-actions';
|
import type { ComputedSectionResult } from './server-actions';
|
||||||
|
|
||||||
@@ -16,13 +17,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
|||||||
ref: React.Ref<HTMLAnchorElement>
|
ref: React.Ref<HTMLAnchorElement>
|
||||||
) {
|
) {
|
||||||
const { query, item, active } = props;
|
const { query, item, active } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
ref={ref}
|
ref={ref}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'[&:has(+:not(&))]:mb-6',
|
// '[&:has(+:not(&))]:mb-6',
|
||||||
|
'-mt-2',
|
||||||
'flex',
|
'flex',
|
||||||
'items-center',
|
'items-center',
|
||||||
'pl-6',
|
'pl-6',
|
||||||
@@ -33,6 +36,8 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
|||||||
'font-normal',
|
'font-normal',
|
||||||
'py-2',
|
'py-2',
|
||||||
'group',
|
'group',
|
||||||
|
'rounded-lg',
|
||||||
|
'straight-corners:rounded-none',
|
||||||
active && [
|
active && [
|
||||||
'is-active',
|
'is-active',
|
||||||
'bg-primary',
|
'bg-primary',
|
||||||
@@ -72,20 +77,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{active ? (
|
||||||
className={tcls(
|
<Button
|
||||||
'p-2',
|
icon="arrow-turn-down-left"
|
||||||
'rounded',
|
size="small"
|
||||||
'straight-corners:rounded-none',
|
label={tString(language, 'view')}
|
||||||
'bg-primary-solid',
|
/>
|
||||||
'text-contrast-primary-solid',
|
) : (
|
||||||
'hidden',
|
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
|
||||||
'sm:block',
|
|
||||||
active ? ['opacity-11', 'block'] : ['opacity-0']
|
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<Icon icon="arrow-turn-down-left" className={tcls('size-4')} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const questionWords = new Set([
|
|||||||
* Return true if an input query looks like a question.
|
* Return true if an input query looks like a question.
|
||||||
*/
|
*/
|
||||||
export function isQuestion(query: string): boolean {
|
export function isQuestion(query: string): boolean {
|
||||||
if (query.length > 25 || query.includes('?') || query.includes(' ')) {
|
if ((query.length > 25 && query.includes(' ')) || query.includes('?')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ import { resolvePageId } from '@/lib/pages';
|
|||||||
import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites';
|
import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites';
|
||||||
import { filterOutNullable } from '@/lib/typescript';
|
import { filterOutNullable } from '@/lib/typescript';
|
||||||
import { getV1BaseContext } from '@/lib/v1';
|
import { getV1BaseContext } from '@/lib/v1';
|
||||||
import type {
|
import {
|
||||||
RevisionPage,
|
AIMessageRole,
|
||||||
SearchAIAnswer,
|
AIModel,
|
||||||
SearchAIRecommendedQuestionStream,
|
type RevisionPage,
|
||||||
SearchPageResult,
|
type SearchAIAnswer,
|
||||||
SearchSpaceResult,
|
type SearchAIRecommendedQuestionStream,
|
||||||
SiteSection,
|
type SearchPageResult,
|
||||||
SiteSectionGroup,
|
type SearchSpaceResult,
|
||||||
Space,
|
type SiteSection,
|
||||||
|
type SiteSectionGroup,
|
||||||
|
type Space,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
|
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
|
||||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
|
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
|
||||||
@@ -24,6 +26,8 @@ import { isV2 } from '@/lib/v2';
|
|||||||
import type { IconName } from '@gitbook/icons';
|
import type { IconName } from '@gitbook/icons';
|
||||||
import { throwIfDataError } from '@v2/lib/data';
|
import { throwIfDataError } from '@v2/lib/data';
|
||||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { streamGenerateDocument, streamGenerateObject } from '../Adaptive/server-actions';
|
||||||
import { DocumentView } from '../DocumentView';
|
import { DocumentView } from '../DocumentView';
|
||||||
|
|
||||||
export type OrderedComputedResult = ComputedPageResult | ComputedSectionResult;
|
export type OrderedComputedResult = ComputedPageResult | ComputedSectionResult;
|
||||||
@@ -410,3 +414,119 @@ async function transformSitePageResult(
|
|||||||
|
|
||||||
return [page, ...pageSections];
|
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 React from 'react';
|
||||||
|
|
||||||
import type { LinkProps } from '../primitives';
|
import type { LinkProps } from '../primitives';
|
||||||
|
|
||||||
export interface SearchState {
|
export interface SearchState {
|
||||||
query: string;
|
query: string;
|
||||||
ask: boolean;
|
|
||||||
global: boolean;
|
global: boolean;
|
||||||
|
mode: 'results' | 'chat' | 'both';
|
||||||
|
manual?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
|
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
|
||||||
const keyMap = {
|
const keyMap = {
|
||||||
q: parseAsString,
|
q: parseAsString,
|
||||||
ask: parseAsBoolean,
|
mode: parseAsStringEnum(['both', 'results', 'chat']).withDefault('both'),
|
||||||
global: parseAsBoolean,
|
global: parseAsBoolean,
|
||||||
|
manual: parseAsBoolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateSearchState = (
|
export type UpdateSearchState = (
|
||||||
@@ -33,7 +35,12 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
|
|||||||
return null;
|
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]);
|
}, [rawState]);
|
||||||
|
|
||||||
const stateRef = React.useRef(state);
|
const stateRef = React.useRef(state);
|
||||||
@@ -52,14 +59,16 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
|
|||||||
if (update === null) {
|
if (update === null) {
|
||||||
return setRawState({
|
return setRawState({
|
||||||
q: null,
|
q: null,
|
||||||
ask: null,
|
mode: null,
|
||||||
global: null,
|
global: null,
|
||||||
|
manual: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return setRawState({
|
return setRawState({
|
||||||
q: update.query,
|
q: update.query,
|
||||||
ask: update.ask ? true : null,
|
mode: update.mode,
|
||||||
global: update.global ? true : null,
|
global: update.global ? true : null,
|
||||||
|
manual: update.manual ? true : null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setRawState]
|
[setRawState]
|
||||||
@@ -78,8 +87,9 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
|||||||
(query) => {
|
(query) => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
searchParams.set('q', query.query ?? '');
|
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');
|
query.global ? searchParams.set('global', 'on') : searchParams.delete('global');
|
||||||
|
searchParams.delete('manual');
|
||||||
return {
|
return {
|
||||||
href: `?${searchParams.toString()}`,
|
href: `?${searchParams.toString()}`,
|
||||||
prefetch: false,
|
prefetch: false,
|
||||||
@@ -87,7 +97,7 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSearch((prev) => ({
|
setSearch((prev) => ({
|
||||||
query: '',
|
query: '',
|
||||||
ask: false,
|
mode: 'both',
|
||||||
global: false,
|
global: false,
|
||||||
...(prev ?? {}),
|
...(prev ?? {}),
|
||||||
...query,
|
...query,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const de = {
|
|||||||
switch_to_light_theme: 'Zum hellen Modus wechseln',
|
switch_to_light_theme: 'Zum hellen Modus wechseln',
|
||||||
switch_to_system_theme: 'Zum Systemmodus wechseln',
|
switch_to_system_theme: 'Zum Systemmodus wechseln',
|
||||||
search: 'Suche',
|
search: 'Suche',
|
||||||
|
view: 'Anzeigen',
|
||||||
search_or_ask: 'Fragen oder Suchen',
|
search_or_ask: 'Fragen oder Suchen',
|
||||||
search_input_placeholder: 'Inhalt durchsuchen',
|
search_input_placeholder: 'Inhalt durchsuchen',
|
||||||
search_ask_input_placeholder: 'Inhalt durchsuchen oder eine Frage stellen',
|
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_light_theme: 'Switch to light theme',
|
||||||
switch_to_system_theme: 'Switch to system theme',
|
switch_to_system_theme: 'Switch to system theme',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
|
view: 'View',
|
||||||
search_or_ask: 'Ask or search',
|
search_or_ask: 'Ask or search',
|
||||||
search_input_placeholder: 'Search content',
|
search_input_placeholder: 'Search content',
|
||||||
search_ask_input_placeholder: 'Search content or ask a question',
|
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_light_theme: 'Cambiar a tema claro',
|
||||||
switch_to_system_theme: 'Cambiar a tema del sistema',
|
switch_to_system_theme: 'Cambiar a tema del sistema',
|
||||||
search: 'Buscar',
|
search: 'Buscar',
|
||||||
|
view: 'Ver',
|
||||||
search_or_ask: 'Preguntar o Buscar',
|
search_or_ask: 'Preguntar o Buscar',
|
||||||
search_input_placeholder: 'Buscar contenido',
|
search_input_placeholder: 'Buscar contenido',
|
||||||
search_ask_input_placeholder: 'Buscar contenido o hacer una pregunta',
|
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_light_theme: 'Passer au thème clair',
|
||||||
switch_to_system_theme: 'Passer au thème système',
|
switch_to_system_theme: 'Passer au thème système',
|
||||||
search: 'Rechercher',
|
search: 'Rechercher',
|
||||||
|
view: 'Voir',
|
||||||
search_or_ask: 'Demander ou rechercher',
|
search_or_ask: 'Demander ou rechercher',
|
||||||
search_input_placeholder: 'Rechercher le contenu',
|
search_input_placeholder: 'Rechercher le contenu',
|
||||||
search_ask_input_placeholder: 'Rechercher du contenu ou poser une question',
|
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_light_theme: 'ライトテーマに切り替え',
|
||||||
switch_to_system_theme: 'システムのテーマに切り替え',
|
switch_to_system_theme: 'システムのテーマに切り替え',
|
||||||
search: '検索',
|
search: '検索',
|
||||||
|
view: '表示',
|
||||||
search_or_ask: '質問または検索',
|
search_or_ask: '質問または検索',
|
||||||
search_input_placeholder: 'コンテンツを検索',
|
search_input_placeholder: 'コンテンツを検索',
|
||||||
search_ask_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_light_theme: 'Schakel over naar lichte modus',
|
||||||
switch_to_system_theme: 'Schakel over naar systeemmodus',
|
switch_to_system_theme: 'Schakel over naar systeemmodus',
|
||||||
search: 'Zoeken',
|
search: 'Zoeken',
|
||||||
|
view: 'Bekijken',
|
||||||
search_or_ask: 'Zoek of vraag',
|
search_or_ask: 'Zoek of vraag',
|
||||||
search_input_placeholder: 'Zoek inhoud',
|
search_input_placeholder: 'Zoek inhoud',
|
||||||
search_ask_input_placeholder: 'Zoek inhoud of stel een vraag',
|
search_ask_input_placeholder: 'Zoek inhoud of stel een vraag',
|
||||||
@@ -17,7 +18,7 @@ export const nl: TranslationLanguage = {
|
|||||||
search_ask: 'Vraag "${1}"',
|
search_ask: 'Vraag "${1}"',
|
||||||
search_ask_description: 'Vind het antwoord met AI',
|
search_ask_description: 'Vind het antwoord met AI',
|
||||||
search_ask_sources: 'Bronnen',
|
search_ask_sources: 'Bronnen',
|
||||||
search_ask_sources_no_answer: 'Gerelateerde pagina’s',
|
search_ask_sources_no_answer: "Gerelateerde pagina's",
|
||||||
search_ask_no_answer:
|
search_ask_no_answer:
|
||||||
'Er kon geen antwoord op je vraag worden gevonden. Probeer je vraag anders te formuleren of wees specifieker.',
|
'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.',
|
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_print: 'Print of opslaan als PDF',
|
||||||
pdf_page_of: '${1} van ${2}',
|
pdf_page_of: '${1} van ${2}',
|
||||||
pdf_mode_only_page: 'Alleen deze pagina',
|
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: "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',
|
more: 'Meer',
|
||||||
link_tooltip_external_link: 'Externe link naar',
|
link_tooltip_external_link: 'Externe link naar',
|
||||||
link_tooltip_page_anchor: 'Spring naar sectie',
|
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_light_theme: 'Bytt til lyst tema',
|
||||||
switch_to_system_theme: 'Bytt til systemtema',
|
switch_to_system_theme: 'Bytt til systemtema',
|
||||||
search: 'Søk',
|
search: 'Søk',
|
||||||
|
view: 'Vis',
|
||||||
search_or_ask: 'Spør eller søk',
|
search_or_ask: 'Spør eller søk',
|
||||||
search_input_placeholder: 'Søk i innhold',
|
search_input_placeholder: 'Søk i innhold',
|
||||||
search_ask_input_placeholder: 'Søk i innhold eller still et spørsmål',
|
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_light_theme: 'Mudar para modo claro',
|
||||||
switch_to_system_theme: 'Mudar para configuração do sistema',
|
switch_to_system_theme: 'Mudar para configuração do sistema',
|
||||||
search: 'Busca',
|
search: 'Busca',
|
||||||
|
view: 'Ver',
|
||||||
search_or_ask: 'Perguntar ou buscar',
|
search_or_ask: 'Perguntar ou buscar',
|
||||||
search_input_placeholder: 'Buscar conteúdo',
|
search_input_placeholder: 'Buscar conteúdo',
|
||||||
search_ask_input_placeholder: 'Buscar conteúdo ou fazer uma pergunta',
|
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_light_theme: '切换到浅色主题',
|
||||||
switch_to_system_theme: '切换到系统主题',
|
switch_to_system_theme: '切换到系统主题',
|
||||||
search: '搜索',
|
search: '搜索',
|
||||||
|
view: '查看',
|
||||||
search_or_ask: '询问或搜索',
|
search_or_ask: '询问或搜索',
|
||||||
search_input_placeholder: '搜索内容',
|
search_input_placeholder: '搜索内容',
|
||||||
search_ask_input_placeholder: '搜索内容或提问',
|
search_ask_input_placeholder: '搜索内容或提问',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"@gitbook/api": "^0.115.0",
|
"@gitbook/api": "^0.117.1",
|
||||||
"@gitbook/icons": "workspace:*"
|
"@gitbook/icons": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user