mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-13 22:29:22 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63971b9803 | |||
| 64455b55e4 | |||
| 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
|
||||||
|
---
|
||||||
|
|
||||||
|
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,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",
|
"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": {
|
"packages/cache-tags": {
|
||||||
"name": "@gitbook/cache-tags",
|
"name": "@gitbook/cache-tags",
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
@@ -48,9 +59,10 @@
|
|||||||
},
|
},
|
||||||
"packages/gitbook": {
|
"packages/gitbook": {
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.14.1",
|
"version": "0.15.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/browser-types": "workspace:*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
@@ -60,7 +72,7 @@
|
|||||||
"@gitbook/react-contentkit": "workspace:*",
|
"@gitbook/react-contentkit": "workspace:*",
|
||||||
"@gitbook/react-math": "workspace:*",
|
"@gitbook/react-math": "workspace:*",
|
||||||
"@gitbook/react-openapi": "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-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||||
@@ -146,7 +158,7 @@
|
|||||||
},
|
},
|
||||||
"packages/icons": {
|
"packages/icons": {
|
||||||
"name": "@gitbook/icons",
|
"name": "@gitbook/icons",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"bin": {
|
"bin": {
|
||||||
"gitbook-icons": "./bin/gitbook-icons.js",
|
"gitbook-icons": "./bin/gitbook-icons.js",
|
||||||
},
|
},
|
||||||
@@ -166,7 +178,7 @@
|
|||||||
},
|
},
|
||||||
"packages/openapi-parser": {
|
"packages/openapi-parser": {
|
||||||
"name": "@gitbook/openapi-parser",
|
"name": "@gitbook/openapi-parser",
|
||||||
"version": "2.2.2",
|
"version": "3.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/openapi-parser": "^0.18.0",
|
"@scalar/openapi-parser": "^0.18.0",
|
||||||
"@scalar/openapi-types": "^0.1.9",
|
"@scalar/openapi-types": "^0.1.9",
|
||||||
@@ -181,7 +193,7 @@
|
|||||||
},
|
},
|
||||||
"packages/react-contentkit": {
|
"packages/react-contentkit": {
|
||||||
"name": "@gitbook/react-contentkit",
|
"name": "@gitbook/react-contentkit",
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/icons": "workspace:*",
|
"@gitbook/icons": "workspace:*",
|
||||||
@@ -196,7 +208,7 @@
|
|||||||
},
|
},
|
||||||
"packages/react-math": {
|
"packages/react-math": {
|
||||||
"name": "@gitbook/react-math",
|
"name": "@gitbook/react-math",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"bin": {
|
"bin": {
|
||||||
"gitbook-math": "./bin/gitbook-math.js",
|
"gitbook-math": "./bin/gitbook-math.js",
|
||||||
},
|
},
|
||||||
@@ -213,7 +225,7 @@
|
|||||||
},
|
},
|
||||||
"packages/react-openapi": {
|
"packages/react-openapi": {
|
||||||
"name": "@gitbook/react-openapi",
|
"name": "@gitbook/react-openapi",
|
||||||
"version": "1.3.4",
|
"version": "1.3.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/openapi-parser": "workspace:*",
|
"@gitbook/openapi-parser": "workspace:*",
|
||||||
"@scalar/api-client-react": "^1.3.16",
|
"@scalar/api-client-react": "^1.3.16",
|
||||||
@@ -246,7 +258,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@gitbook/api": "^0.130.0",
|
"@gitbook/api": "^0.136.0",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
|
"@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=="],
|
"@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"],
|
"@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=="],
|
"@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=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": ["packages/*"],
|
"packages": ["packages/*"],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@gitbook/api": "^0.130.0"
|
"@gitbook/api": "^0.136.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"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,99 @@
|
|||||||
|
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
|
|
||||||
|
export type GitBookIntegrationEvent = 'load' | 'unload';
|
||||||
|
|
||||||
|
export type GitBookIntegrationEventCallback = (...args: unknown[]) => 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.
|
||||||
|
* Returns a registration object that lets the integration signal readiness
|
||||||
|
* and dispose the assistant when no longer needed.
|
||||||
|
*/
|
||||||
|
registerAssistant: (assistant: GitBookAssistant) => GitBookAssistantRegistration;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/**
|
||||||
|
* Global `window.GitBook` object accessible by integrations.
|
||||||
|
*/
|
||||||
|
GitBook?: GitBookGlobal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GitBookAssistantRegistration = ((this: void) => void) & {
|
||||||
|
/**
|
||||||
|
* Call this when the integration has finished initializing and is ready to
|
||||||
|
* receive `open` calls.
|
||||||
|
*/
|
||||||
|
ready: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister the assistant.
|
||||||
|
*/
|
||||||
|
dispose: () => void;
|
||||||
|
};
|
||||||
@@ -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();
|
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
|
||||||
name: 'Search - AI Mode: Search - Complete flow',
|
// - Search - AI Mode: Search - Complete flow
|
||||||
url: getCustomizationURL({
|
// - Search - AI Mode: Search - URL query (Initial)
|
||||||
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
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Search - AI Mode: Search - URL query (Results)',
|
name: 'Search - AI Mode: Search - URL query (Results)',
|
||||||
url: `${getCustomizationURL({
|
url: `${getCustomizationURL({
|
||||||
@@ -158,37 +114,9 @@ const searchTestCases: Test[] = [
|
|||||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
|
||||||
name: 'Ask - AI Mode: Search - URL query (Ask initial)',
|
// - Ask - AI Mode: Search - URL query (Ask initial)
|
||||||
url: `${getCustomizationURL({
|
// - Ask - AI Mode: Search - URL query (Ask results)
|
||||||
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();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Ask - AI Mode: Assistant - Complete flow',
|
name: 'Ask - AI Mode: Assistant - Complete flow',
|
||||||
url: getCustomizationURL({
|
url: getCustomizationURL({
|
||||||
@@ -202,11 +130,12 @@ const searchTestCases: Test[] = [
|
|||||||
|
|
||||||
// Focus search input, expecting recommended questions
|
// Focus search input, expecting recommended questions
|
||||||
await searchInput.focus();
|
await searchInput.focus();
|
||||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
// TODO: Re-enable this part of the test when we have fixed the AI Search timing out
|
||||||
const recommendedQuestions = await page
|
// await expect(page.getByTestId('search-results')).toBeVisible();
|
||||||
.getByTestId('search-recommended-question')
|
// const recommendedQuestions = await page
|
||||||
.all();
|
// .getByTestId('search-recommended-question')
|
||||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
// .all();
|
||||||
|
// await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||||
|
|
||||||
// Fill search input, expecting AI search option
|
// Fill search input, expecting AI search option
|
||||||
await searchInput.fill('What is gitbook?');
|
await searchInput.fill('What is gitbook?');
|
||||||
@@ -225,7 +154,7 @@ const searchTestCases: Test[] = [
|
|||||||
}),
|
}),
|
||||||
screenshot: false,
|
screenshot: false,
|
||||||
run: async (page) => {
|
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')).toBeVisible();
|
||||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
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}"`
|
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
|
||||||
);
|
);
|
||||||
return this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
|
return this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
|
||||||
|
redirect: 'manual',
|
||||||
cf: {
|
cf: {
|
||||||
cacheEverything: false,
|
cacheEverything: false,
|
||||||
},
|
},
|
||||||
@@ -33,6 +34,9 @@ export default class extends WorkerEntrypoint {
|
|||||||
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
|
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
|
||||||
const nextRequest = new Request(modifiedUrl, reqOrResp);
|
const nextRequest = new Request(modifiedUrl, reqOrResp);
|
||||||
return fetch(nextRequest, {
|
return fetch(nextRequest, {
|
||||||
|
// We never want to follow the redirects here.
|
||||||
|
// Redirects are supposed to happen from the client.
|
||||||
|
redirect: 'manual',
|
||||||
cf: {
|
cf: {
|
||||||
cacheEverything: false,
|
cacheEverything: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
// When deployed locally, we don't have access to the tag cache here,
|
// 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
|
// we should just bypass the cache to go to the server directly
|
||||||
"SHOULD_BYPASS_CACHE": "true",
|
"SHOULD_BYPASS_CACHE": "true",
|
||||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||||
|
"GITBOOK_URL": "http://localhost:8771"
|
||||||
},
|
},
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ class GitbookIncrementalCache implements IncrementalCache {
|
|||||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||||
CacheValue<CacheType>
|
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);
|
const r2Object = await r2.get(cacheKey);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
|
"@gitbook/browser-types": "workspace:*",
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
"@gitbook/fonts": "workspace:*",
|
"@gitbook/fonts": "workspace:*",
|
||||||
"@gitbook/icons": "workspace:*",
|
"@gitbook/icons": "workspace:*",
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
"@gitbook/react-contentkit": "workspace:*",
|
"@gitbook/react-contentkit": "workspace:*",
|
||||||
"@gitbook/react-math": "workspace:*",
|
"@gitbook/react-math": "workspace:*",
|
||||||
"@gitbook/react-openapi": "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-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './useAI';
|
||||||
export * from './useAIChat';
|
export * from './useAIChat';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AIToolCallGetPageContent,
|
AIToolCallGetPageContent,
|
||||||
AIToolCallGetPages,
|
AIToolCallGetPages,
|
||||||
AIToolCallMCP,
|
AIToolCallMCP,
|
||||||
|
AIToolCallOther,
|
||||||
AIToolCallSearch,
|
AIToolCallSearch,
|
||||||
ContentRef,
|
ContentRef,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
@@ -58,6 +59,8 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
|
|||||||
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
|
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
|
||||||
case 'mcp':
|
case 'mcp':
|
||||||
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
|
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
|
||||||
|
case 'other':
|
||||||
|
return <DescriptionForOtherToolCall toolCall={toolCall} context={context} />;
|
||||||
default:
|
default:
|
||||||
return <>{toolCall.tool}</>;
|
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: {
|
async function DescriptionForSearchToolCall(props: {
|
||||||
toolCall: AIToolCallSearch;
|
toolCall: AIToolCallSearch;
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
@@ -247,6 +259,8 @@ function getIconForToolCall(toolCall: AIToolCall): IconName {
|
|||||||
return 'magnifying-glass';
|
return 'magnifying-glass';
|
||||||
case 'getPages':
|
case 'getPages':
|
||||||
return 'files';
|
return 'files';
|
||||||
|
case 'other':
|
||||||
|
return (toolCall.summary.icon as IconName) ?? 'hammer';
|
||||||
default:
|
default:
|
||||||
return 'hammer';
|
return 'hammer';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
import type { GitBookBaseContext } from '@/lib/context';
|
import type { GitBookBaseContext } from '@/lib/context';
|
||||||
import { fetchServerActionSiteContext } from '@/lib/server-actions';
|
import { fetchServerActionSiteContext } from '@/lib/server-actions';
|
||||||
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
import {
|
import {
|
||||||
type AIMessage,
|
type AIMessage,
|
||||||
AIMessageRole,
|
AIMessageRole,
|
||||||
@@ -19,6 +20,7 @@ export async function streamRenderAIMessage(
|
|||||||
rawStream: AsyncIterable<AIStreamResponse>,
|
rawStream: AsyncIterable<AIStreamResponse>,
|
||||||
options?: RenderAIMessageOptions
|
options?: RenderAIMessageOptions
|
||||||
) {
|
) {
|
||||||
|
return traceErrorOnly('AI.streamRenderAIMessage', async () => {
|
||||||
const message: AIMessage = {
|
const message: AIMessage = {
|
||||||
id: '',
|
id: '',
|
||||||
role: AIMessageRole.Assistant,
|
role: AIMessageRole.Assistant,
|
||||||
@@ -79,7 +81,8 @@ export async function streamRenderAIMessage(
|
|||||||
case 'response_reasoning':
|
case 'response_reasoning':
|
||||||
case 'response_document': {
|
case 'response_document': {
|
||||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||||
const container = event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
const container =
|
||||||
|
event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
||||||
|
|
||||||
step[container] ??= {
|
step[container] ??= {
|
||||||
object: 'document',
|
object: 'document',
|
||||||
@@ -111,6 +114,7 @@ export async function streamRenderAIMessage(
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
'use server';
|
'use server';
|
||||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
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 { streamRenderAIMessage } from './api';
|
||||||
import type { RenderAIMessageOptions } from './types';
|
import type { RenderAIMessageOptions } from './types';
|
||||||
|
|
||||||
@@ -12,32 +19,44 @@ export async function* streamAIChatResponse({
|
|||||||
message,
|
message,
|
||||||
messageContext,
|
messageContext,
|
||||||
previousResponseId,
|
previousResponseId,
|
||||||
|
toolCall,
|
||||||
|
tools,
|
||||||
options,
|
options,
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message?: string;
|
||||||
messageContext: AIMessageContext;
|
messageContext: AIMessageContext;
|
||||||
previousResponseId?: string;
|
previousResponseId?: string;
|
||||||
|
toolCall?: AIToolCallResult;
|
||||||
|
tools?: AIToolDefinition[];
|
||||||
options?: RenderAIMessageOptions;
|
options?: RenderAIMessageOptions;
|
||||||
}) {
|
}) {
|
||||||
|
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||||
const context = await getServerActionBaseContext();
|
const context = await getServerActionBaseContext();
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
const api = await context.dataFetcher.api();
|
const api = await context.dataFetcher.api();
|
||||||
const rawStream = api.orgs.streamAiResponseInSite(siteURLData.organization, siteURLData.site, {
|
const rawStream = api.orgs.streamAiResponseInSite(
|
||||||
mode: 'assistant',
|
siteURLData.organization,
|
||||||
input: [
|
siteURLData.site,
|
||||||
|
{
|
||||||
|
input: message
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
role: AIMessageRole.User,
|
role: AIMessageRole.User,
|
||||||
content: message,
|
content: message,
|
||||||
context: messageContext,
|
context: messageContext,
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
output: { type: 'document' },
|
: [],
|
||||||
model: AIModel.ReasoningLow,
|
model: AIModel.ReasoningLow,
|
||||||
previousResponseId,
|
previousResponseId,
|
||||||
});
|
toolCall,
|
||||||
|
tools,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
return await streamRenderAIMessage(context, rawStream, options);
|
||||||
|
});
|
||||||
|
|
||||||
for await (const output of stream) {
|
for await (const output of stream) {
|
||||||
yield output;
|
yield output;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||||
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
import { streamRenderAIMessage } from './api';
|
import { streamRenderAIMessage } from './api';
|
||||||
import type { RenderAIMessageOptions } from './types';
|
import type { RenderAIMessageOptions } from './types';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export async function* streamAIResponseById({
|
|||||||
responseId: string;
|
responseId: string;
|
||||||
options?: RenderAIMessageOptions;
|
options?: RenderAIMessageOptions;
|
||||||
}) {
|
}) {
|
||||||
|
const { stream } = await traceErrorOnly('AI.streamAIResponseById', async () => {
|
||||||
const context = await getServerActionBaseContext();
|
const context = await getServerActionBaseContext();
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
@@ -23,7 +25,8 @@ export async function* streamAIResponseById({
|
|||||||
siteURLData.site,
|
siteURLData.site,
|
||||||
responseId
|
responseId
|
||||||
);
|
);
|
||||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
return await streamRenderAIMessage(context, rawStream, options);
|
||||||
|
});
|
||||||
|
|
||||||
for await (const output of stream) {
|
for await (const output of stream) {
|
||||||
yield output;
|
yield output;
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
'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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assistant readiness. Built-in assistants are always ready.
|
||||||
|
* Integrations set this to true via the registration's ready() callback.
|
||||||
|
*/
|
||||||
|
ready?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
ready: true,
|
||||||
|
});
|
||||||
|
} 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',
|
||||||
|
ready: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,15 @@
|
|||||||
|
|
||||||
import * as zustand from 'zustand';
|
import * as zustand from 'zustand';
|
||||||
|
|
||||||
import { AIMessageRole } from '@gitbook/api';
|
import {
|
||||||
|
AIMessageRole,
|
||||||
|
type AIStreamResponseToolCallPending,
|
||||||
|
type AIToolCallResult,
|
||||||
|
} from '@gitbook/api';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
|
import { integrationsAssistantTools } from '../Integrations';
|
||||||
import { useSearch } from '../Search';
|
import { useSearch } from '../Search';
|
||||||
import { streamAIChatResponse } from './server-actions';
|
import { streamAIChatResponse } from './server-actions';
|
||||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||||
@@ -15,6 +21,21 @@ export type AIChatMessage = {
|
|||||||
query?: string;
|
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 = {
|
export type AIChatState = {
|
||||||
/**
|
/**
|
||||||
* If true, the chat is open.
|
* If true, the chat is open.
|
||||||
@@ -46,6 +67,11 @@ export type AIChatState = {
|
|||||||
*/
|
*/
|
||||||
followUpSuggestions: string[];
|
followUpSuggestions: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools that are pending confirmation to be executed.
|
||||||
|
*/
|
||||||
|
pendingTools: AIChatPendingTool[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If true, the session is in progress.
|
* If true, the session is in progress.
|
||||||
*/
|
*/
|
||||||
@@ -71,22 +97,17 @@ export type AIChatController = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Global state store for AI chat
|
// Global state store for AI chat
|
||||||
const globalState = zustand.create<{
|
const globalState = zustand.create<AIChatState>(() => {
|
||||||
state: AIChatState;
|
|
||||||
setState: (fn: (state: AIChatState) => Partial<AIChatState>) => void;
|
|
||||||
}>((set) => {
|
|
||||||
return {
|
return {
|
||||||
state: {
|
|
||||||
opened: false,
|
opened: false,
|
||||||
responseId: null,
|
responseId: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
query: null,
|
query: null,
|
||||||
followUpSuggestions: [],
|
followUpSuggestions: [],
|
||||||
|
pendingTools: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
initialQuery: null,
|
initialQuery: null,
|
||||||
},
|
|
||||||
setState: (fn) => set((state) => ({ state: { ...state.state, ...fn(state.state) } })),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,7 +115,7 @@ const globalState = zustand.create<{
|
|||||||
* Get the current state of the AI chat.
|
* Get the current state of the AI chat.
|
||||||
*/
|
*/
|
||||||
export function useAIChatState(): AIChatState {
|
export function useAIChatState(): AIChatState {
|
||||||
const state = zustand.useStore(globalState, (state) => state.state);
|
const state = zustand.useStore(globalState);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,14 +125,13 @@ export function useAIChatState(): AIChatState {
|
|||||||
*/
|
*/
|
||||||
export function useAIChatController(): AIChatController {
|
export function useAIChatController(): AIChatController {
|
||||||
const messageContextRef = useAIMessageContextRef();
|
const messageContextRef = useAIMessageContextRef();
|
||||||
const setState = zustand.useStore(globalState, (state) => state.setState);
|
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
const [searchState, setSearchState] = useSearch(true);
|
const [, setSearchState] = useSearch();
|
||||||
|
|
||||||
// Open AI chat and sync with search state
|
// Open AI chat and sync with search state
|
||||||
const onOpen = React.useCallback(() => {
|
const onOpen = React.useCallback(() => {
|
||||||
const { initialQuery } = globalState.getState().state;
|
const { initialQuery } = globalState.getState();
|
||||||
setState((state) => ({ ...state, opened: true }));
|
globalState.setState((state) => ({ ...state, opened: true }));
|
||||||
|
|
||||||
// Update search state to show ask mode with first message or current ask value
|
// Update search state to show ask mode with first message or current ask value
|
||||||
setSearchState((prev) => ({
|
setSearchState((prev) => ({
|
||||||
@@ -120,11 +140,11 @@ export function useAIChatController(): AIChatController {
|
|||||||
global: prev?.global ?? false,
|
global: prev?.global ?? false,
|
||||||
open: false, // Close search popover when opening chat
|
open: false, // Close search popover when opening chat
|
||||||
}));
|
}));
|
||||||
}, [setState, setSearchState]);
|
}, [setSearchState]);
|
||||||
|
|
||||||
// Close AI chat and clear ask parameter
|
// Close AI chat and clear ask parameter
|
||||||
const onClose = React.useCallback(() => {
|
const onClose = React.useCallback(() => {
|
||||||
setState((state) => ({ ...state, opened: false }));
|
globalState.setState((state) => ({ ...state, opened: false }));
|
||||||
|
|
||||||
// Clear ask parameter but keep other search state
|
// Clear ask parameter but keep other search state
|
||||||
setSearchState((prev) => ({
|
setSearchState((prev) => ({
|
||||||
@@ -133,65 +153,98 @@ export function useAIChatController(): AIChatController {
|
|||||||
global: prev?.global ?? false,
|
global: prev?.global ?? false,
|
||||||
open: false,
|
open: false,
|
||||||
}));
|
}));
|
||||||
}, [setState, setSearchState]);
|
}, [setSearchState]);
|
||||||
|
|
||||||
// Post a message to the AI chat
|
// Stream a message with the AI backend
|
||||||
const onPostMessage = React.useCallback(
|
const streamResponse = React.useCallback(
|
||||||
async (input: { message: string }) => {
|
async (input: {
|
||||||
const { messages } = globalState.getState().state;
|
/** Text message to send to the AI backend */
|
||||||
|
message?: string;
|
||||||
// For first message, update the ask parameter in URL
|
/** Tool call to send to the AI backend */
|
||||||
if (messages.length === 0) {
|
toolCall?: AIToolCallResult;
|
||||||
setSearchState((prev) => ({
|
}) => {
|
||||||
ask: input.message,
|
globalState.setState((state) => {
|
||||||
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) => {
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
followUpSuggestions: [],
|
||||||
|
pendingTools: [],
|
||||||
|
loading: true,
|
||||||
|
error: false,
|
||||||
messages: [
|
messages: [
|
||||||
...state.messages,
|
...state.messages,
|
||||||
{
|
|
||||||
role: AIMessageRole.User,
|
|
||||||
content: input.message,
|
|
||||||
query: input.message,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
role: AIMessageRole.Assistant,
|
role: AIMessageRole.Assistant,
|
||||||
content: null, // Placeholder for streaming response
|
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 {
|
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({
|
const stream = await streamAIChatResponse({
|
||||||
message: input.message,
|
message: input.message,
|
||||||
|
toolCall: input.toolCall,
|
||||||
messageContext: messageContextRef.current,
|
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
|
// Process streaming response
|
||||||
for await (const data of stream) {
|
for await (const data of stream) {
|
||||||
if (!data) continue;
|
if (!data) continue;
|
||||||
|
|
||||||
|
if (input.message && globalState.getState().query !== input.message) {
|
||||||
|
// Chat was cleared, stop processing the stream
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const event = data.event;
|
const event = data.event;
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'response_finish': {
|
case 'response_finish': {
|
||||||
setState((state) => ({
|
globalState.setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
responseId: event.responseId,
|
responseId: event.responseId,
|
||||||
// Mark as not loading when the response is finished
|
// Mark as not loading when the response is finished
|
||||||
@@ -202,7 +255,7 @@ export function useAIChatController(): AIChatController {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'response_followup_suggestion': {
|
case 'response_followup_suggestion': {
|
||||||
setState((state) => ({
|
globalState.setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
followUpSuggestions: [
|
followUpSuggestions: [
|
||||||
...state.followUpSuggestions,
|
...state.followUpSuggestions,
|
||||||
@@ -211,10 +264,49 @@ export function useAIChatController(): AIChatController {
|
|||||||
}));
|
}));
|
||||||
break;
|
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: `Skipped confirmation of "${confirmation.label}"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
confirm: async () => {
|
||||||
|
await executeToolCall(event);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
toolToExecute = event;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the assistant message with streamed content
|
// Update the assistant message with streamed content
|
||||||
setState((state) => ({
|
globalState.setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
messages: [
|
messages: [
|
||||||
...state.messages.slice(0, -1),
|
...state.messages.slice(0, -1),
|
||||||
@@ -226,30 +318,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,
|
...state,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
setState((state) => ({
|
globalState.setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: true,
|
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
|
// Clear the conversation and reset ask parameter
|
||||||
const onClear = React.useCallback(() => {
|
const onClear = React.useCallback(() => {
|
||||||
setState((state) => ({
|
globalState.setState((state) => ({
|
||||||
opened: state.opened,
|
opened: state.opened,
|
||||||
loading: false,
|
loading: false,
|
||||||
messages: [],
|
messages: [],
|
||||||
query: null,
|
query: null,
|
||||||
followUpSuggestions: [],
|
followUpSuggestions: [],
|
||||||
|
pendingTools: [],
|
||||||
responseId: null,
|
responseId: null,
|
||||||
error: false,
|
error: false,
|
||||||
initialQuery: null,
|
initialQuery: null,
|
||||||
@@ -262,47 +413,7 @@ export function useAIChatController(): AIChatController {
|
|||||||
global: prev?.global ?? false,
|
global: prev?.global ?? false,
|
||||||
open: false,
|
open: false,
|
||||||
}));
|
}));
|
||||||
}, [setState, setSearchState]);
|
}, [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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return React.useMemo(() => {
|
return React.useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAIChatController } from '@/components/AI/useAIChat';
|
import { useAIChatState } from '@/components/AI';
|
||||||
import { useAIChatState } from '@/components/AI/useAIChat';
|
import type { Assistant } from '@/components/AI';
|
||||||
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
|
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
|
||||||
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
|
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
|
||||||
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
|
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
|
||||||
import { getAIChatName } from '@/components/AIChat';
|
|
||||||
import { AIChatIcon } from '@/components/AIChat';
|
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
|
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
@@ -22,32 +20,21 @@ type AIActionType = 'button' | 'dropdown-menu-item';
|
|||||||
/**
|
/**
|
||||||
* Opens our AI Docs Assistant.
|
* Opens our AI Docs Assistant.
|
||||||
*/
|
*/
|
||||||
export function OpenDocsAssistant(props: { type: AIActionType; trademark: boolean }) {
|
export function OpenAIAssistant(props: { assistant: Assistant; type: AIActionType }) {
|
||||||
const { type, trademark } = props;
|
const { assistant, type } = props;
|
||||||
const chatController = useAIChatController();
|
|
||||||
const chat = useAIChatState();
|
const chat = useAIChatState();
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AIActionWrapper
|
<AIActionWrapper
|
||||||
type={type}
|
type={type}
|
||||||
icon={
|
icon={assistant.icon}
|
||||||
<AIChatIcon state={chat.loading ? 'thinking' : 'default'} trademark={trademark} />
|
label={assistant.label}
|
||||||
}
|
|
||||||
label={tString(language, 'ai_chat_ask', getAIChatName(trademark))}
|
|
||||||
shortLabel={tString(language, 'ask')}
|
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}
|
disabled={chat.loading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Open the chat if it's not already open
|
assistant.open(tString(language, 'ai_chat_suggested_questions_about_this_page'));
|
||||||
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'),
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -231,13 +218,15 @@ function AIActionWrapper(props: {
|
|||||||
}
|
}
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
label={shortLabel || label}
|
label={label ?? shortLabel}
|
||||||
className="bg-tint-base text-sm"
|
className="bg-tint-base text-sm"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
href={href}
|
href={href}
|
||||||
target={href ? '_blank' : undefined}
|
target={href ? '_blank' : undefined}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
/>
|
>
|
||||||
|
{shortLabel}
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,13 +240,13 @@ function AIActionWrapper(props: {
|
|||||||
>
|
>
|
||||||
<div className="flex size-5 items-center justify-center text-tint">
|
<div className="flex size-5 items-center justify-center text-tint">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Icon icon="spinner-third" className="size-4 animate-spin" />
|
<Icon icon="spinner-third" className="size-4 shrink-0 animate-spin" />
|
||||||
) : icon ? (
|
) : icon ? (
|
||||||
typeof icon === 'string' ? (
|
typeof icon === 'string' ? (
|
||||||
<Icon
|
<Icon
|
||||||
icon={icon as IconName}
|
icon={icon as IconName}
|
||||||
iconStyle={IconStyle.Regular}
|
iconStyle={IconStyle.Regular}
|
||||||
className="size-4 fill-transparent stroke-current"
|
className="size-4 shrink-0 fill-transparent stroke-current"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
icon
|
icon
|
||||||
@@ -268,9 +257,9 @@ function AIActionWrapper(props: {
|
|||||||
<div className="flex flex-1 flex-col gap-0.5">
|
<div className="flex flex-1 flex-col gap-0.5">
|
||||||
<span className="flex items-center gap-2 text-tint-strong">
|
<span className="flex items-center gap-2 text-tint-strong">
|
||||||
<span className="truncate font-medium text-sm">{label}</span>
|
<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>
|
</span>
|
||||||
{description && <span className="truncate text-tint text-xs">{description}</span>}
|
{description && <span className="text-tint text-xs">{description}</span>}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,21 +2,21 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CopyMarkdown,
|
CopyMarkdown,
|
||||||
OpenDocsAssistant,
|
OpenAIAssistant,
|
||||||
OpenInLLM,
|
OpenInLLM,
|
||||||
ViewAsMarkdown,
|
ViewAsMarkdown,
|
||||||
} from '@/components/AIActions/AIActions';
|
} from '@/components/AIActions/AIActions';
|
||||||
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
||||||
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
||||||
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import type { SiteCustomizationSettings } from '@gitbook/api';
|
import type { SiteCustomizationSettings } from '@gitbook/api';
|
||||||
|
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
import { useAI } from '../AI';
|
||||||
|
|
||||||
interface AIActionsDropdownProps {
|
interface AIActionsDropdownProps {
|
||||||
markdownPageUrl: string;
|
markdownPageUrl: string;
|
||||||
withAIChat?: boolean;
|
className?: string;
|
||||||
trademark: boolean;
|
|
||||||
actions: SiteCustomizationSettings['pageActions'];
|
actions: SiteCustomizationSettings['pageActions'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,9 +25,11 @@ interface AIActionsDropdownProps {
|
|||||||
*/
|
*/
|
||||||
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const assistants = useAI().assistants.filter((assistant) => assistant.ui === true);
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return assistants.length > 0 || props.actions.markdown || props.actions.externalAI ? (
|
||||||
<ButtonGroup ref={ref}>
|
<ButtonGroup ref={ref} className={props.className}>
|
||||||
<DefaultAction {...props} />
|
<DefaultAction {...props} />
|
||||||
{props.actions.markdown || props.actions.externalAI ? (
|
{props.actions.markdown || props.actions.externalAI ? (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
@@ -41,6 +43,7 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
|||||||
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
|
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
label={tString(language, 'more')}
|
||||||
iconOnly
|
iconOnly
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -52,26 +55,31 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
);
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The content of the dropdown menu.
|
* The content of the dropdown menu.
|
||||||
*/
|
*/
|
||||||
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||||
const { markdownPageUrl, withAIChat, trademark, actions } = props;
|
const { markdownPageUrl, actions } = props;
|
||||||
|
const assistants = useAI().assistants.filter((assistant) => assistant.ui === true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{withAIChat ? (
|
{assistants.map((assistant) => (
|
||||||
<OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" />
|
<OpenAIAssistant
|
||||||
) : null}
|
key={assistant.label}
|
||||||
|
assistant={assistant}
|
||||||
|
type="dropdown-menu-item"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{actions.markdown ? (
|
{actions.markdown ? (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator className="first:hidden" />
|
<DropdownMenuSeparator className="first:hidden" />
|
||||||
<CopyMarkdown
|
<CopyMarkdown
|
||||||
isDefaultAction={!withAIChat}
|
isDefaultAction={!assistants.length}
|
||||||
markdownPageUrl={markdownPageUrl}
|
markdownPageUrl={markdownPageUrl}
|
||||||
type="dropdown-menu-item"
|
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
|
* A default action shown as a quick-access button beside the dropdown menu
|
||||||
*/
|
*/
|
||||||
function DefaultAction(props: AIActionsDropdownProps) {
|
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) {
|
if (assistants.length) {
|
||||||
return <OpenDocsAssistant trademark={trademark} type="button" />;
|
return <OpenAIAssistant assistant={assistants[0]} type="button" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actions.markdown) {
|
if (actions.markdown) {
|
||||||
return (
|
return (
|
||||||
<CopyMarkdown
|
<CopyMarkdown
|
||||||
isDefaultAction={!withAIChat}
|
isDefaultAction={!assistants.length}
|
||||||
markdownPageUrl={markdownPageUrl}
|
markdownPageUrl={markdownPageUrl}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type AIChatState,
|
type AIChatState,
|
||||||
useAIChatController,
|
useAIChatController,
|
||||||
useAIChatState,
|
useAIChatState,
|
||||||
} from '../AI/useAIChat';
|
} from '../AI';
|
||||||
import { useNow } from '../hooks';
|
import { useNow } from '../hooks';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
|
||||||
@@ -24,7 +24,7 @@ export function AIChat(props: { trademark: boolean }) {
|
|||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'mod+j',
|
'mod+i',
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
chatController.open();
|
chatController.open();
|
||||||
|
|||||||
@@ -2,42 +2,37 @@
|
|||||||
import { useLanguage } from '@/intl/client';
|
import { useLanguage } from '@/intl/client';
|
||||||
import { t } from '@/intl/translate';
|
import { t } from '@/intl/translate';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
import type { Assistant } from '../AI';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||||
import { getAIChatName } from './AIChat';
|
|
||||||
import { AIChatIcon } from './AIChatIcon';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Button to open/close the AI chat.
|
* Button to open/close the AI chat.
|
||||||
*/
|
*/
|
||||||
export function AIChatButton(props: { trademark: boolean }) {
|
export function AIChatButton(props: {
|
||||||
const { trademark } = props;
|
assistant: Assistant;
|
||||||
const chatController = useAIChatController();
|
showLabel?: boolean;
|
||||||
const chat = useAIChatState();
|
}) {
|
||||||
|
const { assistant, showLabel = true } = props;
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
icon={<AIChatIcon trademark={trademark} />}
|
icon={assistant.icon}
|
||||||
data-testid="ai-chat-button"
|
data-testid="ai-chat-button"
|
||||||
iconOnly
|
iconOnly={!showLabel}
|
||||||
size="default"
|
size="medium"
|
||||||
variant="header"
|
variant="header"
|
||||||
className={tcls('h-9 px-2.5')}
|
className={tcls('h-9 px-2.5')}
|
||||||
label={
|
label={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{t(language, 'ai_chat_ask', getAIChatName(trademark))}
|
{t(language, 'ai_chat_ask', assistant.label)}
|
||||||
<KeyboardShortcut keys={['mod', 'j']} className="border-tint-11 text-tint-1" />
|
<KeyboardShortcut keys={['mod', 'i']} className="border-tint-11 text-tint-1" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => assistant.open()}
|
||||||
if (chat.opened) {
|
>
|
||||||
chatController.close();
|
{showLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null}
|
||||||
} else {
|
</Button>
|
||||||
chatController.open();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function AIChatInput(props: {
|
|||||||
}, [disabled, loading]);
|
}, [disabled, loading]);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'mod+j',
|
'mod+i',
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -54,7 +54,7 @@ export function AIChatInput(props: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
@@ -99,10 +99,12 @@ export function AIChatInput(props: {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{!disabled ? (
|
||||||
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
<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']} />
|
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2">
|
) : null}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
<div className="flex flex-col gap-3 p-2">
|
<div className="flex flex-col gap-3 p-2">
|
||||||
@@ -126,11 +128,11 @@ export function AIChatInput(props: {
|
|||||||
}
|
}
|
||||||
arrow
|
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">
|
<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')}
|
{t(language, 'ai_chat_context_badge')}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
<span>{t(language, 'ai_chat_context_title')}</span>
|
<span className="leading-none">{t(language, 'ai_chat_context_title')}</span>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { AIMessageRole } from '@gitbook/api';
|
import { AIMessageRole } from '@gitbook/api';
|
||||||
import type React from 'react';
|
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 { AIResponseFeedback } from './AIResponseFeedback';
|
||||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ export function AIChatMessages(props: {
|
|||||||
{message.content ? message.content : null}
|
{message.content ? message.content : null}
|
||||||
|
|
||||||
{isLastMessage && chat.loading ? (
|
{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) => (
|
{Array.from({ length: 7 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
@@ -60,13 +61,18 @@ export function AIChatMessages(props: {
|
|||||||
|
|
||||||
{isLastMessage ? (
|
{isLastMessage ? (
|
||||||
<>
|
<>
|
||||||
{!chat.loading && !chat.error && chat.query && chat.responseId && (
|
{!chat.loading &&
|
||||||
|
!chat.error &&
|
||||||
|
chat.query &&
|
||||||
|
chat.responseId &&
|
||||||
|
chat.pendingTools.length === 0 ? (
|
||||||
<AIResponseFeedback
|
<AIResponseFeedback
|
||||||
responseId={chat.responseId}
|
responseId={chat.responseId}
|
||||||
query={chat.query}
|
query={chat.query}
|
||||||
className="-ml-1 -mt-4"
|
className="-ml-1 -mt-4"
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
|
<AIChatToolConfirmations chat={chat} />
|
||||||
<AIChatFollowupSuggestions
|
<AIChatFollowupSuggestions
|
||||||
chat={chat}
|
chat={chat}
|
||||||
chatController={chatController}
|
chatController={chatController}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import type { AIChatController } from '../AI/useAIChat';
|
import type { AIChatController } from '../AI';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
|
|
||||||
export default function AIChatSuggestedQuestions(props: { chatController: AIChatController }) {
|
export default function AIChatSuggestedQuestions(props: { chatController: AIChatController }) {
|
||||||
@@ -17,7 +17,6 @@ export default function AIChatSuggestedQuestions(props: { chatController: AIChat
|
|||||||
{DEFAULT_SUGGESTED_QUESTIONS.map((question, index) => (
|
{DEFAULT_SUGGESTED_QUESTIONS.map((question, index) => (
|
||||||
<Button
|
<Button
|
||||||
key={question}
|
key={question}
|
||||||
label={question}
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="medium"
|
size="medium"
|
||||||
className="max-w-full animate-[present_500ms_both] whitespace-normal"
|
className="max-w-full animate-[present_500ms_both] whitespace-normal"
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { AIChatState } from '../AI';
|
||||||
|
import { Button } from '../primitives';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display buttons to confirm tool calls.
|
||||||
|
*/
|
||||||
|
export function AIChatToolConfirmations(props: {
|
||||||
|
chat: AIChatState;
|
||||||
|
}) {
|
||||||
|
const { chat } = props;
|
||||||
|
|
||||||
|
if (chat.pendingTools.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||||
|
{chat.pendingTools.map((tool, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
onClick={() => {
|
||||||
|
tool.confirm();
|
||||||
|
}}
|
||||||
|
label={tool.label}
|
||||||
|
className="whitespace-normal! max-w-full animate-[present_500ms_both] text-left ring-1 ring-tint-subtle"
|
||||||
|
size="medium"
|
||||||
|
variant="primary"
|
||||||
|
icon={tool.icon}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||||
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
import { AdClassicRendering } from './AdClassicRendering';
|
import { AdClassicRendering } from './AdClassicRendering';
|
||||||
import { AdCoverRendering } from './AdCoverRendering';
|
import { AdCoverRendering } from './AdCoverRendering';
|
||||||
import { AdPixels } from './AdPixels';
|
import { AdPixels } from './AdPixels';
|
||||||
@@ -40,6 +41,7 @@ interface FetchPlaceholderAdOptions {
|
|||||||
* and properly access user-agent and IP.
|
* and properly access user-agent and IP.
|
||||||
*/
|
*/
|
||||||
export async function renderAd(options: FetchAdOptions) {
|
export async function renderAd(options: FetchAdOptions) {
|
||||||
|
return traceErrorOnly('Ads.renderAd', async () => {
|
||||||
const [context, result] = await Promise.all([
|
const [context, result] = await Promise.all([
|
||||||
getServerActionBaseContext(),
|
getServerActionBaseContext(),
|
||||||
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
|
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
|
||||||
@@ -74,6 +76,7 @@ export async function renderAd(options: FetchAdOptions) {
|
|||||||
),
|
),
|
||||||
insightsAd,
|
insightsAd,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAd({
|
async function fetchAd({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { getDataOrNull } from '@/lib/data';
|
import { getDataOrNull } from '@/lib/data';
|
||||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||||
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return true if a change-request has been updated.
|
* Return true if a change-request has been updated.
|
||||||
@@ -11,6 +12,7 @@ export async function hasContentBeenUpdated(props: {
|
|||||||
changeRequestId: string;
|
changeRequestId: string;
|
||||||
revisionId: string;
|
revisionId: string;
|
||||||
}) {
|
}) {
|
||||||
|
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
|
||||||
const context = await getServerActionBaseContext();
|
const context = await getServerActionBaseContext();
|
||||||
const changeRequest = await getDataOrNull(
|
const changeRequest = await getDataOrNull(
|
||||||
context.dataFetcher.getChangeRequest({
|
context.dataFetcher.getChangeRequest({
|
||||||
@@ -22,4 +24,5 @@ export async function hasContentBeenUpdated(props: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return changeRequest.revision !== props.revisionId;
|
return changeRequest.revision !== props.revisionId;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
|||||||
</div>
|
</div>
|
||||||
<CopyCodeButton
|
<CopyCodeButton
|
||||||
codeId={id}
|
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
|
<pre
|
||||||
className={tcls(
|
className={tcls(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/primitives';
|
||||||
import { t, useLanguage } from '@/intl/client';
|
import { t, useLanguage } from '@/intl/client';
|
||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
@@ -41,9 +42,14 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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')}
|
{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
|
// "Parser" language does not exist in Shiki, but it's used in GitBook
|
||||||
// The closest language is "Blade"
|
// The closest language is "Blade"
|
||||||
parser: 'blade',
|
parser: 'blade',
|
||||||
|
|
||||||
|
// From GitBook App we receive "objectivec" instead of "objective-c"
|
||||||
|
objectivec: 'objective-c',
|
||||||
};
|
};
|
||||||
|
|
||||||
function checkIsBundledLanguage(lang: string): lang is BundledLanguage {
|
function checkIsBundledLanguage(lang: string): lang is BundledLanguage {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||||
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
import type { RenderIntegrationUI } from '@gitbook/api';
|
import type { RenderIntegrationUI } from '@gitbook/api';
|
||||||
import { ContentKitOutput } from '@gitbook/react-contentkit';
|
import { ContentKitOutput } from '@gitbook/react-contentkit';
|
||||||
import { contentKitServerContext } from './contentkit';
|
import { contentKitServerContext } from './contentkit';
|
||||||
@@ -19,6 +20,7 @@ export async function renderIntegrationUi({
|
|||||||
};
|
};
|
||||||
request: RenderIntegrationUI;
|
request: RenderIntegrationUI;
|
||||||
}) {
|
}) {
|
||||||
|
return traceErrorOnly('DocumentView.renderIntegrationUi', async () => {
|
||||||
const serverAction = await getServerActionBaseContext();
|
const serverAction = await getServerActionBaseContext();
|
||||||
const output = await fetchSafeIntegrationUI(serverAction, {
|
const output = await fetchSafeIntegrationUI(serverAction, {
|
||||||
integrationName: renderContext.integrationName,
|
integrationName: renderContext.integrationName,
|
||||||
@@ -35,4 +37,5 @@ export async function renderIntegrationUi({
|
|||||||
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
|
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
|
||||||
output: output.data,
|
output: output.data,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-column-preview-body {
|
.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 {
|
.openapi-column-preview pre {
|
||||||
|
|||||||
@@ -26,18 +26,15 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { reusableContent } = resolved;
|
const { reusableContent } = resolved;
|
||||||
if (
|
if (!reusableContent) {
|
||||||
!reusableContent ||
|
|
||||||
!('document' in reusableContent.revisionReusableContent) ||
|
|
||||||
!reusableContent.revisionReusableContent.document
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = await getDataOrNull(
|
const document = await getDataOrNull(
|
||||||
dataFetcher.getDocument({
|
dataFetcher.getRevisionReusableContentDocument({
|
||||||
spaceId: reusableContent.context.space.id,
|
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 {
|
import {
|
||||||
type ContentRef,
|
type ContentRef,
|
||||||
type DocumentTableViewCards,
|
type DocumentTableViewCards,
|
||||||
SiteInsightsLinkPosition,
|
SiteInsightsLinkPosition,
|
||||||
} from '@gitbook/api';
|
} 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 { RecordColumnValue } from './RecordColumnValue';
|
||||||
import type { TableRecordKV, TableViewProps } from './Table';
|
import type { TableRecordKV, TableViewProps } from './Table';
|
||||||
import { RecordCardStyles } from './styles';
|
import { RecordCardStyles } from './styles';
|
||||||
import { getRecordValue } from './utils';
|
import { getRecordCardCovers } from './utils';
|
||||||
|
|
||||||
export async function RecordCard(
|
export async function RecordCard(
|
||||||
props: TableViewProps<DocumentTableViewCards> & {
|
props: TableViewProps<DocumentTableViewCards> & {
|
||||||
@@ -21,25 +19,21 @@ export async function RecordCard(
|
|||||||
) {
|
) {
|
||||||
const { view, record, context, block, isOffscreen } = props;
|
const { view, record, context, block, isOffscreen } = props;
|
||||||
|
|
||||||
const coverFile = view.coverDefinition
|
const { dark, light } = getRecordCardCovers(record[1], view);
|
||||||
? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0]
|
|
||||||
: null;
|
|
||||||
const targetRef = view.targetDefinition
|
const targetRef = view.targetDefinition
|
||||||
? (record[1].values[view.targetDefinition] as ContentRef)
|
? (record[1].values[view.targetDefinition] as ContentRef)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [cover, target] = await Promise.all([
|
const [lightCover, darkCover, target] = await Promise.all([
|
||||||
coverFile && context.contentContext
|
light && context.contentContext ? resolveContentRef(light, context.contentContext) : null,
|
||||||
? resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext)
|
dark && context.contentContext ? resolveContentRef(dark, context.contentContext) : null,
|
||||||
: null,
|
|
||||||
targetRef && context.contentContext
|
targetRef && context.contentContext
|
||||||
? resolveContentRef(targetRef, context.contentContext)
|
? resolveContentRef(targetRef, context.contentContext)
|
||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const coverIsSquareOrPortrait =
|
const darkCoverIsSquareOrPortrait = isSquareOrPortrait(darkCover);
|
||||||
cover?.file?.dimensions &&
|
const lightCoverIsSquareOrPortrait = isSquareOrPortrait(lightCover);
|
||||||
cover.file?.dimensions?.width / cover.file?.dimensions?.height <= 1;
|
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<div
|
<div
|
||||||
@@ -63,23 +57,32 @@ export async function RecordCard(
|
|||||||
// On mobile, check if we can display the cover responsively or not:
|
// 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 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
|
// - If the file is square or portrait, we display it left with 40% of the card width
|
||||||
coverIsSquareOrPortrait
|
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
|
||||||
? [
|
? [
|
||||||
'grid-cols-[40%__1fr]',
|
lightCoverIsSquareOrPortrait
|
||||||
'min-[432px]:grid-cols-none',
|
? 'grid-cols-[40%__1fr] min-[432px]:grid-cols-none min-[432px]:grid-rows-[auto_1fr]'
|
||||||
'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]'
|
: 'grid-rows-[auto_1fr]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{cover ? (
|
{lightCover ? (
|
||||||
<Image
|
<Image
|
||||||
alt="Cover"
|
alt="Cover"
|
||||||
sources={{
|
sources={{
|
||||||
light: {
|
light: {
|
||||||
src: cover.href,
|
src: lightCover.href,
|
||||||
size: cover.file?.dimensions,
|
size: lightCover.file?.dimensions,
|
||||||
},
|
},
|
||||||
|
dark: darkCover
|
||||||
|
? {
|
||||||
|
src: darkCover.href,
|
||||||
|
size: darkCover.file?.dimensions,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}}
|
}}
|
||||||
sizes={[
|
sizes={[
|
||||||
{
|
{
|
||||||
@@ -92,8 +95,15 @@ export async function RecordCard(
|
|||||||
'w-full',
|
'w-full',
|
||||||
'h-full',
|
'h-full',
|
||||||
'object-cover',
|
'object-cover',
|
||||||
coverIsSquareOrPortrait
|
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
|
||||||
? ['min-[432px]:h-auto', 'min-[432px]:aspect-video']
|
? [
|
||||||
|
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']
|
: ['h-auto', 'aspect-video']
|
||||||
)}
|
)}
|
||||||
priority={isOffscreen ? 'lazy' : 'high'}
|
priority={isOffscreen ? 'lazy' : 'high'}
|
||||||
@@ -167,3 +177,16 @@ export async function RecordCard(
|
|||||||
|
|
||||||
return <div className={tcls(RecordCardStyles)}>{body}</div>;
|
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
|
<StyledLink
|
||||||
key={index}
|
key={index}
|
||||||
href={ref.href}
|
href={ref.href}
|
||||||
target="_blank"
|
|
||||||
className="flex flex-row items-center gap-2"
|
className="flex flex-row items-center gap-2"
|
||||||
insights={
|
insights={
|
||||||
ref.file
|
ref.file
|
||||||
@@ -329,6 +328,50 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
</Tag>
|
</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:
|
default:
|
||||||
assertNever(definition);
|
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';
|
import assertNever from 'assert-never';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +19,57 @@ export function getRecordValue<T extends number | string | boolean | string[] |
|
|||||||
return record.values[definitionId];
|
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.
|
* Get the text alignment for a column.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ export function Header(props: {
|
|||||||
'lg:max-w-lg',
|
'lg:max-w-lg',
|
||||||
'lg:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
|
'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)
|
'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',
|
'md:mr-auto',
|
||||||
'order-last',
|
'order-last',
|
||||||
'md:order-[unset]',
|
'md:order-[unset]',
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ export async function HeaderLogo(props: HeaderLogoProps) {
|
|||||||
'lg:max-w-64',
|
'lg:max-w-64',
|
||||||
'lg:site-header-none:page-no-toc:max-w-56',
|
'lg:site-header-none:page-no-toc:max-w-56',
|
||||||
'max-h-10',
|
'max-h-10',
|
||||||
'lg:max-h-12',
|
|
||||||
'h-full',
|
'h-full',
|
||||||
'w-auto'
|
'w-full',
|
||||||
|
'object-contain',
|
||||||
|
'object-left'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,23 +1,95 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
window.GitBook = {
|
const gitbookGlobal: GitBookGlobal = {
|
||||||
events: new Map(),
|
|
||||||
addEventListener: (event, callback) => {
|
addEventListener: (event, callback) => {
|
||||||
const handlers = window.GitBook?.events.get(event) ?? [];
|
const handlers = events.get(event) ?? [];
|
||||||
handlers.push(callback);
|
handlers.push(callback);
|
||||||
window.GitBook?.events.set(event, handlers);
|
events.set(event, handlers);
|
||||||
},
|
},
|
||||||
removeEventListener: (event, callback) => {
|
removeEventListener: (event, callback) => {
|
||||||
const handlers = window.GitBook?.events.get(event) ?? [];
|
const handlers = events.get(event) ?? [];
|
||||||
const index = handlers.indexOf(callback);
|
const index = handlers.indexOf(callback);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
handlers.splice(index, 1);
|
handlers.splice(index, 1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
registerTool: (tool) => {
|
||||||
|
integrationsAssistantTools.setState((state) => ({
|
||||||
|
tools: [...state.tools, tool],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
registerAssistant: (assistant) => {
|
||||||
|
const id = window.crypto.randomUUID();
|
||||||
|
// Add assistant with ready=false until the integration signals readiness
|
||||||
|
integrationAssistants.setState(
|
||||||
|
(state) => [
|
||||||
|
...state,
|
||||||
|
{ ...assistant, id, ui: assistant.ui ?? true, mode: 'overlay', ready: false },
|
||||||
|
],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const ready = () => {
|
||||||
|
integrationAssistants.setState(
|
||||||
|
(state) =>
|
||||||
|
state.map((assistant) =>
|
||||||
|
assistant.id === id ? { ...assistant, ready: true } : assistant
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dispose = () => {
|
||||||
|
integrationAssistants.setState(
|
||||||
|
(state) => state.filter((assistant) => assistant.id !== id),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const registration = (() => dispose()) as ((this: void) => void) & {
|
||||||
|
ready: () => void;
|
||||||
|
dispose: () => void;
|
||||||
|
};
|
||||||
|
registration.ready = ready;
|
||||||
|
registration.dispose = dispose;
|
||||||
|
return registration;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
window.GitBook = gitbookGlobal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current state of the assistants.
|
||||||
|
*/
|
||||||
|
export function useIntegrationAssistants(): Array<Assistant> {
|
||||||
|
return zustand.useStore(integrationAssistants);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +105,6 @@ export function LoadIntegrations() {
|
|||||||
/**
|
/**
|
||||||
* Client function to dispatch a GitBook event.
|
* Client function to dispatch a GitBook event.
|
||||||
*/
|
*/
|
||||||
function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: any[]) {
|
function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: unknown[]) {
|
||||||
const handlers = window.GitBook?.events.get(type) || [];
|
events.get(type)?.forEach((handler) => handler(...args));
|
||||||
handlers.forEach((handler) => handler(...args));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,33 +32,28 @@ export function PageAside(props: {
|
|||||||
withPageFeedback: boolean;
|
withPageFeedback: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { page, document, withPageFeedback, context } = props;
|
const { page, document, withPageFeedback, context } = props;
|
||||||
const { customization, site, space } = context;
|
const { customization, site } = context;
|
||||||
const language = getSpaceLanguage(customization);
|
|
||||||
|
|
||||||
const pdfHref = context.linker.toPathInSpace(
|
|
||||||
`~gitbook/pdf?${getPDFURLSearchParams({
|
|
||||||
page: page.id,
|
|
||||||
only: true,
|
|
||||||
limit: 100,
|
|
||||||
}).toString()}`
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'group/aside',
|
'group/aside',
|
||||||
'hidden',
|
'hidden',
|
||||||
|
'pt-8',
|
||||||
|
|
||||||
'xl:flex',
|
'xl:flex',
|
||||||
'xl:max-3xl:chat-open:hidden',
|
'xl:max-3xl:chat-open:hidden',
|
||||||
'xl:max-3xl:chat-open:opacity-0',
|
'xl:max-3xl:chat-open:opacity-0',
|
||||||
'max-w-56',
|
'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:w-56',
|
||||||
'xl:max-3xl:chat-open:max-w-0',
|
'xl:max-3xl:chat-open:max-w-0',
|
||||||
'xl:max-3xl:chat-open:ml-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',
|
'motion-safe:transition-discrete',
|
||||||
|
|
||||||
'flex-col',
|
|
||||||
'basis-56',
|
'basis-56',
|
||||||
'xl:ml-12',
|
'xl:ml-12',
|
||||||
'grow-0',
|
'grow-0',
|
||||||
@@ -68,6 +63,7 @@ export function PageAside(props: {
|
|||||||
'text-tint',
|
'text-tint',
|
||||||
'contrast-more:text-tint-strong',
|
'contrast-more:text-tint-strong',
|
||||||
'sticky',
|
'sticky',
|
||||||
|
|
||||||
// Without header
|
// Without header
|
||||||
'lg:top-0',
|
'lg:top-0',
|
||||||
'lg:max-h-screen',
|
'lg:max-h-screen',
|
||||||
@@ -80,36 +76,77 @@ export function PageAside(props: {
|
|||||||
'lg:site-header-sections:top-27',
|
'lg:site-header-sections:top-27',
|
||||||
'lg:site-header-sections:max-h-[calc(100vh-6.75rem)]',
|
'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
|
// 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:z-10',
|
||||||
'xl:max-2xl:page-api-block:fixed',
|
'xl:max-2xl:page-api-block:fixed',
|
||||||
'xl:max-2xl:page-api-block:right-8',
|
'xl:max-2xl:page-api-block:right-8',
|
||||||
'xl:max-2xl:page-api-block:w-56',
|
'xl:max-2xl:page-api-block:w-60',
|
||||||
'xl:max-2xl:page-api-block:bg-tint/9',
|
'xl:max-2xl:page-api-block:max-w-60',
|
||||||
'xl:max-2xl:contrast-more:page-api-block:bg-tint',
|
'xl:max-2xl:page-api-block:pb-8',
|
||||||
'xl:max-2xl:page-api-block:backdrop-blur-lg',
|
'xl:max-2xl:page-api-block:pt-10',
|
||||||
|
'xl:max-2xl:[body:has(.openapi-block):has(.page-has-ancestors)_&]:pt-6.5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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',
|
||||||
'xl:max-2xl:page-api-block:border-tint',
|
'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-lg',
|
||||||
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
|
'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:dark:page-api-block:hover:shadow-tint-1/1',
|
||||||
'xl:max-2xl:page-api-block:rounded-md',
|
'xl:max-2xl:page-api-block:not-hover:*:hidden'
|
||||||
'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'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<PageAsideHeader context={context} />
|
||||||
{page.layout.outline ? (
|
{page.layout.outline ? (
|
||||||
<>
|
<div className="overflow-y-auto border-tint xl:max-2xl:page-api-block:border-t">
|
||||||
|
{document ? (
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<PageAsideSections document={document} context={context} />
|
||||||
|
</React.Suspense>
|
||||||
|
) : null}
|
||||||
|
<PageAsideActions
|
||||||
|
page={page}
|
||||||
|
withPageFeedback={withPageFeedback}
|
||||||
|
context={context}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{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
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'hidden',
|
'hidden',
|
||||||
'xl:max-2xl:page-api-block:flex',
|
'xl:max-2xl:page-api-block:flex!',
|
||||||
'text-xs',
|
'text-xs',
|
||||||
'tracking-wide',
|
'tracking-wide',
|
||||||
'font-semibold',
|
'font-semibold',
|
||||||
'uppercase',
|
'uppercase',
|
||||||
|
'px-2',
|
||||||
|
'py-1.5',
|
||||||
|
|
||||||
'flex-row',
|
'flex-row',
|
||||||
'items-center',
|
'items-center',
|
||||||
@@ -118,45 +155,41 @@ export function PageAside(props: {
|
|||||||
>
|
>
|
||||||
<Icon icon="block-quote" className={tcls('size-3')} />
|
<Icon icon="block-quote" className={tcls('size-3')} />
|
||||||
{t(language, 'on_this_page')}
|
{t(language, 'on_this_page')}
|
||||||
<Icon
|
<Icon icon="chevron-down" className={tcls('size-3', 'opacity-6', 'ml-auto')} />
|
||||||
icon="chevron-down"
|
|
||||||
className={tcls(
|
|
||||||
'size-3',
|
|
||||||
'opacity-6',
|
|
||||||
'ml-auto',
|
|
||||||
'xl:max-2xl:page-api-block:group-hover/aside:hidden'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
);
|
||||||
className={tcls(
|
}
|
||||||
'overflow-y-auto',
|
|
||||||
'overflow-x-visible',
|
|
||||||
|
|
||||||
'flex',
|
async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) {
|
||||||
'flex-col',
|
const { document, context } = props;
|
||||||
'shrink',
|
|
||||||
'pb-12',
|
|
||||||
|
|
||||||
'sticky',
|
const sections = await getDocumentSections(context, document);
|
||||||
'lg:top:0',
|
|
||||||
'lg:site-header:top-16',
|
|
||||||
'lg:site-header-sections:top-27',
|
|
||||||
|
|
||||||
'gap-6',
|
return sections.length > 1 ? (
|
||||||
'pt-10',
|
<div className="-mt-8 pt-8 pb-5 empty:hidden xl:max-2xl:page-api-block:mt-0 xl:max-2xl:page-api-block:p-2">
|
||||||
|
<ScrollSectionsList sections={sections} />
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
'xl:max-2xl:page-api-block:py-0',
|
function PageAsideActions(props: {
|
||||||
// Hide it for api page, until hovered
|
withPageFeedback: boolean;
|
||||||
'xl:max-2xl:page-api-block:hidden',
|
context: GitBookSiteContext;
|
||||||
'xl:max-2xl:page-api-block:group-hover/aside:flex'
|
page: RevisionPageDocument;
|
||||||
)}
|
}) {
|
||||||
>
|
const { page, withPageFeedback, context } = props;
|
||||||
{document ? (
|
const { customization, space } = context;
|
||||||
<React.Suspense fallback={null}>
|
const language = getSpaceLanguage(customization);
|
||||||
<PageAsideSections document={document} context={context} />
|
|
||||||
</React.Suspense>
|
const pdfHref = context.linker.toPathInSpace(
|
||||||
) : null}
|
`~gitbook/pdf?${getPDFURLSearchParams({
|
||||||
|
page: page.id,
|
||||||
|
only: true,
|
||||||
|
limit: 100,
|
||||||
|
}).toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'flex',
|
'flex',
|
||||||
@@ -166,9 +199,9 @@ export function PageAside(props: {
|
|||||||
'border-t',
|
'border-t',
|
||||||
'first:border-none',
|
'first:border-none',
|
||||||
'border-tint-subtle',
|
'border-tint-subtle',
|
||||||
'py-6',
|
'py-5',
|
||||||
'first:pt-0',
|
'first:pt-0',
|
||||||
'xl:max-2xl:page-api-block:px-3',
|
'xl:max-2xl:page-api-block:p-5',
|
||||||
'empty:hidden'
|
'empty:hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -223,27 +256,31 @@ export function PageAside(props: {
|
|||||||
'py-2'
|
'py-2'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
|
||||||
icon="file-pdf"
|
|
||||||
className={tcls('size-4', 'mr-1.5')}
|
|
||||||
/>
|
|
||||||
{t(language, 'pdf_download')}
|
{t(language, 'pdf_download')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</>
|
}
|
||||||
) : null}
|
|
||||||
|
async function PageAsideFooter(props: { context: GitBookSiteContext }) {
|
||||||
|
const { context } = props;
|
||||||
|
const { customization, site, space } = context;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
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',
|
'sticky bottom-0 z-10 mt-auto flex flex-col',
|
||||||
'xl:max-2xl:page-api-block:bg-transparent'
|
'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',
|
||||||
|
'py-4'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Mode Switcher */}
|
{/* Mode Switcher */}
|
||||||
{customization.themes.toggeable ? (
|
{customization.themes.toggeable ? (
|
||||||
<div className="mt-4 flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<ThemeToggler />
|
<ThemeToggler />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
@@ -260,18 +297,9 @@ export function PageAside(props: {
|
|||||||
style={site?.ads ? 'mt-4' : undefined}
|
style={site?.ads ? 'mt-4' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) {
|
|
||||||
const { document, context } = props;
|
|
||||||
|
|
||||||
const sections = await getDocumentSections(context, document);
|
|
||||||
|
|
||||||
return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGitSyncName(space: Space): string {
|
function getGitSyncName(space: Space): string {
|
||||||
if (space.gitSync?.installationProvider === 'github') {
|
if (space.gitSync?.installationProvider === 'github') {
|
||||||
return 'GitHub';
|
return 'GitHub';
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={tcls('sidebar-list-line:border-l', 'border-tint-subtle')}>
|
<ul className="flex flex-col border-tint-subtle sidebar-list-line:border-l">
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<motion.li
|
<motion.li
|
||||||
key={section.id}
|
key={section.id}
|
||||||
@@ -50,6 +50,8 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
|||||||
'relative',
|
'relative',
|
||||||
'h-fit',
|
'h-fit',
|
||||||
'mt-2',
|
'mt-2',
|
||||||
|
'first:mt-0',
|
||||||
|
'mb-0.5',
|
||||||
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0']
|
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0']
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function PageBody(props: {
|
|||||||
<main
|
<main
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'relative min-w-0 flex-1',
|
'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.
|
// Allow words to break if they are too long.
|
||||||
'break-anywhere',
|
'break-anywhere',
|
||||||
pageWidthWide ? 'page-width-wide 2xl:px-8' : 'page-width-default',
|
pageWidthWide ? 'page-width-wide 2xl:px-8' : 'page-width-default',
|
||||||
|
|||||||
@@ -43,13 +43,16 @@ export async function PageCover(props: {
|
|||||||
|
|
||||||
const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => {
|
const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => {
|
||||||
if (!resolved && returnNull) return;
|
if (!resolved && returnNull) return;
|
||||||
const [attrs, size] = await Promise.all([
|
// If we don't have a size for the image, we want to calculate it so that we can use srcSet
|
||||||
getImageAttributes({
|
const size =
|
||||||
|
resolved?.file?.dimensions ??
|
||||||
|
(await context.imageResizer?.getImageSize(resolved?.href || defaultPageCover.src, {}));
|
||||||
|
const attrs = await getImageAttributes({
|
||||||
sizes,
|
sizes,
|
||||||
source: resolved
|
source: resolved
|
||||||
? {
|
? {
|
||||||
src: resolved.href,
|
src: resolved.href,
|
||||||
size: resolved.file?.dimensions ?? null,
|
size: size ?? null,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
src: defaultPageCover.src,
|
src: defaultPageCover.src,
|
||||||
@@ -60,14 +63,10 @@ export async function PageCover(props: {
|
|||||||
},
|
},
|
||||||
quality: 100,
|
quality: 100,
|
||||||
resize: context.imageResizer ?? false,
|
resize: context.imageResizer ?? false,
|
||||||
}),
|
});
|
||||||
context.imageResizer
|
|
||||||
?.getImageSize(resolved?.href || defaultPageCover.src, {})
|
|
||||||
.then((size) => size ?? undefined),
|
|
||||||
]);
|
|
||||||
return {
|
return {
|
||||||
...attrs,
|
...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}>
|
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||||
<img
|
<img
|
||||||
src={imgs.light.src}
|
src={imgs.light.src}
|
||||||
|
srcSet={imgs.light.srcSet}
|
||||||
|
sizes={imgs.light.sizes}
|
||||||
fetchPriority="high"
|
fetchPriority="high"
|
||||||
alt="Page cover"
|
alt="Page cover"
|
||||||
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
|
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 && (
|
{imgs.dark && (
|
||||||
<img
|
<img
|
||||||
src={imgs.dark.src}
|
src={imgs.dark.src}
|
||||||
|
srcSet={imgs.dark.srcSet}
|
||||||
|
sizes={imgs.dark.sizes}
|
||||||
fetchPriority="low"
|
fetchPriority="low"
|
||||||
alt="Page cover"
|
alt="Page cover"
|
||||||
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
|
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown';
|
import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown';
|
||||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
|
||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
import type { AncestorRevisionPage } from '@/lib/pages';
|
import type { AncestorRevisionPage } from '@/lib/pages';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
@@ -20,13 +19,9 @@ export async function PageHeader(props: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const withAIChat = isAIChatEnabled(context);
|
const pageActions = context.customization.pageActions;
|
||||||
|
|
||||||
const pageActions = context.customization.pageActions ?? {
|
const hasAncestors = ancestors.length > 0;
|
||||||
// TODO: After 25/07/2025, we can remove this default values as the cache will be updated
|
|
||||||
markdown: true,
|
|
||||||
externalAI: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
@@ -37,27 +32,23 @@ export async function PageHeader(props: {
|
|||||||
'mb-6',
|
'mb-6',
|
||||||
'space-y-3',
|
'space-y-3',
|
||||||
'page-api-block:ml-0',
|
'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 &&
|
{page.layout.tableOfContents ? (
|
||||||
// Show page actions if *any* of the actions are enabled
|
// Show page actions if *any* of the actions are enabled
|
||||||
(withAIChat || pageActions.markdown || pageActions.externalAI) ? (
|
|
||||||
<div
|
|
||||||
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'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AIActionsDropdown
|
<AIActionsDropdown
|
||||||
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
|
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
|
||||||
withAIChat={withAIChat}
|
|
||||||
trademark={context.customization.trademark.enabled}
|
|
||||||
actions={pageActions}
|
actions={pageActions}
|
||||||
|
className={tcls(
|
||||||
|
'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
|
||||||
|
hasAncestors ? '-my-1.5' : '-mt-3 xs:mt-2'
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{ancestors.length > 0 && (
|
|
||||||
|
{hasAncestors && (
|
||||||
<nav aria-label="Breadcrumb">
|
<nav aria-label="Breadcrumb">
|
||||||
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}>
|
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}>
|
||||||
{ancestors.map((breadcrumb, index) => {
|
{ancestors.map((breadcrumb, index) => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { t, tString } from '@/intl/translate';
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { Button } from '../primitives';
|
import { Button, ButtonGroup } from '../primitives';
|
||||||
|
|
||||||
const MAX_COMMENT_LENGTH = 512;
|
const MAX_COMMENT_LENGTH = 512;
|
||||||
|
|
||||||
@@ -61,8 +61,7 @@ export function PageFeedbackForm(props: {
|
|||||||
<div className={tcls('flex flex-col gap-3 text-sm', className)}>
|
<div className={tcls('flex flex-col gap-3 text-sm', className)}>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p>{t(languages, 'was_this_helpful')}</p>
|
<p>{t(languages, 'was_this_helpful')}</p>
|
||||||
<div className="rounded-full border border-tint-subtle bg-tint-base contrast-more:border-tint-12">
|
<ButtonGroup className="rounded-full border border-tint bg-tint-base">
|
||||||
<div className="flex">
|
|
||||||
<RatingButton
|
<RatingButton
|
||||||
rating={PageFeedbackRating.Good}
|
rating={PageFeedbackRating.Good}
|
||||||
label={tString(languages, 'was_this_helpful_positive')}
|
label={tString(languages, 'was_this_helpful_positive')}
|
||||||
@@ -84,8 +83,7 @@ export function PageFeedbackForm(props: {
|
|||||||
active={rating === PageFeedbackRating.Bad}
|
active={rating === PageFeedbackRating.Bad}
|
||||||
disabled={rating !== undefined}
|
disabled={rating !== undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{rating ? (
|
{rating ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -94,7 +92,7 @@ export function PageFeedbackForm(props: {
|
|||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
name="comment"
|
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')}
|
placeholder={tString(languages, 'was_this_helpful_comment')}
|
||||||
aria-label={tString(languages, 'was_this_helpful_comment')}
|
aria-label={tString(languages, 'was_this_helpful_comment')}
|
||||||
onChange={(e) => setComment(e.target.value)}
|
onChange={(e) => setComment(e.target.value)}
|
||||||
@@ -138,7 +136,7 @@ function RatingButton(
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
} & ButtonHTMLAttributes<HTMLButtonElement>
|
} & ButtonHTMLAttributes<HTMLButtonElement>
|
||||||
) {
|
) {
|
||||||
const { rating, label, onClick, active, ...attr } = props;
|
const { rating, label, onClick, active, disabled, ...attr } = props;
|
||||||
|
|
||||||
const ratingIcon =
|
const ratingIcon =
|
||||||
{
|
{
|
||||||
@@ -148,23 +146,18 @@ function RatingButton(
|
|||||||
}[rating] ?? null;
|
}[rating] ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
className={tcls(
|
className="first:pl-2.5 last:pr-2.5"
|
||||||
'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'
|
|
||||||
)}
|
|
||||||
type="button"
|
type="button"
|
||||||
{...attr}
|
variant="blank"
|
||||||
aria-label={label}
|
label={label}
|
||||||
title={label}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
active={active}
|
||||||
{ratingIcon}
|
disabled={disabled}
|
||||||
</button>
|
iconOnly
|
||||||
|
{...attr}
|
||||||
|
icon={ratingIcon}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type React from 'react';
|
|||||||
import { TranslateContext } from '@/intl/client';
|
import { TranslateContext } from '@/intl/client';
|
||||||
import type { TranslationLanguage } from '@/intl/translations';
|
import type { TranslationLanguage } from '@/intl/translations';
|
||||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||||
|
import { HashProvider } from '../hooks';
|
||||||
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
|
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
|
||||||
|
|
||||||
export function ClientContexts(props: {
|
export function ClientContexts(props: {
|
||||||
@@ -16,7 +17,9 @@ export function ClientContexts(props: {
|
|||||||
return (
|
return (
|
||||||
<TranslateContext.Provider value={language}>
|
<TranslateContext.Provider value={language}>
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<HashProvider>
|
||||||
<LoadingStateProvider>{children}</LoadingStateProvider>
|
<LoadingStateProvider>{children}</LoadingStateProvider>
|
||||||
|
</HashProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</TranslateContext.Provider>
|
</TranslateContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export function HighlightQuery(props: {
|
|||||||
query,
|
query,
|
||||||
text,
|
text,
|
||||||
highlight = [
|
highlight = [
|
||||||
|
'-z-1',
|
||||||
|
'relative',
|
||||||
'text-bold',
|
'text-bold',
|
||||||
'bg-primary',
|
'bg-primary',
|
||||||
'text-contrast-primary',
|
'text-contrast-primary',
|
||||||
@@ -33,7 +35,7 @@ export function HighlightQuery(props: {
|
|||||||
const matches = matchString(text, query);
|
const matches = matchString(text, query);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={tcls('whitespace-break-spaces')}>
|
<span className={tcls('relative z-2 whitespace-break-spaces')}>
|
||||||
{matches.map((entry, index) => (
|
{matches.map((entry, index) => (
|
||||||
<span key={index} className={tcls(entry.match ? highlight : null)}>
|
<span key={index} className={tcls(entry.match ? highlight : null)}>
|
||||||
{entry.text}
|
{entry.text}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CustomizationAIMode } from '@gitbook/api';
|
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import { useAI } from '../AI';
|
||||||
|
import { AIChatButton } from '../AIChat';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { Popover } from '../primitives';
|
import { Popover } from '../primitives';
|
||||||
@@ -16,10 +18,10 @@ import { SearchScopeToggle } from './SearchScopeToggle';
|
|||||||
import { useSearch } from './useSearch';
|
import { useSearch } from './useSearch';
|
||||||
|
|
||||||
interface SearchContainerProps {
|
interface SearchContainerProps {
|
||||||
spaceId: string;
|
siteSpaceId: string;
|
||||||
spaceTitle: string;
|
spaceTitle: string;
|
||||||
isMultiVariants: boolean;
|
isMultiVariants: boolean;
|
||||||
aiMode: CustomizationAIMode;
|
style: CustomizationSearchStyle;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +29,11 @@ interface SearchContainerProps {
|
|||||||
* Client component to render the search input and results.
|
* Client component to render the search input and results.
|
||||||
*/
|
*/
|
||||||
export function SearchContainer(props: SearchContainerProps) {
|
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 { assistants } = useAI();
|
||||||
const [state, setSearchState] = useSearch(withAIChat);
|
|
||||||
|
const [state, setSearchState] = useSearch();
|
||||||
const searchAsk = useSearchAskState();
|
const searchAsk = useSearchAskState();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
@@ -39,34 +42,43 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Derive open state from search state
|
const withAI = assistants.length > 0;
|
||||||
const open = state?.open ?? false;
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (state?.ask && aiMode === CustomizationAIMode.Assistant) {
|
if (initialRef.current) return;
|
||||||
// Close the pop-over when we switch to “ask” mode
|
if (assistants.length === 0) return;
|
||||||
setSearchState((prev) => (prev ? { ...prev, open: !state.ask } : prev));
|
if (state?.ask === undefined || state?.ask === null) return;
|
||||||
} else if (state?.ask && aiMode === CustomizationAIMode.None) {
|
|
||||||
// Rewrite ask to query when the site doesn't support AI
|
// Wait for the first assistant to be ready (built-ins are always ready)
|
||||||
setSearchState((prev) => (prev ? { ...prev, query: prev.ask, ask: null } : prev));
|
const first = assistants[0];
|
||||||
}
|
if (first?.ready === false) return;
|
||||||
}, [state?.ask, setSearchState, aiMode]);
|
|
||||||
|
initialRef.current = true;
|
||||||
|
|
||||||
|
// For simplicity we're only triggering the first assistant
|
||||||
|
first.open(state?.ask ?? undefined);
|
||||||
|
}, [state?.ask, assistants.length, assistants, assistants[0]?.ready]);
|
||||||
|
|
||||||
const onClose = React.useCallback(
|
const onClose = React.useCallback(
|
||||||
async (to?: string) => {
|
async (to?: string) => {
|
||||||
if (state) {
|
setSearchState((prev) =>
|
||||||
await setSearchState({
|
prev
|
||||||
...state,
|
? {
|
||||||
|
...prev,
|
||||||
open: false,
|
open: false,
|
||||||
query: state.query === '' ? null : state.query,
|
query: prev.query === '' ? null : prev.query,
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
if (to) {
|
if (to) {
|
||||||
router.push(to);
|
router.push(to);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state, setSearchState, router]
|
[setSearchState, router]
|
||||||
);
|
);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
@@ -79,20 +91,20 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onOpen = React.useCallback(() => {
|
const onOpen = React.useCallback(() => {
|
||||||
if (open) {
|
if (state?.open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSearchState((prev) => ({
|
setSearchState((prev) => ({
|
||||||
ask: aiMode === CustomizationAIMode.Assistant && prev ? prev.ask : null,
|
ask: withAI ? (prev?.ask ?? null) : null,
|
||||||
global: prev?.global ?? false,
|
global: prev?.global ?? false,
|
||||||
query: (!withAIChat ? prev?.ask : null) ?? prev?.query ?? '',
|
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
|
||||||
open: true,
|
open: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
trackEvent({
|
trackEvent({
|
||||||
type: 'search_open',
|
type: 'search_open',
|
||||||
});
|
});
|
||||||
}, [open, setSearchState, trackEvent, withAIChat]);
|
}, [state?.open, setSearchState, trackEvent, withAI, withSearchAI]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -122,7 +134,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
|
|
||||||
const onChange = (value: string) => {
|
const onChange = (value: string) => {
|
||||||
setSearchState((prev) => ({
|
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,
|
query: value,
|
||||||
global: prev?.global ?? false,
|
global: prev?.global ?? false,
|
||||||
open: true,
|
open: true,
|
||||||
@@ -133,14 +145,14 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
const normalizedQuery = state?.query?.trim() ?? '';
|
const normalizedQuery = state?.query?.trim() ?? '';
|
||||||
const normalizedAsk = state?.ask?.trim() ?? '';
|
const normalizedAsk = state?.ask?.trim() ?? '';
|
||||||
|
|
||||||
const showAsk = aiMode === CustomizationAIMode.Search && normalizedAsk;
|
const showAsk = withSearchAI && normalizedAsk; // withSearchAI && normalizedAsk;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchAskProvider value={searchAsk}>
|
<SearchAskProvider value={searchAsk}>
|
||||||
<Popover
|
<Popover
|
||||||
content={
|
content={
|
||||||
// Only show content if there's a query or Ask is enabled
|
// Only show content if there's a query or Ask is enabled
|
||||||
(state?.query || aiMode !== CustomizationAIMode.None) && open ? (
|
state?.query || withSearchAI ? (
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
{isMultiVariants && !showAsk ? (
|
{isMultiVariants && !showAsk ? (
|
||||||
<SearchScopeToggle spaceTitle={spaceTitle} />
|
<SearchScopeToggle spaceTitle={spaceTitle} />
|
||||||
@@ -150,8 +162,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
ref={resultsRef}
|
ref={resultsRef}
|
||||||
query={normalizedQuery}
|
query={normalizedQuery}
|
||||||
global={state?.global ?? false}
|
global={state?.global ?? false}
|
||||||
aiMode={aiMode}
|
siteSpaceId={siteSpaceId}
|
||||||
spaceId={spaceId}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
||||||
@@ -159,7 +170,10 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
rootProps={{
|
rootProps={{
|
||||||
open: open,
|
open: state?.open ?? false,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
setSearchState((prev) => (prev ? { ...prev, open } : null));
|
||||||
|
},
|
||||||
modal: isMobile,
|
modal: isMobile,
|
||||||
}}
|
}}
|
||||||
contentProps={{
|
contentProps={{
|
||||||
@@ -172,7 +186,6 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onClose();
|
|
||||||
},
|
},
|
||||||
sideOffset: 8,
|
sideOffset: 8,
|
||||||
collisionPadding: {
|
collisionPadding: {
|
||||||
@@ -189,15 +202,27 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
>
|
>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
value={(!withAIChat ? state?.ask : null) ?? state?.query ?? ''}
|
value={state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''}
|
||||||
onFocus={onOpen}
|
onFocus={onOpen}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
aiMode={aiMode}
|
withAI={withSearchAI}
|
||||||
isOpen={open}
|
isOpen={state?.open ?? false}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</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>
|
</SearchAskProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { CustomizationAIMode } from '@gitbook/api';
|
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { Button, variantClasses } from '../primitives';
|
import { Button, variantClasses } from '../primitives';
|
||||||
import { useClassnames } from '../primitives/StyleProvider';
|
import { useClassnames } from '../primitives/StyleProvider';
|
||||||
@@ -14,7 +13,7 @@ interface SearchInputProps {
|
|||||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
value: string;
|
value: string;
|
||||||
aiMode: CustomizationAIMode;
|
withAI: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
className?: string;
|
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>(
|
export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||||
function SearchInput(props, ref) {
|
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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
@@ -94,7 +93,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
|||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
value={value}
|
value={value}
|
||||||
// We only show "search or ask" if the search input actually handles both search and ask.
|
// 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}
|
maxLength={512}
|
||||||
size={10}
|
size={10}
|
||||||
data-testid="search-input"
|
data-testid="search-input"
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import type { Assistant } from '@/components/AI';
|
||||||
import { t, tString, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import { AIChatIcon } from '../AIChat/';
|
|
||||||
import { SearchResultItem } from './SearchResultItem';
|
import { SearchResultItem } from './SearchResultItem';
|
||||||
import { useSearchLink } from './useSearch';
|
import { useSearchLink } from './useSearch';
|
||||||
|
|
||||||
@@ -11,11 +10,11 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
|||||||
question: string;
|
question: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
recommended?: boolean;
|
recommended?: boolean;
|
||||||
withAIChat: boolean;
|
assistant: Assistant;
|
||||||
},
|
},
|
||||||
ref: React.Ref<HTMLAnchorElement>
|
ref: React.Ref<HTMLAnchorElement>
|
||||||
) {
|
) {
|
||||||
const { question, recommended = false, active, withAIChat } = props;
|
const { question, recommended = false, active, assistant } = props;
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const getLinkProp = useSearchLink();
|
const getLinkProp = useSearchLink();
|
||||||
|
|
||||||
@@ -26,21 +25,18 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-testid={recommended ? 'search-recommended-question' : 'search-ask-question'}
|
data-testid={recommended ? 'search-recommended-question' : 'search-ask-question'}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
{...getLinkProp({
|
{...getLinkProp(
|
||||||
|
{
|
||||||
ask: question,
|
ask: question,
|
||||||
open: false,
|
|
||||||
query: null,
|
query: null,
|
||||||
})}
|
open: assistant.mode === 'search',
|
||||||
active={active}
|
},
|
||||||
leadingIcon={
|
() => {
|
||||||
recommended ? (
|
assistant.open(question);
|
||||||
<Icon icon="search" className="size-4" />
|
|
||||||
) : withAIChat ? (
|
|
||||||
<AIChatIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<Icon icon="sparkles" className="size-4" />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
)}
|
||||||
|
active={active}
|
||||||
|
leadingIcon={recommended ? 'search' : assistant.icon}
|
||||||
className={recommended ? 'pr-1.5' : ''}
|
className={recommended ? 'pr-1.5' : ''}
|
||||||
>
|
>
|
||||||
{recommended ? (
|
{recommended ? (
|
||||||
@@ -51,7 +47,7 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
|||||||
{t(language, 'search_ask', [question])}
|
{t(language, 'search_ask', [question])}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-tint-subtle">
|
<div className="text-sm text-tint-subtle">
|
||||||
{t(language, 'search_ask_description')}
|
{t(language, 'search_ask_description', assistant.label)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon, type IconName } from '@gitbook/icons';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
import { Link, type LinkInsightsProps } from '../primitives/Link';
|
import { Link, type LinkInsightsProps } from '../primitives/Link';
|
||||||
@@ -53,7 +53,13 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
|
|||||||
)}
|
)}
|
||||||
{...rest}
|
{...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>
|
<div className="grow">{children}</div>
|
||||||
{active ? (
|
{active ? (
|
||||||
<Button label={action} iconOnly variant="primary" icon="arrow-turn-down-left" />
|
<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 assertNever from 'assert-never';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import type { Assistant } from '@/components/AI';
|
||||||
import { t, useLanguage } from '@/intl/client';
|
import { t, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import { CustomizationAIMode } from '@gitbook/api';
|
|
||||||
import { assert } from 'ts-essentials';
|
import { assert } from 'ts-essentials';
|
||||||
|
import { useAI } from '../AI';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { Loading } from '../primitives';
|
import { Loading } from '../primitives';
|
||||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||||
@@ -29,7 +29,7 @@ export interface SearchResultsRef {
|
|||||||
|
|
||||||
type ResultType =
|
type ResultType =
|
||||||
| OrderedComputedResult
|
| OrderedComputedResult
|
||||||
| { type: 'question'; id: string; query: string }
|
| { type: 'question'; id: string; query: string; assistant: Assistant }
|
||||||
| { type: 'recommended-question'; id: string; question: string };
|
| { type: 'recommended-question'; id: string; question: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,12 +51,11 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
query: string;
|
query: string;
|
||||||
global: boolean;
|
global: boolean;
|
||||||
aiMode: CustomizationAIMode;
|
siteSpaceId: string;
|
||||||
spaceId: string;
|
|
||||||
},
|
},
|
||||||
ref: React.Ref<SearchResultsRef>
|
ref: React.Ref<SearchResultsRef>
|
||||||
) {
|
) {
|
||||||
const { children, query, aiMode, global, spaceId } = props;
|
const { children, query, global, siteSpaceId } = props;
|
||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
@@ -67,8 +66,8 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||||
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
||||||
|
|
||||||
const withAI =
|
const { assistants } = useAI();
|
||||||
aiMode === CustomizationAIMode.Search || aiMode === CustomizationAIMode.Assistant;
|
const withAI = assistants.length > 0;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!query) {
|
if (!query) {
|
||||||
@@ -77,9 +76,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cachedRecommendedQuestions.has(spaceId)) {
|
if (cachedRecommendedQuestions.has(siteSpaceId)) {
|
||||||
const results = cachedRecommendedQuestions.get(spaceId);
|
const results = cachedRecommendedQuestions.get(siteSpaceId);
|
||||||
assert(results, `Cached recommended questions should be set for space ${spaceId}`);
|
assert(
|
||||||
|
results,
|
||||||
|
`Cached recommended questions should be set for site-space ${siteSpaceId}`
|
||||||
|
);
|
||||||
setResultsState({ results, fetching: false });
|
setResultsState({ results, fetching: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -98,7 +100,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await streamRecommendedQuestions(spaceId);
|
const response = await streamRecommendedQuestions({ siteSpaceId });
|
||||||
for await (const entry of readStreamableValue(response.stream)) {
|
for await (const entry of readStreamableValue(response.stream)) {
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
continue;
|
continue;
|
||||||
@@ -115,7 +117,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
id: question,
|
id: question,
|
||||||
question,
|
question,
|
||||||
});
|
});
|
||||||
cachedRecommendedQuestions.set(spaceId, recommendedQuestions);
|
cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setResultsState({ results: [...recommendedQuestions], fetching: false });
|
setResultsState({ results: [...recommendedQuestions], fetching: false });
|
||||||
@@ -156,13 +158,13 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
}, [query, global, withAI, trackEvent]);
|
}, [query, global, trackEvent, withAI, siteSpaceId]);
|
||||||
|
|
||||||
const results: ResultType[] = React.useMemo(() => {
|
const results: ResultType[] = React.useMemo(() => {
|
||||||
if (!withAI) {
|
if (!withAI) {
|
||||||
return resultsState.results;
|
return resultsState.results;
|
||||||
}
|
}
|
||||||
return withQuestionResult(resultsState.results, query);
|
return withAskTriggers(resultsState.results, query, assistants);
|
||||||
}, [resultsState.results, query, withAI]);
|
}, [resultsState.results, query, withAI]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -185,7 +187,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
block: 'nearest',
|
block: 'nearest',
|
||||||
inline: 'nearest',
|
inline: 'nearest',
|
||||||
});
|
});
|
||||||
}, [cursor, refs]);
|
}, [cursor]);
|
||||||
|
|
||||||
const moveBy = React.useCallback(
|
const moveBy = React.useCallback(
|
||||||
(delta: number) => {
|
(delta: number) => {
|
||||||
@@ -206,7 +208,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
}
|
}
|
||||||
|
|
||||||
refs.current[cursor]?.click();
|
refs.current[cursor]?.click();
|
||||||
}, [cursor, refs]);
|
}, [cursor]);
|
||||||
|
|
||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
@@ -278,10 +280,10 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
ref={(ref) => {
|
ref={(ref) => {
|
||||||
refs.current[index] = ref;
|
refs.current[index] = ref;
|
||||||
}}
|
}}
|
||||||
withAIChat={aiMode === CustomizationAIMode.Assistant}
|
|
||||||
key={item.id}
|
key={item.id}
|
||||||
question={query}
|
question={query}
|
||||||
active={index === cursor}
|
active={index === cursor}
|
||||||
|
assistant={item.assistant}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -292,9 +294,9 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
refs.current[index] = ref;
|
refs.current[index] = ref;
|
||||||
}}
|
}}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
withAIChat={aiMode === CustomizationAIMode.Assistant}
|
|
||||||
question={item.question}
|
question={item.question}
|
||||||
active={index === cursor}
|
active={index === cursor}
|
||||||
|
assistant={assistants[0]}
|
||||||
recommended
|
recommended
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -327,12 +329,24 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
|||||||
/**
|
/**
|
||||||
* Add a "Ask <question>" item at the top of the results list.
|
* 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');
|
const without = results.filter((result) => result.type !== 'question');
|
||||||
|
|
||||||
if (query.length === 0) {
|
if (query.length === 0) {
|
||||||
return without;
|
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 { throwIfDataError } from '@/lib/data';
|
||||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||||
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
import type { IconName } from '@gitbook/icons';
|
import type { IconName } from '@gitbook/icons';
|
||||||
import { DocumentView } from '../DocumentView';
|
import { DocumentView } from '../DocumentView';
|
||||||
|
|
||||||
@@ -68,18 +69,21 @@ export interface AskAnswerResult {
|
|||||||
* Server action to search content in the entire site.
|
* Server action to search content in the entire site.
|
||||||
*/
|
*/
|
||||||
export async function searchAllSiteContent(query: string): Promise<OrderedComputedResult[]> {
|
export async function searchAllSiteContent(query: string): Promise<OrderedComputedResult[]> {
|
||||||
|
return traceErrorOnly('Search.searchAllSiteContent', async () => {
|
||||||
const context = await getServerActionBaseContext();
|
const context = await getServerActionBaseContext();
|
||||||
|
|
||||||
return await searchSiteContent(context, {
|
return await searchSiteContent(context, {
|
||||||
query,
|
query,
|
||||||
scope: { mode: 'all' },
|
scope: { mode: 'all' },
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server action to search content in a space.
|
* Server action to search content in a space.
|
||||||
*/
|
*/
|
||||||
export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> {
|
export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> {
|
||||||
|
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
|
||||||
const context = await getServerActionBaseContext();
|
const context = await getServerActionBaseContext();
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
@@ -91,6 +95,7 @@ export async function searchSiteSpaceContent(query: string): Promise<OrderedComp
|
|||||||
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
|
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
|
||||||
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
|
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,6 +106,7 @@ export async function streamAskQuestion({
|
|||||||
}: {
|
}: {
|
||||||
question: string;
|
question: string;
|
||||||
}) {
|
}) {
|
||||||
|
return traceErrorOnly('Search.streamAskQuestion', async () => {
|
||||||
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
|
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -181,17 +187,21 @@ export async function streamAskQuestion({
|
|||||||
return {
|
return {
|
||||||
stream: responseStream.value,
|
stream: responseStream.value,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream a list of suggested questions for the site.
|
* Stream a list of suggested questions for the site.
|
||||||
* Optionally scoped to a specific space.
|
* Optionally scoped to a specific space.
|
||||||
*/
|
*/
|
||||||
export async function streamRecommendedQuestions(spaceId?: string) {
|
export async function streamRecommendedQuestions(args: { siteSpaceId?: string }) {
|
||||||
|
return traceErrorOnly('Search.streamRecommendedQuestions', async () => {
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
const context = await getServerActionBaseContext();
|
const context = await getServerActionBaseContext();
|
||||||
|
|
||||||
const responseStream = createStreamableValue<SearchAIRecommendedQuestionStream | undefined>();
|
const responseStream = createStreamableValue<
|
||||||
|
SearchAIRecommendedQuestionStream | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const apiClient = await context.dataFetcher.api();
|
const apiClient = await context.dataFetcher.api();
|
||||||
@@ -199,7 +209,7 @@ export async function streamRecommendedQuestions(spaceId?: string) {
|
|||||||
siteURLData.organization,
|
siteURLData.organization,
|
||||||
siteURLData.site,
|
siteURLData.site,
|
||||||
{
|
{
|
||||||
spaceId,
|
siteSpaceId: args.siteSpaceId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -215,6 +225,7 @@ export async function streamRecommendedQuestions(spaceId?: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { stream: responseStream.value };
|
return { stream: responseStream.value };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+45
-30
@@ -1,6 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
|
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { LinkProps } from '../primitives';
|
import type { LinkProps } from '../primitives';
|
||||||
|
|
||||||
export interface SearchState {
|
export interface SearchState {
|
||||||
@@ -25,9 +26,17 @@ export type UpdateSearchState = (
|
|||||||
) => Promise<URLSearchParams>;
|
) => 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, {
|
const [rawState, setRawState] = useQueryStates(keyMap, {
|
||||||
history: 'replace',
|
history: 'replace',
|
||||||
});
|
});
|
||||||
@@ -44,22 +53,20 @@ export function useSearch(withAIChat = false): [SearchState | null, UpdateSearch
|
|||||||
}
|
}
|
||||||
}, [rawState, setRawState]);
|
}, [rawState, setRawState]);
|
||||||
|
|
||||||
// Separate local state for open (not synchronized with URL)
|
// Local UI state for the popover open/close (not in URL)
|
||||||
// Default to true if there's already a query in the URL
|
|
||||||
const [open, setIsOpen] = React.useState(() => {
|
const [open, setIsOpen] = React.useState(() => {
|
||||||
return rawState?.q !== null || (!withAIChat && rawState?.ask !== null);
|
return rawState?.q !== null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = React.useMemo<SearchState | null>(() => {
|
const state = React.useMemo<SearchState | null>(() => {
|
||||||
if (rawState === null || (rawState.q === null && rawState.ask === null)) {
|
if (rawState === null || (rawState.q === null && rawState.ask === null)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query: rawState.q,
|
query: rawState.q,
|
||||||
ask: rawState.ask,
|
ask: rawState.ask,
|
||||||
global: !!rawState.global,
|
global: !!rawState.global,
|
||||||
open: open,
|
open,
|
||||||
};
|
};
|
||||||
}, [rawState, open]);
|
}, [rawState, open]);
|
||||||
|
|
||||||
@@ -78,16 +85,10 @@ export function useSearch(withAIChat = false): [SearchState | null, UpdateSearch
|
|||||||
|
|
||||||
if (update === null) {
|
if (update === null) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
return setRawState({
|
return setRawState({ q: null, ask: null, global: null });
|
||||||
q: null,
|
|
||||||
ask: null,
|
|
||||||
global: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the local state
|
|
||||||
setIsOpen(update.open);
|
setIsOpen(update.open);
|
||||||
|
|
||||||
return setRawState({
|
return setRawState({
|
||||||
q: update.query,
|
q: update.query,
|
||||||
ask: update.ask,
|
ask: update.ask,
|
||||||
@@ -97,37 +98,51 @@ export function useSearch(withAIChat = false): [SearchState | null, UpdateSearch
|
|||||||
[setRawState]
|
[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.
|
* Hook to create a link to a search query.
|
||||||
*/
|
*/
|
||||||
export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
export function useSearchLink(): (
|
||||||
const [, setSearch] = useSearch();
|
params: Partial<SearchState>,
|
||||||
|
callback?: () => void
|
||||||
|
) => LinkProps {
|
||||||
|
const [, setSearchState] = useSearch();
|
||||||
|
|
||||||
return React.useCallback(
|
return React.useCallback(
|
||||||
(query) => {
|
(params, callback) => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
query.query ? searchParams.set('q', query.query) : searchParams.delete('q');
|
params.query ? searchParams.set('q', params.query) : searchParams.delete('q');
|
||||||
query.ask ? searchParams.set('ask', query.ask) : searchParams.delete('ask');
|
params.ask ? searchParams.set('ask', params.ask) : searchParams.delete('ask');
|
||||||
query.global ? searchParams.set('global', 'true') : searchParams.delete('global');
|
params.global ? searchParams.set('global', 'true') : searchParams.delete('global');
|
||||||
return {
|
return {
|
||||||
href: `?${searchParams.toString()}`,
|
href: `?${searchParams.toString()}`,
|
||||||
prefetch: false,
|
prefetch: false,
|
||||||
onClick: (event) => {
|
onClick: (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSearch((prev) => ({
|
callback?.();
|
||||||
query: null,
|
setSearchState((prev) => ({
|
||||||
ask: null,
|
...prev,
|
||||||
global: false,
|
query: params.query !== undefined ? params.query : null,
|
||||||
open: query.open ?? prev?.open ?? false,
|
ask: params.ask !== undefined ? params.ask : null,
|
||||||
...(prev ?? {}),
|
global: params.global !== undefined ? params.global : false,
|
||||||
...query,
|
open: params.open !== undefined ? params.open : false,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[setSearch]
|
[setSearchState]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,6 +135,9 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
|
|||||||
canonical: linker
|
canonical: linker
|
||||||
.toAbsoluteURL(linker.toPathForPage({ pages: revision.pages, page }))
|
.toAbsoluteURL(linker.toPathForPage({ pages: revision.pages, page }))
|
||||||
.replace(/\/+$/, ''),
|
.replace(/\/+$/, ''),
|
||||||
|
types: {
|
||||||
|
'text/markdown': `${linker.toAbsoluteURL(linker.toPathInSpace(page.path))}.md`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
images: [
|
images: [
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
import { CustomizationAIMode, CustomizationHeaderPreset } from '@gitbook/api';
|
import {
|
||||||
|
CustomizationAIMode,
|
||||||
|
CustomizationHeaderPreset,
|
||||||
|
CustomizationSearchStyle,
|
||||||
|
} from '@gitbook/api';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
@@ -10,12 +14,12 @@ import { tcls } from '@/lib/tailwind';
|
|||||||
|
|
||||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env';
|
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env';
|
||||||
|
import { AIContextProvider } from '../AI';
|
||||||
import { AIChat } from '../AIChat';
|
import { AIChat } from '../AIChat';
|
||||||
import { AIChatButton } from '../AIChat';
|
|
||||||
import { Announcement } from '../Announcement';
|
import { Announcement } from '../Announcement';
|
||||||
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
||||||
import { InsightsProvider } from '../Insights';
|
import { InsightsProvider } from '../Insights';
|
||||||
import { SearchContainer } from '../Search';
|
import { SearchContainer, SearchContextProvider } from '../Search';
|
||||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||||
import { CurrentContentProvider } from '../hooks';
|
import { CurrentContentProvider } from '../hooks';
|
||||||
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
|
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
|
||||||
@@ -51,19 +55,20 @@ export function SpaceLayout(props: {
|
|||||||
|
|
||||||
const aiMode = customization.ai?.mode;
|
const aiMode = customization.ai?.mode;
|
||||||
|
|
||||||
const searchAndAI = (
|
const search = (
|
||||||
<div className="flex grow items-center gap-2">
|
<div className="flex grow items-center gap-2">
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<SearchContainer
|
<SearchContainer
|
||||||
aiMode={aiMode}
|
style={
|
||||||
|
customization.header.preset === CustomizationHeaderPreset.None
|
||||||
|
? CustomizationSearchStyle.Subtle
|
||||||
|
: customization.styling.search
|
||||||
|
}
|
||||||
isMultiVariants={siteSpaces.length > 1}
|
isMultiVariants={siteSpaces.length > 1}
|
||||||
spaceTitle={siteSpace.title}
|
spaceTitle={siteSpace.title}
|
||||||
spaceId={siteSpace.space.id}
|
siteSpaceId={siteSpace.id}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
{aiMode === CustomizationAIMode.Assistant ? (
|
|
||||||
<AIChatButton trademark={customization.trademark.enabled} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -84,9 +89,18 @@ export function SpaceLayout(props: {
|
|||||||
appURL={GITBOOK_APP_URL}
|
appURL={GITBOOK_APP_URL}
|
||||||
apiHost={GITBOOK_API_PUBLIC_URL}
|
apiHost={GITBOOK_API_PUBLIC_URL}
|
||||||
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
||||||
|
>
|
||||||
|
<SearchContextProvider>
|
||||||
|
<AIContextProvider
|
||||||
|
aiMode={aiMode}
|
||||||
|
trademark={customization.trademark.enabled}
|
||||||
>
|
>
|
||||||
<Announcement context={context} />
|
<Announcement context={context} />
|
||||||
<Header withTopHeader={withTopHeader} context={context} search={searchAndAI} />
|
<Header
|
||||||
|
withTopHeader={withTopHeader}
|
||||||
|
context={context}
|
||||||
|
search={search}
|
||||||
|
/>
|
||||||
{aiMode === CustomizationAIMode.Assistant ? (
|
{aiMode === CustomizationAIMode.Assistant ? (
|
||||||
<AIChat trademark={customization.trademark.enabled} />
|
<AIChat trademark={customization.trademark.enabled} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -127,7 +141,7 @@ export function SpaceLayout(props: {
|
|||||||
innerHeader={
|
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.
|
// 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 && search}
|
||||||
{!withTopHeader && withSections && sections && (
|
{!withTopHeader && withSections && sections && (
|
||||||
<SiteSectionList
|
<SiteSectionList
|
||||||
className={tcls('hidden', 'lg:block')}
|
className={tcls('hidden', 'lg:block')}
|
||||||
@@ -152,12 +166,16 @@ export function SpaceLayout(props: {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{withFooter ? <Footer context={context} /> : null}
|
{withFooter ? <Footer context={context} /> : null}
|
||||||
|
</AIContextProvider>
|
||||||
|
</SearchContextProvider>
|
||||||
</InsightsProvider>
|
</InsightsProvider>
|
||||||
</CurrentContentProvider>
|
</CurrentContentProvider>
|
||||||
</SpaceLayoutContextProvider>
|
</SpaceLayoutContextProvider>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup }) {
|
|||||||
<li className="group/page-group-item flex flex-col">
|
<li className="group/page-group-item flex flex-col">
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
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',
|
'font-semibold text-xs uppercase tracking-wide',
|
||||||
'pb-3', // Add extra padding to make the header fade a bit nicer
|
'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.
|
'-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
|
// Set sticky top position based on header
|
||||||
const headerHeight = header?.offsetHeight ?? 0;
|
const headerHeight = header?.offsetHeight ?? 0;
|
||||||
root.style.setProperty('--toc-top-offset', `${headerHeight}px`);
|
|
||||||
|
|
||||||
// Start with full viewport height minus header
|
// Start with full viewport height minus header
|
||||||
let height = window.innerHeight - headerHeight;
|
let height = window.innerHeight - headerHeight;
|
||||||
|
let offset = headerHeight;
|
||||||
|
|
||||||
// Subtract visible banner (if any)
|
// Subtract visible banner (if any)
|
||||||
if (banner && window.getComputedStyle(banner).display !== 'none') {
|
if (banner && window.getComputedStyle(banner).display !== 'none') {
|
||||||
const bannerRect = banner.getBoundingClientRect();
|
const bannerRect = banner.getBoundingClientRect();
|
||||||
if (bannerRect.height > 0 && bannerRect.bottom > 0) {
|
if (bannerRect.height > 0 && bannerRect.bottom > 0) {
|
||||||
height -= Math.min(bannerRect.height, bannerRect.bottom);
|
height -= Math.min(bannerRect.height, bannerRect.bottom);
|
||||||
|
offset += Math.min(bannerRect.height, bannerRect.bottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export function TableOfContentsScript() {
|
|||||||
|
|
||||||
// Update height
|
// Update height
|
||||||
root.style.setProperty('--toc-height', `${height}px`);
|
root.style.setProperty('--toc-height', `${height}px`);
|
||||||
|
root.style.setProperty('--toc-top-offset', `${offset}px`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial update
|
// Initial update
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Icon, type IconName } from '@gitbook/icons';
|
import type { IconName } from '@gitbook/icons';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { Button, ButtonGroup } from '../primitives';
|
||||||
|
|
||||||
type ThemeMode = 'light' | 'system' | 'dark';
|
type ThemeMode = 'light' | 'system' | 'dark';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export function ThemeToggler() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="radiogroup" className="flex flex-row gap-2">
|
<ButtonGroup role="radiogroup" className="gap-2" combinedShape={false}>
|
||||||
<ThemeButton
|
<ThemeButton
|
||||||
active={mounted && theme === 'light'}
|
active={mounted && theme === 'light'}
|
||||||
icon="sun-bright"
|
icon="sun-bright"
|
||||||
@@ -46,7 +46,7 @@ export function ThemeToggler() {
|
|||||||
onClick={() => onSwitchMode('dark')}
|
onClick={() => onSwitchMode('dark')}
|
||||||
title={tString(language, 'switch_to_dark_theme')}
|
title={tString(language, 'switch_to_dark_theme')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,38 +58,21 @@ function ThemeButton(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { icon, onClick, title, active } = props;
|
const { icon, onClick, title, active } = props;
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={title}
|
label={title}
|
||||||
title={title}
|
|
||||||
aria-checked={active}
|
aria-checked={active}
|
||||||
className={tcls(
|
variant="blank"
|
||||||
'p-2',
|
size="default"
|
||||||
'rounded-sm',
|
className={
|
||||||
'straight-corners:rounded-none',
|
active
|
||||||
'circular-corners:rounded-full',
|
? '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'
|
||||||
'transition-all',
|
: ''
|
||||||
'text-tint',
|
}
|
||||||
'contrast-more:text-tint-strong',
|
icon={icon}
|
||||||
'hover:bg-tint-hover',
|
iconOnly
|
||||||
'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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
'hover:text-contrast-primary-solid-hover',
|
||||||
'border-0',
|
'border-0',
|
||||||
'contrast-more:border',
|
'contrast-more:border',
|
||||||
'disabled:bg-tint',
|
'disabled:bg-primary-subtle',
|
||||||
'disabled:text-tint/8',
|
'disabled:text-primary/8',
|
||||||
],
|
],
|
||||||
blank: [
|
blank: [
|
||||||
'bg-transparent',
|
'bg-transparent',
|
||||||
'text-tint',
|
'text-tint',
|
||||||
'border-0',
|
'border-0',
|
||||||
'shadow-none!',
|
'shadow-none!',
|
||||||
'hover:bg-primary-hover',
|
'hover:bg-tint-hover',
|
||||||
'hover:text-primary-strong',
|
'hover:text-tint-strong',
|
||||||
'contrast-more:bg-tint-subtle',
|
'contrast-more:bg-tint-subtle',
|
||||||
'hover:depth-subtle:translate-y-0',
|
'hover:depth-subtle:translate-y-0',
|
||||||
'disabled:text-tint/8',
|
'disabled:text-tint/8',
|
||||||
@@ -52,7 +52,7 @@ export const variantClasses = {
|
|||||||
'text-tint',
|
'text-tint',
|
||||||
'hover:bg-tint-hover',
|
'hover:bg-tint-hover',
|
||||||
'hover:depth-flat:bg-tint-hover',
|
'hover:depth-flat:bg-tint-hover',
|
||||||
'hover:text-primary',
|
'hover:text-tint',
|
||||||
'contrast-more:bg-tint-subtle',
|
'contrast-more:bg-tint-subtle',
|
||||||
'disabled:bg-transparent',
|
'disabled:bg-transparent',
|
||||||
'disabled:text-tint/8',
|
'disabled:text-tint/8',
|
||||||
@@ -81,7 +81,7 @@ export const variantClasses = {
|
|||||||
const activeClasses = {
|
const activeClasses = {
|
||||||
primary: 'bg-primary-solid-hover',
|
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',
|
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',
|
header: 'bg-header-link/3',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ export const Button = React.forwardRef<
|
|||||||
icon
|
icon
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
{iconOnly ? null : label}
|
{iconOnly ? null : (children ?? label)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ export const Button = React.forwardRef<
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return iconOnly && label ? (
|
return (children || iconOnly) && label ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
rootProps={{ open: disabled === true ? false : undefined }}
|
rootProps={{ open: disabled === true ? false : undefined }}
|
||||||
label={label}
|
label={label}
|
||||||
@@ -185,14 +185,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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'flex h-fit items-stretch justify-start *:translate-y-0! *:shadow-none!',
|
'flex h-fit items-stretch justify-start overflow-hidden',
|
||||||
'[&>*: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'
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import React from 'react';
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { SiteExternalLinksTarget } from '@gitbook/api';
|
import { SiteExternalLinksTarget } from '@gitbook/api';
|
||||||
import { type TrackEventInput, useTrackEvent } from '../Insights';
|
import { type TrackEventInput, useTrackEvent } from '../Insights';
|
||||||
|
import { HashContext } from '../hooks';
|
||||||
import { isExternalLink } from '../utils/link';
|
import { isExternalLink } from '../utils/link';
|
||||||
import { type DesignTokenName, useClassnames } from './StyleProvider';
|
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 { href, prefetch, children, insights, classNames, className, ...domProps } = props;
|
||||||
const { externalLinksTarget } = React.useContext(LinkSettingsContext);
|
const { externalLinksTarget } = React.useContext(LinkSettingsContext);
|
||||||
|
const { updateHashFromUrl } = React.useContext(HashContext);
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
const forwardedClassNames = useClassnames(classNames || []);
|
const forwardedClassNames = useClassnames(classNames || []);
|
||||||
const isExternal = isExternalLink(href);
|
const isExternal = isExternalLink(href);
|
||||||
@@ -78,6 +80,9 @@ export const Link = React.forwardRef(function Link(
|
|||||||
|
|
||||||
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
|
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
|
||||||
|
if (!isExternal) {
|
||||||
|
updateHashFromUrl(href);
|
||||||
|
}
|
||||||
|
|
||||||
if (insights) {
|
if (insights) {
|
||||||
trackEvent(insights, undefined, { immediate: isExternalWithOrigin });
|
trackEvent(insights, undefined, { immediate: isExternalWithOrigin });
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ export const ButtonStyles = [
|
|||||||
'border',
|
'border',
|
||||||
'border-tint',
|
'border-tint',
|
||||||
'hover:border-tint-hover',
|
'hover:border-tint-hover',
|
||||||
|
'disabled:border-tint',
|
||||||
|
|
||||||
'depth-subtle:shadow-xs',
|
'depth-subtle:shadow-xs',
|
||||||
'hover:depth-subtle:shadow-md',
|
'hover:depth-subtle:shadow-md',
|
||||||
'focus-visible:depth-subtle:shadow-md',
|
'focus-visible:depth-subtle:shadow-md',
|
||||||
'active:shadow-none',
|
'active:depth-subtle:shadow-xs',
|
||||||
'shadow-tint/6',
|
'shadow-tint/6',
|
||||||
'dark:shadow-tint-1',
|
'dark:shadow-tint-1',
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export const ButtonStyles = [
|
|||||||
|
|
||||||
'hover:depth-subtle:-translate-y-px',
|
'hover:depth-subtle:-translate-y-px',
|
||||||
'focus-visible:depth-subtle:-translate-y-px',
|
'focus-visible:depth-subtle:-translate-y-px',
|
||||||
|
'active:depth-subtle:translate-y-0',
|
||||||
'transition-all',
|
'transition-all',
|
||||||
|
|
||||||
'grow-0',
|
'grow-0',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const de = {
|
|||||||
search_scope_all: 'Alle Inhalte',
|
search_scope_all: 'Alle Inhalte',
|
||||||
ask: 'Fragen',
|
ask: 'Fragen',
|
||||||
search_ask: 'Fragen "${1}"',
|
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: 'Quellen',
|
||||||
search_ask_sources_no_answer: 'Verwandte Seiten',
|
search_ask_sources_no_answer: 'Verwandte Seiten',
|
||||||
search_ask_no_answer:
|
search_ask_no_answer:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const en = {
|
|||||||
search_scope_all: 'All content',
|
search_scope_all: 'All content',
|
||||||
ask: 'Ask',
|
ask: 'Ask',
|
||||||
search_ask: 'Ask "${1}"',
|
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: 'Sources',
|
||||||
search_ask_sources_no_answer: 'Related pages',
|
search_ask_sources_no_answer: 'Related pages',
|
||||||
search_ask_no_answer:
|
search_ask_no_answer:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const es: TranslationLanguage = {
|
|||||||
search_scope_all: 'Todo el contenido',
|
search_scope_all: 'Todo el contenido',
|
||||||
ask: 'Preguntar',
|
ask: 'Preguntar',
|
||||||
search_ask: 'Preguntar "${1}"',
|
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: 'Fuentes',
|
||||||
search_ask_sources_no_answer: 'Páginas relacionadas',
|
search_ask_sources_no_answer: 'Páginas relacionadas',
|
||||||
search_ask_no_answer:
|
search_ask_no_answer:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const fr = {
|
|||||||
search_scope_all: 'Tous les contenus',
|
search_scope_all: 'Tous les contenus',
|
||||||
ask: 'Poser une question',
|
ask: 'Poser une question',
|
||||||
search_ask: 'Demander « ${1} »',
|
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: 'Sources',
|
||||||
search_ask_sources_no_answer: 'Pages associées',
|
search_ask_sources_no_answer: 'Pages associées',
|
||||||
search_ask_no_answer: 'Pas de réponse trouvée. Essayez de reformuler votre question.',
|
search_ask_no_answer: 'Pas de réponse trouvée. Essayez de reformuler votre question.',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ja } from './ja';
|
|||||||
import { nl } from './nl';
|
import { nl } from './nl';
|
||||||
import { no } from './no';
|
import { no } from './no';
|
||||||
import { pt_br } from './pt-br';
|
import { pt_br } from './pt-br';
|
||||||
|
import { ru } from './ru';
|
||||||
import type { TranslationLanguage } from './types';
|
import type { TranslationLanguage } from './types';
|
||||||
import { zh } from './zh';
|
import { zh } from './zh';
|
||||||
|
|
||||||
@@ -27,4 +28,5 @@ export const languages: {
|
|||||||
nl,
|
nl,
|
||||||
no,
|
no,
|
||||||
'pt-br': pt_br,
|
'pt-br': pt_br,
|
||||||
|
ru,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const ja: TranslationLanguage = {
|
|||||||
search_scope_all: '全てのコンテンツ',
|
search_scope_all: '全てのコンテンツ',
|
||||||
ask: '質問する',
|
ask: '質問する',
|
||||||
search_ask: '"${1}" を質問する',
|
search_ask: '"${1}" を質問する',
|
||||||
search_ask_description: 'AIで答えを見つける',
|
search_ask_description: '${1}で答えを見つける',
|
||||||
search_ask_sources: '情報源',
|
search_ask_sources: '情報源',
|
||||||
search_ask_sources_no_answer: '関連ページ',
|
search_ask_sources_no_answer: '関連ページ',
|
||||||
search_ask_no_answer:
|
search_ask_no_answer:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const nl: TranslationLanguage = {
|
|||||||
search_scope_all: 'Alle inhoud',
|
search_scope_all: 'Alle inhoud',
|
||||||
ask: 'Vragen',
|
ask: 'Vragen',
|
||||||
search_ask: 'Vraag "${1}"',
|
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: 'Bronnen',
|
||||||
search_ask_sources_no_answer: "Gerelateerde pagina's",
|
search_ask_sources_no_answer: "Gerelateerde pagina's",
|
||||||
search_ask_no_answer:
|
search_ask_no_answer:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const no: TranslationLanguage = {
|
|||||||
search_scope_all: 'Alt innhold',
|
search_scope_all: 'Alt innhold',
|
||||||
ask: 'Spør',
|
ask: 'Spør',
|
||||||
search_ask: 'Spør "${1}"',
|
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: 'Kilder',
|
||||||
search_ask_sources_no_answer: 'Relaterte sider',
|
search_ask_sources_no_answer: 'Relaterte sider',
|
||||||
search_ask_no_answer:
|
search_ask_no_answer:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const pt_br = {
|
|||||||
search_scope_all: 'Todo o conteúdo',
|
search_scope_all: 'Todo o conteúdo',
|
||||||
ask: 'Perguntar',
|
ask: 'Perguntar',
|
||||||
search_ask: 'Perguntar "${1}"',
|
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: 'Fontes',
|
||||||
search_ask_sources_no_answer: 'Páginas relacionadas',
|
search_ask_sources_no_answer: 'Páginas relacionadas',
|
||||||
search_ask_no_answer:
|
search_ask_no_answer:
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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} и ответь на вопросы о содержимом.',
|
||||||
|
};
|
||||||
@@ -18,7 +18,7 @@ export const zh: TranslationLanguage = {
|
|||||||
search_scope_all: '所有内容',
|
search_scope_all: '所有内容',
|
||||||
ask: '询问',
|
ask: '询问',
|
||||||
search_ask: '询问"${1}"',
|
search_ask: '询问"${1}"',
|
||||||
search_ask_description: '利用人工智能找到答案',
|
search_ask_description: '利用${1}找到答案',
|
||||||
search_ask_sources: '来源',
|
search_ask_sources: '来源',
|
||||||
search_ask_sources_no_answer: '相关页面',
|
search_ask_sources_no_answer: '相关页面',
|
||||||
search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
|
search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ export function createDataFetcher(
|
|||||||
pageId: params.pageId,
|
pageId: params.pageId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
getRevisionReusableContentDocument(params) {
|
||||||
|
return getRevisionReusableContentDocument(input, {
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
revisionId: params.revisionId,
|
||||||
|
reusableContentId: params.reusableContentId,
|
||||||
|
});
|
||||||
|
},
|
||||||
getLatestOpenAPISpecVersionContent(params) {
|
getLatestOpenAPISpecVersionContent(params) {
|
||||||
return getLatestOpenAPISpecVersionContent(input, {
|
return getLatestOpenAPISpecVersionContent(input, {
|
||||||
organizationId: params.organizationId,
|
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(
|
const getRevisionPageByPath = cache(
|
||||||
async (
|
async (
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ export interface GitBookDataFetcher {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
}): Promise<DataFetcherResponse<api.JSONDocument>>;
|
}): Promise<DataFetcherResponse<api.JSONDocument>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the document of a reusable content by its space ID and reusable content ID.
|
||||||
|
*/
|
||||||
|
getRevisionReusableContentDocument(params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
reusableContentId: string;
|
||||||
|
}): Promise<DataFetcherResponse<api.JSONDocument>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a document by its space ID and document ID.
|
* Get a document by its space ID and document ID.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -36,25 +36,28 @@ export async function fetchOpenAPIFilesystem(
|
|||||||
return { filesystem: null, specUrl: null };
|
return { filesystem: null, specUrl: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const filesystem = await (() => {
|
const result = await (() => {
|
||||||
|
// If the reference is a new OpenAPI reference, we return it.
|
||||||
if (ref.kind === 'openapi') {
|
if (ref.kind === 'openapi') {
|
||||||
assert(resolved.openAPIFilesystem);
|
assert(resolved.openAPIFilesystem);
|
||||||
return resolved.openAPIFilesystem;
|
return resolved.openAPIFilesystem;
|
||||||
}
|
}
|
||||||
|
// For legacy blocks ("swagger"), we need to fetch the file system.
|
||||||
return fetchFilesystem(resolved.href, context.space.id);
|
return fetchFilesystem(resolved.href, context.space.id);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if ('error' in filesystem) {
|
if ('error' in result) {
|
||||||
throw new OpenAPIParseError(filesystem.error.message, { code: filesystem.error.code });
|
throw new OpenAPIParseError(result.error.message, { code: result.error.code });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { filesystem: result, specUrl: resolved.href };
|
||||||
filesystem,
|
|
||||||
specUrl: resolved.href,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchFilesystem = async (
|
/**
|
||||||
|
* Fetch the filesystem from the URL.
|
||||||
|
* It's used for legacy "swagger" blocks.
|
||||||
|
*/
|
||||||
|
async function fetchFilesystem(
|
||||||
url: string,
|
url: string,
|
||||||
spaceId: string
|
spaceId: string
|
||||||
): Promise<
|
): Promise<
|
||||||
@@ -65,11 +68,11 @@ const fetchFilesystem = async (
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
> => {
|
> {
|
||||||
'use cache';
|
'use cache';
|
||||||
try {
|
try {
|
||||||
cacheTag(getCacheTag({ tag: 'space', space: spaceId }));
|
cacheTag(getCacheTag({ tag: 'space', space: spaceId }));
|
||||||
return await fetchFilesystemUncached(url);
|
return await fetchFilesystemNoCache(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// To avoid hammering the file with requests, we cache the error for around a minute.
|
// To avoid hammering the file with requests, we cache the error for around a minute.
|
||||||
cacheLife('minutes');
|
cacheLife('minutes');
|
||||||
@@ -86,21 +89,16 @@ const fetchFilesystem = async (
|
|||||||
console.error('Unknown error while fetching OpenAPI file:', error);
|
console.error('Unknown error while fetching OpenAPI file:', error);
|
||||||
return { error: { code: 'invalid' as const, message: 'Unknown error' } };
|
return { error: { code: 'invalid' as const, message: 'Unknown error' } };
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
async function fetchFilesystemUncached(
|
async function fetchFilesystemNoCache(url: string) {
|
||||||
url: string,
|
console.log(url);
|
||||||
options?: {
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
|
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
|
||||||
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
|
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
|
||||||
// https://github.com/cloudflare/workerd/issues/1957
|
// https://github.com/cloudflare/workerd/issues/1957
|
||||||
const response = await fetch(new URL(url), {
|
const response = await fetch(new URL(url), {
|
||||||
...noCacheFetchOptions,
|
...noCacheFetchOptions,
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
signal: options?.signal,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import type {
|
|||||||
|
|
||||||
type ResolveOpenAPIOperationBlockResult = ResolveOpenAPIBlockResult<OpenAPIOperationData>;
|
type ResolveOpenAPIOperationBlockResult = ResolveOpenAPIBlockResult<OpenAPIOperationData>;
|
||||||
|
|
||||||
const weakmap = new WeakMap<
|
const cache = new WeakMap<AnyOpenAPIOperationsBlock, Promise<ResolveOpenAPIOperationBlockResult>>();
|
||||||
AnyOpenAPIOperationsBlock,
|
|
||||||
Promise<ResolveOpenAPIOperationBlockResult>
|
|
||||||
>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache the result of resolving an OpenAPI block.
|
* Cache the result of resolving an OpenAPI block.
|
||||||
@@ -21,22 +18,24 @@ const weakmap = new WeakMap<
|
|||||||
export function resolveOpenAPIOperationBlock(
|
export function resolveOpenAPIOperationBlock(
|
||||||
args: ResolveOpenAPIBlockArgs<AnyOpenAPIOperationsBlock>
|
args: ResolveOpenAPIBlockArgs<AnyOpenAPIOperationsBlock>
|
||||||
): Promise<ResolveOpenAPIOperationBlockResult> {
|
): Promise<ResolveOpenAPIOperationBlockResult> {
|
||||||
if (weakmap.has(args.block)) {
|
const inCache = cache.get(args.block);
|
||||||
return weakmap.get(args.block)!;
|
if (inCache) {
|
||||||
|
return inCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = baseResolveOpenAPIOperationBlock(args);
|
const promise = resolveOpenAPIOperationBlockNoCache(args);
|
||||||
weakmap.set(args.block, result);
|
cache.set(args.block, promise);
|
||||||
return result;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve OpenAPI operation block.
|
* Resolve OpenAPI operation block.
|
||||||
*/
|
*/
|
||||||
async function baseResolveOpenAPIOperationBlock(
|
async function resolveOpenAPIOperationBlockNoCache(
|
||||||
args: ResolveOpenAPIBlockArgs<AnyOpenAPIOperationsBlock>
|
args: ResolveOpenAPIBlockArgs<AnyOpenAPIOperationsBlock>
|
||||||
): Promise<ResolveOpenAPIOperationBlockResult> {
|
): Promise<ResolveOpenAPIOperationBlockResult> {
|
||||||
const { context, block } = args;
|
const { context, block } = args;
|
||||||
|
|
||||||
if (!block.data.path || !block.data.method) {
|
if (!block.data.path || !block.data.method) {
|
||||||
return { data: null, specUrl: null };
|
return { data: null, specUrl: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,41 @@ export async function trace<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a performance trace for the given function, but only log errors.
|
||||||
|
* This is useful to not output too much noise in the logs, while still capturing important errors.
|
||||||
|
*/
|
||||||
|
export async function traceErrorOnly<T>(
|
||||||
|
name: string | TraceName,
|
||||||
|
fn: (span: TraceSpan) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const { operation, name: executionName } =
|
||||||
|
typeof name === 'string' ? { operation: name, name: undefined } : name;
|
||||||
|
const completeName = executionName ? `${operation}(${executionName})` : operation;
|
||||||
|
|
||||||
|
const attributes: Record<string, boolean | string | number> = {};
|
||||||
|
const span: TraceSpan = {
|
||||||
|
setAttribute(label, value) {
|
||||||
|
attributes[label] = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = now();
|
||||||
|
let traceError: null | Error = null;
|
||||||
|
try {
|
||||||
|
return await fn(span);
|
||||||
|
} catch (error) {
|
||||||
|
span.setAttribute('error', true);
|
||||||
|
traceError = error as Error;
|
||||||
|
const logger = getLogger().subLogger(operation);
|
||||||
|
logger.error(
|
||||||
|
`trace ${completeName} failed with ${traceError.message} in ${now() - start}ms`,
|
||||||
|
attributes
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the current time in milliseconds.
|
* Return the current time in milliseconds.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -64,9 +64,22 @@ export async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function validateServerActionRequest(request: NextRequest) {
|
async function validateServerActionRequest(request: NextRequest) {
|
||||||
|
// First thing we need to do is validate that the header is in a correct format.
|
||||||
|
if (request.headers.has('next-action')) {
|
||||||
|
// A server action id is a 1-byte hex string (2 chars) followed by a 20-byte SHA1 hash (40 chars) = 42 total characters.
|
||||||
|
// For ref https://github.com/vercel/next.js/blob/db561cb924cbea0f3384e89f251fc443a8aec1ae/crates/next-custom-transforms/src/transforms/server_actions.rs#L266-L268
|
||||||
|
const regex = /^[a-fA-F0-9]{42}$/;
|
||||||
|
const match = request.headers.get('next-action')?.match(regex);
|
||||||
|
if (!match) {
|
||||||
|
return new Response('Invalid request', {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// We need to reject incorrect server actions requests
|
// We need to reject incorrect server actions requests
|
||||||
// We do not do it in cloudflare workers as there is a bug that prevents us from reading the request body.
|
// We do not do it in cloudflare workers as there is a bug that prevents us from reading the request body.
|
||||||
if (request.headers.has('next-action') && process.env.GITBOOK_RUNTIME !== 'cloudflare') {
|
if (process.env.GITBOOK_RUNTIME !== 'cloudflare') {
|
||||||
// We just test that the json body is parseable
|
// We just test that the json body is parseable
|
||||||
try {
|
try {
|
||||||
const clonedRequest = request.clone();
|
const clonedRequest = request.clone();
|
||||||
@@ -80,6 +93,7 @@ async function validateServerActionRequest(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user