mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-02 02:09:55 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02b1bdbfa1 | |||
| 6217a2ed3b | |||
| ffa866cb99 | |||
| 011aa931cf | |||
| 68308154b5 | |||
| 36af03f4d5 | |||
| cbc71a56b6 | |||
| 1420180220 | |||
| e1b2cf6d81 | |||
| c0e9c49679 | |||
| afc4bd4415 | |||
| bcfa8d8b19 | |||
| 43766d6519 | |||
| 42c17f5c74 | |||
| d655d3eece | |||
| 3500de95e8 | |||
| 7f03b94949 | |||
| f0cf060191 | |||
| 5a137e7b14 | |||
| 28f7fbaa8a | |||
| ff96bb5787 | |||
| 87841124e0 | |||
| a650b58276 | |||
| 388b20d44f | |||
| ea7e94fc51 | |||
| d30bcbabdb | |||
| fb858a1941 | |||
| cc2e615b9a | |||
| a862cde6b4 | |||
| e2b7bec162 | |||
| c1b577ecc9 | |||
| ba7ec14f76 | |||
| 9f0117723c | |||
| f72b6a20db | |||
| 2f798209bb | |||
| eec8f16424 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve `Button` and `ButtonGroup` styling
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix scroll of page outline
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Support "objectivec" as alias for Objective C syntax
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Support custom AI providers
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gitbook/browser-types": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
First version of the public package for typing script integrations.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Small fixes to search modal
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixes to `PageAside`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Tweaks to AIChatButton and AIChatInput
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Allow integrations to provide tools to the Docs Assistant
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve vertical alignment of site items and fix floating page aside
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix search bar layout shift caused by ToC
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Emit a <link rel="alternate"> for the markdown version of a page
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Support new coverDefinitionDark for cards & image type
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Clear AI chat properly
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix bug in search highlight in GBO
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Page outline: scroll to active item
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@gitbook/openapi-parser": patch
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve OpenAPI parsing errors
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix Custom logo not rendering on the published site
|
||||||
@@ -10,6 +10,17 @@
|
|||||||
"vercel": "^39.3.0",
|
"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,84 @@
|
|||||||
|
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
|
|
||||||
|
export type GitBookIntegrationEvent = 'load' | 'unload';
|
||||||
|
|
||||||
|
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
|
||||||
|
|
||||||
|
export type GitBookIntegrationTool = AIToolDefinition & {
|
||||||
|
/**
|
||||||
|
* Confirmation action to be displayed to the user before executing the tool.
|
||||||
|
*/
|
||||||
|
confirmation?: {
|
||||||
|
icon?: IconName;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the tool is executed.
|
||||||
|
* The input is provided by the AI assistant following the input schema of the tool.
|
||||||
|
*/
|
||||||
|
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitBookAssistant = {
|
||||||
|
/**
|
||||||
|
* Name of the assistant displayed in the UI.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon of the assistant displayed in the UI.
|
||||||
|
* Any FontAwesome icon name is supported.
|
||||||
|
* @example 'sparkle'
|
||||||
|
*/
|
||||||
|
icon: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the assistant is opened.
|
||||||
|
*/
|
||||||
|
open: (query?: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to display the triggers for this assistant in the UI.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
ui?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitBookGlobal = {
|
||||||
|
/**
|
||||||
|
* Register an event listener.
|
||||||
|
*/
|
||||||
|
addEventListener: (
|
||||||
|
type: GitBookIntegrationEvent,
|
||||||
|
func: GitBookIntegrationEventCallback
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an event listener.
|
||||||
|
*/
|
||||||
|
removeEventListener: (
|
||||||
|
type: GitBookIntegrationEvent,
|
||||||
|
func: GitBookIntegrationEventCallback
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom tool to be exposed to the AI assistant.
|
||||||
|
*/
|
||||||
|
registerTool: (tool: GitBookIntegrationTool) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom assistant to be available on the site.
|
||||||
|
*/
|
||||||
|
registerAssistant: (assistant: GitBookAssistant) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/**
|
||||||
|
* Global `window.GitBook` object accessible by integrations.
|
||||||
|
*/
|
||||||
|
GitBook?: GitBookGlobal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -97,53 +97,9 @@ const searchTestCases: Test[] = [
|
|||||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
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,97 +20,100 @@ export async function streamRenderAIMessage(
|
|||||||
rawStream: AsyncIterable<AIStreamResponse>,
|
rawStream: AsyncIterable<AIStreamResponse>,
|
||||||
options?: RenderAIMessageOptions
|
options?: RenderAIMessageOptions
|
||||||
) {
|
) {
|
||||||
const message: AIMessage = {
|
return traceErrorOnly('AI.streamRenderAIMessage', async () => {
|
||||||
id: '',
|
const message: AIMessage = {
|
||||||
role: AIMessageRole.Assistant,
|
id: '',
|
||||||
steps: [],
|
role: AIMessageRole.Assistant,
|
||||||
};
|
steps: [],
|
||||||
|
|
||||||
const updateProcessingMessageStep = (
|
|
||||||
stepIndex: number,
|
|
||||||
callback: (step: AIMessageStep) => void
|
|
||||||
) => {
|
|
||||||
if (stepIndex > message.steps.length) {
|
|
||||||
throw new Error(
|
|
||||||
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.steps[stepIndex]) {
|
|
||||||
message.steps = [...message.steps];
|
|
||||||
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
|
||||||
callback(message.steps[stepIndex]);
|
|
||||||
} else {
|
|
||||||
message.steps = [
|
|
||||||
...message.steps,
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
object: 'document',
|
|
||||||
data: {},
|
|
||||||
nodes: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
callback(message.steps[stepIndex]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch the full-context in the background to avoid blocking the stream.
|
|
||||||
const promiseContext = fetchServerActionSiteContext(baseContext);
|
|
||||||
|
|
||||||
return parseResponse<{
|
|
||||||
content: React.ReactNode;
|
|
||||||
event: AIStreamResponse;
|
|
||||||
}>(rawStream, async (event) => {
|
|
||||||
switch (event.type) {
|
|
||||||
/**
|
|
||||||
* The agent is processing a tool call in a new message.
|
|
||||||
*/
|
|
||||||
case 'response_tool_call': {
|
|
||||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
|
||||||
step.toolCalls ??= [];
|
|
||||||
step.toolCalls.push(event.toolCall);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The agent is writing the content of a new message.
|
|
||||||
*/
|
|
||||||
case 'response_reasoning':
|
|
||||||
case 'response_document': {
|
|
||||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
|
||||||
const container = event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
|
||||||
|
|
||||||
step[container] ??= {
|
|
||||||
object: 'document',
|
|
||||||
data: {},
|
|
||||||
nodes: [],
|
|
||||||
};
|
|
||||||
step[container] = {
|
|
||||||
...step[container],
|
|
||||||
nodes: [...step[container].nodes],
|
|
||||||
};
|
|
||||||
if (event.operation === 'insert') {
|
|
||||||
step[container].nodes.push(...event.blocks);
|
|
||||||
} else {
|
|
||||||
step[container].nodes.splice(
|
|
||||||
-event.blocks.length,
|
|
||||||
event.blocks.length,
|
|
||||||
...event.blocks
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
event,
|
|
||||||
content: (
|
|
||||||
<AIMessageView message={message} context={await promiseContext} {...options} />
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateProcessingMessageStep = (
|
||||||
|
stepIndex: number,
|
||||||
|
callback: (step: AIMessageStep) => void
|
||||||
|
) => {
|
||||||
|
if (stepIndex > message.steps.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.steps[stepIndex]) {
|
||||||
|
message.steps = [...message.steps];
|
||||||
|
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
||||||
|
callback(message.steps[stepIndex]);
|
||||||
|
} else {
|
||||||
|
message.steps = [
|
||||||
|
...message.steps,
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
object: 'document',
|
||||||
|
data: {},
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
callback(message.steps[stepIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch the full-context in the background to avoid blocking the stream.
|
||||||
|
const promiseContext = fetchServerActionSiteContext(baseContext);
|
||||||
|
|
||||||
|
return parseResponse<{
|
||||||
|
content: React.ReactNode;
|
||||||
|
event: AIStreamResponse;
|
||||||
|
}>(rawStream, async (event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
/**
|
||||||
|
* The agent is processing a tool call in a new message.
|
||||||
|
*/
|
||||||
|
case 'response_tool_call': {
|
||||||
|
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||||
|
step.toolCalls ??= [];
|
||||||
|
step.toolCalls.push(event.toolCall);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent is writing the content of a new message.
|
||||||
|
*/
|
||||||
|
case 'response_reasoning':
|
||||||
|
case 'response_document': {
|
||||||
|
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||||
|
const container =
|
||||||
|
event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
||||||
|
|
||||||
|
step[container] ??= {
|
||||||
|
object: 'document',
|
||||||
|
data: {},
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
step[container] = {
|
||||||
|
...step[container],
|
||||||
|
nodes: [...step[container].nodes],
|
||||||
|
};
|
||||||
|
if (event.operation === 'insert') {
|
||||||
|
step[container].nodes.push(...event.blocks);
|
||||||
|
} else {
|
||||||
|
step[container].nodes.splice(
|
||||||
|
-event.blocks.length,
|
||||||
|
event.blocks.length,
|
||||||
|
...event.blocks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
content: (
|
||||||
|
<AIMessageView message={message} context={await promiseContext} {...options} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
'use server';
|
'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 context = await getServerActionBaseContext();
|
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const context = await getServerActionBaseContext();
|
||||||
|
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,
|
||||||
{
|
{
|
||||||
role: AIMessageRole.User,
|
input: message
|
||||||
content: message,
|
? [
|
||||||
context: messageContext,
|
{
|
||||||
},
|
role: AIMessageRole.User,
|
||||||
],
|
content: message,
|
||||||
output: { type: 'document' },
|
context: messageContext,
|
||||||
model: AIModel.ReasoningLow,
|
},
|
||||||
previousResponseId,
|
]
|
||||||
});
|
: [],
|
||||||
|
model: AIModel.ReasoningLow,
|
||||||
|
previousResponseId,
|
||||||
|
toolCall,
|
||||||
|
tools,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
return await streamRenderAIMessage(context, rawStream, options);
|
||||||
|
});
|
||||||
|
|
||||||
for await (const output of stream) {
|
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,16 +15,18 @@ export async function* streamAIResponseById({
|
|||||||
responseId: string;
|
responseId: string;
|
||||||
options?: RenderAIMessageOptions;
|
options?: RenderAIMessageOptions;
|
||||||
}) {
|
}) {
|
||||||
const context = await getServerActionBaseContext();
|
const { stream } = await traceErrorOnly('AI.streamAIResponseById', async () => {
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const context = await getServerActionBaseContext();
|
||||||
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
const api = await context.dataFetcher.api();
|
const api = await context.dataFetcher.api();
|
||||||
const rawStream = api.orgs.streamExistingAiResponseInSite(
|
const rawStream = api.orgs.streamExistingAiResponseInSite(
|
||||||
siteURLData.organization,
|
siteURLData.organization,
|
||||||
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,136 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
|
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
|
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||||
|
import { useAIChatController, useAIChatState } from '.';
|
||||||
|
import { AIChatIcon, getAIChatName } from '../AIChat';
|
||||||
|
import { useIntegrationAssistants } from '../Integrations';
|
||||||
|
import { useSearch } from '../Search/useSearch';
|
||||||
|
|
||||||
|
// Unify assistants configuration context with the assistants hook in one place
|
||||||
|
export type AIConfig = {
|
||||||
|
aiMode: CustomizationAIMode;
|
||||||
|
trademark: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
|
||||||
|
/**
|
||||||
|
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
|
||||||
|
* @example '123e4567-e89b-12d3-a456-426614174000'
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display mode for the assistant. Currently, only `overlay` is supported for custom assistants.
|
||||||
|
* - `overlay`: Display the assistant in an overlay on top of the page.
|
||||||
|
* - `sidebar`: Display the assistant in a sidebar next to the page. Only supported for the GitBook Assistant.
|
||||||
|
* - `search`: Display the assistant inside the search container. Only supported for GitBook AI Search.
|
||||||
|
* @default 'overlay'
|
||||||
|
*/
|
||||||
|
mode?: 'overlay' | 'sidebar' | 'search';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon of the assistant displayed in the UI.
|
||||||
|
*/
|
||||||
|
icon: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AIContext = React.createContext<AIConfig | null>(null);
|
||||||
|
|
||||||
|
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
|
||||||
|
const { aiMode, trademark, children } = props;
|
||||||
|
const value = React.useMemo(() => ({ aiMode, trademark }), [aiMode, trademark]);
|
||||||
|
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAIConfig(): AIConfig {
|
||||||
|
const ctx = React.useContext(AIContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useAI must be used within AIContextProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIContext = {
|
||||||
|
config: AIConfig;
|
||||||
|
assistants: Assistant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified assistants list combining the built-in GitBook Assistant (when enabled)
|
||||||
|
* with any custom assistants registered at runtime.
|
||||||
|
*/
|
||||||
|
export function useAI(): AIContext {
|
||||||
|
const config = useAIConfig();
|
||||||
|
const chat = useAIChatState();
|
||||||
|
const chatController = useAIChatController();
|
||||||
|
const language = useLanguage();
|
||||||
|
const [, setSearchState] = useSearch();
|
||||||
|
|
||||||
|
const assistants: Assistant[] = [];
|
||||||
|
|
||||||
|
if (config.aiMode === CustomizationAIMode.Assistant) {
|
||||||
|
assistants.push({
|
||||||
|
id: 'gitbook-assistant',
|
||||||
|
label: getAIChatName(config.trademark),
|
||||||
|
icon: (
|
||||||
|
<AIChatIcon
|
||||||
|
state={chat.loading ? 'thinking' : 'default'}
|
||||||
|
trademark={config.trademark}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
open: (query?: string) => {
|
||||||
|
chatController.open();
|
||||||
|
if (query) {
|
||||||
|
chatController.postMessage({ message: query });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ui: true,
|
||||||
|
mode: 'sidebar',
|
||||||
|
});
|
||||||
|
} else if (config.aiMode === CustomizationAIMode.Search) {
|
||||||
|
assistants.push({
|
||||||
|
id: 'gitbook-ai-search',
|
||||||
|
label: tString(language, 'ai_chat_context_badge'),
|
||||||
|
icon: (
|
||||||
|
<div className="relative">
|
||||||
|
<Icon icon="search" className="size-4" />
|
||||||
|
<Icon
|
||||||
|
icon="sparkle"
|
||||||
|
iconStyle={IconStyle.Solid}
|
||||||
|
className="absolute top-[2.5px] left-[2.6px] size-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
open: (query?: string) => {
|
||||||
|
if (query) {
|
||||||
|
setSearchState((prev) =>
|
||||||
|
prev ? { ...prev, query: null, ask: query, open: true } : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ui: false,
|
||||||
|
mode: 'search',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrationAssistants = useIntegrationAssistants();
|
||||||
|
if (integrationAssistants.length > 0) {
|
||||||
|
assistants.push(
|
||||||
|
...integrationAssistants.map((assistant) => ({
|
||||||
|
...assistant,
|
||||||
|
icon: <Icon icon={assistant.icon as IconName} className="size-4" />,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
assistants,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,9 +2,17 @@
|
|||||||
|
|
||||||
import * as zustand from 'zustand';
|
import * as zustand from 'zustand';
|
||||||
|
|
||||||
import { AIMessageRole } from '@gitbook/api';
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { tString } from '@/intl/translate';
|
||||||
|
import {
|
||||||
|
AIMessageRole,
|
||||||
|
type AIStreamResponseToolCallPending,
|
||||||
|
type AIToolCallResult,
|
||||||
|
} from '@gitbook/api';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
import * as React from 'react';
|
import * 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 +23,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 +69,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 +99,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 +117,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 +127,14 @@ 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();
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
// 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 +143,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 +156,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 {
|
||||||
|
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 {
|
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 +258,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 +267,53 @@ 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: tString(
|
||||||
|
language,
|
||||||
|
'tool_call_skipped',
|
||||||
|
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 +325,89 @@ export function useAIChatController(): AIChatController {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
setState((state) => ({
|
// Execute the tool call if it doesn't require confirmation
|
||||||
|
if (toolToExecute) {
|
||||||
|
await executeToolCall(toolToExecute);
|
||||||
|
}
|
||||||
|
|
||||||
|
globalState.setState((state) => ({
|
||||||
...state,
|
...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 +420,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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { t, tString, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
@@ -9,7 +10,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,12 +25,14 @@ 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();
|
||||||
},
|
},
|
||||||
[]
|
{
|
||||||
|
enableOnFormTags: true,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
@@ -121,7 +124,12 @@ export function AIChatWindow(props: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="ai-chat"
|
data-testid="ai-chat"
|
||||||
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enter-from-right lg:pr-4 lg:pl-0 xl:w-96"
|
className={tcls(
|
||||||
|
'ai-chat sticky right-0 z-40 ml-8 flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-[width] duration-300 sm:px-6 lg:w-80 lg:animate-enter-from-right lg:px-0 xl:w-96',
|
||||||
|
'lg:[html[style*="--toc-top-offset"]_&]:top-(--toc-top-offset)!',
|
||||||
|
'lg:[html[style*="--toc-height"]_&]:h-(--toc-height)!',
|
||||||
|
'lg:-mr-[calc(max(0px,((100vw-96rem)/2)))]' // screen (100vw) - sidebar (18rem) - content (48rem) - chat window (20rem)
|
||||||
|
)}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
<div className="relative flex h-full grow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint-base text-sm text-tint depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
<div className="relative flex h-full grow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint-base text-sm text-tint depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
||||||
@@ -137,7 +145,9 @@ export function AIChatWindow(props: {
|
|||||||
? 'working'
|
? 'working'
|
||||||
: 'thinking'
|
: 'thinking'
|
||||||
: chat.messages.length > 0
|
: chat.messages.length > 0
|
||||||
? 'done'
|
? chat.pendingTools.length > 0
|
||||||
|
? 'confirm'
|
||||||
|
: 'done'
|
||||||
: 'default'
|
: 'default'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type React from 'react';
|
|||||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error';
|
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
|
||||||
trademark?: boolean;
|
trademark?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ export const AIChatIcon = ({
|
|||||||
preserveAspectRatio="xMaxYMid meet"
|
preserveAspectRatio="xMaxYMid meet"
|
||||||
className={className}
|
className={className}
|
||||||
aria-busy={state === 'thinking'}
|
aria-busy={state === 'thinking'}
|
||||||
|
overflow="visible"
|
||||||
{...props}
|
{...props}
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -56,6 +57,7 @@ export const AIChatIcon = ({
|
|||||||
(state === 'working' || state === 'thinking') &&
|
(state === 'working' || state === 'thinking') &&
|
||||||
'animate-[fadeIn_.5s_.3s_both,spin_2s_1s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
'animate-[fadeIn_.5s_.3s_both,spin_2s_1s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
||||||
state === 'done' && 'animate-[fadeOut_.5s_both]',
|
state === 'done' && 'animate-[fadeOut_.5s_both]',
|
||||||
|
state === 'confirm' && 'animate-[fadeOut_.5s_both]',
|
||||||
state === 'default' && 'animate-[fadeIn_0s_both]',
|
state === 'default' && 'animate-[fadeIn_0s_both]',
|
||||||
state === 'error' && 'hidden'
|
state === 'error' && 'hidden'
|
||||||
)}
|
)}
|
||||||
@@ -95,10 +97,24 @@ export const AIChatIcon = ({
|
|||||||
state === 'done'
|
state === 'done'
|
||||||
? 'animate-[fadeIn_.5s_.3s_both]'
|
? 'animate-[fadeIn_.5s_.3s_both]'
|
||||||
: 'animate-[fadeOut_.5s_both]',
|
: 'animate-[fadeOut_.5s_both]',
|
||||||
state === 'intro' && 'hidden'
|
state === 'intro' && 'hidden',
|
||||||
|
state === 'confirm' && 'hidden'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Confirm */}
|
||||||
|
<path
|
||||||
|
className={tcls(
|
||||||
|
'fill-primary-9',
|
||||||
|
state === 'confirm'
|
||||||
|
? 'animate-[fadeIn_.5s_.3s_both,bounceSmall_1s_infinite_both]'
|
||||||
|
: state === 'thinking'
|
||||||
|
? 'animate-[fadeOut_.5s_both]'
|
||||||
|
: 'hidden'
|
||||||
|
)}
|
||||||
|
d="M12.9463 5.24512C13.3688 5.24422 13.713 5.58625 13.7139 6.00879C13.7146 6.43114 13.3725 6.77338 12.9502 6.77441C12.5279 6.77505 12.1845 6.43408 12.1836 6.01172C12.1828 5.58953 12.5242 5.24649 12.9463 5.24512ZM13.0391 0.0751953C14.0688 0.0730893 14.9049 0.90586 14.9072 1.93555C14.9084 2.5063 14.6484 3.04679 14.2012 3.40137L13.7773 3.7373C13.6151 3.86604 13.5201 4.06239 13.5205 4.26953V4.30371C13.5211 4.62139 13.2639 4.879 12.9463 4.87988C12.6288 4.88032 12.3701 4.62417 12.3691 4.30664V4.27246C12.3679 3.71272 12.6238 3.18263 13.0625 2.83496L13.4854 2.49902C13.6565 2.36341 13.7562 2.1568 13.7559 1.93848C13.755 1.54463 13.4358 1.22503 13.042 1.22559H12.9385C12.488 1.22679 12.1225 1.59352 12.123 2.04395L12.124 2.21875C12.1245 2.53649 11.8676 2.79522 11.5498 2.7959C11.2321 2.79653 10.9746 2.53928 10.9736 2.22168L10.9727 2.04688C10.9706 0.960578 11.8493 0.0778178 12.9355 0.0751953H13.0391Z"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<path
|
<path
|
||||||
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
||||||
|
|||||||
@@ -45,16 +45,18 @@ export function AIChatInput(props: {
|
|||||||
}, [disabled, loading]);
|
}, [disabled, loading]);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'mod+j',
|
'mod+i',
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
},
|
},
|
||||||
[]
|
{
|
||||||
|
enableOnFormTags: true,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
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 +101,12 @@ export function AIChatInput(props: {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
{!disabled ? (
|
||||||
<KeyboardShortcut keys={['mod', 'j']} />
|
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
||||||
</div>
|
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
|
||||||
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2">
|
</div>
|
||||||
|
) : 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 +130,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,69 @@
|
|||||||
|
'use client';
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { t } from '@/intl/translate';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import type { AIChatState } from '../AI';
|
||||||
|
import { Button } from '../primitives';
|
||||||
|
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display buttons to confirm tool calls.
|
||||||
|
*/
|
||||||
|
export function AIChatToolConfirmations(props: {
|
||||||
|
chat: AIChatState;
|
||||||
|
}) {
|
||||||
|
const { chat } = props;
|
||||||
|
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
|
useHotkeys(
|
||||||
|
'mod+enter',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
chat.pendingTools[0]?.confirm();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableOnFormTags: true,
|
||||||
|
},
|
||||||
|
[chat.pendingTools]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||||
|
{chat.pendingTools.map((tool, index) => (
|
||||||
|
<div
|
||||||
|
className="flex w-full animate-present-slow flex-col gap-1"
|
||||||
|
key={index}
|
||||||
|
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
tool.confirm();
|
||||||
|
}}
|
||||||
|
tabIndex={index}
|
||||||
|
label={tool.label}
|
||||||
|
className="w-full justify-center"
|
||||||
|
size={index === 0 ? 'default' : 'medium'}
|
||||||
|
variant={index === 0 ? 'primary' : 'secondary'}
|
||||||
|
icon={tool.icon}
|
||||||
|
/>
|
||||||
|
{index === 0 && (
|
||||||
|
<div
|
||||||
|
className="flex pointer-none:hidden w-full animate-fade-in-slow items-center justify-end gap-2 text-tint text-xs"
|
||||||
|
style={{ animationDelay: '1000ms' }}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
language,
|
||||||
|
'press_to_confirm',
|
||||||
|
<KeyboardShortcut
|
||||||
|
keys={['mod', 'enter']}
|
||||||
|
className="mx-0 text-tint"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
|
|||||||
import { headers } from 'next/headers';
|
import { 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,40 +41,42 @@ 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) {
|
||||||
const [context, result] = await Promise.all([
|
return traceErrorOnly('Ads.renderAd', async () => {
|
||||||
getServerActionBaseContext(),
|
const [context, result] = await Promise.all([
|
||||||
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
|
getServerActionBaseContext(),
|
||||||
]);
|
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
|
||||||
|
]);
|
||||||
|
|
||||||
const mode = options.source === 'live' ? options.mode : 'classic';
|
const mode = options.source === 'live' ? options.mode : 'classic';
|
||||||
if (!result || !result.ad.description || !result.ad.statlink) {
|
if (!result || !result.ad.description || !result.ad.statlink) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ad } = result;
|
const { ad } = result;
|
||||||
|
|
||||||
const insightsAd: SiteInsightsAd | null =
|
const insightsAd: SiteInsightsAd | null =
|
||||||
options.source === 'live'
|
options.source === 'live'
|
||||||
? {
|
? {
|
||||||
placement: options.placement,
|
placement: options.placement,
|
||||||
zoneId: options.zoneId,
|
zoneId: options.zoneId,
|
||||||
domain: 'company' in ad ? ad.company : '',
|
domain: 'company' in ad ? ad.company : '',
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
{mode === 'classic' || !('callToAction' in ad) ? (
|
{mode === 'classic' || !('callToAction' in ad) ? (
|
||||||
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={context} />
|
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={context} />
|
||||||
) : (
|
) : (
|
||||||
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={context} />
|
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={context} />
|
||||||
)}
|
)}
|
||||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
insightsAd,
|
insightsAd,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAd({
|
async function fetchAd({
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function AnnouncementBanner(props: {
|
|||||||
data-nosnippet=""
|
data-nosnippet=""
|
||||||
>
|
>
|
||||||
<div className="scroll-nojump">
|
<div className="scroll-nojump">
|
||||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
<div className="transition-all duration-300">
|
||||||
<div className={tcls('relative', CONTAINER_STYLE)}>
|
<div className={tcls('relative', CONTAINER_STYLE)}>
|
||||||
<Tag
|
<Tag
|
||||||
href={contentRef?.href ?? ''}
|
href={contentRef?.href ?? ''}
|
||||||
|
|||||||
@@ -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,15 +12,17 @@ export async function hasContentBeenUpdated(props: {
|
|||||||
changeRequestId: string;
|
changeRequestId: string;
|
||||||
revisionId: string;
|
revisionId: string;
|
||||||
}) {
|
}) {
|
||||||
const context = await getServerActionBaseContext();
|
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
|
||||||
const changeRequest = await getDataOrNull(
|
const context = await getServerActionBaseContext();
|
||||||
context.dataFetcher.getChangeRequest({
|
const changeRequest = await getDataOrNull(
|
||||||
spaceId: props.spaceId,
|
context.dataFetcher.getChangeRequest({
|
||||||
changeRequestId: props.changeRequestId,
|
spaceId: props.spaceId,
|
||||||
})
|
changeRequestId: props.changeRequestId,
|
||||||
);
|
})
|
||||||
if (!changeRequest) {
|
);
|
||||||
return false;
|
if (!changeRequest) {
|
||||||
}
|
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,20 +20,22 @@ export async function renderIntegrationUi({
|
|||||||
};
|
};
|
||||||
request: RenderIntegrationUI;
|
request: RenderIntegrationUI;
|
||||||
}) {
|
}) {
|
||||||
const serverAction = await getServerActionBaseContext();
|
return traceErrorOnly('DocumentView.renderIntegrationUi', async () => {
|
||||||
const output = await fetchSafeIntegrationUI(serverAction, {
|
const serverAction = await getServerActionBaseContext();
|
||||||
integrationName: renderContext.integrationName,
|
const output = await fetchSafeIntegrationUI(serverAction, {
|
||||||
request,
|
integrationName: renderContext.integrationName,
|
||||||
});
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (output.error) {
|
||||||
|
return {
|
||||||
|
error: output.error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (output.error) {
|
|
||||||
return {
|
return {
|
||||||
error: output.error.message,
|
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
|
||||||
|
output: output.data,
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
|
|
||||||
output: output.data,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-column-preview-body {
|
.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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function Header(props: {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="scroll-nojump">
|
<div className="scroll-nojump">
|
||||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
<div className="transition-all duration-300">
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'gap-4',
|
'gap-4',
|
||||||
@@ -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]',
|
||||||
@@ -146,7 +145,7 @@ export function Header(props: {
|
|||||||
|
|
||||||
{sections || siteSpaces.length > 1 ? (
|
{sections || siteSpaces.length > 1 ? (
|
||||||
<div className="scroll-nojump">
|
<div className="scroll-nojump">
|
||||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
<div className="transition-all duration-300">
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'w-full',
|
'w-full',
|
||||||
|
|||||||
@@ -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,73 @@
|
|||||||
'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();
|
||||||
|
integrationAssistants.setState(
|
||||||
|
(state) => [
|
||||||
|
...state,
|
||||||
|
{ ...assistant, id, ui: assistant.ui ?? true, mode: 'overlay' },
|
||||||
|
],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
integrationAssistants.setState((state) => state.filter((a) => a.id !== id), true);
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
window.GitBook = gitbookGlobal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current state of the assistants.
|
||||||
|
*/
|
||||||
|
export function useIntegrationAssistants(): Array<Assistant> {
|
||||||
|
return zustand.useStore(integrationAssistants);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +84,5 @@ export function LoadIntegrations() {
|
|||||||
* Client function to dispatch a GitBook event.
|
* Client function to dispatch a GitBook event.
|
||||||
*/
|
*/
|
||||||
function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: any[]) {
|
function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: any[]) {
|
||||||
const handlers = window.GitBook?.events.get(type) || [];
|
events.get(type)?.forEach((handler) => handler(...args));
|
||||||
handlers.forEach((handler) => handler(...args));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,33 +32,29 @@ 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',
|
||||||
|
'pb-4',
|
||||||
|
|
||||||
'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 +64,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,190 +77,90 @@ 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:page-api-block:border',
|
'xl:max-2xl:[body:has(.openapi-block):has(.page-has-ancestors)_&]:pt-6.5'
|
||||||
'xl:max-2xl:page-api-block:border-tint',
|
|
||||||
'xl:max-2xl:page-api-block:hover:shadow-lg',
|
|
||||||
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
|
|
||||||
'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1',
|
|
||||||
'xl:max-2xl:page-api-block:rounded-md',
|
|
||||||
'xl:max-2xl:page-api-block:h-auto',
|
|
||||||
'xl:max-2xl:page-api-block:my-9',
|
|
||||||
'page-api-block:px-2',
|
|
||||||
'page-api-block:py-1.5'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{page.layout.outline ? (
|
<div
|
||||||
<>
|
className={tcls(
|
||||||
<div
|
'flex flex-col',
|
||||||
className={tcls(
|
'overflow-hidden',
|
||||||
'hidden',
|
'w-full',
|
||||||
'xl:max-2xl:page-api-block:flex',
|
'xl:max-2xl:rounded-corners:page-api-block:rounded-md',
|
||||||
'text-xs',
|
'xl:max-2xl:circular-corners:page-api-block:rounded-xl',
|
||||||
'tracking-wide',
|
'xl:max-2xl:page-api-block:border',
|
||||||
'font-semibold',
|
'xl:max-2xl:page-api-block:border-tint',
|
||||||
'uppercase',
|
'xl:max-2xl:page-api-block:bg-tint/9',
|
||||||
|
'xl:max-2xl:page-api-block:backdrop-blur-lg',
|
||||||
'flex-row',
|
'xl:max-2xl:contrast-more:page-api-block:bg-tint',
|
||||||
'items-center',
|
'xl:max-2xl:page-api-block:hover:shadow-lg',
|
||||||
'gap-2'
|
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
|
||||||
)}
|
'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1',
|
||||||
>
|
'xl:max-2xl:page-api-block:not-hover:*:hidden'
|
||||||
<Icon icon="block-quote" className={tcls('size-3')} />
|
)}
|
||||||
{t(language, 'on_this_page')}
|
>
|
||||||
<Icon
|
<PageAsideHeader context={context} />
|
||||||
icon="chevron-down"
|
{page.layout.outline ? (
|
||||||
className={tcls(
|
<div className="flex shrink flex-col overflow-hidden">
|
||||||
'size-3',
|
|
||||||
'opacity-6',
|
|
||||||
'ml-auto',
|
|
||||||
'xl:max-2xl:page-api-block:group-hover/aside:hidden'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={tcls(
|
|
||||||
'overflow-y-auto',
|
|
||||||
'overflow-x-visible',
|
|
||||||
|
|
||||||
'flex',
|
|
||||||
'flex-col',
|
|
||||||
'shrink',
|
|
||||||
'pb-12',
|
|
||||||
|
|
||||||
'sticky',
|
|
||||||
'lg:top:0',
|
|
||||||
'lg:site-header:top-16',
|
|
||||||
'lg:site-header-sections:top-27',
|
|
||||||
|
|
||||||
'gap-6',
|
|
||||||
'pt-10',
|
|
||||||
|
|
||||||
'xl:max-2xl:page-api-block:py-0',
|
|
||||||
// Hide it for api page, until hovered
|
|
||||||
'xl:max-2xl:page-api-block:hidden',
|
|
||||||
'xl:max-2xl:page-api-block:group-hover/aside:flex'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{document ? (
|
{document ? (
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<PageAsideSections document={document} context={context} />
|
<PageAsideSections document={document} context={context} />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<PageAsideActions
|
||||||
className={tcls(
|
page={page}
|
||||||
'flex',
|
withPageFeedback={withPageFeedback}
|
||||||
'flex-col',
|
context={context}
|
||||||
'gap-3',
|
/>
|
||||||
'sidebar-list-default:px-3',
|
|
||||||
'border-t',
|
|
||||||
'first:border-none',
|
|
||||||
'border-tint-subtle',
|
|
||||||
'py-6',
|
|
||||||
'first:pt-0',
|
|
||||||
'xl:max-2xl:page-api-block:px-3',
|
|
||||||
'empty:hidden'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{withPageFeedback ? (
|
|
||||||
<React.Suspense fallback={null}>
|
|
||||||
<PageFeedbackForm pageId={page.id} />
|
|
||||||
</React.Suspense>
|
|
||||||
) : null}
|
|
||||||
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href={urlJoin(space.gitSync.url, page.git.path)}
|
|
||||||
className={tcls(
|
|
||||||
'flex',
|
|
||||||
'flex-row',
|
|
||||||
'items-center',
|
|
||||||
'text-sm',
|
|
||||||
'hover:text-tint-strong',
|
|
||||||
'links-accent:hover:underline',
|
|
||||||
'links-accent:hover:underline-offset-4',
|
|
||||||
'links-accent:hover:decoration-[3px]',
|
|
||||||
'links-accent:hover:decoration-primary-subtle',
|
|
||||||
'py-2'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
space.gitSync.installationProvider === 'gitlab'
|
|
||||||
? 'gitlab'
|
|
||||||
: 'github'
|
|
||||||
}
|
|
||||||
className={tcls('size-4', 'mr-1.5')}
|
|
||||||
/>
|
|
||||||
{t(language, 'edit_on_git', getGitSyncName(space))}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{customization.pdf.enabled ? (
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href={pdfHref}
|
|
||||||
className={tcls(
|
|
||||||
'flex',
|
|
||||||
'flex-row',
|
|
||||||
'items-center',
|
|
||||||
'text-sm',
|
|
||||||
'hover:text-tint-strong',
|
|
||||||
'links-accent:hover:underline',
|
|
||||||
'links-accent:hover:underline-offset-4',
|
|
||||||
'links-accent:hover:decoration-[3px]',
|
|
||||||
'links-accent:hover:decoration-primary-subtle',
|
|
||||||
'py-2'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="file-pdf"
|
|
||||||
className={tcls('size-4', 'mr-1.5')}
|
|
||||||
/>
|
|
||||||
{t(language, 'pdf_download')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={tcls(
|
|
||||||
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 xl:max-2xl:page-api-block:hidden xl:max-2xl:page-api-block:pb-0 xl:max-2xl:page-api-block:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
|
|
||||||
'xl:max-2xl:page-api-block:bg-transparent'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Mode Switcher */}
|
|
||||||
{customization.themes.toggeable ? (
|
|
||||||
<div className="mt-4 flex items-center justify-end">
|
|
||||||
<React.Suspense fallback={null}>
|
|
||||||
<ThemeToggler />
|
|
||||||
</React.Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Ad
|
{customization.themes.toggeable || site.ads ? (
|
||||||
zoneId={
|
<PageAsideFooter context={context} />
|
||||||
site?.ads && site.ads.status === SiteAdsStatus.Live ? site.ads.zoneId : null
|
) : null}
|
||||||
}
|
|
||||||
placement={SiteInsightsAdPlacement.Aside}
|
|
||||||
spaceId={space.id}
|
|
||||||
siteAdsStatus={site?.ads?.status ? site.ads.status : undefined}
|
|
||||||
ignore={process.env.NODE_ENV !== 'production'}
|
|
||||||
style={site?.ads ? 'mt-4' : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PageAsideHeader(props: { context: GitBookSiteContext }) {
|
||||||
|
const { context } = props;
|
||||||
|
const language = getSpaceLanguage(context.customization);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'hidden',
|
||||||
|
'xl:max-2xl:page-api-block:flex!',
|
||||||
|
'text-xs',
|
||||||
|
'tracking-wide',
|
||||||
|
'font-semibold',
|
||||||
|
'uppercase',
|
||||||
|
'px-2',
|
||||||
|
'py-1.5',
|
||||||
|
|
||||||
|
'flex-row',
|
||||||
|
'items-center',
|
||||||
|
'gap-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon="block-quote" className={tcls('size-3')} />
|
||||||
|
{t(language, 'on_this_page')}
|
||||||
|
<Icon icon="chevron-down" className={tcls('size-3', 'opacity-6', 'ml-auto')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) {
|
async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) {
|
||||||
const { document, context } = props;
|
const { document, context } = props;
|
||||||
|
|
||||||
@@ -272,6 +169,134 @@ async function PageAsideSections(props: { document: JSONDocument; context: GitBo
|
|||||||
return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
|
return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PageAsideActions(props: {
|
||||||
|
withPageFeedback: boolean;
|
||||||
|
context: GitBookSiteContext;
|
||||||
|
page: RevisionPageDocument;
|
||||||
|
}) {
|
||||||
|
const { page, withPageFeedback, context } = props;
|
||||||
|
const { customization, space } = context;
|
||||||
|
const language = getSpaceLanguage(customization);
|
||||||
|
|
||||||
|
const pdfHref = context.linker.toPathInSpace(
|
||||||
|
`~gitbook/pdf?${getPDFURLSearchParams({
|
||||||
|
page: page.id,
|
||||||
|
only: true,
|
||||||
|
limit: 100,
|
||||||
|
}).toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'flex-col',
|
||||||
|
'gap-3',
|
||||||
|
'sidebar-list-default:px-3',
|
||||||
|
'border-t',
|
||||||
|
'first:border-none',
|
||||||
|
'border-tint-subtle',
|
||||||
|
'pt-5',
|
||||||
|
'first:pt-0',
|
||||||
|
'xl:max-2xl:page-api-block:p-5',
|
||||||
|
'empty:hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{withPageFeedback ? (
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<PageFeedbackForm pageId={page.id} />
|
||||||
|
</React.Suspense>
|
||||||
|
) : null}
|
||||||
|
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={urlJoin(space.gitSync.url, page.git.path)}
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'flex-row',
|
||||||
|
'items-center',
|
||||||
|
'text-sm',
|
||||||
|
'hover:text-tint-strong',
|
||||||
|
'links-accent:hover:underline',
|
||||||
|
'links-accent:hover:underline-offset-4',
|
||||||
|
'links-accent:hover:decoration-[3px]',
|
||||||
|
'links-accent:hover:decoration-primary-subtle',
|
||||||
|
'py-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={
|
||||||
|
space.gitSync.installationProvider === 'gitlab'
|
||||||
|
? 'gitlab'
|
||||||
|
: 'github'
|
||||||
|
}
|
||||||
|
className={tcls('size-4', 'mr-1.5')}
|
||||||
|
/>
|
||||||
|
{t(language, 'edit_on_git', getGitSyncName(space))}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{customization.pdf.enabled ? (
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={pdfHref}
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'flex-row',
|
||||||
|
'items-center',
|
||||||
|
'text-sm',
|
||||||
|
'hover:text-tint-strong',
|
||||||
|
'links-accent:hover:underline',
|
||||||
|
'links-accent:hover:underline-offset-4',
|
||||||
|
'links-accent:hover:decoration-[3px]',
|
||||||
|
'links-accent:hover:decoration-primary-subtle',
|
||||||
|
'py-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
|
||||||
|
{t(language, 'pdf_download')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function PageAsideFooter(props: { context: GitBookSiteContext }) {
|
||||||
|
const { context } = props;
|
||||||
|
const { customization, site, space } = context;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'sticky bottom-0 z-10 mt-auto flex flex-col',
|
||||||
|
'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
|
||||||
|
'border-tint-subtle xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2',
|
||||||
|
'pt-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Mode Switcher */}
|
||||||
|
{customization.themes.toggeable ? (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<ThemeToggler />
|
||||||
|
</React.Suspense>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Ad
|
||||||
|
zoneId={
|
||||||
|
site?.ads && site.ads.status === SiteAdsStatus.Live ? site.ads.zoneId : null
|
||||||
|
}
|
||||||
|
placement={SiteInsightsAdPlacement.Aside}
|
||||||
|
spaceId={space.id}
|
||||||
|
siteAdsStatus={site?.ads?.status ? site.ads.status : undefined}
|
||||||
|
ignore={process.env.NODE_ENV !== 'production'}
|
||||||
|
style={site?.ads ? 'mt-4' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getGitSyncName(space: Space): string {
|
function getGitSyncName(space: Space): string {
|
||||||
if (space.gitSync?.installationProvider === 'github') {
|
if (space.gitSync?.installationProvider === 'github') {
|
||||||
return 'GitHub';
|
return 'GitHub';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { useScrollActiveId } from '@/components/hooks';
|
import { useScrollActiveId } from '@/components/hooks';
|
||||||
@@ -15,6 +14,11 @@ import { AsideSectionHighlight } from './AsideSectionHighlight';
|
|||||||
*/
|
*/
|
||||||
const SECTION_INTERSECTING_THRESHOLD = 0.9;
|
const SECTION_INTERSECTING_THRESHOLD = 0.9;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offset from the top of the page when scrolling to the active item.
|
||||||
|
*/
|
||||||
|
const ACTIVE_ITEM_OFFSET = 100;
|
||||||
|
|
||||||
const springCurve = {
|
const springCurve = {
|
||||||
type: 'spring',
|
type: 'spring',
|
||||||
stiffness: 700,
|
stiffness: 700,
|
||||||
@@ -39,10 +43,25 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
|||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scrollContainerRef = React.useRef<HTMLUListElement>(null);
|
||||||
|
const activeItemRef = React.useRef<HTMLLIElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (activeId && activeItemRef.current && scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current?.scrollTo({
|
||||||
|
top: activeItemRef.current.offsetTop - ACTIVE_ITEM_OFFSET,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={tcls('sidebar-list-line:border-l', 'border-tint-subtle')}>
|
<ul
|
||||||
|
className="relative flex flex-col overflow-y-auto border-tint-subtle sidebar-list-line:border-l pb-5 xl:max-2xl:page-api-block:mt-0 xl:max-2xl:page-api-block:p-2"
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
>
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<motion.li
|
<li
|
||||||
key={section.id}
|
key={section.id}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'flex',
|
'flex',
|
||||||
@@ -50,8 +69,11 @@ 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']
|
||||||
)}
|
)}
|
||||||
|
ref={activeId === section.id ? activeItemRef : null}
|
||||||
>
|
>
|
||||||
{activeId === section.id ? (
|
{activeId === section.id ? (
|
||||||
<AsideSectionHighlight
|
<AsideSectionHighlight
|
||||||
@@ -146,7 +168,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
|||||||
{section.title}
|
{section.title}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</motion.li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,31 +43,30 @@ 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 =
|
||||||
sizes,
|
resolved?.file?.dimensions ??
|
||||||
source: resolved
|
(await context.imageResizer?.getImageSize(resolved?.href || defaultPageCover.src, {}));
|
||||||
? {
|
const attrs = await getImageAttributes({
|
||||||
src: resolved.href,
|
sizes,
|
||||||
size: resolved.file?.dimensions ?? null,
|
source: resolved
|
||||||
}
|
? {
|
||||||
: {
|
src: resolved.href,
|
||||||
src: defaultPageCover.src,
|
size: size ?? null,
|
||||||
size: {
|
}
|
||||||
width: defaultPageCover.width,
|
: {
|
||||||
height: defaultPageCover.height,
|
src: defaultPageCover.src,
|
||||||
},
|
size: {
|
||||||
|
width: defaultPageCover.width,
|
||||||
|
height: defaultPageCover.height,
|
||||||
},
|
},
|
||||||
quality: 100,
|
},
|
||||||
resize: context.imageResizer ?? false,
|
quality: 100,
|
||||||
}),
|
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) ? (
|
<AIActionsDropdown
|
||||||
<div
|
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
|
||||||
|
actions={pageActions}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'float-right mb-2 ml-4 xl:max-2xl:page-api-block:mr-58',
|
'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
|
||||||
ancestors.length > 0 ? '-mt-2' : '-mt-3 xs:mt-2'
|
hasAncestors ? '-my-1.5' : '-mt-3 xs:mt-2'
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
<AIActionsDropdown
|
|
||||||
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
|
|
||||||
withAIChat={withAIChat}
|
|
||||||
trademark={context.customization.trademark.enabled}
|
|
||||||
actions={pageActions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : 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,31 +61,29 @@ 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')}
|
onClick={() => onSubmitRating(PageFeedbackRating.Good)}
|
||||||
onClick={() => onSubmitRating(PageFeedbackRating.Good)}
|
active={rating === PageFeedbackRating.Good}
|
||||||
active={rating === PageFeedbackRating.Good}
|
disabled={rating !== undefined}
|
||||||
disabled={rating !== undefined}
|
/>
|
||||||
/>
|
<RatingButton
|
||||||
<RatingButton
|
rating={PageFeedbackRating.Ok}
|
||||||
rating={PageFeedbackRating.Ok}
|
label={tString(languages, 'was_this_helpful_neutral')}
|
||||||
label={tString(languages, 'was_this_helpful_neutral')}
|
onClick={() => onSubmitRating(PageFeedbackRating.Ok)}
|
||||||
onClick={() => onSubmitRating(PageFeedbackRating.Ok)}
|
active={rating === PageFeedbackRating.Ok}
|
||||||
active={rating === PageFeedbackRating.Ok}
|
disabled={rating !== undefined}
|
||||||
disabled={rating !== undefined}
|
/>
|
||||||
/>
|
<RatingButton
|
||||||
<RatingButton
|
rating={PageFeedbackRating.Bad}
|
||||||
rating={PageFeedbackRating.Bad}
|
label={tString(languages, 'was_this_helpful_negative')}
|
||||||
label={tString(languages, 'was_this_helpful_negative')}
|
onClick={() => onSubmitRating(PageFeedbackRating.Bad)}
|
||||||
onClick={() => onSubmitRating(PageFeedbackRating.Bad)}
|
active={rating === PageFeedbackRating.Bad}
|
||||||
active={rating === PageFeedbackRating.Bad}
|
disabled={rating !== undefined}
|
||||||
disabled={rating !== undefined}
|
/>
|
||||||
/>
|
</ButtonGroup>
|
||||||
</div>
|
|
||||||
</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}>
|
||||||
<LoadingStateProvider>{children}</LoadingStateProvider>
|
<HashProvider>
|
||||||
|
<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,39 @@ 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
|
initialRef.current = true;
|
||||||
setSearchState((prev) => (prev ? { ...prev, query: prev.ask, ask: null } : prev));
|
|
||||||
}
|
// For simplicity we're only triggering the first assistant
|
||||||
}, [state?.ask, setSearchState, aiMode]);
|
assistants[0].open(state?.ask ?? undefined);
|
||||||
|
}, [state?.ask, assistants.length, assistants[0]?.open]);
|
||||||
|
|
||||||
const onClose = React.useCallback(
|
const onClose = React.useCallback(
|
||||||
async (to?: string) => {
|
async (to?: string) => {
|
||||||
if (state) {
|
setSearchState((prev) =>
|
||||||
await setSearchState({
|
prev
|
||||||
...state,
|
? {
|
||||||
open: false,
|
...prev,
|
||||||
query: state.query === '' ? null : state.query,
|
open: false,
|
||||||
});
|
query: prev.query === '' ? null : prev.query,
|
||||||
}
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
if (to) {
|
if (to) {
|
||||||
router.push(to);
|
router.push(to);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[state, setSearchState, router]
|
[setSearchState, router]
|
||||||
);
|
);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
@@ -75,24 +83,26 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onOpen();
|
onOpen();
|
||||||
},
|
},
|
||||||
[]
|
{
|
||||||
|
enableOnFormTags: true,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
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 +132,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 +143,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 || withAI ? (
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
{isMultiVariants && !showAsk ? (
|
{isMultiVariants && !showAsk ? (
|
||||||
<SearchScopeToggle spaceTitle={spaceTitle} />
|
<SearchScopeToggle spaceTitle={spaceTitle} />
|
||||||
@@ -150,8 +160,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 +168,10 @@ export function SearchContainer(props: SearchContainerProps) {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
rootProps={{
|
rootProps={{
|
||||||
open: open,
|
open: state?.open ?? false,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
open ? onOpen() : onClose();
|
||||||
|
},
|
||||||
modal: isMobile,
|
modal: isMobile,
|
||||||
}}
|
}}
|
||||||
contentProps={{
|
contentProps={{
|
||||||
@@ -172,7 +184,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 +200,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,
|
{
|
||||||
open: false,
|
ask: question,
|
||||||
query: null,
|
query: null,
|
||||||
})}
|
open: assistant.mode === 'search',
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
assistant.open(question);
|
||||||
|
}
|
||||||
|
)}
|
||||||
active={active}
|
active={active}
|
||||||
leadingIcon={
|
leadingIcon={recommended ? 'search' : assistant.icon}
|
||||||
recommended ? (
|
|
||||||
<Icon icon="search" className="size-4" />
|
|
||||||
) : withAIChat ? (
|
|
||||||
<AIChatIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<Icon icon="sparkles" className="size-4" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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,11 +69,13 @@ 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[]> {
|
||||||
const context = await getServerActionBaseContext();
|
return traceErrorOnly('Search.searchAllSiteContent', async () => {
|
||||||
|
const context = await getServerActionBaseContext();
|
||||||
|
|
||||||
return await searchSiteContent(context, {
|
return await searchSiteContent(context, {
|
||||||
query,
|
query,
|
||||||
scope: { mode: 'all' },
|
scope: { mode: 'all' },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,16 +83,18 @@ export async function searchAllSiteContent(query: string): Promise<OrderedComput
|
|||||||
* 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[]> {
|
||||||
const context = await getServerActionBaseContext();
|
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const context = await getServerActionBaseContext();
|
||||||
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
return await searchSiteContent(context, {
|
return await searchSiteContent(context, {
|
||||||
query,
|
query,
|
||||||
// If we have a siteSectionId that means its a sections site use `current` mode
|
// If we have a siteSectionId that means its a sections site use `current` mode
|
||||||
// which searches in the current space + all default spaces of sections
|
// which searches in the current space + all default spaces of sections
|
||||||
scope: siteURLData.siteSection
|
scope: siteURLData.siteSection
|
||||||
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
|
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
|
||||||
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
|
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,120 +106,126 @@ export async function streamAskQuestion({
|
|||||||
}: {
|
}: {
|
||||||
question: string;
|
question: string;
|
||||||
}) {
|
}) {
|
||||||
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
|
return traceErrorOnly('Search.streamAskQuestion', async () => {
|
||||||
|
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const context = await fetchServerActionSiteContext(await getServerActionBaseContext());
|
const context = await fetchServerActionSiteContext(await getServerActionBaseContext());
|
||||||
|
|
||||||
const apiClient = await context.dataFetcher.api();
|
const apiClient = await context.dataFetcher.api();
|
||||||
|
|
||||||
const stream = apiClient.orgs.streamAskInSite(
|
const stream = apiClient.orgs.streamAskInSite(
|
||||||
context.organizationId,
|
context.organizationId,
|
||||||
context.site.id,
|
context.site.id,
|
||||||
{
|
{
|
||||||
question,
|
question,
|
||||||
context: {
|
context: {
|
||||||
siteSpaceId: context.siteSpace.id,
|
siteSpaceId: context.siteSpace.id,
|
||||||
|
},
|
||||||
|
scope: {
|
||||||
|
mode: 'default',
|
||||||
|
// Include the current site space regardless.
|
||||||
|
includedSiteSpaces: [context.siteSpace.id],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
scope: {
|
{ format: 'document' }
|
||||||
mode: 'default',
|
|
||||||
// Include the current site space regardless.
|
|
||||||
includedSiteSpaces: [context.siteSpace.id],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ format: 'document' }
|
|
||||||
);
|
|
||||||
|
|
||||||
const spacePromises = new Map<string, Promise<Revision>>();
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
const answer = chunk.answer;
|
|
||||||
|
|
||||||
// Register the space of each page source into the promise queue.
|
|
||||||
const spaces = answer.sources
|
|
||||||
.map((source) => {
|
|
||||||
if (source.type !== 'page') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!spacePromises.has(source.space)) {
|
|
||||||
spacePromises.set(
|
|
||||||
source.space,
|
|
||||||
throwIfDataError(
|
|
||||||
context.dataFetcher.getRevision({
|
|
||||||
spaceId: source.space,
|
|
||||||
revisionId: source.revision,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return source.space;
|
|
||||||
})
|
|
||||||
.filter(filterOutNullable);
|
|
||||||
|
|
||||||
// Get the pages for all spaces referenced by this answer.
|
|
||||||
const pages = await Promise.all(
|
|
||||||
spaces.map(async (space) => {
|
|
||||||
const revision = await spacePromises.get(space);
|
|
||||||
return { space, pages: revision?.pages };
|
|
||||||
})
|
|
||||||
).then((results) => {
|
|
||||||
return results.reduce((map, result) => {
|
|
||||||
if (result.pages) {
|
|
||||||
map.set(result.space, result.pages);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, new Map<string, RevisionPage[]>());
|
|
||||||
});
|
|
||||||
responseStream.update(
|
|
||||||
await transformAnswer(context, { answer: chunk.answer, spacePages: pages })
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
})()
|
|
||||||
.then(() => {
|
|
||||||
responseStream.done();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
responseStream.error(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
const spacePromises = new Map<string, Promise<Revision>>();
|
||||||
stream: responseStream.value,
|
for await (const chunk of stream) {
|
||||||
};
|
const answer = chunk.answer;
|
||||||
|
|
||||||
|
// Register the space of each page source into the promise queue.
|
||||||
|
const spaces = answer.sources
|
||||||
|
.map((source) => {
|
||||||
|
if (source.type !== 'page') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!spacePromises.has(source.space)) {
|
||||||
|
spacePromises.set(
|
||||||
|
source.space,
|
||||||
|
throwIfDataError(
|
||||||
|
context.dataFetcher.getRevision({
|
||||||
|
spaceId: source.space,
|
||||||
|
revisionId: source.revision,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.space;
|
||||||
|
})
|
||||||
|
.filter(filterOutNullable);
|
||||||
|
|
||||||
|
// Get the pages for all spaces referenced by this answer.
|
||||||
|
const pages = await Promise.all(
|
||||||
|
spaces.map(async (space) => {
|
||||||
|
const revision = await spacePromises.get(space);
|
||||||
|
return { space, pages: revision?.pages };
|
||||||
|
})
|
||||||
|
).then((results) => {
|
||||||
|
return results.reduce((map, result) => {
|
||||||
|
if (result.pages) {
|
||||||
|
map.set(result.space, result.pages);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, new Map<string, RevisionPage[]>());
|
||||||
|
});
|
||||||
|
responseStream.update(
|
||||||
|
await transformAnswer(context, { answer: chunk.answer, spacePages: pages })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.then(() => {
|
||||||
|
responseStream.done();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
responseStream.error(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream: responseStream.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream a list of suggested questions for the site.
|
* 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 }) {
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
return traceErrorOnly('Search.streamRecommendedQuestions', async () => {
|
||||||
const context = await getServerActionBaseContext();
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
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();
|
||||||
const apiStream = apiClient.orgs.streamRecommendedQuestionsInSite(
|
const apiStream = apiClient.orgs.streamRecommendedQuestionsInSite(
|
||||||
siteURLData.organization,
|
siteURLData.organization,
|
||||||
siteURLData.site,
|
siteURLData.site,
|
||||||
{
|
{
|
||||||
spaceId,
|
siteSpaceId: args.siteSpaceId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const chunk of apiStream) {
|
||||||
|
responseStream.update(chunk);
|
||||||
}
|
}
|
||||||
);
|
})()
|
||||||
|
.then(() => {
|
||||||
|
responseStream.done();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
responseStream.error(error);
|
||||||
|
});
|
||||||
|
|
||||||
for await (const chunk of apiStream) {
|
return { stream: responseStream.value };
|
||||||
responseStream.update(chunk);
|
});
|
||||||
}
|
|
||||||
})()
|
|
||||||
.then(() => {
|
|
||||||
responseStream.done();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
responseStream.error(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -85,79 +90,92 @@ export function SpaceLayout(props: {
|
|||||||
apiHost={GITBOOK_API_PUBLIC_URL}
|
apiHost={GITBOOK_API_PUBLIC_URL}
|
||||||
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
||||||
>
|
>
|
||||||
<Announcement context={context} />
|
<SearchContextProvider>
|
||||||
<Header withTopHeader={withTopHeader} context={context} search={searchAndAI} />
|
<AIContextProvider
|
||||||
{aiMode === CustomizationAIMode.Assistant ? (
|
aiMode={aiMode}
|
||||||
<AIChat trademark={customization.trademark.enabled} />
|
trademark={customization.trademark.enabled}
|
||||||
) : null}
|
>
|
||||||
<div className="scroll-nojump">
|
<Announcement context={context} />
|
||||||
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
|
<Header
|
||||||
<div
|
withTopHeader={withTopHeader}
|
||||||
className={tcls(
|
context={context}
|
||||||
'flex',
|
search={search}
|
||||||
'flex-col',
|
/>
|
||||||
'lg:flex-row',
|
<div className="scroll-nojump">
|
||||||
CONTAINER_STYLE,
|
<div className="motion-safe:transition-all motion-safe:duration-300">
|
||||||
'site-width-wide:max-w-full',
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex',
|
||||||
|
'flex-col',
|
||||||
|
'lg:flex-row',
|
||||||
|
CONTAINER_STYLE,
|
||||||
|
'site-width-wide:max-w-full',
|
||||||
|
|
||||||
// Ensure the footer is display below the viewport even if the content is not enough
|
// Ensure the footer is display below the viewport even if the content is not enough
|
||||||
withFooter && 'min-h-[calc(100vh-64px)]',
|
withFooter && 'min-h-[calc(100vh-64px)]',
|
||||||
withTopHeader ? null : 'lg:min-h-screen'
|
withTopHeader ? null : 'lg:min-h-screen'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TableOfContents
|
<TableOfContents
|
||||||
context={context}
|
context={context}
|
||||||
header={
|
header={
|
||||||
withTopHeader ? null : (
|
withTopHeader ? null : (
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'hidden',
|
'hidden',
|
||||||
'pr-4',
|
'pr-4',
|
||||||
'md:flex',
|
'md:flex',
|
||||||
'grow-0',
|
'grow-0',
|
||||||
'flex-wrap',
|
'flex-wrap',
|
||||||
'dark:shadow-light/1',
|
'dark:shadow-light/1',
|
||||||
'text-base/tight'
|
'text-base/tight'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HeaderLogo context={context} />
|
<HeaderLogo context={context} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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')}
|
||||||
sections={encodeClientSiteSections(
|
sections={encodeClientSiteSections(
|
||||||
context,
|
context,
|
||||||
sections
|
sections
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
{isMultiVariants && !sections && (
|
||||||
)}
|
<SpacesDropdown
|
||||||
{isMultiVariants && !sections && (
|
context={context}
|
||||||
<SpacesDropdown
|
siteSpace={siteSpace}
|
||||||
context={context}
|
siteSpaces={siteSpaces}
|
||||||
siteSpace={siteSpace}
|
className={tcls(
|
||||||
siteSpaces={siteSpaces}
|
'w-full',
|
||||||
className={tcls(
|
'page-no-toc:hidden',
|
||||||
'w-full',
|
'page-no-toc:site-header-none:flex'
|
||||||
'page-no-toc:hidden',
|
)}
|
||||||
'page-no-toc:site-header-none:flex'
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
</>
|
||||||
)}
|
}
|
||||||
</>
|
/>
|
||||||
}
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
/>
|
{children}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
|
</div>
|
||||||
|
{aiMode === CustomizationAIMode.Assistant ? (
|
||||||
|
<AIChat trademark={customization.trademark.enabled} />
|
||||||
|
) : null}
|
||||||
|
</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)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -168,10 +168,11 @@ export const Button = React.forwardRef<
|
|||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
{trailing}
|
||||||
</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 +186,21 @@ export const Button = React.forwardRef<
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonProps>(({ children }, ref) => {
|
export const ButtonGroup = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ButtonProps & { combinedShape?: boolean }
|
||||||
|
>(({ children, className, combinedShape = true, ...rest }, ref) => {
|
||||||
return (
|
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>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
function getOperatingSystem() {
|
function getOperatingSystem() {
|
||||||
@@ -25,28 +26,41 @@ export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue
|
|||||||
<div
|
<div
|
||||||
aria-busy={operatingSystem === null ? 'true' : undefined}
|
aria-busy={operatingSystem === null ? 'true' : undefined}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex',
|
'shortcut hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex',
|
||||||
operatingSystem
|
operatingSystem
|
||||||
? 'motion-safe:animate-fade-in motion-reduce:opacity-100'
|
? 'motion-safe:animate-fade-in motion-reduce:opacity-100'
|
||||||
: 'opacity-0'
|
: 'opacity-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{keys.map((key) => (
|
{keys.map((key, index) => {
|
||||||
<kbd
|
let element: React.ReactNode = key;
|
||||||
key={key}
|
|
||||||
className={tcls(
|
switch (key) {
|
||||||
'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1',
|
case 'mod':
|
||||||
key === 'mod'
|
element = operatingSystem === 'mac' ? '⌘' : 'Ctrl';
|
||||||
? operatingSystem === 'mac'
|
break;
|
||||||
? 'text-sm'
|
|
||||||
: 'text-xs'
|
case 'enter':
|
||||||
: 'uppercase',
|
element = <Icon icon="arrow-turn-down-left" className="size-[.75em]" />;
|
||||||
className
|
break;
|
||||||
)}
|
}
|
||||||
>
|
return (
|
||||||
{key === 'mod' ? (operatingSystem === 'mac' ? '⌘' : 'Ctrl') : key}
|
<kbd
|
||||||
</kbd>
|
key={index}
|
||||||
))}
|
className={tcls(
|
||||||
|
'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1',
|
||||||
|
key === 'mod'
|
||||||
|
? operatingSystem === 'mac'
|
||||||
|
? 'text-sm'
|
||||||
|
: 'text-xs'
|
||||||
|
: 'uppercase',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{element}
|
||||||
|
</kbd>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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:
|
||||||
@@ -108,4 +108,6 @@ export const de = {
|
|||||||
view_page_plaintext: 'Diese Seite als Klartext anzeigen',
|
view_page_plaintext: 'Diese Seite als Klartext anzeigen',
|
||||||
open_in: 'Öffnen in ${1}',
|
open_in: 'Öffnen in ${1}',
|
||||||
open_in_llms_pre_prompt: 'Lies ${1} und beantworte Fragen zum Inhalt.',
|
open_in_llms_pre_prompt: 'Lies ${1} und beantworte Fragen zum Inhalt.',
|
||||||
|
press_to_confirm: 'Drücke ${1} zum Bestätigen',
|
||||||
|
tool_call_skipped: 'Übersprungen "${1}"',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const en = {
|
|||||||
search_scope_all: 'All content',
|
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:
|
||||||
@@ -105,4 +105,6 @@ export const en = {
|
|||||||
view_page_plaintext: 'View this page as plain text',
|
view_page_plaintext: 'View this page as plain text',
|
||||||
open_in: 'Open in ${1}',
|
open_in: 'Open in ${1}',
|
||||||
open_in_llms_pre_prompt: 'Read ${1} and answer questions about the content.',
|
open_in_llms_pre_prompt: 'Read ${1} and answer questions about the content.',
|
||||||
|
press_to_confirm: 'Press ${1} to confirm',
|
||||||
|
tool_call_skipped: 'Skipped "${1}"',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const es: TranslationLanguage = {
|
|||||||
search_scope_all: 'Todo el contenido',
|
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:
|
||||||
@@ -109,4 +109,6 @@ export const es: TranslationLanguage = {
|
|||||||
view_page_plaintext: 'Ver esta página como texto plano',
|
view_page_plaintext: 'Ver esta página como texto plano',
|
||||||
open_in: 'Abrir en ${1}',
|
open_in: 'Abrir en ${1}',
|
||||||
open_in_llms_pre_prompt: 'Lee ${1} y responde preguntas sobre el contenido.',
|
open_in_llms_pre_prompt: 'Lee ${1} y responde preguntas sobre el contenido.',
|
||||||
|
press_to_confirm: 'Presiona ${1} para confirmar',
|
||||||
|
tool_call_skipped: 'Omitido "${1}"',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const fr = {
|
|||||||
search_scope_all: 'Tous les contenus',
|
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.',
|
||||||
@@ -104,4 +104,6 @@ export const fr = {
|
|||||||
view_page_plaintext: 'Voir en texte brut',
|
view_page_plaintext: 'Voir en texte brut',
|
||||||
open_in: 'Ouvrir dans ${1}',
|
open_in: 'Ouvrir dans ${1}',
|
||||||
open_in_llms_pre_prompt: 'Lire ${1} et répondre aux questions sur le contenu.',
|
open_in_llms_pre_prompt: 'Lire ${1} et répondre aux questions sur le contenu.',
|
||||||
|
press_to_confirm: 'Appuyez sur ${1} pour confirmer',
|
||||||
|
tool_call_skipped: 'Ignoré "${1}"',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ja } from './ja';
|
|||||||
import { nl } from './nl';
|
import { 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:
|
||||||
@@ -107,4 +107,6 @@ export const ja: TranslationLanguage = {
|
|||||||
view_page_plaintext: 'このページをプレーンテキストで表示',
|
view_page_plaintext: 'このページをプレーンテキストで表示',
|
||||||
open_in: '${1} で開く',
|
open_in: '${1} で開く',
|
||||||
open_in_llms_pre_prompt: '${1} を読んで内容に関する質問に答えてください。',
|
open_in_llms_pre_prompt: '${1} を読んで内容に関する質問に答えてください。',
|
||||||
|
press_to_confirm: '確認するには${1}を押してください',
|
||||||
|
tool_call_skipped: '"${1}" をスキップしました',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const nl: TranslationLanguage = {
|
|||||||
search_scope_all: 'Alle inhoud',
|
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:
|
||||||
@@ -107,4 +107,6 @@ export const nl: TranslationLanguage = {
|
|||||||
view_page_plaintext: 'Bekijk deze pagina als platte tekst',
|
view_page_plaintext: 'Bekijk deze pagina als platte tekst',
|
||||||
open_in: 'Open in ${1}',
|
open_in: 'Open in ${1}',
|
||||||
open_in_llms_pre_prompt: 'Lees ${1} en beantwoord vragen over de inhoud.',
|
open_in_llms_pre_prompt: 'Lees ${1} en beantwoord vragen over de inhoud.',
|
||||||
|
press_to_confirm: 'Druk op ${1} om te bevestigen',
|
||||||
|
tool_call_skipped: '"${1}" overgeslagen',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const no: TranslationLanguage = {
|
|||||||
search_scope_all: 'Alt innhold',
|
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:
|
||||||
@@ -108,4 +108,6 @@ export const no: TranslationLanguage = {
|
|||||||
view_page_plaintext: 'Vis denne siden som ren tekst',
|
view_page_plaintext: 'Vis denne siden som ren tekst',
|
||||||
open_in: 'Åpne i ${1}',
|
open_in: 'Åpne i ${1}',
|
||||||
open_in_llms_pre_prompt: 'Les ${1} og besvar spørsmål om innholdet.',
|
open_in_llms_pre_prompt: 'Les ${1} og besvar spørsmål om innholdet.',
|
||||||
|
press_to_confirm: 'Trykk ${1} for å bekrefte',
|
||||||
|
tool_call_skipped: 'Hoppet over "${1}"',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const pt_br = {
|
|||||||
search_scope_all: 'Todo o conteúdo',
|
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:
|
||||||
@@ -107,4 +107,6 @@ export const pt_br = {
|
|||||||
view_page_plaintext: 'Ver esta página em texto simples',
|
view_page_plaintext: 'Ver esta página em texto simples',
|
||||||
open_in: 'Abrir no ${1}',
|
open_in: 'Abrir no ${1}',
|
||||||
open_in_llms_pre_prompt: 'Leia ${1} e responda perguntas sobre o conteúdo.',
|
open_in_llms_pre_prompt: 'Leia ${1} e responda perguntas sobre o conteúdo.',
|
||||||
|
press_to_confirm: 'Pressione ${1} para confirmar',
|
||||||
|
tool_call_skipped: 'Pulado "${1}"',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
export const ru = {
|
||||||
|
locale: 'ru',
|
||||||
|
powered_by_gitbook: 'Работает на GitBook',
|
||||||
|
sponsored_via_gitbook: 'Спонсируется GitBook',
|
||||||
|
switch_to_dark_theme: 'Переключиться на тёмную тему',
|
||||||
|
switch_to_light_theme: 'Переключиться на светлую тему',
|
||||||
|
switch_to_system_theme: 'Переключиться на системную тему',
|
||||||
|
search: 'Поиск',
|
||||||
|
search_or_ask: 'Найти или спросить',
|
||||||
|
search_input_placeholder: 'Поиск по содержимому',
|
||||||
|
search_ask_input_placeholder: 'Найти в содержимом или задать вопрос',
|
||||||
|
search_no_results_for: 'Нет результатов для "${1}".',
|
||||||
|
search_no_results: 'Нет результатов',
|
||||||
|
search_results_count: '${1} — число результатов',
|
||||||
|
search_scope_space: '${1}',
|
||||||
|
search_scope_all: 'Все материалы',
|
||||||
|
ask: 'Спросить',
|
||||||
|
search_ask: 'Спросить "${1}"',
|
||||||
|
search_ask_description: 'Найти ответ с помощью ${1}',
|
||||||
|
search_ask_sources: 'Источники',
|
||||||
|
search_ask_sources_no_answer: 'Похожие страницы',
|
||||||
|
search_ask_no_answer:
|
||||||
|
'Не удалось найти ответ на ваш вопрос. Попробуйте перефразировать его или задать более конкретный вопрос.',
|
||||||
|
search_ask_error: 'Что-то пошло не так. Пожалуйста, попробуйте позже.',
|
||||||
|
on_this_page: 'На этой странице',
|
||||||
|
next_page: 'Следующая',
|
||||||
|
previous_page: 'Предыдущая',
|
||||||
|
page_last_modified: 'Последнее обновление ${1}',
|
||||||
|
was_this_helpful: 'Это было полезно?',
|
||||||
|
was_this_helpful_positive: 'Да, было!',
|
||||||
|
was_this_helpful_neutral: 'Не знаю',
|
||||||
|
was_this_helpful_negative: 'Нет',
|
||||||
|
was_this_helpful_thank_you: 'Спасибо!',
|
||||||
|
was_this_helpful_comment: 'Хотите что-нибудь добавить?',
|
||||||
|
was_this_helpful_positive_label: 'Полезно',
|
||||||
|
was_this_helpful_negative_label: 'Не полезно',
|
||||||
|
submit: 'Отправить',
|
||||||
|
annotation_button_label: 'Открыть аннотацию',
|
||||||
|
code_copied: 'Скопировано!',
|
||||||
|
code_copy: 'Копировать',
|
||||||
|
table_of_contents_button_label: 'Открыть оглавление',
|
||||||
|
cookies_title: 'Файлы cookies',
|
||||||
|
cookies_prompt:
|
||||||
|
'Этот сайт использует файлы cookies для предоставления услуг и анализа трафика. Просматривая этот сайт, вы принимаете условия ${1}.',
|
||||||
|
cookies_prompt_privacy: 'политики конфиденциальности',
|
||||||
|
cookies_accept: 'Принять',
|
||||||
|
cookies_reject: 'Отклонить',
|
||||||
|
close: 'Закрыть',
|
||||||
|
view: 'Посмотреть',
|
||||||
|
edit_on_git: 'Редактировать в ${1}',
|
||||||
|
notfound_title: 'Страница не найдена',
|
||||||
|
notfound_link: 'Эта ссылка ведёт на страницу, которая была удалена или больше не существует.',
|
||||||
|
notfound: 'Страница, которую вы ищете, не существует.',
|
||||||
|
unexpected_error_title: 'Произошла ошибка',
|
||||||
|
unexpected_error: 'Извините, произошла непредвиденная ошибка. Пожалуйста, попробуйте позже.',
|
||||||
|
unexpected_error_retry: 'Попробуйте снова',
|
||||||
|
pdf_download: 'Экспортировать как PDF',
|
||||||
|
pdf_goback: 'Вернуться к материалу',
|
||||||
|
pdf_print: 'Напечатать или сохранить как PDF',
|
||||||
|
pdf_page_of: '${1} из ${2}',
|
||||||
|
pdf_mode_only_page: 'Только эта страница',
|
||||||
|
pdf_mode_all: 'Все страницы',
|
||||||
|
pdf_limit_reached:
|
||||||
|
'Не удалось создать PDF-файл для ${1} страниц, генерация остановлена на ${2}.',
|
||||||
|
pdf_limit_reached_continue: 'Расширьте ещё на ${1} страниц.',
|
||||||
|
more: 'Ещё',
|
||||||
|
link_tooltip_external_link: 'Внешняя ссылка на',
|
||||||
|
link_tooltip_page_anchor: 'Перейти к разделу',
|
||||||
|
open_in_new_tab: 'Открыть в новой вкладке',
|
||||||
|
ai_chat_assistant_name: 'GitBook-помощник',
|
||||||
|
ai_chat_assistant_name_unbranded: 'ИИ-помощник',
|
||||||
|
ai_chat_assistant_description: 'Я здесь, чтобы помочь вам с базой знаний.',
|
||||||
|
ai_chat_assistant_greeting_morning: 'Доброе утро',
|
||||||
|
ai_chat_assistant_greeting_afternoon: 'Добрый день',
|
||||||
|
ai_chat_assistant_greeting_evening: 'Добрый вечер',
|
||||||
|
ai_chat_assistant_greeting_night: 'Доброй ночи',
|
||||||
|
ai_chat_clear_conversation: 'Очистить диалог',
|
||||||
|
ai_chat_thinking: 'Думает…',
|
||||||
|
ai_chat_working: 'Работает…',
|
||||||
|
ai_chat_context_badge: 'ИИ',
|
||||||
|
ai_chat_error: 'Что-то пошло не так.',
|
||||||
|
ai_chat_context_title: 'На основе вашего контекста',
|
||||||
|
ai_chat_context_description:
|
||||||
|
'Помощник использует ИИ и ваш контекст для генерации ответов и выполнения действий.',
|
||||||
|
ai_chat_context_pages_youve_read: 'Страницы, которые вы прочитали',
|
||||||
|
ai_chat_context_info_provided_by_the_site: 'Информация предоставлена сайтом',
|
||||||
|
ai_chat_context_previous_messages: 'Предыдущие сообщения',
|
||||||
|
ai_chat_context_disclaimer: 'Ответы ИИ могут содержать ошибки.',
|
||||||
|
ai_chat_input_placeholder: 'Спросите, найдите или объясните...',
|
||||||
|
send: 'Отправить',
|
||||||
|
actions: 'Действия',
|
||||||
|
ai_chat_suggested_questions_title: 'Предложенные вопросы',
|
||||||
|
ai_chat_suggested_questions_about_this_page: 'О чём эта страница?',
|
||||||
|
ai_chat_suggested_questions_read_next: 'Что мне нужно прочитать дальше?',
|
||||||
|
ai_chat_suggested_questions_example: 'Можете ли вы привести пример?',
|
||||||
|
searched_for: 'Выполнен поиск ${1}',
|
||||||
|
ai_chat_tools_listed_pages: 'Просмотрены документы',
|
||||||
|
ai_chat_tools_read_page: 'Прочитана страница ${1}',
|
||||||
|
ai_chat_tools_mcp_tool: 'Вызван ${1}',
|
||||||
|
ai_chat_ask: 'Спросить ${1}',
|
||||||
|
ai_chat_ask_about_page: 'Спросить ${1} об этой странице',
|
||||||
|
copy_for_llms: 'Скопировать для LLM',
|
||||||
|
copy_page_markdown: 'Скопировать страницу как Markdown для LLM',
|
||||||
|
copy_page: 'Скопировать страницу',
|
||||||
|
view_page_markdown: 'Посмотреть как Markdown',
|
||||||
|
view_page_plaintext: 'Посмотреть как текст без форматирования',
|
||||||
|
open_in: 'Открыть в ${1}',
|
||||||
|
open_in_llms_pre_prompt: 'Прочитай ${1} и ответь на вопросы о содержимом.',
|
||||||
|
press_to_confirm: 'Нажмите ${1} для подтверждения',
|
||||||
|
tool_call_skipped: 'Пропущен "${1}"',
|
||||||
|
};
|
||||||
@@ -18,7 +18,7 @@ export const zh: TranslationLanguage = {
|
|||||||
search_scope_all: '所有内容',
|
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: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
|
||||||
@@ -104,4 +104,6 @@ export const zh: TranslationLanguage = {
|
|||||||
view_page_plaintext: '以纯文本查看此页面',
|
view_page_plaintext: '以纯文本查看此页面',
|
||||||
open_in: '在 ${1} 中打开',
|
open_in: '在 ${1} 中打开',
|
||||||
open_in_llms_pre_prompt: '阅读 ${1} 并回答内容相关的问题。',
|
open_in_llms_pre_prompt: '阅读 ${1} 并回答内容相关的问题。',
|
||||||
|
press_to_confirm: '按 ${1} 确认',
|
||||||
|
tool_call_skipped: '已跳过 "${1}"',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ export function createDataFetcher(
|
|||||||
pageId: params.pageId,
|
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,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user