mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-02 10:13:46 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02b1bdbfa1 | |||
| 6217a2ed3b | |||
| ffa866cb99 | |||
| 011aa931cf | |||
| 68308154b5 | |||
| 36af03f4d5 | |||
| cbc71a56b6 | |||
| 1420180220 | |||
| e1b2cf6d81 | |||
| c0e9c49679 | |||
| afc4bd4415 | |||
| bcfa8d8b19 | |||
| 43766d6519 | |||
| 42c17f5c74 | |||
| d655d3eece | |||
| 3500de95e8 | |||
| 7f03b94949 | |||
| f0cf060191 | |||
| 5a137e7b14 | |||
| 28f7fbaa8a | |||
| ff96bb5787 | |||
| 87841124e0 | |||
| a650b58276 | |||
| 388b20d44f | |||
| ea7e94fc51 | |||
| d30bcbabdb | |||
| fb858a1941 | |||
| cc2e615b9a | |||
| a862cde6b4 | |||
| e2b7bec162 | |||
| c1b577ecc9 | |||
| ba7ec14f76 | |||
| 9f0117723c | |||
| f72b6a20db | |||
| 2f798209bb | |||
| eec8f16424 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve `Button` and `ButtonGroup` styling
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix scroll of page outline
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support "objectivec" as alias for Objective C syntax
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support custom AI providers
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/browser-types": minor
|
||||
---
|
||||
|
||||
First version of the public package for typing script integrations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Small fixes to search modal
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fixes to `PageAside`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Tweaks to AIChatButton and AIChatInput
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Allow integrations to provide tools to the Docs Assistant
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve vertical alignment of site items and fix floating page aside
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix search bar layout shift caused by ToC
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Emit a <link rel="alternate"> for the markdown version of a page
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Support new coverDefinitionDark for cards & image type
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Clear AI chat properly
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix bug in search highlight in GBO
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Page outline: scroll to active item
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve OpenAPI parsing errors
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix Custom logo not rendering on the published site
|
||||
@@ -10,6 +10,17 @@
|
||||
"vercel": "^39.3.0",
|
||||
},
|
||||
},
|
||||
"packages/browser-types": {
|
||||
"name": "@gitbook/browser-types",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
},
|
||||
},
|
||||
"packages/cache-tags": {
|
||||
"name": "@gitbook/cache-tags",
|
||||
"version": "0.3.1",
|
||||
@@ -48,9 +59,10 @@
|
||||
},
|
||||
"packages/gitbook": {
|
||||
"name": "gitbook",
|
||||
"version": "0.14.1",
|
||||
"version": "0.15.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
@@ -60,7 +72,7 @@
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.6.2",
|
||||
"@opennextjs/cloudflare": "^1.6.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
@@ -146,7 +158,7 @@
|
||||
},
|
||||
"packages/icons": {
|
||||
"name": "@gitbook/icons",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"bin": {
|
||||
"gitbook-icons": "./bin/gitbook-icons.js",
|
||||
},
|
||||
@@ -166,7 +178,7 @@
|
||||
},
|
||||
"packages/openapi-parser": {
|
||||
"name": "@gitbook/openapi-parser",
|
||||
"version": "2.2.2",
|
||||
"version": "3.0.0",
|
||||
"dependencies": {
|
||||
"@scalar/openapi-parser": "^0.18.0",
|
||||
"@scalar/openapi-types": "^0.1.9",
|
||||
@@ -181,7 +193,7 @@
|
||||
},
|
||||
"packages/react-contentkit": {
|
||||
"name": "@gitbook/react-contentkit",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -196,7 +208,7 @@
|
||||
},
|
||||
"packages/react-math": {
|
||||
"name": "@gitbook/react-math",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"bin": {
|
||||
"gitbook-math": "./bin/gitbook-math.js",
|
||||
},
|
||||
@@ -213,7 +225,7 @@
|
||||
},
|
||||
"packages/react-openapi": {
|
||||
"name": "@gitbook/react-openapi",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.5",
|
||||
"dependencies": {
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
"@scalar/api-client-react": "^1.3.16",
|
||||
@@ -246,7 +258,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.130.0",
|
||||
"@gitbook/api": "^0.136.0",
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
|
||||
@@ -611,7 +623,9 @@
|
||||
|
||||
"@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.130.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-nfAPfTISUou+hJPZLrBsuUSSaHEeWB6eZ4adapWAagmaeeA3CpfopDROXcqGJPBHYPfvfLZIGdgyYhyYC3Objw=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.136.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-IxNqmXE6yUEUq0IzbenN8S/PcMfgxr4+akY8xh6V5ShI/+U37ixujJHZp2zJq6NdZOdBQoR3UQmhPnvtWrkF7g=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
"@gitbook/cache-tags": ["@gitbook/cache-tags@workspace:packages/cache-tags"],
|
||||
|
||||
@@ -789,9 +803,9 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@opennextjs/aws": ["@opennextjs/aws@3.7.1", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-ryV5cQucSJDU0TF3+Jzqn9g0/+zhUZgjNopSnchPvH0SxSAbKkNaNH7SlnWBhykjusqHaFzQ8dfEjcjAq7TJSQ=="],
|
||||
"@opennextjs/aws": ["@opennextjs/aws@3.7.4", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-s50dmKrgQ62GliffoI/hGGQVb3q/7ZN5VRa4jJw0ZsEGLfk25XuAulO/ySCYeE7/A23KoAYuhafnKr/h+vxOeQ=="],
|
||||
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.6.2", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.7.1", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "wrangler": "^4.24.4" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-N96OTbp1970NwLP6dNkAzBDrxjthLQ3VXHcjKl8edqNbJ/iT75H60ZYHU/ZZ7gSZQeJluPvPb1n0ZPQ08EooCQ=="],
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.6.4", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.7.4", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "wrangler": "^4.24.4" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-vGEHr1+Dli4BSrs+CM+b2bnXoUdY/GUFiLfdH28UTRHggLMG7cL49M39LtzIVR5D8eqzZ38PDB2CZMn1LJ2gSw=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.130.0"
|
||||
"@gitbook/api": "^0.136.0"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -0,0 +1,3 @@
|
||||
# `@gitbook/browser-types`
|
||||
|
||||
Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@gitbook/browser-types",
|
||||
"description": "Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
export type GitBookIntegrationEvent = 'load' | 'unload';
|
||||
|
||||
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
|
||||
|
||||
export type GitBookIntegrationTool = AIToolDefinition & {
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing the tool.
|
||||
*/
|
||||
confirmation?: {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback when the tool is executed.
|
||||
* The input is provided by the AI assistant following the input schema of the tool.
|
||||
*/
|
||||
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||
};
|
||||
|
||||
export type GitBookAssistant = {
|
||||
/**
|
||||
* Name of the assistant displayed in the UI.
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Icon of the assistant displayed in the UI.
|
||||
* Any FontAwesome icon name is supported.
|
||||
* @example 'sparkle'
|
||||
*/
|
||||
icon: string;
|
||||
|
||||
/**
|
||||
* Callback when the assistant is opened.
|
||||
*/
|
||||
open: (query?: string) => void;
|
||||
|
||||
/**
|
||||
* Whether to display the triggers for this assistant in the UI.
|
||||
* @default true
|
||||
*/
|
||||
ui?: boolean;
|
||||
};
|
||||
|
||||
export type GitBookGlobal = {
|
||||
/**
|
||||
* Register an event listener.
|
||||
*/
|
||||
addEventListener: (
|
||||
type: GitBookIntegrationEvent,
|
||||
func: GitBookIntegrationEventCallback
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Remove an event listener.
|
||||
*/
|
||||
removeEventListener: (
|
||||
type: GitBookIntegrationEvent,
|
||||
func: GitBookIntegrationEventCallback
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Register a custom tool to be exposed to the AI assistant.
|
||||
*/
|
||||
registerTool: (tool: GitBookIntegrationTool) => void;
|
||||
|
||||
/**
|
||||
* Register a custom assistant to be available on the site.
|
||||
*/
|
||||
registerAssistant: (assistant: GitBookAssistant) => () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/**
|
||||
* Global `window.GitBook` object accessible by integrations.
|
||||
*/
|
||||
GitBook?: GitBookGlobal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": [
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -97,53 +97,9 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search - AI Mode: Search - Complete flow',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
|
||||
// Focus search input, expecting recommended questions
|
||||
await searchInput.focus();
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.fill('What is gitbook?');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const aiSearchResult = page.getByTestId('search-ask-question');
|
||||
await expect(aiSearchResult).toBeVisible();
|
||||
await aiSearchResult.click();
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search - AI Mode: Search - URL query (Initial)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
})}&q=`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
},
|
||||
},
|
||||
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
|
||||
// - Search - AI Mode: Search - Complete flow
|
||||
// - Search - AI Mode: Search - URL query (Initial)
|
||||
{
|
||||
name: 'Search - AI Mode: Search - URL query (Results)',
|
||||
url: `${getCustomizationURL({
|
||||
@@ -158,37 +114,9 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Search - URL query (Ask initial)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
})}&ask=`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Search - URL query (Ask results)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
})}&ask=What+is+GitBook%3F`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('What is GitBook?');
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
|
||||
},
|
||||
},
|
||||
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
|
||||
// - Ask - AI Mode: Search - URL query (Ask initial)
|
||||
// - Ask - AI Mode: Search - URL query (Ask results)
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Complete flow',
|
||||
url: getCustomizationURL({
|
||||
@@ -202,11 +130,12 @@ const searchTestCases: Test[] = [
|
||||
|
||||
// Focus search input, expecting recommended questions
|
||||
await searchInput.focus();
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
// TODO: Re-enable this part of the test when we have fixed the AI Search timing out
|
||||
// await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
// const recommendedQuestions = await page
|
||||
// .getByTestId('search-recommended-question')
|
||||
// .all();
|
||||
// await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.fill('What is gitbook?');
|
||||
@@ -225,7 +154,7 @@ const searchTestCases: Test[] = [
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await page.keyboard.press('ControlOrMeta+J');
|
||||
await page.keyboard.press('ControlOrMeta+I');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ export default class extends WorkerEntrypoint {
|
||||
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
|
||||
);
|
||||
return this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
|
||||
redirect: 'manual',
|
||||
cf: {
|
||||
cacheEverything: false,
|
||||
},
|
||||
@@ -33,6 +34,9 @@ export default class extends WorkerEntrypoint {
|
||||
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
|
||||
const nextRequest = new Request(modifiedUrl, reqOrResp);
|
||||
return fetch(nextRequest, {
|
||||
// We never want to follow the redirects here.
|
||||
// Redirects are supposed to happen from the client.
|
||||
redirect: 'manual',
|
||||
cf: {
|
||||
cacheEverything: false,
|
||||
},
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
// When deployed locally, we don't have access to the tag cache here,
|
||||
// we should just bypass the cache to go to the server directly
|
||||
"SHOULD_BYPASS_CACHE": "true",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"GITBOOK_URL": "http://localhost:8771"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
|
||||
@@ -48,7 +48,13 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||
CacheValue<CacheType>
|
||||
>;
|
||||
return this.returnNullOn404(result);
|
||||
return this.returnNullOn404({
|
||||
...result,
|
||||
// Because we use tag cache and also invalidate them every time,
|
||||
// if we get a cache hit, we don't need to check the tag cache as we already know it's not been revalidated
|
||||
// this should improve performance even further, and reduce costs
|
||||
shouldBypassTagCache: true,
|
||||
});
|
||||
}
|
||||
|
||||
const r2Object = await r2.get(cacheKey);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/fonts": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -13,7 +14,7 @@
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.6.2",
|
||||
"@opennextjs/cloudflare": "^1.6.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './useAI';
|
||||
export * from './useAIChat';
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AIToolCallGetPageContent,
|
||||
AIToolCallGetPages,
|
||||
AIToolCallMCP,
|
||||
AIToolCallOther,
|
||||
AIToolCallSearch,
|
||||
ContentRef,
|
||||
} from '@gitbook/api';
|
||||
@@ -58,6 +59,8 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
|
||||
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
|
||||
case 'mcp':
|
||||
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
|
||||
case 'other':
|
||||
return <DescriptionForOtherToolCall toolCall={toolCall} context={context} />;
|
||||
default:
|
||||
return <>{toolCall.tool}</>;
|
||||
}
|
||||
@@ -112,6 +115,15 @@ function DescriptionForMCPToolCall(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionForOtherToolCall(props: {
|
||||
toolCall: AIToolCallOther;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall } = props;
|
||||
|
||||
return <p>{toolCall.summary.text}</p>;
|
||||
}
|
||||
|
||||
async function DescriptionForSearchToolCall(props: {
|
||||
toolCall: AIToolCallSearch;
|
||||
context: GitBookSiteContext;
|
||||
@@ -247,6 +259,8 @@ function getIconForToolCall(toolCall: AIToolCall): IconName {
|
||||
return 'magnifying-glass';
|
||||
case 'getPages':
|
||||
return 'files';
|
||||
case 'other':
|
||||
return (toolCall.summary.icon as IconName) ?? 'hammer';
|
||||
default:
|
||||
return 'hammer';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
import type { GitBookBaseContext } from '@/lib/context';
|
||||
import { fetchServerActionSiteContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import {
|
||||
type AIMessage,
|
||||
AIMessageRole,
|
||||
@@ -19,97 +20,100 @@ export async function streamRenderAIMessage(
|
||||
rawStream: AsyncIterable<AIStreamResponse>,
|
||||
options?: RenderAIMessageOptions
|
||||
) {
|
||||
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]);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the full-context in the background to avoid blocking the stream.
|
||||
const promiseContext = fetchServerActionSiteContext(baseContext);
|
||||
|
||||
return parseResponse<{
|
||||
content: React.ReactNode;
|
||||
event: AIStreamResponse;
|
||||
}>(rawStream, async (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 {
|
||||
event,
|
||||
content: (
|
||||
<AIMessageView message={message} context={await promiseContext} {...options} />
|
||||
),
|
||||
return traceErrorOnly('AI.streamRenderAIMessage', async () => {
|
||||
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]);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the full-context in the background to avoid blocking the stream.
|
||||
const promiseContext = fetchServerActionSiteContext(baseContext);
|
||||
|
||||
return parseResponse<{
|
||||
content: React.ReactNode;
|
||||
event: AIStreamResponse;
|
||||
}>(rawStream, async (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 {
|
||||
event,
|
||||
content: (
|
||||
<AIMessageView message={message} context={await promiseContext} {...options} />
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
'use server';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { type AIMessageContext, AIMessageRole, AIModel } from '@gitbook/api';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import {
|
||||
type AIMessageContext,
|
||||
AIMessageRole,
|
||||
AIModel,
|
||||
type AIToolCallResult,
|
||||
type AIToolDefinition,
|
||||
} from '@gitbook/api';
|
||||
import { streamRenderAIMessage } from './api';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
|
||||
@@ -12,32 +19,44 @@ export async function* streamAIChatResponse({
|
||||
message,
|
||||
messageContext,
|
||||
previousResponseId,
|
||||
toolCall,
|
||||
tools,
|
||||
options,
|
||||
}: {
|
||||
message: string;
|
||||
message?: string;
|
||||
messageContext: AIMessageContext;
|
||||
previousResponseId?: string;
|
||||
toolCall?: AIToolCallResult;
|
||||
tools?: AIToolDefinition[];
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const api = await context.dataFetcher.api();
|
||||
const rawStream = api.orgs.streamAiResponseInSite(siteURLData.organization, siteURLData.site, {
|
||||
mode: 'assistant',
|
||||
input: [
|
||||
const api = await context.dataFetcher.api();
|
||||
const rawStream = api.orgs.streamAiResponseInSite(
|
||||
siteURLData.organization,
|
||||
siteURLData.site,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: message,
|
||||
context: messageContext,
|
||||
},
|
||||
],
|
||||
output: { type: 'document' },
|
||||
model: AIModel.ReasoningLow,
|
||||
previousResponseId,
|
||||
});
|
||||
input: message
|
||||
? [
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: message,
|
||||
context: messageContext,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
model: AIModel.ReasoningLow,
|
||||
previousResponseId,
|
||||
toolCall,
|
||||
tools,
|
||||
}
|
||||
);
|
||||
|
||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
||||
return await streamRenderAIMessage(context, rawStream, options);
|
||||
});
|
||||
|
||||
for await (const output of stream) {
|
||||
yield output;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import { streamRenderAIMessage } from './api';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
|
||||
@@ -14,16 +15,18 @@ export async function* streamAIResponseById({
|
||||
responseId: string;
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
const { stream } = await traceErrorOnly('AI.streamAIResponseById', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const api = await context.dataFetcher.api();
|
||||
const rawStream = api.orgs.streamExistingAiResponseInSite(
|
||||
siteURLData.organization,
|
||||
siteURLData.site,
|
||||
responseId
|
||||
);
|
||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
||||
const api = await context.dataFetcher.api();
|
||||
const rawStream = api.orgs.streamExistingAiResponseInSite(
|
||||
siteURLData.organization,
|
||||
siteURLData.site,
|
||||
responseId
|
||||
);
|
||||
return await streamRenderAIMessage(context, rawStream, options);
|
||||
});
|
||||
|
||||
for await (const output of stream) {
|
||||
yield output;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||
import { useAIChatController, useAIChatState } from '.';
|
||||
import { AIChatIcon, getAIChatName } from '../AIChat';
|
||||
import { useIntegrationAssistants } from '../Integrations';
|
||||
import { useSearch } from '../Search/useSearch';
|
||||
|
||||
// Unify assistants configuration context with the assistants hook in one place
|
||||
export type AIConfig = {
|
||||
aiMode: CustomizationAIMode;
|
||||
trademark: boolean;
|
||||
};
|
||||
|
||||
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
|
||||
/**
|
||||
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
|
||||
* @example '123e4567-e89b-12d3-a456-426614174000'
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Display mode for the assistant. Currently, only `overlay` is supported for custom assistants.
|
||||
* - `overlay`: Display the assistant in an overlay on top of the page.
|
||||
* - `sidebar`: Display the assistant in a sidebar next to the page. Only supported for the GitBook Assistant.
|
||||
* - `search`: Display the assistant inside the search container. Only supported for GitBook AI Search.
|
||||
* @default 'overlay'
|
||||
*/
|
||||
mode?: 'overlay' | 'sidebar' | 'search';
|
||||
|
||||
/**
|
||||
* Icon of the assistant displayed in the UI.
|
||||
*/
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const AIContext = React.createContext<AIConfig | null>(null);
|
||||
|
||||
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
|
||||
const { aiMode, trademark, children } = props;
|
||||
const value = React.useMemo(() => ({ aiMode, trademark }), [aiMode, trademark]);
|
||||
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||
}
|
||||
|
||||
function useAIConfig(): AIConfig {
|
||||
const ctx = React.useContext(AIContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAI must be used within AIContextProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
type AIContext = {
|
||||
config: AIConfig;
|
||||
assistants: Assistant[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified assistants list combining the built-in GitBook Assistant (when enabled)
|
||||
* with any custom assistants registered at runtime.
|
||||
*/
|
||||
export function useAI(): AIContext {
|
||||
const config = useAIConfig();
|
||||
const chat = useAIChatState();
|
||||
const chatController = useAIChatController();
|
||||
const language = useLanguage();
|
||||
const [, setSearchState] = useSearch();
|
||||
|
||||
const assistants: Assistant[] = [];
|
||||
|
||||
if (config.aiMode === CustomizationAIMode.Assistant) {
|
||||
assistants.push({
|
||||
id: 'gitbook-assistant',
|
||||
label: getAIChatName(config.trademark),
|
||||
icon: (
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
trademark={config.trademark}
|
||||
/>
|
||||
),
|
||||
open: (query?: string) => {
|
||||
chatController.open();
|
||||
if (query) {
|
||||
chatController.postMessage({ message: query });
|
||||
}
|
||||
},
|
||||
ui: true,
|
||||
mode: 'sidebar',
|
||||
});
|
||||
} else if (config.aiMode === CustomizationAIMode.Search) {
|
||||
assistants.push({
|
||||
id: 'gitbook-ai-search',
|
||||
label: tString(language, 'ai_chat_context_badge'),
|
||||
icon: (
|
||||
<div className="relative">
|
||||
<Icon icon="search" className="size-4" />
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="absolute top-[2.5px] left-[2.6px] size-2"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
open: (query?: string) => {
|
||||
if (query) {
|
||||
setSearchState((prev) =>
|
||||
prev ? { ...prev, query: null, ask: query, open: true } : null
|
||||
);
|
||||
}
|
||||
},
|
||||
ui: false,
|
||||
mode: 'search',
|
||||
});
|
||||
}
|
||||
|
||||
const integrationAssistants = useIntegrationAssistants();
|
||||
if (integrationAssistants.length > 0) {
|
||||
assistants.push(
|
||||
...integrationAssistants.map((assistant) => ({
|
||||
...assistant,
|
||||
icon: <Icon icon={assistant.icon as IconName} className="size-4" />,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
assistants,
|
||||
};
|
||||
}
|
||||
@@ -2,9 +2,17 @@
|
||||
|
||||
import * as zustand from 'zustand';
|
||||
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
AIMessageRole,
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { useSearch } from '../Search';
|
||||
import { streamAIChatResponse } from './server-actions';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
@@ -15,6 +23,21 @@ export type AIChatMessage = {
|
||||
query?: string;
|
||||
};
|
||||
|
||||
export type AIChatPendingTool = {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Confirm the tool call by calling this function.
|
||||
*/
|
||||
confirm: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Tool call result to cancel it.
|
||||
*/
|
||||
cancelToolCall: AIToolCallResult;
|
||||
};
|
||||
|
||||
export type AIChatState = {
|
||||
/**
|
||||
* If true, the chat is open.
|
||||
@@ -46,6 +69,11 @@ export type AIChatState = {
|
||||
*/
|
||||
followUpSuggestions: string[];
|
||||
|
||||
/**
|
||||
* Tools that are pending confirmation to be executed.
|
||||
*/
|
||||
pendingTools: AIChatPendingTool[];
|
||||
|
||||
/**
|
||||
* If true, the session is in progress.
|
||||
*/
|
||||
@@ -71,22 +99,17 @@ export type AIChatController = {
|
||||
};
|
||||
|
||||
// Global state store for AI chat
|
||||
const globalState = zustand.create<{
|
||||
state: AIChatState;
|
||||
setState: (fn: (state: AIChatState) => Partial<AIChatState>) => void;
|
||||
}>((set) => {
|
||||
const globalState = zustand.create<AIChatState>(() => {
|
||||
return {
|
||||
state: {
|
||||
opened: false,
|
||||
responseId: null,
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
},
|
||||
setState: (fn) => set((state) => ({ state: { ...state.state, ...fn(state.state) } })),
|
||||
opened: false,
|
||||
responseId: null,
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -94,7 +117,7 @@ const globalState = zustand.create<{
|
||||
* Get the current state of the AI chat.
|
||||
*/
|
||||
export function useAIChatState(): AIChatState {
|
||||
const state = zustand.useStore(globalState, (state) => state.state);
|
||||
const state = zustand.useStore(globalState);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -104,14 +127,14 @@ export function useAIChatState(): AIChatState {
|
||||
*/
|
||||
export function useAIChatController(): AIChatController {
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const setState = zustand.useStore(globalState, (state) => state.setState);
|
||||
const trackEvent = useTrackEvent();
|
||||
const [searchState, setSearchState] = useSearch(true);
|
||||
const [, setSearchState] = useSearch();
|
||||
const language = useLanguage();
|
||||
|
||||
// Open AI chat and sync with search state
|
||||
const onOpen = React.useCallback(() => {
|
||||
const { initialQuery } = globalState.getState().state;
|
||||
setState((state) => ({ ...state, opened: true }));
|
||||
const { initialQuery } = globalState.getState();
|
||||
globalState.setState((state) => ({ ...state, opened: true }));
|
||||
|
||||
// Update search state to show ask mode with first message or current ask value
|
||||
setSearchState((prev) => ({
|
||||
@@ -120,11 +143,11 @@ export function useAIChatController(): AIChatController {
|
||||
global: prev?.global ?? false,
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
}, [setState, setSearchState]);
|
||||
}, [setSearchState]);
|
||||
|
||||
// Close AI chat and clear ask parameter
|
||||
const onClose = React.useCallback(() => {
|
||||
setState((state) => ({ ...state, opened: false }));
|
||||
globalState.setState((state) => ({ ...state, opened: false }));
|
||||
|
||||
// Clear ask parameter but keep other search state
|
||||
setSearchState((prev) => ({
|
||||
@@ -133,65 +156,98 @@ export function useAIChatController(): AIChatController {
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}, [setState, setSearchState]);
|
||||
}, [setSearchState]);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { messages } = globalState.getState().state;
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
|
||||
trackEvent({ type: 'ask_question', query: input.message });
|
||||
|
||||
// Add user message and placeholder for AI response
|
||||
setState((state) => {
|
||||
// Stream a message with the AI backend
|
||||
const streamResponse = React.useCallback(
|
||||
async (input: {
|
||||
/** Text message to send to the AI backend */
|
||||
message?: string;
|
||||
/** Tool call to send to the AI backend */
|
||||
toolCall?: AIToolCallResult;
|
||||
}) => {
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null, // Placeholder for streaming response
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
responseId: null,
|
||||
followUpSuggestions: [],
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
});
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const toolDef = integrationTools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toolDef.execute(event.toolCall.input);
|
||||
streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
summary: {
|
||||
icon: 'bomb',
|
||||
text: 'An error occurred while executing the tool',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().state.responseId ?? undefined,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
tools: integrationTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
});
|
||||
|
||||
// Process streaming response
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
|
||||
if (input.message && globalState.getState().query !== input.message) {
|
||||
// Chat was cleared, stop processing the stream
|
||||
break;
|
||||
}
|
||||
|
||||
const event = data.event;
|
||||
|
||||
switch (event.type) {
|
||||
case 'response_finish': {
|
||||
setState((state) => ({
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responseId: event.responseId,
|
||||
// Mark as not loading when the response is finished
|
||||
@@ -202,7 +258,7 @@ export function useAIChatController(): AIChatController {
|
||||
break;
|
||||
}
|
||||
case 'response_followup_suggestion': {
|
||||
setState((state) => ({
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
followUpSuggestions: [
|
||||
...state.followUpSuggestions,
|
||||
@@ -211,10 +267,53 @@ export function useAIChatController(): AIChatController {
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_tool_call_pending': {
|
||||
const toolDef = integrationTools.find(
|
||||
(tool) => tool.name === event.toolCall.tool
|
||||
);
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
const confirmation = toolDef.confirmation;
|
||||
if (confirmation) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
pendingTools: [
|
||||
...state.pendingTools,
|
||||
{
|
||||
icon: confirmation.icon,
|
||||
label: confirmation.label,
|
||||
cancelToolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
cancelled: 'User did not confirm the tool call',
|
||||
},
|
||||
summary: {
|
||||
icon: 'forward',
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
confirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
confirm: async () => {
|
||||
await executeToolCall(event);
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
} else {
|
||||
toolToExecute = event;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the assistant message with streamed content
|
||||
setState((state) => ({
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages.slice(0, -1),
|
||||
@@ -226,30 +325,89 @@ export function useAIChatController(): AIChatController {
|
||||
}));
|
||||
}
|
||||
|
||||
setState((state) => ({
|
||||
// Execute the tool call if it doesn't require confirmation
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
}
|
||||
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
} catch {
|
||||
setState((state) => ({
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[messageContextRef.current, setState, setSearchState, trackEvent]
|
||||
[messageContextRef.current]
|
||||
);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, pendingTools } = globalState.getState();
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
|
||||
if (query === input.message) {
|
||||
// Return early if the message is the same as the previous message
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent({ type: 'ask_question', query: input.message });
|
||||
|
||||
// Add user message and placeholder for AI response
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
followUpSuggestions: [],
|
||||
loading: true,
|
||||
error: false,
|
||||
initialQuery: state.initialQuery ?? input.message,
|
||||
};
|
||||
});
|
||||
|
||||
const pendingTool = pendingTools[0];
|
||||
streamResponse({
|
||||
message: input.message,
|
||||
// If we had a pending tool call, we need to send it as being cancelled
|
||||
// otherwise the AI will fail to process the message
|
||||
...(pendingTool ? { toolCall: pendingTool.cancelToolCall } : {}),
|
||||
});
|
||||
},
|
||||
[setSearchState, trackEvent, streamResponse]
|
||||
);
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
const onClear = React.useCallback(() => {
|
||||
setState((state) => ({
|
||||
globalState.setState((state) => ({
|
||||
opened: state.opened,
|
||||
loading: false,
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
@@ -262,47 +420,7 @@ export function useAIChatController(): AIChatController {
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}, [setState, setSearchState]);
|
||||
|
||||
// Auto-trigger AI chat when ?ask= parameter appears in URL (only once)
|
||||
React.useEffect(() => {
|
||||
const hasNoAsk = searchState?.ask === undefined || searchState?.ask === null;
|
||||
const hasQuery = searchState?.query !== null;
|
||||
|
||||
// Don't trigger if we have a regular search query active
|
||||
if (hasNoAsk) return;
|
||||
if (hasQuery && searchState.open === false) return;
|
||||
|
||||
// Open the chat when ask parameter appears
|
||||
onOpen();
|
||||
|
||||
// Auto-post the message if ask has content
|
||||
if (searchState?.ask?.trim()) {
|
||||
const trimmedAsk = searchState.ask.trim();
|
||||
const { loading, initialQuery } = globalState.getState().state;
|
||||
|
||||
// Don't trigger if we're already posting a message
|
||||
if (loading) return;
|
||||
|
||||
// Only initialize once per URL ask value
|
||||
if (initialQuery === trimmedAsk) return;
|
||||
|
||||
// Wait for messageContextRef to be defined before proceeding
|
||||
if (!messageContextRef.current?.location) return;
|
||||
|
||||
// Mark this ask value as processed
|
||||
setState((state) => ({ ...state, initialQuery: trimmedAsk }));
|
||||
onPostMessage({ message: trimmedAsk });
|
||||
}
|
||||
}, [
|
||||
searchState?.ask,
|
||||
searchState?.query,
|
||||
searchState?.open,
|
||||
messageContextRef,
|
||||
onOpen,
|
||||
setState,
|
||||
onPostMessage,
|
||||
]);
|
||||
}, [setSearchState]);
|
||||
|
||||
return React.useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useAIChatController } from '@/components/AI/useAIChat';
|
||||
import { useAIChatState } from '@/components/AI/useAIChat';
|
||||
import { useAIChatState } from '@/components/AI';
|
||||
import type { Assistant } from '@/components/AI';
|
||||
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
|
||||
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
|
||||
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
|
||||
import { getAIChatName } from '@/components/AIChat';
|
||||
import { AIChatIcon } from '@/components/AIChat';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
@@ -22,32 +20,21 @@ type AIActionType = 'button' | 'dropdown-menu-item';
|
||||
/**
|
||||
* Opens our AI Docs Assistant.
|
||||
*/
|
||||
export function OpenDocsAssistant(props: { type: AIActionType; trademark: boolean }) {
|
||||
const { type, trademark } = props;
|
||||
const chatController = useAIChatController();
|
||||
export function OpenAIAssistant(props: { assistant: Assistant; type: AIActionType }) {
|
||||
const { assistant, type } = props;
|
||||
const chat = useAIChatState();
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<AIActionWrapper
|
||||
type={type}
|
||||
icon={
|
||||
<AIChatIcon state={chat.loading ? 'thinking' : 'default'} trademark={trademark} />
|
||||
}
|
||||
label={tString(language, 'ai_chat_ask', getAIChatName(trademark))}
|
||||
icon={assistant.icon}
|
||||
label={assistant.label}
|
||||
shortLabel={tString(language, 'ask')}
|
||||
description={tString(language, 'ai_chat_ask_about_page', getAIChatName(trademark))}
|
||||
description={tString(language, 'ai_chat_ask_about_page', assistant.label)}
|
||||
disabled={chat.loading}
|
||||
onClick={() => {
|
||||
// Open the chat if it's not already open
|
||||
if (!chat.opened) {
|
||||
chatController.open();
|
||||
}
|
||||
|
||||
// Send the "What is this page about?" message
|
||||
chatController.postMessage({
|
||||
message: tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||
});
|
||||
assistant.open(tString(language, 'ai_chat_suggested_questions_about_this_page'));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -231,13 +218,15 @@ function AIActionWrapper(props: {
|
||||
}
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
label={shortLabel || label}
|
||||
label={label ?? shortLabel}
|
||||
className="bg-tint-base text-sm"
|
||||
onClick={onClick}
|
||||
href={href}
|
||||
target={href ? '_blank' : undefined}
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
>
|
||||
{shortLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,13 +240,13 @@ function AIActionWrapper(props: {
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center text-tint">
|
||||
{loading ? (
|
||||
<Icon icon="spinner-third" className="size-4 animate-spin" />
|
||||
<Icon icon="spinner-third" className="size-4 shrink-0 animate-spin" />
|
||||
) : icon ? (
|
||||
typeof icon === 'string' ? (
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Regular}
|
||||
className="size-4 fill-transparent stroke-current"
|
||||
className="size-4 shrink-0 fill-transparent stroke-current"
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
@@ -268,9 +257,9 @@ function AIActionWrapper(props: {
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2 text-tint-strong">
|
||||
<span className="truncate font-medium text-sm">{label}</span>
|
||||
{href ? <Icon icon="arrow-up-right" className="size-3" /> : null}
|
||||
{href ? <Icon icon="arrow-up-right" className="size-3 shrink-0" /> : null}
|
||||
</span>
|
||||
{description && <span className="truncate text-tint text-xs">{description}</span>}
|
||||
{description && <span className="text-tint text-xs">{description}</span>}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
import {
|
||||
CopyMarkdown,
|
||||
OpenDocsAssistant,
|
||||
OpenAIAssistant,
|
||||
OpenInLLM,
|
||||
ViewAsMarkdown,
|
||||
} from '@/components/AIActions/AIActions';
|
||||
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
||||
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { SiteCustomizationSettings } from '@gitbook/api';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useRef } from 'react';
|
||||
import { useAI } from '../AI';
|
||||
|
||||
interface AIActionsDropdownProps {
|
||||
markdownPageUrl: string;
|
||||
withAIChat?: boolean;
|
||||
trademark: boolean;
|
||||
className?: string;
|
||||
actions: SiteCustomizationSettings['pageActions'];
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ interface AIActionsDropdownProps {
|
||||
*/
|
||||
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const assistants = useAI().assistants.filter((assistant) => assistant.ui === true);
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<ButtonGroup ref={ref}>
|
||||
return assistants.length > 0 || props.actions.markdown || props.actions.externalAI ? (
|
||||
<ButtonGroup ref={ref} className={props.className}>
|
||||
<DefaultAction {...props} />
|
||||
{props.actions.markdown || props.actions.externalAI ? (
|
||||
<DropdownMenu
|
||||
@@ -41,6 +43,7 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
|
||||
/>
|
||||
}
|
||||
label={tString(language, 'more')}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
@@ -52,26 +55,31 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</ButtonGroup>
|
||||
);
|
||||
) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The content of the dropdown menu.
|
||||
*/
|
||||
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, withAIChat, trademark, actions } = props;
|
||||
const { markdownPageUrl, actions } = props;
|
||||
const assistants = useAI().assistants.filter((assistant) => assistant.ui === true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{withAIChat ? (
|
||||
<OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" />
|
||||
) : null}
|
||||
{assistants.map((assistant) => (
|
||||
<OpenAIAssistant
|
||||
key={assistant.label}
|
||||
assistant={assistant}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
))}
|
||||
|
||||
{actions.markdown ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!withAIChat}
|
||||
isDefaultAction={!assistants.length}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
@@ -94,16 +102,17 @@ function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||
* A default action shown as a quick-access button beside the dropdown menu
|
||||
*/
|
||||
function DefaultAction(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, withAIChat, trademark, actions } = props;
|
||||
const { markdownPageUrl, actions } = props;
|
||||
const assistants = useAI().assistants.filter((assistant) => assistant.ui === true);
|
||||
|
||||
if (withAIChat) {
|
||||
return <OpenDocsAssistant trademark={trademark} type="button" />;
|
||||
if (assistants.length) {
|
||||
return <OpenAIAssistant assistant={assistants[0]} type="button" />;
|
||||
}
|
||||
|
||||
if (actions.markdown) {
|
||||
return (
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!withAIChat}
|
||||
isDefaultAction={!assistants.length}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="button"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
type AIChatState,
|
||||
useAIChatController,
|
||||
useAIChatState,
|
||||
} from '../AI/useAIChat';
|
||||
} from '../AI';
|
||||
import { useNow } from '../hooks';
|
||||
import { Button } from '../primitives';
|
||||
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
|
||||
@@ -24,12 +25,14 @@ export function AIChat(props: { trademark: boolean }) {
|
||||
const chatController = useAIChatController();
|
||||
|
||||
useHotkeys(
|
||||
'mod+j',
|
||||
'mod+i',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
chatController.open();
|
||||
},
|
||||
[]
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
}
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
@@ -121,7 +124,12 @@ export function AIChatWindow(props: {
|
||||
return (
|
||||
<div
|
||||
data-testid="ai-chat"
|
||||
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enter-from-right lg:pr-4 lg:pl-0 xl:w-96"
|
||||
className={tcls(
|
||||
'ai-chat sticky right-0 z-40 ml-8 flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-[width] duration-300 sm:px-6 lg:w-80 lg:animate-enter-from-right lg:px-0 xl:w-96',
|
||||
'lg:[html[style*="--toc-top-offset"]_&]:top-(--toc-top-offset)!',
|
||||
'lg:[html[style*="--toc-height"]_&]:h-(--toc-height)!',
|
||||
'lg:-mr-[calc(max(0px,((100vw-96rem)/2)))]' // screen (100vw) - sidebar (18rem) - content (48rem) - chat window (20rem)
|
||||
)}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="relative flex h-full grow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint-base text-sm text-tint depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
||||
@@ -137,7 +145,9 @@ export function AIChatWindow(props: {
|
||||
? 'working'
|
||||
: 'thinking'
|
||||
: chat.messages.length > 0
|
||||
? 'done'
|
||||
? chat.pendingTools.length > 0
|
||||
? 'confirm'
|
||||
: 'done'
|
||||
: 'default'
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -2,42 +2,37 @@
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
||||
import type { Assistant } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { getAIChatName } from './AIChat';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
|
||||
/**
|
||||
* Button to open/close the AI chat.
|
||||
*/
|
||||
export function AIChatButton(props: { trademark: boolean }) {
|
||||
const { trademark } = props;
|
||||
const chatController = useAIChatController();
|
||||
const chat = useAIChatState();
|
||||
export function AIChatButton(props: {
|
||||
assistant: Assistant;
|
||||
showLabel?: boolean;
|
||||
}) {
|
||||
const { assistant, showLabel = true } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={<AIChatIcon trademark={trademark} />}
|
||||
icon={assistant.icon}
|
||||
data-testid="ai-chat-button"
|
||||
iconOnly
|
||||
size="default"
|
||||
iconOnly={!showLabel}
|
||||
size="medium"
|
||||
variant="header"
|
||||
className={tcls('h-9 px-2.5')}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{t(language, 'ai_chat_ask', getAIChatName(trademark))}
|
||||
<KeyboardShortcut keys={['mod', 'j']} className="border-tint-11 text-tint-1" />
|
||||
{t(language, 'ai_chat_ask', assistant.label)}
|
||||
<KeyboardShortcut keys={['mod', 'i']} className="border-tint-11 text-tint-1" />
|
||||
</div>
|
||||
}
|
||||
onClick={() => {
|
||||
if (chat.opened) {
|
||||
chatController.close();
|
||||
} else {
|
||||
chatController.open();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
onClick={() => assistant.open()}
|
||||
>
|
||||
{showLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type React from 'react';
|
||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
size?: number;
|
||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error';
|
||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
|
||||
trademark?: boolean;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const AIChatIcon = ({
|
||||
preserveAspectRatio="xMaxYMid meet"
|
||||
className={className}
|
||||
aria-busy={state === 'thinking'}
|
||||
overflow="visible"
|
||||
{...props}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -56,6 +57,7 @@ export const AIChatIcon = ({
|
||||
(state === 'working' || state === 'thinking') &&
|
||||
'animate-[fadeIn_.5s_.3s_both,spin_2s_1s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
||||
state === 'done' && 'animate-[fadeOut_.5s_both]',
|
||||
state === 'confirm' && 'animate-[fadeOut_.5s_both]',
|
||||
state === 'default' && 'animate-[fadeIn_0s_both]',
|
||||
state === 'error' && 'hidden'
|
||||
)}
|
||||
@@ -95,10 +97,24 @@ export const AIChatIcon = ({
|
||||
state === 'done'
|
||||
? 'animate-[fadeIn_.5s_.3s_both]'
|
||||
: 'animate-[fadeOut_.5s_both]',
|
||||
state === 'intro' && 'hidden'
|
||||
state === 'intro' && 'hidden',
|
||||
state === 'confirm' && 'hidden'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Confirm */}
|
||||
<path
|
||||
className={tcls(
|
||||
'fill-primary-9',
|
||||
state === 'confirm'
|
||||
? 'animate-[fadeIn_.5s_.3s_both,bounceSmall_1s_infinite_both]'
|
||||
: state === 'thinking'
|
||||
? 'animate-[fadeOut_.5s_both]'
|
||||
: 'hidden'
|
||||
)}
|
||||
d="M12.9463 5.24512C13.3688 5.24422 13.713 5.58625 13.7139 6.00879C13.7146 6.43114 13.3725 6.77338 12.9502 6.77441C12.5279 6.77505 12.1845 6.43408 12.1836 6.01172C12.1828 5.58953 12.5242 5.24649 12.9463 5.24512ZM13.0391 0.0751953C14.0688 0.0730893 14.9049 0.90586 14.9072 1.93555C14.9084 2.5063 14.6484 3.04679 14.2012 3.40137L13.7773 3.7373C13.6151 3.86604 13.5201 4.06239 13.5205 4.26953V4.30371C13.5211 4.62139 13.2639 4.879 12.9463 4.87988C12.6288 4.88032 12.3701 4.62417 12.3691 4.30664V4.27246C12.3679 3.71272 12.6238 3.18263 13.0625 2.83496L13.4854 2.49902C13.6565 2.36341 13.7562 2.1568 13.7559 1.93848C13.755 1.54463 13.4358 1.22503 13.042 1.22559H12.9385C12.488 1.22679 12.1225 1.59352 12.123 2.04395L12.124 2.21875C12.1245 2.53649 11.8676 2.79522 11.5498 2.7959C11.2321 2.79653 10.9746 2.53928 10.9736 2.22168L10.9727 2.04688C10.9706 0.960578 11.8493 0.0778178 12.9355 0.0751953H13.0391Z"
|
||||
/>
|
||||
|
||||
{/* Background */}
|
||||
<path
|
||||
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
||||
|
||||
@@ -45,16 +45,18 @@ export function AIChatInput(props: {
|
||||
}, [disabled, loading]);
|
||||
|
||||
useHotkeys(
|
||||
'mod+j',
|
||||
'mod+i',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[]
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base">
|
||||
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
disabled={disabled || loading}
|
||||
@@ -99,10 +101,12 @@ export function AIChatInput(props: {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
||||
<KeyboardShortcut keys={['mod', 'j']} />
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2">
|
||||
{!disabled ? (
|
||||
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
||||
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
|
||||
<Tooltip
|
||||
label={
|
||||
<div className="flex flex-col gap-3 p-2">
|
||||
@@ -126,11 +130,11 @@ export function AIChatInput(props: {
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2 py-1 text-tint/7 text-xs transition-all hover:bg-tint">
|
||||
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
|
||||
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-sm bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
|
||||
{t(language, 'ai_chat_context_badge')}
|
||||
</span>{' '}
|
||||
<span>{t(language, 'ai_chat_context_title')}</span>
|
||||
<span className="leading-none">{t(language, 'ai_chat_context_title')}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import type React from 'react';
|
||||
import type { AIChatController, AIChatState } from '../AI/useAIChat';
|
||||
import type { AIChatController, AIChatState } from '../AI';
|
||||
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
|
||||
@@ -44,7 +45,7 @@ export function AIChatMessages(props: {
|
||||
{message.content ? message.content : null}
|
||||
|
||||
{isLastMessage && chat.loading ? (
|
||||
<div className="flex w-full animate-fade-in-slow flex-wrap gap-2 group-has-[.has-content]/message:hidden">
|
||||
<div className="flex w-full animate-fade-in-slow flex-wrap gap-2">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
@@ -60,13 +61,18 @@ export function AIChatMessages(props: {
|
||||
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.loading && !chat.error && chat.query && chat.responseId && (
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.pendingTools.length === 0 ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { AIChatController } from '../AI/useAIChat';
|
||||
import type { AIChatController } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
export default function AIChatSuggestedQuestions(props: { chatController: AIChatController }) {
|
||||
@@ -17,7 +17,6 @@ export default function AIChatSuggestedQuestions(props: { chatController: AIChat
|
||||
{DEFAULT_SUGGESTED_QUESTIONS.map((question, index) => (
|
||||
<Button
|
||||
key={question}
|
||||
label={question}
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
className="max-w-full animate-[present_500ms_both] whitespace-normal"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import type { AIChatState } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
/**
|
||||
* Display buttons to confirm tool calls.
|
||||
*/
|
||||
export function AIChatToolConfirmations(props: {
|
||||
chat: AIChatState;
|
||||
}) {
|
||||
const { chat } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
useHotkeys(
|
||||
'mod+enter',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
chat.pendingTools[0]?.confirm();
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[chat.pendingTools]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||
{chat.pendingTools.map((tool, index) => (
|
||||
<div
|
||||
className="flex w-full animate-present-slow flex-col gap-1"
|
||||
key={index}
|
||||
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
tool.confirm();
|
||||
}}
|
||||
tabIndex={index}
|
||||
label={tool.label}
|
||||
className="w-full justify-center"
|
||||
size={index === 0 ? 'default' : 'medium'}
|
||||
variant={index === 0 ? 'primary' : 'secondary'}
|
||||
icon={tool.icon}
|
||||
/>
|
||||
{index === 0 && (
|
||||
<div
|
||||
className="flex pointer-none:hidden w-full animate-fade-in-slow items-center justify-end gap-2 text-tint text-xs"
|
||||
style={{ animationDelay: '1000ms' }}
|
||||
>
|
||||
{t(
|
||||
language,
|
||||
'press_to_confirm',
|
||||
<KeyboardShortcut
|
||||
keys={['mod', 'enter']}
|
||||
className="mx-0 text-tint"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import { AdClassicRendering } from './AdClassicRendering';
|
||||
import { AdCoverRendering } from './AdCoverRendering';
|
||||
import { AdPixels } from './AdPixels';
|
||||
@@ -40,40 +41,42 @@ interface FetchPlaceholderAdOptions {
|
||||
* and properly access user-agent and IP.
|
||||
*/
|
||||
export async function renderAd(options: FetchAdOptions) {
|
||||
const [context, result] = await Promise.all([
|
||||
getServerActionBaseContext(),
|
||||
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
|
||||
]);
|
||||
return traceErrorOnly('Ads.renderAd', async () => {
|
||||
const [context, result] = await Promise.all([
|
||||
getServerActionBaseContext(),
|
||||
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
|
||||
]);
|
||||
|
||||
const mode = options.source === 'live' ? options.mode : 'classic';
|
||||
if (!result || !result.ad.description || !result.ad.statlink) {
|
||||
return null;
|
||||
}
|
||||
const mode = options.source === 'live' ? options.mode : 'classic';
|
||||
if (!result || !result.ad.description || !result.ad.statlink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { ad } = result;
|
||||
const { ad } = result;
|
||||
|
||||
const insightsAd: SiteInsightsAd | null =
|
||||
options.source === 'live'
|
||||
? {
|
||||
placement: options.placement,
|
||||
zoneId: options.zoneId,
|
||||
domain: 'company' in ad ? ad.company : '',
|
||||
}
|
||||
: null;
|
||||
const insightsAd: SiteInsightsAd | null =
|
||||
options.source === 'live'
|
||||
? {
|
||||
placement: options.placement,
|
||||
zoneId: options.zoneId,
|
||||
domain: 'company' in ad ? ad.company : '',
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
children: (
|
||||
<>
|
||||
{mode === 'classic' || !('callToAction' in ad) ? (
|
||||
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={context} />
|
||||
) : (
|
||||
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={context} />
|
||||
)}
|
||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||
</>
|
||||
),
|
||||
insightsAd,
|
||||
};
|
||||
return {
|
||||
children: (
|
||||
<>
|
||||
{mode === 'classic' || !('callToAction' in ad) ? (
|
||||
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={context} />
|
||||
) : (
|
||||
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={context} />
|
||||
)}
|
||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||
</>
|
||||
),
|
||||
insightsAd,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAd({
|
||||
|
||||
@@ -35,7 +35,7 @@ export function AnnouncementBanner(props: {
|
||||
data-nosnippet=""
|
||||
>
|
||||
<div className="scroll-nojump">
|
||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div className="transition-all duration-300">
|
||||
<div className={tcls('relative', CONTAINER_STYLE)}>
|
||||
<Tag
|
||||
href={contentRef?.href ?? ''}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getDataOrNull } from '@/lib/data';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
|
||||
/**
|
||||
* Return true if a change-request has been updated.
|
||||
@@ -11,15 +12,17 @@ export async function hasContentBeenUpdated(props: {
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
}) {
|
||||
const context = await getServerActionBaseContext();
|
||||
const changeRequest = await getDataOrNull(
|
||||
context.dataFetcher.getChangeRequest({
|
||||
spaceId: props.spaceId,
|
||||
changeRequestId: props.changeRequestId,
|
||||
})
|
||||
);
|
||||
if (!changeRequest) {
|
||||
return false;
|
||||
}
|
||||
return changeRequest.revision !== props.revisionId;
|
||||
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
const changeRequest = await getDataOrNull(
|
||||
context.dataFetcher.getChangeRequest({
|
||||
spaceId: props.spaceId,
|
||||
changeRequestId: props.changeRequestId,
|
||||
})
|
||||
);
|
||||
if (!changeRequest) {
|
||||
return false;
|
||||
}
|
||||
return changeRequest.revision !== props.revisionId;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
</div>
|
||||
<CopyCodeButton
|
||||
codeId={id}
|
||||
style="z-2 mt-2 mr-2 self-start justify-self-end rounded-md bg-transparent p-1 text-tint text-xs leading-none opacity-0 ring-1 ring-tint backdrop-blur-md transition-opacity duration-75 [grid-area:2/1] hover:ring-tint-hover group-hover/codeblock:opacity-11"
|
||||
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
|
||||
/>
|
||||
<pre
|
||||
className={tcls(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/components/primitives';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -41,9 +42,14 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className={tcls(style, 'print:hidden')}>
|
||||
<Button
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
onClick={onClick}
|
||||
className={tcls(style, 'translate-y-0!', 'print:hidden')}
|
||||
>
|
||||
{t(language, copied ? 'code_copied' : 'code_copy')}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,9 @@ const syntaxAliases: Record<string, BundledLanguage> = {
|
||||
// "Parser" language does not exist in Shiki, but it's used in GitBook
|
||||
// The closest language is "Blade"
|
||||
parser: 'blade',
|
||||
|
||||
// From GitBook App we receive "objectivec" instead of "objective-c"
|
||||
objectivec: 'objective-c',
|
||||
};
|
||||
|
||||
function checkIsBundledLanguage(lang: string): lang is BundledLanguage {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import type { RenderIntegrationUI } from '@gitbook/api';
|
||||
import { ContentKitOutput } from '@gitbook/react-contentkit';
|
||||
import { contentKitServerContext } from './contentkit';
|
||||
@@ -19,20 +20,22 @@ export async function renderIntegrationUi({
|
||||
};
|
||||
request: RenderIntegrationUI;
|
||||
}) {
|
||||
const serverAction = await getServerActionBaseContext();
|
||||
const output = await fetchSafeIntegrationUI(serverAction, {
|
||||
integrationName: renderContext.integrationName,
|
||||
request,
|
||||
});
|
||||
return traceErrorOnly('DocumentView.renderIntegrationUi', async () => {
|
||||
const serverAction = await getServerActionBaseContext();
|
||||
const output = await fetchSafeIntegrationUI(serverAction, {
|
||||
integrationName: renderContext.integrationName,
|
||||
request,
|
||||
});
|
||||
|
||||
if (output.error) {
|
||||
return {
|
||||
error: output.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (output.error) {
|
||||
return {
|
||||
error: output.error.message,
|
||||
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
|
||||
output: output.data,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
|
||||
output: output.data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
}
|
||||
|
||||
.openapi-column-preview-body {
|
||||
@apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header:xl:max-2xl:top-32 site-header-sections:top-32 site-header-sections:xl:max-2xl:top-44 print-mode:static;
|
||||
@apply flex flex-col gap-4 sticky top-[calc(var(--toc-top-offset)+5rem)] print-mode:static;
|
||||
}
|
||||
|
||||
.openapi-column-preview pre {
|
||||
|
||||
@@ -26,18 +26,15 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
|
||||
}
|
||||
|
||||
const { reusableContent } = resolved;
|
||||
if (
|
||||
!reusableContent ||
|
||||
!('document' in reusableContent.revisionReusableContent) ||
|
||||
!reusableContent.revisionReusableContent.document
|
||||
) {
|
||||
if (!reusableContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const document = await getDataOrNull(
|
||||
dataFetcher.getDocument({
|
||||
dataFetcher.getRevisionReusableContentDocument({
|
||||
spaceId: reusableContent.context.space.id,
|
||||
documentId: reusableContent.revisionReusableContent.document,
|
||||
revisionId: reusableContent.context.revisionId,
|
||||
reusableContentId: reusableContent.revisionReusableContent.id,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { LinkBox, LinkOverlay } from '@/components/primitives';
|
||||
import { Image } from '@/components/utils';
|
||||
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import {
|
||||
type ContentRef,
|
||||
type DocumentTableViewCards,
|
||||
SiteInsightsLinkPosition,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { LinkBox, LinkOverlay } from '@/components/primitives';
|
||||
import { Image } from '@/components/utils';
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { RecordColumnValue } from './RecordColumnValue';
|
||||
import type { TableRecordKV, TableViewProps } from './Table';
|
||||
import { RecordCardStyles } from './styles';
|
||||
import { getRecordValue } from './utils';
|
||||
import { getRecordCardCovers } from './utils';
|
||||
|
||||
export async function RecordCard(
|
||||
props: TableViewProps<DocumentTableViewCards> & {
|
||||
@@ -21,25 +19,21 @@ export async function RecordCard(
|
||||
) {
|
||||
const { view, record, context, block, isOffscreen } = props;
|
||||
|
||||
const coverFile = view.coverDefinition
|
||||
? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0]
|
||||
: null;
|
||||
const { dark, light } = getRecordCardCovers(record[1], view);
|
||||
const targetRef = view.targetDefinition
|
||||
? (record[1].values[view.targetDefinition] as ContentRef)
|
||||
: null;
|
||||
|
||||
const [cover, target] = await Promise.all([
|
||||
coverFile && context.contentContext
|
||||
? resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext)
|
||||
: null,
|
||||
const [lightCover, darkCover, target] = await Promise.all([
|
||||
light && context.contentContext ? resolveContentRef(light, context.contentContext) : null,
|
||||
dark && context.contentContext ? resolveContentRef(dark, context.contentContext) : null,
|
||||
targetRef && context.contentContext
|
||||
? resolveContentRef(targetRef, context.contentContext)
|
||||
: null,
|
||||
]);
|
||||
|
||||
const coverIsSquareOrPortrait =
|
||||
cover?.file?.dimensions &&
|
||||
cover.file?.dimensions?.width / cover.file?.dimensions?.height <= 1;
|
||||
const darkCoverIsSquareOrPortrait = isSquareOrPortrait(darkCover);
|
||||
const lightCoverIsSquareOrPortrait = isSquareOrPortrait(lightCover);
|
||||
|
||||
const body = (
|
||||
<div
|
||||
@@ -63,23 +57,32 @@ export async function RecordCard(
|
||||
// On mobile, check if we can display the cover responsively or not:
|
||||
// - If the file has a landscape aspect ratio, we display it normally
|
||||
// - If the file is square or portrait, we display it left with 40% of the card width
|
||||
coverIsSquareOrPortrait
|
||||
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
|
||||
? [
|
||||
'grid-cols-[40%__1fr]',
|
||||
'min-[432px]:grid-cols-none',
|
||||
'min-[432px]:grid-rows-[auto_1fr]',
|
||||
]
|
||||
lightCoverIsSquareOrPortrait
|
||||
? 'grid-cols-[40%__1fr] min-[432px]:grid-cols-none min-[432px]:grid-rows-[auto_1fr]'
|
||||
: '',
|
||||
darkCoverIsSquareOrPortrait
|
||||
? 'dark:grid-cols-[40%__1fr] dark:min-[432px]:grid-cols-none dark:min-[432px]:grid-rows-[auto_1fr]'
|
||||
: '',
|
||||
].filter(Boolean)
|
||||
: 'grid-rows-[auto_1fr]'
|
||||
)}
|
||||
>
|
||||
{cover ? (
|
||||
{lightCover ? (
|
||||
<Image
|
||||
alt="Cover"
|
||||
sources={{
|
||||
light: {
|
||||
src: cover.href,
|
||||
size: cover.file?.dimensions,
|
||||
src: lightCover.href,
|
||||
size: lightCover.file?.dimensions,
|
||||
},
|
||||
dark: darkCover
|
||||
? {
|
||||
src: darkCover.href,
|
||||
size: darkCover.file?.dimensions,
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
sizes={[
|
||||
{
|
||||
@@ -92,8 +95,15 @@ export async function RecordCard(
|
||||
'w-full',
|
||||
'h-full',
|
||||
'object-cover',
|
||||
coverIsSquareOrPortrait
|
||||
? ['min-[432px]:h-auto', 'min-[432px]:aspect-video']
|
||||
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
|
||||
? [
|
||||
lightCoverIsSquareOrPortrait
|
||||
? 'min-[432px]:aspect-video min-[432px]:h-auto'
|
||||
: '',
|
||||
darkCoverIsSquareOrPortrait
|
||||
? 'dark:min-[432px]:aspect-video dark:min-[432px]:h-auto'
|
||||
: '',
|
||||
].filter(Boolean)
|
||||
: ['h-auto', 'aspect-video']
|
||||
)}
|
||||
priority={isOffscreen ? 'lazy' : 'high'}
|
||||
@@ -167,3 +177,16 @@ export async function RecordCard(
|
||||
|
||||
return <div className={tcls(RecordCardStyles)}>{body}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is square or portrait.
|
||||
*/
|
||||
function isSquareOrPortrait(contentRef: ResolvedContentRef | null) {
|
||||
const file = contentRef?.file;
|
||||
|
||||
if (!file || !file.dimensions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return file.dimensions?.width / file.dimensions?.height <= 1;
|
||||
}
|
||||
|
||||
@@ -174,7 +174,6 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
<StyledLink
|
||||
key={index}
|
||||
href={ref.href}
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2"
|
||||
insights={
|
||||
ref.file
|
||||
@@ -329,6 +328,50 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case 'image': {
|
||||
const image = context.contentContext
|
||||
? await resolveContentRef(value as ContentRef, context.contentContext)
|
||||
: null;
|
||||
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag className={tcls('text-base')} aria-labelledby={ariaLabelledBy}>
|
||||
<StyledLink
|
||||
href={image.href}
|
||||
className="flex flex-row items-center gap-2"
|
||||
insights={
|
||||
image.file
|
||||
? {
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: value as ContentRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Image
|
||||
style={['max-h-lh', 'h-lh']}
|
||||
alt={image.text}
|
||||
sizes={[{ width: 24 }]}
|
||||
resize={context.contentContext?.imageResizer}
|
||||
sources={{
|
||||
light: {
|
||||
src: image.href,
|
||||
size: image.file?.dimensions,
|
||||
},
|
||||
}}
|
||||
priority="lazy"
|
||||
/>
|
||||
{image.text}
|
||||
</StyledLink>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(definition);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { ContentRef, DocumentTableDefinition, DocumentTableRecord } from '@gitbook/api';
|
||||
import type {
|
||||
ContentRef,
|
||||
ContentRefFile,
|
||||
ContentRefURL,
|
||||
DocumentTableDefinition,
|
||||
DocumentTableRecord,
|
||||
DocumentTableViewCards,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
/**
|
||||
@@ -12,6 +19,57 @@ export function getRecordValue<T extends number | string | boolean | string[] |
|
||||
return record.values[definitionId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the covers for a record card.
|
||||
* Returns both the light and dark covers.
|
||||
* The light cover is a string or a content ref (image or files column type).
|
||||
* The dark cover is a content ref (image column type).
|
||||
*/
|
||||
export function getRecordCardCovers(
|
||||
record: DocumentTableRecord,
|
||||
view: DocumentTableViewCards
|
||||
): { [key in 'light' | 'dark']: ContentRefFile | ContentRefURL | null } {
|
||||
return {
|
||||
light: (() => {
|
||||
if (!view.coverDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = getRecordValue(record, view.coverDefinition) as
|
||||
| ContentRefFile
|
||||
| ContentRefURL
|
||||
| string[];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value[0] === 'string') {
|
||||
return { kind: 'file', file: value[0] };
|
||||
}
|
||||
}
|
||||
|
||||
return value as ContentRefFile | ContentRefURL;
|
||||
})(),
|
||||
dark: (() => {
|
||||
if (!view.coverDefinitionDark) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = getRecordValue(record, view.coverDefinitionDark) as
|
||||
| ContentRefFile
|
||||
| ContentRefURL;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text alignment for a column.
|
||||
*/
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Header(props: {
|
||||
)}
|
||||
>
|
||||
<div className="scroll-nojump">
|
||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div className="transition-all duration-300">
|
||||
<div
|
||||
className={tcls(
|
||||
'gap-4',
|
||||
@@ -110,7 +110,6 @@ export function Header(props: {
|
||||
'lg:max-w-lg',
|
||||
'lg:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
|
||||
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
|
||||
'lg:page-no-toc:ml-[max(calc((100%-18rem-48rem-18rem-3rem)/2),0rem)]',
|
||||
'md:mr-auto',
|
||||
'order-last',
|
||||
'md:order-[unset]',
|
||||
@@ -146,7 +145,7 @@ export function Header(props: {
|
||||
|
||||
{sections || siteSpaces.length > 1 ? (
|
||||
<div className="scroll-nojump">
|
||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div className="transition-all duration-300">
|
||||
<div
|
||||
className={tcls(
|
||||
'w-full',
|
||||
|
||||
@@ -55,9 +55,10 @@ export async function HeaderLogo(props: HeaderLogoProps) {
|
||||
'lg:max-w-64',
|
||||
'lg:site-header-none:page-no-toc:max-w-56',
|
||||
'max-h-10',
|
||||
'lg:max-h-12',
|
||||
'h-full',
|
||||
'w-auto'
|
||||
'w-full',
|
||||
'object-contain',
|
||||
'object-left'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,23 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as zustand from 'zustand';
|
||||
|
||||
import type {
|
||||
GitBookGlobal,
|
||||
GitBookIntegrationEvent,
|
||||
GitBookIntegrationEventCallback,
|
||||
GitBookIntegrationTool,
|
||||
} from '@gitbook/browser-types';
|
||||
import type { Assistant } from '../AI';
|
||||
|
||||
const events = new Map<GitBookIntegrationEvent, GitBookIntegrationEventCallback[]>();
|
||||
|
||||
export const integrationsAssistantTools = zustand.createStore<{
|
||||
/**
|
||||
* Tools exposed to the assistant by integrations
|
||||
*/
|
||||
tools: GitBookIntegrationTool[];
|
||||
}>(() => {
|
||||
return {
|
||||
tools: [],
|
||||
};
|
||||
});
|
||||
|
||||
export const integrationAssistants = zustand.createStore<Array<Assistant>>(() => []);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.GitBook = {
|
||||
events: new Map(),
|
||||
const gitbookGlobal: GitBookGlobal = {
|
||||
addEventListener: (event, callback) => {
|
||||
const handlers = window.GitBook?.events.get(event) ?? [];
|
||||
const handlers = events.get(event) ?? [];
|
||||
handlers.push(callback);
|
||||
window.GitBook?.events.set(event, handlers);
|
||||
events.set(event, handlers);
|
||||
},
|
||||
removeEventListener: (event, callback) => {
|
||||
const handlers = window.GitBook?.events.get(event) ?? [];
|
||||
const handlers = events.get(event) ?? [];
|
||||
const index = handlers.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
},
|
||||
registerTool: (tool) => {
|
||||
integrationsAssistantTools.setState((state) => ({
|
||||
tools: [...state.tools, tool],
|
||||
}));
|
||||
},
|
||||
registerAssistant: (assistant) => {
|
||||
const id = window.crypto.randomUUID();
|
||||
integrationAssistants.setState(
|
||||
(state) => [
|
||||
...state,
|
||||
{ ...assistant, id, ui: assistant.ui ?? true, mode: 'overlay' },
|
||||
],
|
||||
true
|
||||
);
|
||||
|
||||
return () => {
|
||||
integrationAssistants.setState((state) => state.filter((a) => a.id !== id), true);
|
||||
};
|
||||
},
|
||||
};
|
||||
window.GitBook = gitbookGlobal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state of the assistants.
|
||||
*/
|
||||
export function useIntegrationAssistants(): Array<Assistant> {
|
||||
return zustand.useStore(integrationAssistants);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +84,5 @@ export function LoadIntegrations() {
|
||||
* Client function to dispatch a GitBook event.
|
||||
*/
|
||||
function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: any[]) {
|
||||
const handlers = window.GitBook?.events.get(type) || [];
|
||||
handlers.forEach((handler) => handler(...args));
|
||||
events.get(type)?.forEach((handler) => handler(...args));
|
||||
}
|
||||
|
||||
@@ -32,33 +32,29 @@ export function PageAside(props: {
|
||||
withPageFeedback: boolean;
|
||||
}) {
|
||||
const { page, document, withPageFeedback, context } = props;
|
||||
const { customization, site, space } = context;
|
||||
const language = getSpaceLanguage(customization);
|
||||
const { customization, site } = context;
|
||||
|
||||
const pdfHref = context.linker.toPathInSpace(
|
||||
`~gitbook/pdf?${getPDFURLSearchParams({
|
||||
page: page.id,
|
||||
only: true,
|
||||
limit: 100,
|
||||
}).toString()}`
|
||||
);
|
||||
return (
|
||||
<aside
|
||||
className={tcls(
|
||||
'group/aside',
|
||||
'hidden',
|
||||
'pt-8',
|
||||
'pb-4',
|
||||
|
||||
'xl:flex',
|
||||
'xl:max-3xl:chat-open:hidden',
|
||||
'xl:max-3xl:chat-open:opacity-0',
|
||||
'max-w-56',
|
||||
|
||||
// Animate the width of the aside when the chat is open
|
||||
'xl:max-3xl:*:chat-open:w-56',
|
||||
'xl:max-3xl:chat-open:max-w-0',
|
||||
'xl:max-3xl:chat-open:ml-0',
|
||||
|
||||
'motion-safe:xl:transition-all motion-safe:xl:duration-300',
|
||||
'motion-safe:xl:transition-[width,max-width,margin,opacity,display] motion-safe:xl:duration-300',
|
||||
'motion-safe:transition-discrete',
|
||||
|
||||
'flex-col',
|
||||
'basis-56',
|
||||
'xl:ml-12',
|
||||
'grow-0',
|
||||
@@ -68,6 +64,7 @@ export function PageAside(props: {
|
||||
'text-tint',
|
||||
'contrast-more:text-tint-strong',
|
||||
'sticky',
|
||||
|
||||
// Without header
|
||||
'lg:top-0',
|
||||
'lg:max-h-screen',
|
||||
@@ -80,190 +77,90 @@ export function PageAside(props: {
|
||||
'lg:site-header-sections:top-27',
|
||||
'lg:site-header-sections:max-h-[calc(100vh-6.75rem)]',
|
||||
|
||||
// Client-side dynamic positioning (CSS vars applied by script)
|
||||
'lg:[html[style*="--toc-top-offset"]_&]:top-(--toc-top-offset)!',
|
||||
'lg:[html[style*="--toc-height"]_&]:max-h-(--toc-height)!',
|
||||
|
||||
// When in api page mode, we display it as an overlay on non-large resolutions
|
||||
'xl:max-2xl:page-api-block:z-10',
|
||||
'xl:max-2xl:page-api-block:fixed',
|
||||
'xl:max-2xl:page-api-block:right-8',
|
||||
'xl:max-2xl:page-api-block:w-56',
|
||||
'xl:max-2xl:page-api-block:bg-tint/9',
|
||||
'xl:max-2xl:contrast-more:page-api-block:bg-tint',
|
||||
'xl:max-2xl:page-api-block:backdrop-blur-lg',
|
||||
'xl:max-2xl:page-api-block:border',
|
||||
'xl:max-2xl:page-api-block:border-tint',
|
||||
'xl:max-2xl:page-api-block:hover:shadow-lg',
|
||||
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
|
||||
'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1',
|
||||
'xl:max-2xl:page-api-block:rounded-md',
|
||||
'xl:max-2xl:page-api-block:h-auto',
|
||||
'xl:max-2xl:page-api-block:my-9',
|
||||
'page-api-block:px-2',
|
||||
'page-api-block:py-1.5'
|
||||
'xl:max-2xl:page-api-block:w-60',
|
||||
'xl:max-2xl:page-api-block:max-w-60',
|
||||
'xl:max-2xl:page-api-block:pb-8',
|
||||
'xl:max-2xl:page-api-block:pt-10',
|
||||
'xl:max-2xl:[body:has(.openapi-block):has(.page-has-ancestors)_&]:pt-6.5'
|
||||
)}
|
||||
>
|
||||
{page.layout.outline ? (
|
||||
<>
|
||||
<div
|
||||
className={tcls(
|
||||
'hidden',
|
||||
'xl:max-2xl:page-api-block:flex',
|
||||
'text-xs',
|
||||
'tracking-wide',
|
||||
'font-semibold',
|
||||
'uppercase',
|
||||
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-2'
|
||||
)}
|
||||
>
|
||||
<Icon icon="block-quote" className={tcls('size-3')} />
|
||||
{t(language, 'on_this_page')}
|
||||
<Icon
|
||||
icon="chevron-down"
|
||||
className={tcls(
|
||||
'size-3',
|
||||
'opacity-6',
|
||||
'ml-auto',
|
||||
'xl:max-2xl:page-api-block:group-hover/aside:hidden'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'overflow-y-auto',
|
||||
'overflow-x-visible',
|
||||
|
||||
'flex',
|
||||
'flex-col',
|
||||
'shrink',
|
||||
'pb-12',
|
||||
|
||||
'sticky',
|
||||
'lg:top:0',
|
||||
'lg:site-header:top-16',
|
||||
'lg:site-header-sections:top-27',
|
||||
|
||||
'gap-6',
|
||||
'pt-10',
|
||||
|
||||
'xl:max-2xl:page-api-block:py-0',
|
||||
// Hide it for api page, until hovered
|
||||
'xl:max-2xl:page-api-block:hidden',
|
||||
'xl:max-2xl:page-api-block:group-hover/aside:flex'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex flex-col',
|
||||
'overflow-hidden',
|
||||
'w-full',
|
||||
'xl:max-2xl:rounded-corners:page-api-block:rounded-md',
|
||||
'xl:max-2xl:circular-corners:page-api-block:rounded-xl',
|
||||
'xl:max-2xl:page-api-block:border',
|
||||
'xl:max-2xl:page-api-block:border-tint',
|
||||
'xl:max-2xl:page-api-block:bg-tint/9',
|
||||
'xl:max-2xl:page-api-block:backdrop-blur-lg',
|
||||
'xl:max-2xl:contrast-more:page-api-block:bg-tint',
|
||||
'xl:max-2xl:page-api-block:hover:shadow-lg',
|
||||
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
|
||||
'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1',
|
||||
'xl:max-2xl:page-api-block:not-hover:*:hidden'
|
||||
)}
|
||||
>
|
||||
<PageAsideHeader context={context} />
|
||||
{page.layout.outline ? (
|
||||
<div className="flex shrink flex-col overflow-hidden">
|
||||
{document ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<PageAsideSections document={document} context={context} />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-3',
|
||||
'sidebar-list-default:px-3',
|
||||
'border-t',
|
||||
'first:border-none',
|
||||
'border-tint-subtle',
|
||||
'py-6',
|
||||
'first:pt-0',
|
||||
'xl:max-2xl:page-api-block:px-3',
|
||||
'empty:hidden'
|
||||
)}
|
||||
>
|
||||
{withPageFeedback ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<PageFeedbackForm pageId={page.id} />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
|
||||
<div>
|
||||
<a
|
||||
href={urlJoin(space.gitSync.url, page.git.path)}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'hover:text-tint-strong',
|
||||
'links-accent:hover:underline',
|
||||
'links-accent:hover:underline-offset-4',
|
||||
'links-accent:hover:decoration-[3px]',
|
||||
'links-accent:hover:decoration-primary-subtle',
|
||||
'py-2'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
space.gitSync.installationProvider === 'gitlab'
|
||||
? 'gitlab'
|
||||
: 'github'
|
||||
}
|
||||
className={tcls('size-4', 'mr-1.5')}
|
||||
/>
|
||||
{t(language, 'edit_on_git', getGitSyncName(space))}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{customization.pdf.enabled ? (
|
||||
<div>
|
||||
<a
|
||||
href={pdfHref}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'hover:text-tint-strong',
|
||||
'links-accent:hover:underline',
|
||||
'links-accent:hover:underline-offset-4',
|
||||
'links-accent:hover:decoration-[3px]',
|
||||
'links-accent:hover:decoration-primary-subtle',
|
||||
'py-2'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon="file-pdf"
|
||||
className={tcls('size-4', 'mr-1.5')}
|
||||
/>
|
||||
{t(language, 'pdf_download')}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div
|
||||
className={tcls(
|
||||
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 xl:max-2xl:page-api-block:hidden xl:max-2xl:page-api-block:pb-0 xl:max-2xl:page-api-block:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
|
||||
'xl:max-2xl:page-api-block:bg-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Mode Switcher */}
|
||||
{customization.themes.toggeable ? (
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<React.Suspense fallback={null}>
|
||||
<ThemeToggler />
|
||||
</React.Suspense>
|
||||
<PageAsideActions
|
||||
page={page}
|
||||
withPageFeedback={withPageFeedback}
|
||||
context={context}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Ad
|
||||
zoneId={
|
||||
site?.ads && site.ads.status === SiteAdsStatus.Live ? site.ads.zoneId : null
|
||||
}
|
||||
placement={SiteInsightsAdPlacement.Aside}
|
||||
spaceId={space.id}
|
||||
siteAdsStatus={site?.ads?.status ? site.ads.status : undefined}
|
||||
ignore={process.env.NODE_ENV !== 'production'}
|
||||
style={site?.ads ? 'mt-4' : undefined}
|
||||
/>
|
||||
{customization.themes.toggeable || site.ads ? (
|
||||
<PageAsideFooter context={context} />
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PageAsideHeader(props: { context: GitBookSiteContext }) {
|
||||
const { context } = props;
|
||||
const language = getSpaceLanguage(context.customization);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'hidden',
|
||||
'xl:max-2xl:page-api-block:flex!',
|
||||
'text-xs',
|
||||
'tracking-wide',
|
||||
'font-semibold',
|
||||
'uppercase',
|
||||
'px-2',
|
||||
'py-1.5',
|
||||
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-2'
|
||||
)}
|
||||
>
|
||||
<Icon icon="block-quote" className={tcls('size-3')} />
|
||||
{t(language, 'on_this_page')}
|
||||
<Icon icon="chevron-down" className={tcls('size-3', 'opacity-6', 'ml-auto')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) {
|
||||
const { document, context } = props;
|
||||
|
||||
@@ -272,6 +169,134 @@ async function PageAsideSections(props: { document: JSONDocument; context: GitBo
|
||||
return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
|
||||
}
|
||||
|
||||
function PageAsideActions(props: {
|
||||
withPageFeedback: boolean;
|
||||
context: GitBookSiteContext;
|
||||
page: RevisionPageDocument;
|
||||
}) {
|
||||
const { page, withPageFeedback, context } = props;
|
||||
const { customization, space } = context;
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
const pdfHref = context.linker.toPathInSpace(
|
||||
`~gitbook/pdf?${getPDFURLSearchParams({
|
||||
page: page.id,
|
||||
only: true,
|
||||
limit: 100,
|
||||
}).toString()}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-3',
|
||||
'sidebar-list-default:px-3',
|
||||
'border-t',
|
||||
'first:border-none',
|
||||
'border-tint-subtle',
|
||||
'pt-5',
|
||||
'first:pt-0',
|
||||
'xl:max-2xl:page-api-block:p-5',
|
||||
'empty:hidden'
|
||||
)}
|
||||
>
|
||||
{withPageFeedback ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<PageFeedbackForm pageId={page.id} />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
|
||||
<div>
|
||||
<a
|
||||
href={urlJoin(space.gitSync.url, page.git.path)}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'hover:text-tint-strong',
|
||||
'links-accent:hover:underline',
|
||||
'links-accent:hover:underline-offset-4',
|
||||
'links-accent:hover:decoration-[3px]',
|
||||
'links-accent:hover:decoration-primary-subtle',
|
||||
'py-2'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
space.gitSync.installationProvider === 'gitlab'
|
||||
? 'gitlab'
|
||||
: 'github'
|
||||
}
|
||||
className={tcls('size-4', 'mr-1.5')}
|
||||
/>
|
||||
{t(language, 'edit_on_git', getGitSyncName(space))}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{customization.pdf.enabled ? (
|
||||
<div>
|
||||
<a
|
||||
href={pdfHref}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'hover:text-tint-strong',
|
||||
'links-accent:hover:underline',
|
||||
'links-accent:hover:underline-offset-4',
|
||||
'links-accent:hover:decoration-[3px]',
|
||||
'links-accent:hover:decoration-primary-subtle',
|
||||
'py-2'
|
||||
)}
|
||||
>
|
||||
<Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
|
||||
{t(language, 'pdf_download')}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function PageAsideFooter(props: { context: GitBookSiteContext }) {
|
||||
const { context } = props;
|
||||
const { customization, site, space } = context;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'sticky bottom-0 z-10 mt-auto flex flex-col',
|
||||
'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
|
||||
'border-tint-subtle xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2',
|
||||
'pt-4'
|
||||
)}
|
||||
>
|
||||
{/* Mode Switcher */}
|
||||
{customization.themes.toggeable ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<React.Suspense fallback={null}>
|
||||
<ThemeToggler />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
) : null}
|
||||
<Ad
|
||||
zoneId={
|
||||
site?.ads && site.ads.status === SiteAdsStatus.Live ? site.ads.zoneId : null
|
||||
}
|
||||
placement={SiteInsightsAdPlacement.Aside}
|
||||
spaceId={space.id}
|
||||
siteAdsStatus={site?.ads?.status ? site.ads.status : undefined}
|
||||
ignore={process.env.NODE_ENV !== 'production'}
|
||||
style={site?.ads ? 'mt-4' : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getGitSyncName(space: Space): string {
|
||||
if (space.gitSync?.installationProvider === 'github') {
|
||||
return 'GitHub';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
import { useScrollActiveId } from '@/components/hooks';
|
||||
@@ -15,6 +14,11 @@ import { AsideSectionHighlight } from './AsideSectionHighlight';
|
||||
*/
|
||||
const SECTION_INTERSECTING_THRESHOLD = 0.9;
|
||||
|
||||
/**
|
||||
* The offset from the top of the page when scrolling to the active item.
|
||||
*/
|
||||
const ACTIVE_ITEM_OFFSET = 100;
|
||||
|
||||
const springCurve = {
|
||||
type: 'spring',
|
||||
stiffness: 700,
|
||||
@@ -39,10 +43,25 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
||||
enabled,
|
||||
});
|
||||
|
||||
const scrollContainerRef = React.useRef<HTMLUListElement>(null);
|
||||
const activeItemRef = React.useRef<HTMLLIElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeId && activeItemRef.current && scrollContainerRef.current) {
|
||||
scrollContainerRef.current?.scrollTo({
|
||||
top: activeItemRef.current.offsetTop - ACTIVE_ITEM_OFFSET,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [activeId]);
|
||||
|
||||
return (
|
||||
<ul className={tcls('sidebar-list-line:border-l', 'border-tint-subtle')}>
|
||||
<ul
|
||||
className="relative flex flex-col overflow-y-auto border-tint-subtle sidebar-list-line:border-l pb-5 xl:max-2xl:page-api-block:mt-0 xl:max-2xl:page-api-block:p-2"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<motion.li
|
||||
<li
|
||||
key={section.id}
|
||||
className={tcls(
|
||||
'flex',
|
||||
@@ -50,8 +69,11 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
||||
'relative',
|
||||
'h-fit',
|
||||
'mt-2',
|
||||
'first:mt-0',
|
||||
'mb-0.5',
|
||||
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0']
|
||||
)}
|
||||
ref={activeId === section.id ? activeItemRef : null}
|
||||
>
|
||||
{activeId === section.id ? (
|
||||
<AsideSectionHighlight
|
||||
@@ -146,7 +168,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
||||
{section.title}
|
||||
</span>
|
||||
</a>
|
||||
</motion.li>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function PageBody(props: {
|
||||
<main
|
||||
className={tcls(
|
||||
'relative min-w-0 flex-1',
|
||||
'mx-auto max-w-screen-2xl py-11',
|
||||
'mx-auto max-w-screen-2xl py-8',
|
||||
// Allow words to break if they are too long.
|
||||
'break-anywhere',
|
||||
pageWidthWide ? 'page-width-wide 2xl:px-8' : 'page-width-default',
|
||||
|
||||
@@ -43,31 +43,30 @@ export async function PageCover(props: {
|
||||
|
||||
const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => {
|
||||
if (!resolved && returnNull) return;
|
||||
const [attrs, size] = await Promise.all([
|
||||
getImageAttributes({
|
||||
sizes,
|
||||
source: resolved
|
||||
? {
|
||||
src: resolved.href,
|
||||
size: resolved.file?.dimensions ?? null,
|
||||
}
|
||||
: {
|
||||
src: defaultPageCover.src,
|
||||
size: {
|
||||
width: defaultPageCover.width,
|
||||
height: defaultPageCover.height,
|
||||
},
|
||||
// If we don't have a size for the image, we want to calculate it so that we can use srcSet
|
||||
const size =
|
||||
resolved?.file?.dimensions ??
|
||||
(await context.imageResizer?.getImageSize(resolved?.href || defaultPageCover.src, {}));
|
||||
const attrs = await getImageAttributes({
|
||||
sizes,
|
||||
source: resolved
|
||||
? {
|
||||
src: resolved.href,
|
||||
size: size ?? null,
|
||||
}
|
||||
: {
|
||||
src: defaultPageCover.src,
|
||||
size: {
|
||||
width: defaultPageCover.width,
|
||||
height: defaultPageCover.height,
|
||||
},
|
||||
quality: 100,
|
||||
resize: context.imageResizer ?? false,
|
||||
}),
|
||||
context.imageResizer
|
||||
?.getImageSize(resolved?.href || defaultPageCover.src, {})
|
||||
.then((size) => size ?? undefined),
|
||||
]);
|
||||
},
|
||||
quality: 100,
|
||||
resize: context.imageResizer ?? false,
|
||||
});
|
||||
return {
|
||||
...attrs,
|
||||
size,
|
||||
size: size ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||
<img
|
||||
src={imgs.light.src}
|
||||
srcSet={imgs.light.srcSet}
|
||||
sizes={imgs.light.sizes}
|
||||
fetchPriority="high"
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
|
||||
@@ -55,6 +57,8 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
{imgs.dark && (
|
||||
<img
|
||||
src={imgs.dark.src}
|
||||
srcSet={imgs.dark.srcSet}
|
||||
sizes={imgs.dark.sizes}
|
||||
fetchPriority="low"
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { AncestorRevisionPage } from '@/lib/pages';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -20,13 +19,9 @@ export async function PageHeader(props: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withAIChat = isAIChatEnabled(context);
|
||||
const pageActions = context.customization.pageActions;
|
||||
|
||||
const pageActions = context.customization.pageActions ?? {
|
||||
// TODO: After 25/07/2025, we can remove this default values as the cache will be updated
|
||||
markdown: true,
|
||||
externalAI: true,
|
||||
};
|
||||
const hasAncestors = ancestors.length > 0;
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -37,27 +32,23 @@ export async function PageHeader(props: {
|
||||
'mb-6',
|
||||
'space-y-3',
|
||||
'page-api-block:ml-0',
|
||||
'page-api-block:max-w-full'
|
||||
'page-api-block:max-w-full',
|
||||
hasAncestors ? 'page-has-ancestors' : 'page-no-ancestors'
|
||||
)}
|
||||
>
|
||||
{page.layout.tableOfContents &&
|
||||
// Show page actions if *any* of the actions are enabled
|
||||
(withAIChat || pageActions.markdown || pageActions.externalAI) ? (
|
||||
<div
|
||||
{page.layout.tableOfContents ? (
|
||||
// Show page actions if *any* of the actions are enabled
|
||||
<AIActionsDropdown
|
||||
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
|
||||
actions={pageActions}
|
||||
className={tcls(
|
||||
'float-right mb-2 ml-4 xl:max-2xl:page-api-block:mr-58',
|
||||
ancestors.length > 0 ? '-mt-2' : '-mt-3 xs:mt-2'
|
||||
'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
|
||||
hasAncestors ? '-my-1.5' : '-mt-3 xs:mt-2'
|
||||
)}
|
||||
>
|
||||
<AIActionsDropdown
|
||||
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
|
||||
withAIChat={withAIChat}
|
||||
trademark={context.customization.trademark.enabled}
|
||||
actions={pageActions}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
) : null}
|
||||
{ancestors.length > 0 && (
|
||||
|
||||
{hasAncestors && (
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}>
|
||||
{ancestors.map((breadcrumb, index) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { t, tString } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Button } from '../primitives';
|
||||
import { Button, ButtonGroup } from '../primitives';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 512;
|
||||
|
||||
@@ -61,31 +61,29 @@ export function PageFeedbackForm(props: {
|
||||
<div className={tcls('flex flex-col gap-3 text-sm', className)}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p>{t(languages, 'was_this_helpful')}</p>
|
||||
<div className="rounded-full border border-tint-subtle bg-tint-base contrast-more:border-tint-12">
|
||||
<div className="flex">
|
||||
<RatingButton
|
||||
rating={PageFeedbackRating.Good}
|
||||
label={tString(languages, 'was_this_helpful_positive')}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Good)}
|
||||
active={rating === PageFeedbackRating.Good}
|
||||
disabled={rating !== undefined}
|
||||
/>
|
||||
<RatingButton
|
||||
rating={PageFeedbackRating.Ok}
|
||||
label={tString(languages, 'was_this_helpful_neutral')}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Ok)}
|
||||
active={rating === PageFeedbackRating.Ok}
|
||||
disabled={rating !== undefined}
|
||||
/>
|
||||
<RatingButton
|
||||
rating={PageFeedbackRating.Bad}
|
||||
label={tString(languages, 'was_this_helpful_negative')}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Bad)}
|
||||
active={rating === PageFeedbackRating.Bad}
|
||||
disabled={rating !== undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonGroup className="rounded-full border border-tint bg-tint-base">
|
||||
<RatingButton
|
||||
rating={PageFeedbackRating.Good}
|
||||
label={tString(languages, 'was_this_helpful_positive')}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Good)}
|
||||
active={rating === PageFeedbackRating.Good}
|
||||
disabled={rating !== undefined}
|
||||
/>
|
||||
<RatingButton
|
||||
rating={PageFeedbackRating.Ok}
|
||||
label={tString(languages, 'was_this_helpful_neutral')}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Ok)}
|
||||
active={rating === PageFeedbackRating.Ok}
|
||||
disabled={rating !== undefined}
|
||||
/>
|
||||
<RatingButton
|
||||
rating={PageFeedbackRating.Bad}
|
||||
label={tString(languages, 'was_this_helpful_negative')}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Bad)}
|
||||
active={rating === PageFeedbackRating.Bad}
|
||||
disabled={rating !== undefined}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{rating ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -94,7 +92,7 @@ export function PageFeedbackForm(props: {
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
name="comment"
|
||||
className="max-h-40 min-h-16 grow rounded-sm straight-corners:rounded-none bg-tint-base p-2 ring-1 ring-tint ring-inset placeholder:text-sm placeholder:text-tint contrast-more:ring-tint-12 contrast-more:placeholder:text-tint-strong"
|
||||
className="mx-0.5 max-h-40 min-h-16 grow rounded-sm straight-corners:rounded-none bg-tint-base p-2 ring-1 ring-tint ring-inset placeholder:text-sm placeholder:text-tint contrast-more:ring-tint-12 contrast-more:placeholder:text-tint-strong"
|
||||
placeholder={tString(languages, 'was_this_helpful_comment')}
|
||||
aria-label={tString(languages, 'was_this_helpful_comment')}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
@@ -138,7 +136,7 @@ function RatingButton(
|
||||
active: boolean;
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>
|
||||
) {
|
||||
const { rating, label, onClick, active, ...attr } = props;
|
||||
const { rating, label, onClick, active, disabled, ...attr } = props;
|
||||
|
||||
const ratingIcon =
|
||||
{
|
||||
@@ -148,23 +146,18 @@ function RatingButton(
|
||||
}[rating] ?? null;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={tcls(
|
||||
'p-2 first:rounded-l-full first:pl-2.5 last:rounded-r-full last:pr-2.5 hover:bg-primary-hover hover:text-primary-strong',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-inherit disabled:hover:text-inherit dark:disabled:hover:text-inherit',
|
||||
'ring-tint contrast-more:hover:ring-1',
|
||||
active
|
||||
? 'bg-primary-active text-primary-strong disabled:hover:bg-primary-active disabled:hover:text-primary-strong contrast-more:ring-2 contrast-more:hover:ring-2'
|
||||
: 'disabled:opacity-7 contrast-more:disabled:ring-0'
|
||||
)}
|
||||
<Button
|
||||
className="first:pl-2.5 last:pr-2.5"
|
||||
type="button"
|
||||
{...attr}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
variant="blank"
|
||||
label={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
{ratingIcon}
|
||||
</button>
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
iconOnly
|
||||
{...attr}
|
||||
icon={ratingIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type React from 'react';
|
||||
import { TranslateContext } from '@/intl/client';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { HashProvider } from '../hooks';
|
||||
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
|
||||
|
||||
export function ClientContexts(props: {
|
||||
@@ -16,7 +17,9 @@ export function ClientContexts(props: {
|
||||
return (
|
||||
<TranslateContext.Provider value={language}>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<LoadingStateProvider>{children}</LoadingStateProvider>
|
||||
<HashProvider>
|
||||
<LoadingStateProvider>{children}</LoadingStateProvider>
|
||||
</HashProvider>
|
||||
</TooltipProvider>
|
||||
</TranslateContext.Provider>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ export function HighlightQuery(props: {
|
||||
query,
|
||||
text,
|
||||
highlight = [
|
||||
'-z-1',
|
||||
'relative',
|
||||
'text-bold',
|
||||
'bg-primary',
|
||||
'text-contrast-primary',
|
||||
@@ -33,7 +35,7 @@ export function HighlightQuery(props: {
|
||||
const matches = matchString(text, query);
|
||||
|
||||
return (
|
||||
<span className={tcls('whitespace-break-spaces')}>
|
||||
<span className={tcls('relative z-2 whitespace-break-spaces')}>
|
||||
{matches.map((entry, index) => (
|
||||
<span key={index} className={tcls(entry.match ? highlight : null)}>
|
||||
{entry.text}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useAI } from '../AI';
|
||||
import { AIChatButton } from '../AIChat';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Popover } from '../primitives';
|
||||
@@ -16,10 +18,10 @@ import { SearchScopeToggle } from './SearchScopeToggle';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
interface SearchContainerProps {
|
||||
spaceId: string;
|
||||
siteSpaceId: string;
|
||||
spaceTitle: string;
|
||||
isMultiVariants: boolean;
|
||||
aiMode: CustomizationAIMode;
|
||||
style: CustomizationSearchStyle;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -27,10 +29,11 @@ interface SearchContainerProps {
|
||||
* Client component to render the search input and results.
|
||||
*/
|
||||
export function SearchContainer(props: SearchContainerProps) {
|
||||
const { spaceId, spaceTitle, isMultiVariants, aiMode, className } = props;
|
||||
const { siteSpaceId, spaceTitle, isMultiVariants, style, className } = props;
|
||||
|
||||
const withAIChat = aiMode === CustomizationAIMode.Assistant;
|
||||
const [state, setSearchState] = useSearch(withAIChat);
|
||||
const { assistants } = useAI();
|
||||
|
||||
const [state, setSearchState] = useSearch();
|
||||
const searchAsk = useSearchAskState();
|
||||
const router = useRouter();
|
||||
const trackEvent = useTrackEvent();
|
||||
@@ -39,34 +42,39 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Derive open state from search state
|
||||
const open = state?.open ?? false;
|
||||
const withAI = assistants.length > 0;
|
||||
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
|
||||
|
||||
// Handle initial ask state on page load, once assistants are ready
|
||||
const initialRef = React.useRef(state?.ask === undefined || state?.ask === null); // If ask is not set on page load, we will never trigger
|
||||
React.useEffect(() => {
|
||||
if (state?.ask && aiMode === CustomizationAIMode.Assistant) {
|
||||
// Close the pop-over when we switch to “ask” mode
|
||||
setSearchState((prev) => (prev ? { ...prev, open: !state.ask } : prev));
|
||||
} else if (state?.ask && aiMode === CustomizationAIMode.None) {
|
||||
// Rewrite ask to query when the site doesn't support AI
|
||||
setSearchState((prev) => (prev ? { ...prev, query: prev.ask, ask: null } : prev));
|
||||
}
|
||||
}, [state?.ask, setSearchState, aiMode]);
|
||||
if (initialRef.current) return;
|
||||
if (assistants.length === 0) return;
|
||||
if (state?.ask === undefined || state?.ask === null) return;
|
||||
|
||||
initialRef.current = true;
|
||||
|
||||
// For simplicity we're only triggering the first assistant
|
||||
assistants[0].open(state?.ask ?? undefined);
|
||||
}, [state?.ask, assistants.length, assistants[0]?.open]);
|
||||
|
||||
const onClose = React.useCallback(
|
||||
async (to?: string) => {
|
||||
if (state) {
|
||||
await setSearchState({
|
||||
...state,
|
||||
open: false,
|
||||
query: state.query === '' ? null : state.query,
|
||||
});
|
||||
}
|
||||
setSearchState((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
open: false,
|
||||
query: prev.query === '' ? null : prev.query,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (to) {
|
||||
router.push(to);
|
||||
}
|
||||
},
|
||||
[state, setSearchState, router]
|
||||
[setSearchState, router]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
@@ -75,24 +83,26 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
},
|
||||
[]
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
}
|
||||
);
|
||||
|
||||
const onOpen = React.useCallback(() => {
|
||||
if (open) {
|
||||
if (state?.open) {
|
||||
return;
|
||||
}
|
||||
setSearchState((prev) => ({
|
||||
ask: aiMode === CustomizationAIMode.Assistant && prev ? prev.ask : null,
|
||||
ask: withAI ? (prev?.ask ?? null) : null,
|
||||
global: prev?.global ?? false,
|
||||
query: (!withAIChat ? prev?.ask : null) ?? prev?.query ?? '',
|
||||
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
|
||||
open: true,
|
||||
}));
|
||||
|
||||
trackEvent({
|
||||
type: 'search_open',
|
||||
});
|
||||
}, [open, setSearchState, trackEvent, withAIChat]);
|
||||
}, [state?.open, setSearchState, trackEvent, withAI, withSearchAI]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -122,7 +132,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
|
||||
const onChange = (value: string) => {
|
||||
setSearchState((prev) => ({
|
||||
ask: aiMode === CustomizationAIMode.Assistant && prev ? prev.ask : null, // When typing, we go back to the default search mode
|
||||
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
|
||||
query: value,
|
||||
global: prev?.global ?? false,
|
||||
open: true,
|
||||
@@ -133,14 +143,14 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
const normalizedQuery = state?.query?.trim() ?? '';
|
||||
const normalizedAsk = state?.ask?.trim() ?? '';
|
||||
|
||||
const showAsk = aiMode === CustomizationAIMode.Search && normalizedAsk;
|
||||
const showAsk = withSearchAI && normalizedAsk; // withSearchAI && normalizedAsk;
|
||||
|
||||
return (
|
||||
<SearchAskProvider value={searchAsk}>
|
||||
<Popover
|
||||
content={
|
||||
// Only show content if there's a query or Ask is enabled
|
||||
(state?.query || aiMode !== CustomizationAIMode.None) && open ? (
|
||||
state?.query || withAI ? (
|
||||
<React.Suspense fallback={null}>
|
||||
{isMultiVariants && !showAsk ? (
|
||||
<SearchScopeToggle spaceTitle={spaceTitle} />
|
||||
@@ -150,8 +160,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
ref={resultsRef}
|
||||
query={normalizedQuery}
|
||||
global={state?.global ?? false}
|
||||
aiMode={aiMode}
|
||||
spaceId={spaceId}
|
||||
siteSpaceId={siteSpaceId}
|
||||
/>
|
||||
) : null}
|
||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
||||
@@ -159,7 +168,10 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
) : null
|
||||
}
|
||||
rootProps={{
|
||||
open: open,
|
||||
open: state?.open ?? false,
|
||||
onOpenChange: (open) => {
|
||||
open ? onOpen() : onClose();
|
||||
},
|
||||
modal: isMobile,
|
||||
}}
|
||||
contentProps={{
|
||||
@@ -172,7 +184,6 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
sideOffset: 8,
|
||||
collisionPadding: {
|
||||
@@ -189,15 +200,27 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
>
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
value={(!withAIChat ? state?.ask : null) ?? state?.query ?? ''}
|
||||
value={state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''}
|
||||
onFocus={onOpen}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
aiMode={aiMode}
|
||||
isOpen={open}
|
||||
withAI={withSearchAI}
|
||||
isOpen={state?.open ?? false}
|
||||
className={className}
|
||||
/>
|
||||
</Popover>
|
||||
{assistants
|
||||
.filter((assistant) => assistant.ui === true)
|
||||
.map((assistant) => (
|
||||
<AIChatButton
|
||||
key={assistant.id}
|
||||
assistant={assistant}
|
||||
showLabel={
|
||||
assistants.filter((assistant) => assistant.ui === true).length === 1 &&
|
||||
style === CustomizationSearchStyle.Prominent
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SearchAskProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Button, variantClasses } from '../primitives';
|
||||
import { useClassnames } from '../primitives/StyleProvider';
|
||||
@@ -14,7 +13,7 @@ interface SearchInputProps {
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onFocus: () => void;
|
||||
value: string;
|
||||
aiMode: CustomizationAIMode;
|
||||
withAI: boolean;
|
||||
isOpen: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -27,7 +26,7 @@ const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'md:circular-corners:px-4'];
|
||||
*/
|
||||
export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
function SearchInput(props, ref) {
|
||||
const { onChange, onKeyDown, onFocus, value, aiMode, isOpen, className } = props;
|
||||
const { onChange, onKeyDown, onFocus, value, withAI, isOpen, className } = props;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const language = useLanguage();
|
||||
@@ -94,7 +93,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
// We only show "search or ask" if the search input actually handles both search and ask.
|
||||
placeholder={`${tString(language, aiMode === CustomizationAIMode.Search ? 'search_or_ask' : 'search')}…`}
|
||||
placeholder={`${tString(language, withAI ? 'search_or_ask' : 'search')}…`}
|
||||
maxLength={512}
|
||||
size={10}
|
||||
data-testid="search-input"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { Assistant } from '@/components/AI';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { AIChatIcon } from '../AIChat/';
|
||||
import { SearchResultItem } from './SearchResultItem';
|
||||
import { useSearchLink } from './useSearch';
|
||||
|
||||
@@ -11,11 +10,11 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
question: string;
|
||||
active: boolean;
|
||||
recommended?: boolean;
|
||||
withAIChat: boolean;
|
||||
assistant: Assistant;
|
||||
},
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
const { question, recommended = false, active, withAIChat } = props;
|
||||
const { question, recommended = false, active, assistant } = props;
|
||||
const language = useLanguage();
|
||||
const getLinkProp = useSearchLink();
|
||||
|
||||
@@ -26,21 +25,18 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
ref={ref}
|
||||
data-testid={recommended ? 'search-recommended-question' : 'search-ask-question'}
|
||||
scroll={false}
|
||||
{...getLinkProp({
|
||||
ask: question,
|
||||
open: false,
|
||||
query: null,
|
||||
})}
|
||||
{...getLinkProp(
|
||||
{
|
||||
ask: question,
|
||||
query: null,
|
||||
open: assistant.mode === 'search',
|
||||
},
|
||||
() => {
|
||||
assistant.open(question);
|
||||
}
|
||||
)}
|
||||
active={active}
|
||||
leadingIcon={
|
||||
recommended ? (
|
||||
<Icon icon="search" className="size-4" />
|
||||
) : withAIChat ? (
|
||||
<AIChatIcon className="size-4" />
|
||||
) : (
|
||||
<Icon icon="sparkles" className="size-4" />
|
||||
)
|
||||
}
|
||||
leadingIcon={recommended ? 'search' : assistant.icon}
|
||||
className={recommended ? 'pr-1.5' : ''}
|
||||
>
|
||||
{recommended ? (
|
||||
@@ -51,7 +47,7 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
{t(language, 'search_ask', [question])}
|
||||
</div>
|
||||
<div className="text-sm text-tint-subtle">
|
||||
{t(language, 'search_ask_description')}
|
||||
{t(language, 'search_ask_description', assistant.label)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import { Button } from '../primitives';
|
||||
import { Link, type LinkInsightsProps } from '../primitives/Link';
|
||||
@@ -53,7 +53,13 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<div className="size-4 shrink-0 text-tint-subtle">{leadingIcon}</div>
|
||||
<div className="size-4 shrink-0 text-tint-subtle">
|
||||
{typeof leadingIcon === 'string' ? (
|
||||
<Icon icon={leadingIcon as IconName} className="size-4" />
|
||||
) : (
|
||||
leadingIcon
|
||||
)}
|
||||
</div>
|
||||
<div className="grow">{children}</div>
|
||||
{active ? (
|
||||
<Button label={action} iconOnly variant="primary" icon="arrow-turn-down-left" />
|
||||
|
||||
@@ -4,11 +4,11 @@ import { readStreamableValue } from 'ai/rsc';
|
||||
import assertNever from 'assert-never';
|
||||
import React from 'react';
|
||||
|
||||
import type { Assistant } from '@/components/AI';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { assert } from 'ts-essentials';
|
||||
import { useAI } from '../AI';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Loading } from '../primitives';
|
||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||
@@ -29,7 +29,7 @@ export interface SearchResultsRef {
|
||||
|
||||
type ResultType =
|
||||
| OrderedComputedResult
|
||||
| { type: 'question'; id: string; query: string }
|
||||
| { type: 'question'; id: string; query: string; assistant: Assistant }
|
||||
| { type: 'recommended-question'; id: string; question: string };
|
||||
|
||||
/**
|
||||
@@ -51,12 +51,11 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
children?: React.ReactNode;
|
||||
query: string;
|
||||
global: boolean;
|
||||
aiMode: CustomizationAIMode;
|
||||
spaceId: string;
|
||||
siteSpaceId: string;
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>
|
||||
) {
|
||||
const { children, query, aiMode, global, spaceId } = props;
|
||||
const { children, query, global, siteSpaceId } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
@@ -67,8 +66,8 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
||||
|
||||
const withAI =
|
||||
aiMode === CustomizationAIMode.Search || aiMode === CustomizationAIMode.Assistant;
|
||||
const { assistants } = useAI();
|
||||
const withAI = assistants.length > 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
@@ -77,9 +76,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedRecommendedQuestions.has(spaceId)) {
|
||||
const results = cachedRecommendedQuestions.get(spaceId);
|
||||
assert(results, `Cached recommended questions should be set for space ${spaceId}`);
|
||||
if (cachedRecommendedQuestions.has(siteSpaceId)) {
|
||||
const results = cachedRecommendedQuestions.get(siteSpaceId);
|
||||
assert(
|
||||
results,
|
||||
`Cached recommended questions should be set for site-space ${siteSpaceId}`
|
||||
);
|
||||
setResultsState({ results, fetching: false });
|
||||
return;
|
||||
}
|
||||
@@ -98,7 +100,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await streamRecommendedQuestions(spaceId);
|
||||
const response = await streamRecommendedQuestions({ siteSpaceId });
|
||||
for await (const entry of readStreamableValue(response.stream)) {
|
||||
if (!entry) {
|
||||
continue;
|
||||
@@ -115,7 +117,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
id: question,
|
||||
question,
|
||||
});
|
||||
cachedRecommendedQuestions.set(spaceId, recommendedQuestions);
|
||||
cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
|
||||
|
||||
if (!cancelled) {
|
||||
setResultsState({ results: [...recommendedQuestions], fetching: false });
|
||||
@@ -156,13 +158,13 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [query, global, withAI, trackEvent]);
|
||||
}, [query, global, trackEvent, withAI, siteSpaceId]);
|
||||
|
||||
const results: ResultType[] = React.useMemo(() => {
|
||||
if (!withAI) {
|
||||
return resultsState.results;
|
||||
}
|
||||
return withQuestionResult(resultsState.results, query);
|
||||
return withAskTriggers(resultsState.results, query, assistants);
|
||||
}, [resultsState.results, query, withAI]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -185,7 +187,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}, [cursor, refs]);
|
||||
}, [cursor]);
|
||||
|
||||
const moveBy = React.useCallback(
|
||||
(delta: number) => {
|
||||
@@ -206,7 +208,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
}
|
||||
|
||||
refs.current[cursor]?.click();
|
||||
}, [cursor, refs]);
|
||||
}, [cursor]);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
@@ -278,10 +280,10 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
withAIChat={aiMode === CustomizationAIMode.Assistant}
|
||||
key={item.id}
|
||||
question={query}
|
||||
active={index === cursor}
|
||||
assistant={item.assistant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -292,9 +294,9 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
withAIChat={aiMode === CustomizationAIMode.Assistant}
|
||||
question={item.question}
|
||||
active={index === cursor}
|
||||
assistant={assistants[0]}
|
||||
recommended
|
||||
/>
|
||||
);
|
||||
@@ -327,12 +329,24 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
/**
|
||||
* Add a "Ask <question>" item at the top of the results list.
|
||||
*/
|
||||
function withQuestionResult(results: ResultType[], query: string): ResultType[] {
|
||||
function withAskTriggers(
|
||||
results: ResultType[],
|
||||
query: string,
|
||||
assistants: Assistant[]
|
||||
): ResultType[] {
|
||||
const without = results.filter((result) => result.type !== 'question');
|
||||
|
||||
if (query.length === 0) {
|
||||
return without;
|
||||
}
|
||||
|
||||
return [{ type: 'question', id: 'question', query }, ...(without ?? [])];
|
||||
return [
|
||||
...assistants.map((assistant, index) => ({
|
||||
type: 'question' as const,
|
||||
id: `question-${index}`,
|
||||
query,
|
||||
assistant,
|
||||
})),
|
||||
...(without ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type * as React from 'react';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { DocumentView } from '../DocumentView';
|
||||
|
||||
@@ -68,11 +69,13 @@ export interface AskAnswerResult {
|
||||
* Server action to search content in the entire site.
|
||||
*/
|
||||
export async function searchAllSiteContent(query: string): Promise<OrderedComputedResult[]> {
|
||||
const context = await getServerActionBaseContext();
|
||||
return traceErrorOnly('Search.searchAllSiteContent', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
|
||||
return await searchSiteContent(context, {
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
return await searchSiteContent(context, {
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,16 +83,18 @@ export async function searchAllSiteContent(query: string): Promise<OrderedComput
|
||||
* Server action to search content in a space.
|
||||
*/
|
||||
export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
return await searchSiteContent(context, {
|
||||
query,
|
||||
// If we have a siteSectionId that means its a sections site use `current` mode
|
||||
// which searches in the current space + all default spaces of sections
|
||||
scope: siteURLData.siteSection
|
||||
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
|
||||
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
|
||||
return await searchSiteContent(context, {
|
||||
query,
|
||||
// If we have a siteSectionId that means its a sections site use `current` mode
|
||||
// which searches in the current space + all default spaces of sections
|
||||
scope: siteURLData.siteSection
|
||||
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
|
||||
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,120 +106,126 @@ export async function streamAskQuestion({
|
||||
}: {
|
||||
question: string;
|
||||
}) {
|
||||
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
|
||||
return traceErrorOnly('Search.streamAskQuestion', async () => {
|
||||
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
|
||||
|
||||
(async () => {
|
||||
const context = await fetchServerActionSiteContext(await getServerActionBaseContext());
|
||||
(async () => {
|
||||
const context = await fetchServerActionSiteContext(await getServerActionBaseContext());
|
||||
|
||||
const apiClient = await context.dataFetcher.api();
|
||||
const apiClient = await context.dataFetcher.api();
|
||||
|
||||
const stream = apiClient.orgs.streamAskInSite(
|
||||
context.organizationId,
|
||||
context.site.id,
|
||||
{
|
||||
question,
|
||||
context: {
|
||||
siteSpaceId: context.siteSpace.id,
|
||||
const stream = apiClient.orgs.streamAskInSite(
|
||||
context.organizationId,
|
||||
context.site.id,
|
||||
{
|
||||
question,
|
||||
context: {
|
||||
siteSpaceId: context.siteSpace.id,
|
||||
},
|
||||
scope: {
|
||||
mode: 'default',
|
||||
// Include the current site space regardless.
|
||||
includedSiteSpaces: [context.siteSpace.id],
|
||||
},
|
||||
},
|
||||
scope: {
|
||||
mode: 'default',
|
||||
// Include the current site space regardless.
|
||||
includedSiteSpaces: [context.siteSpace.id],
|
||||
},
|
||||
},
|
||||
{ format: 'document' }
|
||||
);
|
||||
|
||||
const spacePromises = new Map<string, Promise<Revision>>();
|
||||
for await (const chunk of stream) {
|
||||
const answer = chunk.answer;
|
||||
|
||||
// Register the space of each page source into the promise queue.
|
||||
const spaces = answer.sources
|
||||
.map((source) => {
|
||||
if (source.type !== 'page') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!spacePromises.has(source.space)) {
|
||||
spacePromises.set(
|
||||
source.space,
|
||||
throwIfDataError(
|
||||
context.dataFetcher.getRevision({
|
||||
spaceId: source.space,
|
||||
revisionId: source.revision,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return source.space;
|
||||
})
|
||||
.filter(filterOutNullable);
|
||||
|
||||
// Get the pages for all spaces referenced by this answer.
|
||||
const pages = await Promise.all(
|
||||
spaces.map(async (space) => {
|
||||
const revision = await spacePromises.get(space);
|
||||
return { space, pages: revision?.pages };
|
||||
})
|
||||
).then((results) => {
|
||||
return results.reduce((map, result) => {
|
||||
if (result.pages) {
|
||||
map.set(result.space, result.pages);
|
||||
}
|
||||
return map;
|
||||
}, new Map<string, RevisionPage[]>());
|
||||
});
|
||||
responseStream.update(
|
||||
await transformAnswer(context, { answer: chunk.answer, spacePages: pages })
|
||||
{ format: 'document' }
|
||||
);
|
||||
}
|
||||
})()
|
||||
.then(() => {
|
||||
responseStream.done();
|
||||
})
|
||||
.catch((error) => {
|
||||
responseStream.error(error);
|
||||
});
|
||||
|
||||
return {
|
||||
stream: responseStream.value,
|
||||
};
|
||||
const spacePromises = new Map<string, Promise<Revision>>();
|
||||
for await (const chunk of stream) {
|
||||
const answer = chunk.answer;
|
||||
|
||||
// Register the space of each page source into the promise queue.
|
||||
const spaces = answer.sources
|
||||
.map((source) => {
|
||||
if (source.type !== 'page') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!spacePromises.has(source.space)) {
|
||||
spacePromises.set(
|
||||
source.space,
|
||||
throwIfDataError(
|
||||
context.dataFetcher.getRevision({
|
||||
spaceId: source.space,
|
||||
revisionId: source.revision,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return source.space;
|
||||
})
|
||||
.filter(filterOutNullable);
|
||||
|
||||
// Get the pages for all spaces referenced by this answer.
|
||||
const pages = await Promise.all(
|
||||
spaces.map(async (space) => {
|
||||
const revision = await spacePromises.get(space);
|
||||
return { space, pages: revision?.pages };
|
||||
})
|
||||
).then((results) => {
|
||||
return results.reduce((map, result) => {
|
||||
if (result.pages) {
|
||||
map.set(result.space, result.pages);
|
||||
}
|
||||
return map;
|
||||
}, new Map<string, RevisionPage[]>());
|
||||
});
|
||||
responseStream.update(
|
||||
await transformAnswer(context, { answer: chunk.answer, spacePages: pages })
|
||||
);
|
||||
}
|
||||
})()
|
||||
.then(() => {
|
||||
responseStream.done();
|
||||
})
|
||||
.catch((error) => {
|
||||
responseStream.error(error);
|
||||
});
|
||||
|
||||
return {
|
||||
stream: responseStream.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a list of suggested questions for the site.
|
||||
* Optionally scoped to a specific space.
|
||||
*/
|
||||
export async function streamRecommendedQuestions(spaceId?: string) {
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
const context = await getServerActionBaseContext();
|
||||
export async function streamRecommendedQuestions(args: { siteSpaceId?: string }) {
|
||||
return traceErrorOnly('Search.streamRecommendedQuestions', async () => {
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
const context = await getServerActionBaseContext();
|
||||
|
||||
const responseStream = createStreamableValue<SearchAIRecommendedQuestionStream | undefined>();
|
||||
const responseStream = createStreamableValue<
|
||||
SearchAIRecommendedQuestionStream | undefined
|
||||
>();
|
||||
|
||||
(async () => {
|
||||
const apiClient = await context.dataFetcher.api();
|
||||
const apiStream = apiClient.orgs.streamRecommendedQuestionsInSite(
|
||||
siteURLData.organization,
|
||||
siteURLData.site,
|
||||
{
|
||||
spaceId,
|
||||
(async () => {
|
||||
const apiClient = await context.dataFetcher.api();
|
||||
const apiStream = apiClient.orgs.streamRecommendedQuestionsInSite(
|
||||
siteURLData.organization,
|
||||
siteURLData.site,
|
||||
{
|
||||
siteSpaceId: args.siteSpaceId,
|
||||
}
|
||||
);
|
||||
|
||||
for await (const chunk of apiStream) {
|
||||
responseStream.update(chunk);
|
||||
}
|
||||
);
|
||||
})()
|
||||
.then(() => {
|
||||
responseStream.done();
|
||||
})
|
||||
.catch((error) => {
|
||||
responseStream.error(error);
|
||||
});
|
||||
|
||||
for await (const chunk of apiStream) {
|
||||
responseStream.update(chunk);
|
||||
}
|
||||
})()
|
||||
.then(() => {
|
||||
responseStream.done();
|
||||
})
|
||||
.catch((error) => {
|
||||
responseStream.error(error);
|
||||
});
|
||||
|
||||
return { stream: responseStream.value };
|
||||
return { stream: responseStream.value };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+45
-30
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
|
||||
import React from 'react';
|
||||
|
||||
import type { LinkProps } from '../primitives';
|
||||
|
||||
export interface SearchState {
|
||||
@@ -25,9 +26,17 @@ export type UpdateSearchState = (
|
||||
) => Promise<URLSearchParams>;
|
||||
|
||||
/**
|
||||
* Hook to access the current search query and update it.
|
||||
* Context to share the search state updater so all consumers use the same instance.
|
||||
*/
|
||||
export function useSearch(withAIChat = false): [SearchState | null, UpdateSearchState] {
|
||||
export const SearchContext = React.createContext<{
|
||||
state: SearchState | null;
|
||||
setState: UpdateSearchState;
|
||||
} | null>(null);
|
||||
|
||||
export function SearchContextProvider(props: React.PropsWithChildren): React.ReactElement {
|
||||
const { children } = props;
|
||||
|
||||
// URL-backed state
|
||||
const [rawState, setRawState] = useQueryStates(keyMap, {
|
||||
history: 'replace',
|
||||
});
|
||||
@@ -44,22 +53,20 @@ export function useSearch(withAIChat = false): [SearchState | null, UpdateSearch
|
||||
}
|
||||
}, [rawState, setRawState]);
|
||||
|
||||
// Separate local state for open (not synchronized with URL)
|
||||
// Default to true if there's already a query in the URL
|
||||
// Local UI state for the popover open/close (not in URL)
|
||||
const [open, setIsOpen] = React.useState(() => {
|
||||
return rawState?.q !== null || (!withAIChat && rawState?.ask !== null);
|
||||
return rawState?.q !== null;
|
||||
});
|
||||
|
||||
const state = React.useMemo<SearchState | null>(() => {
|
||||
if (rawState === null || (rawState.q === null && rawState.ask === null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
query: rawState.q,
|
||||
ask: rawState.ask,
|
||||
global: !!rawState.global,
|
||||
open: open,
|
||||
open,
|
||||
};
|
||||
}, [rawState, open]);
|
||||
|
||||
@@ -78,16 +85,10 @@ export function useSearch(withAIChat = false): [SearchState | null, UpdateSearch
|
||||
|
||||
if (update === null) {
|
||||
setIsOpen(false);
|
||||
return setRawState({
|
||||
q: null,
|
||||
ask: null,
|
||||
global: null,
|
||||
});
|
||||
return setRawState({ q: null, ask: null, global: null });
|
||||
}
|
||||
|
||||
// Update the local state
|
||||
setIsOpen(update.open);
|
||||
|
||||
return setRawState({
|
||||
q: update.query,
|
||||
ask: update.ask,
|
||||
@@ -97,37 +98,51 @@ export function useSearch(withAIChat = false): [SearchState | null, UpdateSearch
|
||||
[setRawState]
|
||||
);
|
||||
|
||||
return [state, setState];
|
||||
return <SearchContext.Provider value={{ state, setState }}>{children}</SearchContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the current search query and update it.
|
||||
*/
|
||||
export function useSearch(): [SearchState | null, UpdateSearchState] {
|
||||
const ctx = React.useContext(SearchContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useSearch must be used within SearchContextProvider');
|
||||
}
|
||||
return [ctx.state, ctx.setState];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create a link to a search query.
|
||||
*/
|
||||
export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
||||
const [, setSearch] = useSearch();
|
||||
export function useSearchLink(): (
|
||||
params: Partial<SearchState>,
|
||||
callback?: () => void
|
||||
) => LinkProps {
|
||||
const [, setSearchState] = useSearch();
|
||||
|
||||
return React.useCallback(
|
||||
(query) => {
|
||||
(params, callback) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
query.query ? searchParams.set('q', query.query) : searchParams.delete('q');
|
||||
query.ask ? searchParams.set('ask', query.ask) : searchParams.delete('ask');
|
||||
query.global ? searchParams.set('global', 'true') : searchParams.delete('global');
|
||||
params.query ? searchParams.set('q', params.query) : searchParams.delete('q');
|
||||
params.ask ? searchParams.set('ask', params.ask) : searchParams.delete('ask');
|
||||
params.global ? searchParams.set('global', 'true') : searchParams.delete('global');
|
||||
return {
|
||||
href: `?${searchParams.toString()}`,
|
||||
prefetch: false,
|
||||
onClick: (event) => {
|
||||
event.preventDefault();
|
||||
setSearch((prev) => ({
|
||||
query: null,
|
||||
ask: null,
|
||||
global: false,
|
||||
open: query.open ?? prev?.open ?? false,
|
||||
...(prev ?? {}),
|
||||
...query,
|
||||
callback?.();
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
query: params.query !== undefined ? params.query : null,
|
||||
ask: params.ask !== undefined ? params.ask : null,
|
||||
global: params.global !== undefined ? params.global : false,
|
||||
open: params.open !== undefined ? params.open : false,
|
||||
}));
|
||||
},
|
||||
};
|
||||
},
|
||||
[setSearch]
|
||||
[setSearchState]
|
||||
);
|
||||
}
|
||||
@@ -135,6 +135,9 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
|
||||
canonical: linker
|
||||
.toAbsoluteURL(linker.toPathForPage({ pages: revision.pages, page }))
|
||||
.replace(/\/+$/, ''),
|
||||
types: {
|
||||
'text/markdown': `${linker.toAbsoluteURL(linker.toPathInSpace(page.path))}.md`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
images: [
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { CustomizationAIMode, CustomizationHeaderPreset } from '@gitbook/api';
|
||||
import {
|
||||
CustomizationAIMode,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationSearchStyle,
|
||||
} from '@gitbook/api';
|
||||
import React from 'react';
|
||||
|
||||
import { Footer } from '@/components/Footer';
|
||||
@@ -10,12 +14,12 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env';
|
||||
import { AIContextProvider } from '../AI';
|
||||
import { AIChat } from '../AIChat';
|
||||
import { AIChatButton } from '../AIChat';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
||||
import { InsightsProvider } from '../Insights';
|
||||
import { SearchContainer } from '../Search';
|
||||
import { SearchContainer, SearchContextProvider } from '../Search';
|
||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||
import { CurrentContentProvider } from '../hooks';
|
||||
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
|
||||
@@ -51,19 +55,20 @@ export function SpaceLayout(props: {
|
||||
|
||||
const aiMode = customization.ai?.mode;
|
||||
|
||||
const searchAndAI = (
|
||||
const search = (
|
||||
<div className="flex grow items-center gap-2">
|
||||
<React.Suspense fallback={null}>
|
||||
<SearchContainer
|
||||
aiMode={aiMode}
|
||||
style={
|
||||
customization.header.preset === CustomizationHeaderPreset.None
|
||||
? CustomizationSearchStyle.Subtle
|
||||
: customization.styling.search
|
||||
}
|
||||
isMultiVariants={siteSpaces.length > 1}
|
||||
spaceTitle={siteSpace.title}
|
||||
spaceId={siteSpace.space.id}
|
||||
siteSpaceId={siteSpace.id}
|
||||
/>
|
||||
</React.Suspense>
|
||||
{aiMode === CustomizationAIMode.Assistant ? (
|
||||
<AIChatButton trademark={customization.trademark.enabled} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -85,79 +90,92 @@ export function SpaceLayout(props: {
|
||||
apiHost={GITBOOK_API_PUBLIC_URL}
|
||||
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
||||
>
|
||||
<Announcement context={context} />
|
||||
<Header withTopHeader={withTopHeader} context={context} search={searchAndAI} />
|
||||
{aiMode === CustomizationAIMode.Assistant ? (
|
||||
<AIChat trademark={customization.trademark.enabled} />
|
||||
) : null}
|
||||
<div className="scroll-nojump">
|
||||
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'lg:flex-row',
|
||||
CONTAINER_STYLE,
|
||||
'site-width-wide:max-w-full',
|
||||
<SearchContextProvider>
|
||||
<AIContextProvider
|
||||
aiMode={aiMode}
|
||||
trademark={customization.trademark.enabled}
|
||||
>
|
||||
<Announcement context={context} />
|
||||
<Header
|
||||
withTopHeader={withTopHeader}
|
||||
context={context}
|
||||
search={search}
|
||||
/>
|
||||
<div className="scroll-nojump">
|
||||
<div className="motion-safe:transition-all motion-safe:duration-300">
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'lg:flex-row',
|
||||
CONTAINER_STYLE,
|
||||
'site-width-wide:max-w-full',
|
||||
|
||||
// Ensure the footer is display below the viewport even if the content is not enough
|
||||
withFooter && 'min-h-[calc(100vh-64px)]',
|
||||
withTopHeader ? null : 'lg:min-h-screen'
|
||||
)}
|
||||
>
|
||||
<TableOfContents
|
||||
context={context}
|
||||
header={
|
||||
withTopHeader ? null : (
|
||||
<div
|
||||
className={tcls(
|
||||
'hidden',
|
||||
'pr-4',
|
||||
'md:flex',
|
||||
'grow-0',
|
||||
'flex-wrap',
|
||||
'dark:shadow-light/1',
|
||||
'text-base/tight'
|
||||
)}
|
||||
>
|
||||
<HeaderLogo context={context} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
innerHeader={
|
||||
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
|
||||
<>
|
||||
{!withTopHeader && searchAndAI}
|
||||
{!withTopHeader && withSections && sections && (
|
||||
<SiteSectionList
|
||||
className={tcls('hidden', 'lg:block')}
|
||||
sections={encodeClientSiteSections(
|
||||
context,
|
||||
sections
|
||||
// Ensure the footer is display below the viewport even if the content is not enough
|
||||
withFooter && 'min-h-[calc(100vh-64px)]',
|
||||
withTopHeader ? null : 'lg:min-h-screen'
|
||||
)}
|
||||
>
|
||||
<TableOfContents
|
||||
context={context}
|
||||
header={
|
||||
withTopHeader ? null : (
|
||||
<div
|
||||
className={tcls(
|
||||
'hidden',
|
||||
'pr-4',
|
||||
'md:flex',
|
||||
'grow-0',
|
||||
'flex-wrap',
|
||||
'dark:shadow-light/1',
|
||||
'text-base/tight'
|
||||
)}
|
||||
>
|
||||
<HeaderLogo context={context} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
innerHeader={
|
||||
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
|
||||
<>
|
||||
{!withTopHeader && search}
|
||||
{!withTopHeader && withSections && sections && (
|
||||
<SiteSectionList
|
||||
className={tcls('hidden', 'lg:block')}
|
||||
sections={encodeClientSiteSections(
|
||||
context,
|
||||
sections
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isMultiVariants && !sections && (
|
||||
<SpacesDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
className={tcls(
|
||||
'w-full',
|
||||
'page-no-toc:hidden',
|
||||
'page-no-toc:site-header-none:flex'
|
||||
{isMultiVariants && !sections && (
|
||||
<SpacesDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
className={tcls(
|
||||
'w-full',
|
||||
'page-no-toc:hidden',
|
||||
'page-no-toc:site-header-none:flex'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{children}
|
||||
</div>
|
||||
{aiMode === CustomizationAIMode.Assistant ? (
|
||||
<AIChat trademark={customization.trademark.enabled} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{withFooter ? <Footer context={context} /> : null}
|
||||
{withFooter ? <Footer context={context} /> : null}
|
||||
</AIContextProvider>
|
||||
</SearchContextProvider>
|
||||
</InsightsProvider>
|
||||
</CurrentContentProvider>
|
||||
</SpaceLayoutContextProvider>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup }) {
|
||||
<li className="group/page-group-item flex flex-col">
|
||||
<div
|
||||
className={tcls(
|
||||
'-top-5 group-first/page-group-item:-mt-5 sticky z-1 flex items-center gap-3 px-3 pt-6',
|
||||
'-top-6 group-first/page-group-item:-mt-6 sticky z-1 flex items-center gap-3 px-3 pt-6',
|
||||
'font-semibold text-xs uppercase tracking-wide',
|
||||
'pb-3', // Add extra padding to make the header fade a bit nicer
|
||||
'-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px.
|
||||
|
||||
@@ -18,16 +18,17 @@ export function TableOfContentsScript() {
|
||||
|
||||
// Set sticky top position based on header
|
||||
const headerHeight = header?.offsetHeight ?? 0;
|
||||
root.style.setProperty('--toc-top-offset', `${headerHeight}px`);
|
||||
|
||||
// Start with full viewport height minus header
|
||||
let height = window.innerHeight - headerHeight;
|
||||
let offset = headerHeight;
|
||||
|
||||
// Subtract visible banner (if any)
|
||||
if (banner && window.getComputedStyle(banner).display !== 'none') {
|
||||
const bannerRect = banner.getBoundingClientRect();
|
||||
if (bannerRect.height > 0 && bannerRect.bottom > 0) {
|
||||
height -= Math.min(bannerRect.height, bannerRect.bottom);
|
||||
offset += Math.min(bannerRect.height, bannerRect.bottom);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ export function TableOfContentsScript() {
|
||||
|
||||
// Update height
|
||||
root.style.setProperty('--toc-height', `${height}px`);
|
||||
root.style.setProperty('--toc-top-offset', `${offset}px`);
|
||||
};
|
||||
|
||||
// Initial update
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { useTheme } from 'next-themes';
|
||||
import React from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Button, ButtonGroup } from '../primitives';
|
||||
|
||||
type ThemeMode = 'light' | 'system' | 'dark';
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ThemeToggler() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div role="radiogroup" className="flex flex-row gap-2">
|
||||
<ButtonGroup role="radiogroup" className="gap-2" combinedShape={false}>
|
||||
<ThemeButton
|
||||
active={mounted && theme === 'light'}
|
||||
icon="sun-bright"
|
||||
@@ -46,7 +46,7 @@ export function ThemeToggler() {
|
||||
onClick={() => onSwitchMode('dark')}
|
||||
title={tString(language, 'switch_to_dark_theme')}
|
||||
/>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,38 +58,21 @@ function ThemeButton(props: {
|
||||
}) {
|
||||
const { icon, onClick, title, active } = props;
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
role="radio"
|
||||
onClick={onClick}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
label={title}
|
||||
aria-checked={active}
|
||||
className={tcls(
|
||||
'p-2',
|
||||
'rounded-sm',
|
||||
'straight-corners:rounded-none',
|
||||
'circular-corners:rounded-full',
|
||||
'transition-all',
|
||||
'text-tint',
|
||||
'contrast-more:text-tint-strong',
|
||||
'hover:bg-tint-hover',
|
||||
'contrast-more:hover:ring-2',
|
||||
'contrast-more:focus:ring-2',
|
||||
'ring-tint',
|
||||
active && [
|
||||
'bg-primary',
|
||||
'theme-muted:bg-primary-hover',
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:bg-primary-hover',
|
||||
'hover:bg-primary',
|
||||
'text-primary-strong',
|
||||
'contrast-more:text-primary-strong',
|
||||
'contrast-more:ring-1',
|
||||
'ring-primary',
|
||||
]
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} className={tcls('size-4')} />
|
||||
</button>
|
||||
variant="blank"
|
||||
size="default"
|
||||
className={
|
||||
active
|
||||
? 'bg-primary theme-muted:bg-primary-hover text-primary-strong ring-primary hover:bg-primary contrast-more:text-primary-strong contrast-more:ring-1 [html.sidebar-filled.theme-bold.tint_&]:bg-primary-hover'
|
||||
: ''
|
||||
}
|
||||
icon={icon}
|
||||
iconOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
function getHash(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return window.location.hash.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the current hash from the URL.
|
||||
* @see https://github.com/vercel/next.js/discussions/49465
|
||||
*/
|
||||
export function useHash() {
|
||||
const params = useParams();
|
||||
const [hash, setHash] = React.useState<string | null>(getHash);
|
||||
React.useEffect(() => {
|
||||
setHash(getHash());
|
||||
}, [params]);
|
||||
return hash;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
|
||||
export const HashContext = React.createContext<{
|
||||
hash: string | null;
|
||||
/**
|
||||
* Updates the hash value from the URL provided here.
|
||||
* It will then be used by the `useHash` hook.
|
||||
* URL can be relative or absolute.
|
||||
*/
|
||||
updateHashFromUrl: (href: string) => void;
|
||||
}>({
|
||||
hash: null,
|
||||
updateHashFromUrl: () => {},
|
||||
});
|
||||
|
||||
function getHash(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return window.location.hash.slice(1);
|
||||
}
|
||||
|
||||
export const HashProvider: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
|
||||
const [hash, setHash] = React.useState<string | null>(getHash);
|
||||
const updateHashFromUrl = React.useCallback((href: string) => {
|
||||
const url = new URL(
|
||||
href,
|
||||
typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
|
||||
);
|
||||
setHash(url.hash.slice(1));
|
||||
}, []);
|
||||
const memoizedValue = React.useMemo(
|
||||
() => ({ hash, updateHashFromUrl }),
|
||||
[hash, updateHashFromUrl]
|
||||
);
|
||||
return <HashContext.Provider value={memoizedValue}>{children}</HashContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the current hash from the URL.
|
||||
* @see https://github.com/vercel/next.js/discussions/49465
|
||||
* We use a different hack than this one, because for same page link it don't work
|
||||
* We can't use the `hashChange` event because it doesn't fire for `replaceState` and `pushState` which are used by Next.js.
|
||||
* Since we have a single Link component that handles all links, we can use a context to share the hash.
|
||||
*/
|
||||
export function useHash() {
|
||||
// const params = useParams();
|
||||
const { hash } = React.useContext(HashContext);
|
||||
|
||||
return hash;
|
||||
}
|
||||
@@ -31,16 +31,16 @@ export const variantClasses = {
|
||||
'hover:text-contrast-primary-solid-hover',
|
||||
'border-0',
|
||||
'contrast-more:border',
|
||||
'disabled:bg-tint',
|
||||
'disabled:text-tint/8',
|
||||
'disabled:bg-primary-subtle',
|
||||
'disabled:text-primary/8',
|
||||
],
|
||||
blank: [
|
||||
'bg-transparent',
|
||||
'text-tint',
|
||||
'border-0',
|
||||
'shadow-none!',
|
||||
'hover:bg-primary-hover',
|
||||
'hover:text-primary-strong',
|
||||
'hover:bg-tint-hover',
|
||||
'hover:text-tint-strong',
|
||||
'contrast-more:bg-tint-subtle',
|
||||
'hover:depth-subtle:translate-y-0',
|
||||
'disabled:text-tint/8',
|
||||
@@ -52,7 +52,7 @@ export const variantClasses = {
|
||||
'text-tint',
|
||||
'hover:bg-tint-hover',
|
||||
'hover:depth-flat:bg-tint-hover',
|
||||
'hover:text-primary',
|
||||
'hover:text-tint',
|
||||
'contrast-more:bg-tint-subtle',
|
||||
'disabled:bg-transparent',
|
||||
'disabled:text-tint/8',
|
||||
@@ -81,7 +81,7 @@ export const variantClasses = {
|
||||
const activeClasses = {
|
||||
primary: 'bg-primary-solid-hover',
|
||||
blank: 'bg-primary-active disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active',
|
||||
secondary: 'bg-tint-active',
|
||||
secondary: 'bg-tint-active disabled:bg-tint-active',
|
||||
header: 'bg-header-link/3',
|
||||
};
|
||||
|
||||
@@ -135,7 +135,7 @@ export const Button = React.forwardRef<
|
||||
icon
|
||||
)
|
||||
) : null}
|
||||
{iconOnly ? null : label}
|
||||
{iconOnly ? null : (children ?? label)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -168,10 +168,11 @@ export const Button = React.forwardRef<
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
{trailing}
|
||||
</button>
|
||||
);
|
||||
|
||||
return iconOnly && label ? (
|
||||
return (children || iconOnly) && label ? (
|
||||
<Tooltip
|
||||
rootProps={{ open: disabled === true ? false : undefined }}
|
||||
label={label}
|
||||
@@ -185,14 +186,21 @@ export const Button = React.forwardRef<
|
||||
}
|
||||
);
|
||||
|
||||
export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonProps>(({ children }, ref) => {
|
||||
export const ButtonGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ButtonProps & { combinedShape?: boolean }
|
||||
>(({ children, className, combinedShape = true, ...rest }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={tcls(
|
||||
'flex h-fit items-stretch justify-start *:translate-y-0! *:shadow-none!',
|
||||
'[&>*:not(:first-child)]:border-l-0 [&>*:not(:first-child,:last-child)]:rounded-none [&>*:not(:only-child):first-child]:rounded-r-none [&>*:not(:only-child):last-child]:rounded-l-none'
|
||||
'flex h-fit items-stretch justify-start overflow-hidden',
|
||||
combinedShape
|
||||
? '*:translate-y-0! *:shadow-none! [&>*:not(:first-child)]:border-l-0 [&>*:not(:first-child,:last-child)]:rounded-none! [&>*:not(:only-child):first-child]:rounded-r-none [&>*:not(:only-child):last-child]:rounded-l-none'
|
||||
: '',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
|
||||
function getOperatingSystem() {
|
||||
@@ -25,28 +26,41 @@ export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue
|
||||
<div
|
||||
aria-busy={operatingSystem === null ? 'true' : undefined}
|
||||
className={tcls(
|
||||
'shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex',
|
||||
'shortcut hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex',
|
||||
operatingSystem
|
||||
? 'motion-safe:animate-fade-in motion-reduce:opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
>
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className={tcls(
|
||||
'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1',
|
||||
key === 'mod'
|
||||
? operatingSystem === 'mac'
|
||||
? 'text-sm'
|
||||
: 'text-xs'
|
||||
: 'uppercase',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{key === 'mod' ? (operatingSystem === 'mac' ? '⌘' : 'Ctrl') : key}
|
||||
</kbd>
|
||||
))}
|
||||
{keys.map((key, index) => {
|
||||
let element: React.ReactNode = key;
|
||||
|
||||
switch (key) {
|
||||
case 'mod':
|
||||
element = operatingSystem === 'mac' ? '⌘' : 'Ctrl';
|
||||
break;
|
||||
|
||||
case 'enter':
|
||||
element = <Icon icon="arrow-turn-down-left" className="size-[.75em]" />;
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<kbd
|
||||
key={index}
|
||||
className={tcls(
|
||||
'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1',
|
||||
key === 'mod'
|
||||
? operatingSystem === 'mac'
|
||||
? 'text-sm'
|
||||
: 'text-xs'
|
||||
: 'uppercase',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{element}
|
||||
</kbd>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import React from 'react';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { SiteExternalLinksTarget } from '@gitbook/api';
|
||||
import { type TrackEventInput, useTrackEvent } from '../Insights';
|
||||
import { HashContext } from '../hooks';
|
||||
import { isExternalLink } from '../utils/link';
|
||||
import { type DesignTokenName, useClassnames } from './StyleProvider';
|
||||
|
||||
@@ -71,6 +72,7 @@ export const Link = React.forwardRef(function Link(
|
||||
) {
|
||||
const { href, prefetch, children, insights, classNames, className, ...domProps } = props;
|
||||
const { externalLinksTarget } = React.useContext(LinkSettingsContext);
|
||||
const { updateHashFromUrl } = React.useContext(HashContext);
|
||||
const trackEvent = useTrackEvent();
|
||||
const forwardedClassNames = useClassnames(classNames || []);
|
||||
const isExternal = isExternalLink(href);
|
||||
@@ -78,6 +80,9 @@ export const Link = React.forwardRef(function Link(
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
|
||||
if (!isExternal) {
|
||||
updateHashFromUrl(href);
|
||||
}
|
||||
|
||||
if (insights) {
|
||||
trackEvent(insights, undefined, { immediate: isExternalWithOrigin });
|
||||
|
||||
@@ -13,11 +13,12 @@ export const ButtonStyles = [
|
||||
'border',
|
||||
'border-tint',
|
||||
'hover:border-tint-hover',
|
||||
'disabled:border-tint',
|
||||
|
||||
'depth-subtle:shadow-xs',
|
||||
'hover:depth-subtle:shadow-md',
|
||||
'focus-visible:depth-subtle:shadow-md',
|
||||
'active:shadow-none',
|
||||
'active:depth-subtle:shadow-xs',
|
||||
'shadow-tint/6',
|
||||
'dark:shadow-tint-1',
|
||||
|
||||
@@ -27,6 +28,7 @@ export const ButtonStyles = [
|
||||
|
||||
'hover:depth-subtle:-translate-y-px',
|
||||
'focus-visible:depth-subtle:-translate-y-px',
|
||||
'active:depth-subtle:translate-y-0',
|
||||
'transition-all',
|
||||
|
||||
'grow-0',
|
||||
|
||||
@@ -16,7 +16,7 @@ export const de = {
|
||||
search_scope_all: 'Alle Inhalte',
|
||||
ask: 'Fragen',
|
||||
search_ask: 'Fragen "${1}"',
|
||||
search_ask_description: 'Finden Sie die Antwort mit AI',
|
||||
search_ask_description: 'Finden Sie die Antwort mit ${1}',
|
||||
search_ask_sources: 'Quellen',
|
||||
search_ask_sources_no_answer: 'Verwandte Seiten',
|
||||
search_ask_no_answer:
|
||||
@@ -108,4 +108,6 @@ export const de = {
|
||||
view_page_plaintext: 'Diese Seite als Klartext anzeigen',
|
||||
open_in: 'Öffnen in ${1}',
|
||||
open_in_llms_pre_prompt: 'Lies ${1} und beantworte Fragen zum Inhalt.',
|
||||
press_to_confirm: 'Drücke ${1} zum Bestätigen',
|
||||
tool_call_skipped: 'Übersprungen "${1}"',
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export const en = {
|
||||
search_scope_all: 'All content',
|
||||
ask: 'Ask',
|
||||
search_ask: 'Ask "${1}"',
|
||||
search_ask_description: 'Find the answer with AI',
|
||||
search_ask_description: 'Find the answer with ${1}',
|
||||
search_ask_sources: 'Sources',
|
||||
search_ask_sources_no_answer: 'Related pages',
|
||||
search_ask_no_answer:
|
||||
@@ -105,4 +105,6 @@ export const en = {
|
||||
view_page_plaintext: 'View this page as plain text',
|
||||
open_in: 'Open in ${1}',
|
||||
open_in_llms_pre_prompt: 'Read ${1} and answer questions about the content.',
|
||||
press_to_confirm: 'Press ${1} to confirm',
|
||||
tool_call_skipped: 'Skipped "${1}"',
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const es: TranslationLanguage = {
|
||||
search_scope_all: 'Todo el contenido',
|
||||
ask: 'Preguntar',
|
||||
search_ask: 'Preguntar "${1}"',
|
||||
search_ask_description: 'Encuentra la respuesta con IA',
|
||||
search_ask_description: 'Encuentra la respuesta con ${1}',
|
||||
search_ask_sources: 'Fuentes',
|
||||
search_ask_sources_no_answer: 'Páginas relacionadas',
|
||||
search_ask_no_answer:
|
||||
@@ -109,4 +109,6 @@ export const es: TranslationLanguage = {
|
||||
view_page_plaintext: 'Ver esta página como texto plano',
|
||||
open_in: 'Abrir en ${1}',
|
||||
open_in_llms_pre_prompt: 'Lee ${1} y responde preguntas sobre el contenido.',
|
||||
press_to_confirm: 'Presiona ${1} para confirmar',
|
||||
tool_call_skipped: 'Omitido "${1}"',
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export const fr = {
|
||||
search_scope_all: 'Tous les contenus',
|
||||
ask: 'Poser une question',
|
||||
search_ask: 'Demander « ${1} »',
|
||||
search_ask_description: 'Trouvez la réponse avec l’IA',
|
||||
search_ask_description: 'Trouvez la réponse avec ${1}',
|
||||
search_ask_sources: 'Sources',
|
||||
search_ask_sources_no_answer: 'Pages associées',
|
||||
search_ask_no_answer: 'Pas de réponse trouvée. Essayez de reformuler votre question.',
|
||||
@@ -104,4 +104,6 @@ export const fr = {
|
||||
view_page_plaintext: 'Voir en texte brut',
|
||||
open_in: 'Ouvrir dans ${1}',
|
||||
open_in_llms_pre_prompt: 'Lire ${1} et répondre aux questions sur le contenu.',
|
||||
press_to_confirm: 'Appuyez sur ${1} pour confirmer',
|
||||
tool_call_skipped: 'Ignoré "${1}"',
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ja } from './ja';
|
||||
import { nl } from './nl';
|
||||
import { no } from './no';
|
||||
import { pt_br } from './pt-br';
|
||||
import { ru } from './ru';
|
||||
import type { TranslationLanguage } from './types';
|
||||
import { zh } from './zh';
|
||||
|
||||
@@ -27,4 +28,5 @@ export const languages: {
|
||||
nl,
|
||||
no,
|
||||
'pt-br': pt_br,
|
||||
ru,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const ja: TranslationLanguage = {
|
||||
search_scope_all: '全てのコンテンツ',
|
||||
ask: '質問する',
|
||||
search_ask: '"${1}" を質問する',
|
||||
search_ask_description: 'AIで答えを見つける',
|
||||
search_ask_description: '${1}で答えを見つける',
|
||||
search_ask_sources: '情報源',
|
||||
search_ask_sources_no_answer: '関連ページ',
|
||||
search_ask_no_answer:
|
||||
@@ -107,4 +107,6 @@ export const ja: TranslationLanguage = {
|
||||
view_page_plaintext: 'このページをプレーンテキストで表示',
|
||||
open_in: '${1} で開く',
|
||||
open_in_llms_pre_prompt: '${1} を読んで内容に関する質問に答えてください。',
|
||||
press_to_confirm: '確認するには${1}を押してください',
|
||||
tool_call_skipped: '"${1}" をスキップしました',
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const nl: TranslationLanguage = {
|
||||
search_scope_all: 'Alle inhoud',
|
||||
ask: 'Vragen',
|
||||
search_ask: 'Vraag "${1}"',
|
||||
search_ask_description: 'Vind het antwoord met AI',
|
||||
search_ask_description: 'Vind het antwoord met ${1}',
|
||||
search_ask_sources: 'Bronnen',
|
||||
search_ask_sources_no_answer: "Gerelateerde pagina's",
|
||||
search_ask_no_answer:
|
||||
@@ -107,4 +107,6 @@ export const nl: TranslationLanguage = {
|
||||
view_page_plaintext: 'Bekijk deze pagina als platte tekst',
|
||||
open_in: 'Open in ${1}',
|
||||
open_in_llms_pre_prompt: 'Lees ${1} en beantwoord vragen over de inhoud.',
|
||||
press_to_confirm: 'Druk op ${1} om te bevestigen',
|
||||
tool_call_skipped: '"${1}" overgeslagen',
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const no: TranslationLanguage = {
|
||||
search_scope_all: 'Alt innhold',
|
||||
ask: 'Spør',
|
||||
search_ask: 'Spør "${1}"',
|
||||
search_ask_description: 'Finn svaret med AI',
|
||||
search_ask_description: 'Finn svaret med ${1}',
|
||||
search_ask_sources: 'Kilder',
|
||||
search_ask_sources_no_answer: 'Relaterte sider',
|
||||
search_ask_no_answer:
|
||||
@@ -108,4 +108,6 @@ export const no: TranslationLanguage = {
|
||||
view_page_plaintext: 'Vis denne siden som ren tekst',
|
||||
open_in: 'Åpne i ${1}',
|
||||
open_in_llms_pre_prompt: 'Les ${1} og besvar spørsmål om innholdet.',
|
||||
press_to_confirm: 'Trykk ${1} for å bekrefte',
|
||||
tool_call_skipped: 'Hoppet over "${1}"',
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export const pt_br = {
|
||||
search_scope_all: 'Todo o conteúdo',
|
||||
ask: 'Perguntar',
|
||||
search_ask: 'Perguntar "${1}"',
|
||||
search_ask_description: 'Encontre a resposta com IA',
|
||||
search_ask_description: 'Encontre a resposta com ${1}',
|
||||
search_ask_sources: 'Fontes',
|
||||
search_ask_sources_no_answer: 'Páginas relacionadas',
|
||||
search_ask_no_answer:
|
||||
@@ -107,4 +107,6 @@ export const pt_br = {
|
||||
view_page_plaintext: 'Ver esta página em texto simples',
|
||||
open_in: 'Abrir no ${1}',
|
||||
open_in_llms_pre_prompt: 'Leia ${1} e responda perguntas sobre o conteúdo.',
|
||||
press_to_confirm: 'Pressione ${1} para confirmar',
|
||||
tool_call_skipped: 'Pulado "${1}"',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
export const ru = {
|
||||
locale: 'ru',
|
||||
powered_by_gitbook: 'Работает на GitBook',
|
||||
sponsored_via_gitbook: 'Спонсируется GitBook',
|
||||
switch_to_dark_theme: 'Переключиться на тёмную тему',
|
||||
switch_to_light_theme: 'Переключиться на светлую тему',
|
||||
switch_to_system_theme: 'Переключиться на системную тему',
|
||||
search: 'Поиск',
|
||||
search_or_ask: 'Найти или спросить',
|
||||
search_input_placeholder: 'Поиск по содержимому',
|
||||
search_ask_input_placeholder: 'Найти в содержимом или задать вопрос',
|
||||
search_no_results_for: 'Нет результатов для "${1}".',
|
||||
search_no_results: 'Нет результатов',
|
||||
search_results_count: '${1} — число результатов',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Все материалы',
|
||||
ask: 'Спросить',
|
||||
search_ask: 'Спросить "${1}"',
|
||||
search_ask_description: 'Найти ответ с помощью ${1}',
|
||||
search_ask_sources: 'Источники',
|
||||
search_ask_sources_no_answer: 'Похожие страницы',
|
||||
search_ask_no_answer:
|
||||
'Не удалось найти ответ на ваш вопрос. Попробуйте перефразировать его или задать более конкретный вопрос.',
|
||||
search_ask_error: 'Что-то пошло не так. Пожалуйста, попробуйте позже.',
|
||||
on_this_page: 'На этой странице',
|
||||
next_page: 'Следующая',
|
||||
previous_page: 'Предыдущая',
|
||||
page_last_modified: 'Последнее обновление ${1}',
|
||||
was_this_helpful: 'Это было полезно?',
|
||||
was_this_helpful_positive: 'Да, было!',
|
||||
was_this_helpful_neutral: 'Не знаю',
|
||||
was_this_helpful_negative: 'Нет',
|
||||
was_this_helpful_thank_you: 'Спасибо!',
|
||||
was_this_helpful_comment: 'Хотите что-нибудь добавить?',
|
||||
was_this_helpful_positive_label: 'Полезно',
|
||||
was_this_helpful_negative_label: 'Не полезно',
|
||||
submit: 'Отправить',
|
||||
annotation_button_label: 'Открыть аннотацию',
|
||||
code_copied: 'Скопировано!',
|
||||
code_copy: 'Копировать',
|
||||
table_of_contents_button_label: 'Открыть оглавление',
|
||||
cookies_title: 'Файлы cookies',
|
||||
cookies_prompt:
|
||||
'Этот сайт использует файлы cookies для предоставления услуг и анализа трафика. Просматривая этот сайт, вы принимаете условия ${1}.',
|
||||
cookies_prompt_privacy: 'политики конфиденциальности',
|
||||
cookies_accept: 'Принять',
|
||||
cookies_reject: 'Отклонить',
|
||||
close: 'Закрыть',
|
||||
view: 'Посмотреть',
|
||||
edit_on_git: 'Редактировать в ${1}',
|
||||
notfound_title: 'Страница не найдена',
|
||||
notfound_link: 'Эта ссылка ведёт на страницу, которая была удалена или больше не существует.',
|
||||
notfound: 'Страница, которую вы ищете, не существует.',
|
||||
unexpected_error_title: 'Произошла ошибка',
|
||||
unexpected_error: 'Извините, произошла непредвиденная ошибка. Пожалуйста, попробуйте позже.',
|
||||
unexpected_error_retry: 'Попробуйте снова',
|
||||
pdf_download: 'Экспортировать как PDF',
|
||||
pdf_goback: 'Вернуться к материалу',
|
||||
pdf_print: 'Напечатать или сохранить как PDF',
|
||||
pdf_page_of: '${1} из ${2}',
|
||||
pdf_mode_only_page: 'Только эта страница',
|
||||
pdf_mode_all: 'Все страницы',
|
||||
pdf_limit_reached:
|
||||
'Не удалось создать PDF-файл для ${1} страниц, генерация остановлена на ${2}.',
|
||||
pdf_limit_reached_continue: 'Расширьте ещё на ${1} страниц.',
|
||||
more: 'Ещё',
|
||||
link_tooltip_external_link: 'Внешняя ссылка на',
|
||||
link_tooltip_page_anchor: 'Перейти к разделу',
|
||||
open_in_new_tab: 'Открыть в новой вкладке',
|
||||
ai_chat_assistant_name: 'GitBook-помощник',
|
||||
ai_chat_assistant_name_unbranded: 'ИИ-помощник',
|
||||
ai_chat_assistant_description: 'Я здесь, чтобы помочь вам с базой знаний.',
|
||||
ai_chat_assistant_greeting_morning: 'Доброе утро',
|
||||
ai_chat_assistant_greeting_afternoon: 'Добрый день',
|
||||
ai_chat_assistant_greeting_evening: 'Добрый вечер',
|
||||
ai_chat_assistant_greeting_night: 'Доброй ночи',
|
||||
ai_chat_clear_conversation: 'Очистить диалог',
|
||||
ai_chat_thinking: 'Думает…',
|
||||
ai_chat_working: 'Работает…',
|
||||
ai_chat_context_badge: 'ИИ',
|
||||
ai_chat_error: 'Что-то пошло не так.',
|
||||
ai_chat_context_title: 'На основе вашего контекста',
|
||||
ai_chat_context_description:
|
||||
'Помощник использует ИИ и ваш контекст для генерации ответов и выполнения действий.',
|
||||
ai_chat_context_pages_youve_read: 'Страницы, которые вы прочитали',
|
||||
ai_chat_context_info_provided_by_the_site: 'Информация предоставлена сайтом',
|
||||
ai_chat_context_previous_messages: 'Предыдущие сообщения',
|
||||
ai_chat_context_disclaimer: 'Ответы ИИ могут содержать ошибки.',
|
||||
ai_chat_input_placeholder: 'Спросите, найдите или объясните...',
|
||||
send: 'Отправить',
|
||||
actions: 'Действия',
|
||||
ai_chat_suggested_questions_title: 'Предложенные вопросы',
|
||||
ai_chat_suggested_questions_about_this_page: 'О чём эта страница?',
|
||||
ai_chat_suggested_questions_read_next: 'Что мне нужно прочитать дальше?',
|
||||
ai_chat_suggested_questions_example: 'Можете ли вы привести пример?',
|
||||
searched_for: 'Выполнен поиск ${1}',
|
||||
ai_chat_tools_listed_pages: 'Просмотрены документы',
|
||||
ai_chat_tools_read_page: 'Прочитана страница ${1}',
|
||||
ai_chat_tools_mcp_tool: 'Вызван ${1}',
|
||||
ai_chat_ask: 'Спросить ${1}',
|
||||
ai_chat_ask_about_page: 'Спросить ${1} об этой странице',
|
||||
copy_for_llms: 'Скопировать для LLM',
|
||||
copy_page_markdown: 'Скопировать страницу как Markdown для LLM',
|
||||
copy_page: 'Скопировать страницу',
|
||||
view_page_markdown: 'Посмотреть как Markdown',
|
||||
view_page_plaintext: 'Посмотреть как текст без форматирования',
|
||||
open_in: 'Открыть в ${1}',
|
||||
open_in_llms_pre_prompt: 'Прочитай ${1} и ответь на вопросы о содержимом.',
|
||||
press_to_confirm: 'Нажмите ${1} для подтверждения',
|
||||
tool_call_skipped: 'Пропущен "${1}"',
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export const zh: TranslationLanguage = {
|
||||
search_scope_all: '所有内容',
|
||||
ask: '询问',
|
||||
search_ask: '询问"${1}"',
|
||||
search_ask_description: '利用人工智能找到答案',
|
||||
search_ask_description: '利用${1}找到答案',
|
||||
search_ask_sources: '来源',
|
||||
search_ask_sources_no_answer: '相关页面',
|
||||
search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
|
||||
@@ -104,4 +104,6 @@ export const zh: TranslationLanguage = {
|
||||
view_page_plaintext: '以纯文本查看此页面',
|
||||
open_in: '在 ${1} 中打开',
|
||||
open_in_llms_pre_prompt: '阅读 ${1} 并回答内容相关的问题。',
|
||||
press_to_confirm: '按 ${1} 确认',
|
||||
tool_call_skipped: '已跳过 "${1}"',
|
||||
};
|
||||
|
||||
@@ -92,6 +92,13 @@ export function createDataFetcher(
|
||||
pageId: params.pageId,
|
||||
});
|
||||
},
|
||||
getRevisionReusableContentDocument(params) {
|
||||
return getRevisionReusableContentDocument(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
reusableContentId: params.reusableContentId,
|
||||
});
|
||||
},
|
||||
getLatestOpenAPISpecVersionContent(params) {
|
||||
return getLatestOpenAPISpecVersionContent(input, {
|
||||
organizationId: params.organizationId,
|
||||
@@ -354,6 +361,39 @@ const getRevisionPageDocument = cache(
|
||||
}
|
||||
);
|
||||
|
||||
const getRevisionReusableContentDocument = cache(
|
||||
async (
|
||||
input: DataFetcherInput,
|
||||
params: { spaceId: string; revisionId: string; reusableContentId: string }
|
||||
) => {
|
||||
'use cache';
|
||||
return wrapCacheDataFetcherError(async () => {
|
||||
return trace(
|
||||
`getRevisionReusableContentDocument(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`,
|
||||
async () => {
|
||||
const api = apiClient(input);
|
||||
const res = await api.spaces.getReusableContentDocumentInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.reusableContentId,
|
||||
{
|
||||
evaluated: true,
|
||||
},
|
||||
{
|
||||
...noCacheFetchOptions,
|
||||
}
|
||||
);
|
||||
|
||||
cacheTag(...getCacheTagsFromResponse(res));
|
||||
cacheLifeFromResponse(res, 'max');
|
||||
|
||||
return res.data;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const getRevisionPageByPath = cache(
|
||||
async (
|
||||
input: DataFetcherInput,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user