Compare commits

...

36 Commits

Author SHA1 Message Date
Zeno Kapitein 02b1bdbfa1 First attempt at chat in content instead of fixed 2025-08-20 16:38:21 +02:00
Zeno Kapitein 6217a2ed3b Page outline: scroll to active item (#3578) 2025-08-20 15:55:16 +02:00
Zeno Kapitein ffa866cb99 Fixes to search modal (#3577) 2025-08-20 10:50:58 +00:00
Zeno Kapitein 011aa931cf Integration tools styling (#3575)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-08-20 09:48:55 +00:00
Taran Vohra 68308154b5 Register custom assistant (#3569)
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-08-19 12:07:47 +02:00
Zeno Kapitein 36af03f4d5 Pageaside fixes (#3567) 2025-08-18 18:50:00 +02:00
Samy Pessé cbc71a56b6 API to let integrations define custom tools for Docs Assistant (#3568) 2025-08-17 10:03:54 +02:00
Zeno Kapitein 1420180220 Assistant: Prevent sending queries multiple times (#3565) 2025-08-15 08:44:31 +02:00
Zeno Kapitein e1b2cf6d81 Fix page outline scroll (#3563) 2025-08-14 17:52:56 +02:00
conico974 c0e9c49679 Optimize cache retrieval performance and reduce costs on 2C (#3542)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-14 09:14:40 +02:00
conico974 afc4bd4415 Improve image resizing logic in fetchImage function (#3553)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-13 15:35:14 +02:00
Zeno Kapitein bcfa8d8b19 Improve vertical alignment of site items and fix floating page aside (#3560) 2025-08-13 14:36:07 +02:00
Zeno Kapitein 43766d6519 Fix custom logo not rendering on the published site (#3557) 2025-08-13 14:29:49 +02:00
Greg Bergé 42c17f5c74 Improve OpenAPI parsing errors (#3555) 2025-08-13 13:38:14 +02:00
Greg Bergé d655d3eece Support "objectivec" as alias for Objective C syntax (#3556) 2025-08-13 13:37:06 +02:00
conico974 3500de95e8 Never follow redirect in the middleware (#3552)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-13 12:58:43 +02:00
Taran Vohra 7f03b94949 Fix an issue where recommended questions werent using the current siteSpace context (#3559) 2025-08-13 11:06:05 +05:30
Zeno Kapitein f0cf060191 Disable AI Search tests for now (#3561) 2025-08-12 22:12:51 +02:00
Zeno Kapitein 5a137e7b14 Always show placeholder while generating (#3554) 2025-08-12 15:43:29 +02:00
conico974 28f7fbaa8a Fix image format handling and enhance PageCoverImage component (#3527)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-08 18:30:00 +02:00
Nolann B. ff96bb5787 Support new coverDefinitionDark for cards & image type (#3547) 2025-08-08 18:08:11 +02:00
conico974 87841124e0 Add HashContext and HashProvider for managing URL hash state (#3541)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-08 17:01:21 +02:00
conico974 a650b58276 feat: add tracing to server actions (#3543)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-08 16:10:06 +02:00
Zeno Kapitein 388b20d44f Clear AI chat stream properly (#3544) 2025-08-07 19:07:25 +02:00
Claire Chabas ea7e94fc51 Fix search bar layout shit caused by the ToC (#3545) 2025-08-07 15:19:20 +02:00
Zeno Kapitein d30bcbabdb Improve Button and ButtonGroup styling (#3540) 2025-08-06 16:10:55 +02:00
Zeno Kapitein fb858a1941 Tweaks to AIChatButton and AIChatInput (#3539) 2025-08-06 16:10:05 +02:00
Samy Pessé cc2e615b9a Emit a <link rel="alternate"> for the markdown version of a page (#3528) 2025-08-06 08:55:02 +02:00
Samy Pessé a862cde6b4 Use new API to fetch the reusable content document (#3536) 2025-08-06 08:54:43 +02:00
Zeno Kapitein e2b7bec162 Add linewrap to AI actions dropdown (#3534) 2025-08-05 12:17:00 +00:00
Zeno Kapitein c1b577ecc9 Updates to RU translation (#3532) 2025-08-05 14:10:45 +02:00
Zeno Kapitein ba7ec14f76 Fix z-index of search highlight (#3533) 2025-08-05 14:08:46 +02:00
Zeno Kapitein 9f0117723c Add russian translation (#3531) 2025-08-05 12:52:30 +02:00
conico974 f72b6a20db Validate 'next-action' header format in server action requests (#3523)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-05 11:31:43 +02:00
conico974 2f798209bb Bump OpenNext to 1.6.4 (#3520)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-05 11:18:56 +02:00
conico974 eec8f16424 Increase timeout for search answer visibility check (#3530)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-05 11:18:12 +02:00
112 changed files with 2444 additions and 1250 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve `Button` and `ButtonGroup` styling
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix scroll of page outline
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support "objectivec" as alias for Objective C syntax
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support custom AI providers
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/browser-types": minor
---
First version of the public package for typing script integrations.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Small fixes to search modal
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fixes to `PageAside`
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Tweaks to AIChatButton and AIChatInput
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Allow integrations to provide tools to the Docs Assistant
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve vertical alignment of site items and fix floating page aside
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix search bar layout shift caused by ToC
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Emit a <link rel="alternate"> for the markdown version of a page
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Support new coverDefinitionDark for cards & image type
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Clear AI chat properly
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix bug in search highlight in GBO
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Page outline: scroll to active item
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/openapi-parser": patch
"gitbook": patch
---
Improve OpenAPI parsing errors
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix Custom logo not rendering on the published site
+25 -11
View File
@@ -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
View File
@@ -34,7 +34,7 @@
"workspaces": { "workspaces": {
"packages": ["packages/*"], "packages": ["packages/*"],
"catalog": { "catalog": {
"@gitbook/api": "^0.130.0" "@gitbook/api": "^0.136.0"
} }
}, },
"patchedDependencies": { "patchedDependencies": {
+1
View File
@@ -0,0 +1 @@
dist/
+3
View File
@@ -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.
+24
View File
@@ -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"]
}
+84
View File
@@ -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;
}
}
+24
View File
@@ -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"]
}
+13 -84
View File
@@ -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);
+2 -1
View File
@@ -6,6 +6,7 @@
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/cache-tags": "workspace:*", "@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*", "@gitbook/colors": "workspace:*",
"@gitbook/browser-types": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/fonts": "workspace:*", "@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*", "@gitbook/icons": "workspace:*",
@@ -13,7 +14,7 @@
"@gitbook/react-contentkit": "workspace:*", "@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*", "@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*", "@gitbook/react-openapi": "workspace:*",
"@opennextjs/cloudflare": "^1.6.2", "@opennextjs/cloudflare": "^1.6.4",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-navigation-menu": "^1.2.3",
@@ -1 +1,2 @@
export * from './useAI';
export * from './useAIChat'; export * from './useAIChat';
@@ -10,6 +10,7 @@ import type {
AIToolCallGetPageContent, AIToolCallGetPageContent,
AIToolCallGetPages, AIToolCallGetPages,
AIToolCallMCP, AIToolCallMCP,
AIToolCallOther,
AIToolCallSearch, AIToolCallSearch,
ContentRef, ContentRef,
} from '@gitbook/api'; } from '@gitbook/api';
@@ -58,6 +59,8 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />; return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
case 'mcp': case 'mcp':
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />; return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
case 'other':
return <DescriptionForOtherToolCall toolCall={toolCall} context={context} />;
default: default:
return <>{toolCall.tool}</>; return <>{toolCall.tool}</>;
} }
@@ -112,6 +115,15 @@ function DescriptionForMCPToolCall(props: {
); );
} }
function DescriptionForOtherToolCall(props: {
toolCall: AIToolCallOther;
context: GitBookSiteContext;
}) {
const { toolCall } = props;
return <p>{toolCall.summary.text}</p>;
}
async function DescriptionForSearchToolCall(props: { async function DescriptionForSearchToolCall(props: {
toolCall: AIToolCallSearch; toolCall: AIToolCallSearch;
context: GitBookSiteContext; context: GitBookSiteContext;
@@ -247,6 +259,8 @@ function getIconForToolCall(toolCall: AIToolCall): IconName {
return 'magnifying-glass'; return 'magnifying-glass';
case 'getPages': case 'getPages':
return 'files'; return 'files';
case 'other':
return (toolCall.summary.icon as IconName) ?? 'hammer';
default: default:
return 'hammer'; return 'hammer';
} }
@@ -1,6 +1,7 @@
'use server'; 'use server';
import type { GitBookBaseContext } from '@/lib/context'; import type { GitBookBaseContext } from '@/lib/context';
import { fetchServerActionSiteContext } from '@/lib/server-actions'; import { fetchServerActionSiteContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import { import {
type AIMessage, type AIMessage,
AIMessageRole, AIMessageRole,
@@ -19,6 +20,7 @@ export async function streamRenderAIMessage(
rawStream: AsyncIterable<AIStreamResponse>, rawStream: AsyncIterable<AIStreamResponse>,
options?: RenderAIMessageOptions options?: RenderAIMessageOptions
) { ) {
return traceErrorOnly('AI.streamRenderAIMessage', async () => {
const message: AIMessage = { const message: AIMessage = {
id: '', id: '',
role: AIMessageRole.Assistant, role: AIMessageRole.Assistant,
@@ -79,7 +81,8 @@ export async function streamRenderAIMessage(
case 'response_reasoning': case 'response_reasoning':
case 'response_document': { case 'response_document': {
updateProcessingMessageStep(event.stepIndex, (step) => { updateProcessingMessageStep(event.stepIndex, (step) => {
const container = event.type === 'response_reasoning' ? 'reasoning' : 'content'; const container =
event.type === 'response_reasoning' ? 'reasoning' : 'content';
step[container] ??= { step[container] ??= {
object: 'document', object: 'document',
@@ -111,6 +114,7 @@ export async function streamRenderAIMessage(
), ),
}; };
}); });
});
} }
/** /**
@@ -1,7 +1,14 @@
'use server'; 'use server';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware'; import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions'; import { getServerActionBaseContext } from '@/lib/server-actions';
import { type AIMessageContext, AIMessageRole, AIModel } from '@gitbook/api'; import { traceErrorOnly } from '@/lib/tracing';
import {
type AIMessageContext,
AIMessageRole,
AIModel,
type AIToolCallResult,
type AIToolDefinition,
} from '@gitbook/api';
import { streamRenderAIMessage } from './api'; import { streamRenderAIMessage } from './api';
import type { RenderAIMessageOptions } from './types'; import type { RenderAIMessageOptions } from './types';
@@ -12,32 +19,44 @@ export async function* streamAIChatResponse({
message, message,
messageContext, messageContext,
previousResponseId, previousResponseId,
toolCall,
tools,
options, options,
}: { }: {
message: string; message?: string;
messageContext: AIMessageContext; messageContext: AIMessageContext;
previousResponseId?: string; previousResponseId?: string;
toolCall?: AIToolCallResult;
tools?: AIToolDefinition[];
options?: RenderAIMessageOptions; options?: RenderAIMessageOptions;
}) { }) {
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
const context = await getServerActionBaseContext(); const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware(); const siteURLData = await getSiteURLDataFromMiddleware();
const api = await context.dataFetcher.api(); const api = await context.dataFetcher.api();
const rawStream = api.orgs.streamAiResponseInSite(siteURLData.organization, siteURLData.site, { const rawStream = api.orgs.streamAiResponseInSite(
mode: 'assistant', siteURLData.organization,
input: [ siteURLData.site,
{
input: message
? [
{ {
role: AIMessageRole.User, role: AIMessageRole.User,
content: message, content: message,
context: messageContext, context: messageContext,
}, },
], ]
output: { type: 'document' }, : [],
model: AIModel.ReasoningLow, model: AIModel.ReasoningLow,
previousResponseId, previousResponseId,
}); toolCall,
tools,
}
);
const { stream } = await streamRenderAIMessage(context, rawStream, options); return await streamRenderAIMessage(context, rawStream, options);
});
for await (const output of stream) { for await (const output of stream) {
yield output; yield output;
@@ -1,6 +1,7 @@
'use server'; 'use server';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware'; import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions'; import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import { streamRenderAIMessage } from './api'; import { streamRenderAIMessage } from './api';
import type { RenderAIMessageOptions } from './types'; import type { RenderAIMessageOptions } from './types';
@@ -14,6 +15,7 @@ export async function* streamAIResponseById({
responseId: string; responseId: string;
options?: RenderAIMessageOptions; options?: RenderAIMessageOptions;
}) { }) {
const { stream } = await traceErrorOnly('AI.streamAIResponseById', async () => {
const context = await getServerActionBaseContext(); const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware(); const siteURLData = await getSiteURLDataFromMiddleware();
@@ -23,7 +25,8 @@ export async function* streamAIResponseById({
siteURLData.site, siteURLData.site,
responseId responseId
); );
const { stream } = await streamRenderAIMessage(context, rawStream, options); return await streamRenderAIMessage(context, rawStream, options);
});
for await (const output of stream) { for await (const output of stream) {
yield output; yield output;
@@ -0,0 +1,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,
};
}
+212 -94
View File
@@ -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 { try {
const result = await toolDef.execute(event.toolCall.input);
streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
} catch (error) {
streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: {
error: error instanceof Error ? error.message : 'Unknown error',
},
summary: {
icon: 'bomb',
text: 'An error occurred while executing the tool',
},
},
});
}
};
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const integrationTools = integrationsAssistantTools.getState().tools;
const stream = await streamAIChatResponse({ const stream = await streamAIChatResponse({
message: input.message, message: input.message,
toolCall: input.toolCall,
messageContext: messageContextRef.current, messageContext: messageContextRef.current,
previousResponseId: globalState.getState().state.responseId ?? undefined, previousResponseId: globalState.getState().responseId ?? undefined,
tools: integrationTools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
}); });
// Process streaming response // Process streaming response
for await (const data of stream) { for await (const data of stream) {
if (!data) continue; if (!data) continue;
if (input.message && globalState.getState().query !== input.message) {
// Chat was cleared, stop processing the stream
break;
}
const event = data.event; const event = data.event;
switch (event.type) { switch (event.type) {
case 'response_finish': { case 'response_finish': {
setState((state) => ({ globalState.setState((state) => ({
...state, ...state,
responseId: event.responseId, responseId: event.responseId,
// Mark as not loading when the response is finished // Mark as not loading when the response is finished
@@ -202,7 +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: {
} }
}} }}
/> />
{!disabled ? (
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden"> <div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
<KeyboardShortcut keys={['mod', 'j']} /> <KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
</div> </div>
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2"> ) : null}
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
<Tooltip <Tooltip
label={ label={
<div className="flex flex-col gap-3 p-2"> <div className="flex flex-col gap-3 p-2">
@@ -126,11 +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,6 +41,7 @@ interface FetchPlaceholderAdOptions {
* and properly access user-agent and IP. * and properly access user-agent and IP.
*/ */
export async function renderAd(options: FetchAdOptions) { export async function renderAd(options: FetchAdOptions) {
return traceErrorOnly('Ads.renderAd', async () => {
const [context, result] = await Promise.all([ const [context, result] = await Promise.all([
getServerActionBaseContext(), getServerActionBaseContext(),
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(), options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
@@ -74,6 +76,7 @@ export async function renderAd(options: FetchAdOptions) {
), ),
insightsAd, insightsAd,
}; };
});
} }
async function fetchAd({ async function fetchAd({
@@ -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,6 +12,7 @@ export async function hasContentBeenUpdated(props: {
changeRequestId: string; changeRequestId: string;
revisionId: string; revisionId: string;
}) { }) {
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
const context = await getServerActionBaseContext(); const context = await getServerActionBaseContext();
const changeRequest = await getDataOrNull( const changeRequest = await getDataOrNull(
context.dataFetcher.getChangeRequest({ context.dataFetcher.getChangeRequest({
@@ -22,4 +24,5 @@ export async function hasContentBeenUpdated(props: {
return false; return false;
} }
return changeRequest.revision !== props.revisionId; return changeRequest.revision !== props.revisionId;
});
} }
@@ -43,7 +43,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
</div> </div>
<CopyCodeButton <CopyCodeButton
codeId={id} codeId={id}
style="z-2 mt-2 mr-2 self-start justify-self-end rounded-md bg-transparent p-1 text-tint text-xs leading-none opacity-0 ring-1 ring-tint backdrop-blur-md transition-opacity duration-75 [grid-area:2/1] hover:ring-tint-hover group-hover/codeblock:opacity-11" style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
/> />
<pre <pre
className={tcls( className={tcls(
@@ -2,6 +2,7 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client'; import { t, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind'; import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -41,9 +42,14 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
}; };
return ( return (
<button onClick={onClick} className={tcls(style, 'print:hidden')}> <Button
size="xsmall"
variant="secondary"
onClick={onClick}
className={tcls(style, 'translate-y-0!', 'print:hidden')}
>
{t(language, copied ? 'code_copied' : 'code_copy')} {t(language, copied ? 'code_copied' : 'code_copy')}
</button> </Button>
); );
} }
@@ -136,6 +136,9 @@ const syntaxAliases: Record<string, BundledLanguage> = {
// "Parser" language does not exist in Shiki, but it's used in GitBook // "Parser" language does not exist in Shiki, but it's used in GitBook
// The closest language is "Blade" // The closest language is "Blade"
parser: 'blade', parser: 'blade',
// From GitBook App we receive "objectivec" instead of "objective-c"
objectivec: 'objective-c',
}; };
function checkIsBundledLanguage(lang: string): lang is BundledLanguage { function checkIsBundledLanguage(lang: string): lang is BundledLanguage {
@@ -1,6 +1,7 @@
'use server'; 'use server';
import { getServerActionBaseContext } from '@/lib/server-actions'; import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import type { RenderIntegrationUI } from '@gitbook/api'; import type { RenderIntegrationUI } from '@gitbook/api';
import { ContentKitOutput } from '@gitbook/react-contentkit'; import { ContentKitOutput } from '@gitbook/react-contentkit';
import { contentKitServerContext } from './contentkit'; import { contentKitServerContext } from './contentkit';
@@ -19,6 +20,7 @@ export async function renderIntegrationUi({
}; };
request: RenderIntegrationUI; request: RenderIntegrationUI;
}) { }) {
return traceErrorOnly('DocumentView.renderIntegrationUi', async () => {
const serverAction = await getServerActionBaseContext(); const serverAction = await getServerActionBaseContext();
const output = await fetchSafeIntegrationUI(serverAction, { const output = await fetchSafeIntegrationUI(serverAction, {
integrationName: renderContext.integrationName, integrationName: renderContext.integrationName,
@@ -35,4 +37,5 @@ export async function renderIntegrationUi({
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />, children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
output: output.data, output: output.data,
}; };
});
} }
@@ -147,7 +147,7 @@
} }
.openapi-column-preview-body { .openapi-column-preview-body {
@apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header:xl:max-2xl:top-32 site-header-sections:top-32 site-header-sections:xl:max-2xl:top-44 print-mode:static; @apply flex flex-col gap-4 sticky top-[calc(var(--toc-top-offset)+5rem)] print-mode:static;
} }
.openapi-column-preview pre { .openapi-column-preview pre {
@@ -26,18 +26,15 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
} }
const { reusableContent } = resolved; const { reusableContent } = resolved;
if ( if (!reusableContent) {
!reusableContent ||
!('document' in reusableContent.revisionReusableContent) ||
!reusableContent.revisionReusableContent.document
) {
return null; return null;
} }
const document = await getDataOrNull( const document = await getDataOrNull(
dataFetcher.getDocument({ dataFetcher.getRevisionReusableContentDocument({
spaceId: reusableContent.context.space.id, spaceId: reusableContent.context.space.id,
documentId: reusableContent.revisionReusableContent.document, revisionId: reusableContent.context.revisionId,
reusableContentId: reusableContent.revisionReusableContent.id,
}) })
); );
@@ -1,18 +1,16 @@
import { LinkBox, LinkOverlay } from '@/components/primitives';
import { Image } from '@/components/utils';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { import {
type ContentRef, type ContentRef,
type DocumentTableViewCards, type DocumentTableViewCards,
SiteInsightsLinkPosition, SiteInsightsLinkPosition,
} from '@gitbook/api'; } from '@gitbook/api';
import { LinkBox, LinkOverlay } from '@/components/primitives';
import { Image } from '@/components/utils';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { RecordColumnValue } from './RecordColumnValue'; import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table'; import type { TableRecordKV, TableViewProps } from './Table';
import { RecordCardStyles } from './styles'; import { RecordCardStyles } from './styles';
import { getRecordValue } from './utils'; import { getRecordCardCovers } from './utils';
export async function RecordCard( export async function RecordCard(
props: TableViewProps<DocumentTableViewCards> & { props: TableViewProps<DocumentTableViewCards> & {
@@ -21,25 +19,21 @@ export async function RecordCard(
) { ) {
const { view, record, context, block, isOffscreen } = props; const { view, record, context, block, isOffscreen } = props;
const coverFile = view.coverDefinition const { dark, light } = getRecordCardCovers(record[1], view);
? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0]
: null;
const targetRef = view.targetDefinition const targetRef = view.targetDefinition
? (record[1].values[view.targetDefinition] as ContentRef) ? (record[1].values[view.targetDefinition] as ContentRef)
: null; : null;
const [cover, target] = await Promise.all([ const [lightCover, darkCover, target] = await Promise.all([
coverFile && context.contentContext light && context.contentContext ? resolveContentRef(light, context.contentContext) : null,
? resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext) dark && context.contentContext ? resolveContentRef(dark, context.contentContext) : null,
: null,
targetRef && context.contentContext targetRef && context.contentContext
? resolveContentRef(targetRef, context.contentContext) ? resolveContentRef(targetRef, context.contentContext)
: null, : null,
]); ]);
const coverIsSquareOrPortrait = const darkCoverIsSquareOrPortrait = isSquareOrPortrait(darkCover);
cover?.file?.dimensions && const lightCoverIsSquareOrPortrait = isSquareOrPortrait(lightCover);
cover.file?.dimensions?.width / cover.file?.dimensions?.height <= 1;
const body = ( const body = (
<div <div
@@ -63,23 +57,32 @@ export async function RecordCard(
// On mobile, check if we can display the cover responsively or not: // On mobile, check if we can display the cover responsively or not:
// - If the file has a landscape aspect ratio, we display it normally // - If the file has a landscape aspect ratio, we display it normally
// - If the file is square or portrait, we display it left with 40% of the card width // - If the file is square or portrait, we display it left with 40% of the card width
coverIsSquareOrPortrait lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
? [ ? [
'grid-cols-[40%__1fr]', lightCoverIsSquareOrPortrait
'min-[432px]:grid-cols-none', ? 'grid-cols-[40%__1fr] min-[432px]:grid-cols-none min-[432px]:grid-rows-[auto_1fr]'
'min-[432px]:grid-rows-[auto_1fr]', : '',
] darkCoverIsSquareOrPortrait
? 'dark:grid-cols-[40%__1fr] dark:min-[432px]:grid-cols-none dark:min-[432px]:grid-rows-[auto_1fr]'
: '',
].filter(Boolean)
: 'grid-rows-[auto_1fr]' : 'grid-rows-[auto_1fr]'
)} )}
> >
{cover ? ( {lightCover ? (
<Image <Image
alt="Cover" alt="Cover"
sources={{ sources={{
light: { light: {
src: cover.href, src: lightCover.href,
size: cover.file?.dimensions, size: lightCover.file?.dimensions,
}, },
dark: darkCover
? {
src: darkCover.href,
size: darkCover.file?.dimensions,
}
: null,
}} }}
sizes={[ sizes={[
{ {
@@ -92,8 +95,15 @@ export async function RecordCard(
'w-full', 'w-full',
'h-full', 'h-full',
'object-cover', 'object-cover',
coverIsSquareOrPortrait lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
? ['min-[432px]:h-auto', 'min-[432px]:aspect-video'] ? [
lightCoverIsSquareOrPortrait
? 'min-[432px]:aspect-video min-[432px]:h-auto'
: '',
darkCoverIsSquareOrPortrait
? 'dark:min-[432px]:aspect-video dark:min-[432px]:h-auto'
: '',
].filter(Boolean)
: ['h-auto', 'aspect-video'] : ['h-auto', 'aspect-video']
)} )}
priority={isOffscreen ? 'lazy' : 'high'} priority={isOffscreen ? 'lazy' : 'high'}
@@ -167,3 +177,16 @@ export async function RecordCard(
return <div className={tcls(RecordCardStyles)}>{body}</div>; return <div className={tcls(RecordCardStyles)}>{body}</div>;
} }
/**
* Check if a file is square or portrait.
*/
function isSquareOrPortrait(contentRef: ResolvedContentRef | null) {
const file = contentRef?.file;
if (!file || !file.dimensions) {
return false;
}
return file.dimensions?.width / file.dimensions?.height <= 1;
}
@@ -174,7 +174,6 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
<StyledLink <StyledLink
key={index} key={index}
href={ref.href} href={ref.href}
target="_blank"
className="flex flex-row items-center gap-2" className="flex flex-row items-center gap-2"
insights={ insights={
ref.file ref.file
@@ -329,6 +328,50 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
</Tag> </Tag>
); );
} }
case 'image': {
const image = context.contentContext
? await resolveContentRef(value as ContentRef, context.contentContext)
: null;
if (!image) {
return null;
}
return (
<Tag className={tcls('text-base')} aria-labelledby={ariaLabelledBy}>
<StyledLink
href={image.href}
className="flex flex-row items-center gap-2"
insights={
image.file
? {
type: 'link_click',
link: {
target: value as ContentRef,
position: SiteInsightsLinkPosition.Content,
},
}
: undefined
}
>
<Image
style={['max-h-lh', 'h-lh']}
alt={image.text}
sizes={[{ width: 24 }]}
resize={context.contentContext?.imageResizer}
sources={{
light: {
src: image.href,
size: image.file?.dimensions,
},
}}
priority="lazy"
/>
{image.text}
</StyledLink>
</Tag>
);
}
default: default:
assertNever(definition); assertNever(definition);
} }
@@ -1,4 +1,11 @@
import type { ContentRef, DocumentTableDefinition, DocumentTableRecord } from '@gitbook/api'; import type {
ContentRef,
ContentRefFile,
ContentRefURL,
DocumentTableDefinition,
DocumentTableRecord,
DocumentTableViewCards,
} from '@gitbook/api';
import assertNever from 'assert-never'; import assertNever from 'assert-never';
/** /**
@@ -12,6 +19,57 @@ export function getRecordValue<T extends number | string | boolean | string[] |
return record.values[definitionId]; return record.values[definitionId];
} }
/**
* Get the covers for a record card.
* Returns both the light and dark covers.
* The light cover is a string or a content ref (image or files column type).
* The dark cover is a content ref (image column type).
*/
export function getRecordCardCovers(
record: DocumentTableRecord,
view: DocumentTableViewCards
): { [key in 'light' | 'dark']: ContentRefFile | ContentRefURL | null } {
return {
light: (() => {
if (!view.coverDefinition) {
return null;
}
const value = getRecordValue(record, view.coverDefinition) as
| ContentRefFile
| ContentRefURL
| string[];
if (Array.isArray(value)) {
if (value.length === 0) {
return null;
}
if (typeof value[0] === 'string') {
return { kind: 'file', file: value[0] };
}
}
return value as ContentRefFile | ContentRefURL;
})(),
dark: (() => {
if (!view.coverDefinitionDark) {
return null;
}
const value = getRecordValue(record, view.coverDefinitionDark) as
| ContentRefFile
| ContentRefURL;
if (!value) {
return null;
}
return value;
})(),
};
}
/** /**
* Get the text alignment for a column. * Get the text alignment for a column.
*/ */
@@ -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,36 +77,77 @@ export function PageAside(props: {
'lg:site-header-sections:top-27', 'lg:site-header-sections:top-27',
'lg:site-header-sections:max-h-[calc(100vh-6.75rem)]', 'lg:site-header-sections:max-h-[calc(100vh-6.75rem)]',
// Client-side dynamic positioning (CSS vars applied by script)
'lg:[html[style*="--toc-top-offset"]_&]:top-(--toc-top-offset)!',
'lg:[html[style*="--toc-height"]_&]:max-h-(--toc-height)!',
// When in api page mode, we display it as an overlay on non-large resolutions // When in api page mode, we display it as an overlay on non-large resolutions
'xl:max-2xl:page-api-block:z-10', 'xl:max-2xl:page-api-block:z-10',
'xl:max-2xl:page-api-block:fixed', 'xl:max-2xl:page-api-block:fixed',
'xl:max-2xl:page-api-block:right-8', 'xl:max-2xl:page-api-block:right-8',
'xl:max-2xl:page-api-block:w-56', 'xl:max-2xl:page-api-block:w-60',
'xl:max-2xl:page-api-block:bg-tint/9', 'xl:max-2xl:page-api-block:max-w-60',
'xl:max-2xl:contrast-more:page-api-block:bg-tint', 'xl:max-2xl:page-api-block:pb-8',
'xl:max-2xl:page-api-block:backdrop-blur-lg', 'xl:max-2xl:page-api-block:pt-10',
'xl:max-2xl:[body:has(.openapi-block):has(.page-has-ancestors)_&]:pt-6.5'
)}
>
<div
className={tcls(
'flex flex-col',
'overflow-hidden',
'w-full',
'xl:max-2xl:rounded-corners:page-api-block:rounded-md',
'xl:max-2xl:circular-corners:page-api-block:rounded-xl',
'xl:max-2xl:page-api-block:border', 'xl:max-2xl:page-api-block:border',
'xl:max-2xl:page-api-block:border-tint', 'xl:max-2xl:page-api-block:border-tint',
'xl:max-2xl:page-api-block:bg-tint/9',
'xl:max-2xl:page-api-block:backdrop-blur-lg',
'xl:max-2xl:contrast-more:page-api-block:bg-tint',
'xl:max-2xl:page-api-block:hover:shadow-lg', 'xl:max-2xl:page-api-block:hover:shadow-lg',
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1', 'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1', 'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1',
'xl:max-2xl:page-api-block:rounded-md', 'xl:max-2xl:page-api-block:not-hover:*:hidden'
'xl:max-2xl:page-api-block:h-auto',
'xl:max-2xl:page-api-block:my-9',
'page-api-block:px-2',
'page-api-block:py-1.5'
)} )}
> >
<PageAsideHeader context={context} />
{page.layout.outline ? ( {page.layout.outline ? (
<> <div className="flex shrink flex-col overflow-hidden">
{document ? (
<React.Suspense fallback={null}>
<PageAsideSections document={document} context={context} />
</React.Suspense>
) : null}
<PageAsideActions
page={page}
withPageFeedback={withPageFeedback}
context={context}
/>
</div>
) : null}
{customization.themes.toggeable || site.ads ? (
<PageAsideFooter context={context} />
) : null}
</div>
</aside>
);
}
function PageAsideHeader(props: { context: GitBookSiteContext }) {
const { context } = props;
const language = getSpaceLanguage(context.customization);
return (
<div <div
className={tcls( className={tcls(
'hidden', 'hidden',
'xl:max-2xl:page-api-block:flex', 'xl:max-2xl:page-api-block:flex!',
'text-xs', 'text-xs',
'tracking-wide', 'tracking-wide',
'font-semibold', 'font-semibold',
'uppercase', 'uppercase',
'px-2',
'py-1.5',
'flex-row', 'flex-row',
'items-center', 'items-center',
@@ -118,45 +156,37 @@ export function PageAside(props: {
> >
<Icon icon="block-quote" className={tcls('size-3')} /> <Icon icon="block-quote" className={tcls('size-3')} />
{t(language, 'on_this_page')} {t(language, 'on_this_page')}
<Icon <Icon icon="chevron-down" className={tcls('size-3', 'opacity-6', 'ml-auto')} />
icon="chevron-down"
className={tcls(
'size-3',
'opacity-6',
'ml-auto',
'xl:max-2xl:page-api-block:group-hover/aside:hidden'
)}
/>
</div> </div>
<div );
className={tcls( }
'overflow-y-auto',
'overflow-x-visible',
'flex', async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) {
'flex-col', const { document, context } = props;
'shrink',
'pb-12',
'sticky', const sections = await getDocumentSections(context, document);
'lg:top:0',
'lg:site-header:top-16',
'lg:site-header-sections:top-27',
'gap-6', return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
'pt-10', }
'xl:max-2xl:page-api-block:py-0', function PageAsideActions(props: {
// Hide it for api page, until hovered withPageFeedback: boolean;
'xl:max-2xl:page-api-block:hidden', context: GitBookSiteContext;
'xl:max-2xl:page-api-block:group-hover/aside:flex' page: RevisionPageDocument;
)} }) {
> const { page, withPageFeedback, context } = props;
{document ? ( const { customization, space } = context;
<React.Suspense fallback={null}> const language = getSpaceLanguage(customization);
<PageAsideSections document={document} context={context} />
</React.Suspense> const pdfHref = context.linker.toPathInSpace(
) : null} `~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
);
return (
<div <div
className={tcls( className={tcls(
'flex', 'flex',
@@ -166,9 +196,9 @@ export function PageAside(props: {
'border-t', 'border-t',
'first:border-none', 'first:border-none',
'border-tint-subtle', 'border-tint-subtle',
'py-6', 'pt-5',
'first:pt-0', 'first:pt-0',
'xl:max-2xl:page-api-block:px-3', 'xl:max-2xl:page-api-block:p-5',
'empty:hidden' 'empty:hidden'
)} )}
> >
@@ -223,27 +253,31 @@ export function PageAside(props: {
'py-2' 'py-2'
)} )}
> >
<Icon <Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
icon="file-pdf"
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'pdf_download')} {t(language, 'pdf_download')}
</a> </a>
</div> </div>
) : null} ) : null}
</div> </div>
</div> );
</> }
) : null}
async function PageAsideFooter(props: { context: GitBookSiteContext }) {
const { context } = props;
const { customization, site, space } = context;
return (
<div <div
className={tcls( className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 xl:max-2xl:page-api-block:hidden xl:max-2xl:page-api-block:pb-0 xl:max-2xl:page-api-block:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle', 'sticky bottom-0 z-10 mt-auto flex flex-col',
'xl:max-2xl:page-api-block:bg-transparent' 'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'border-tint-subtle xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2',
'pt-4'
)} )}
> >
{/* Mode Switcher */} {/* Mode Switcher */}
{customization.themes.toggeable ? ( {customization.themes.toggeable ? (
<div className="mt-4 flex items-center justify-end"> <div className="flex items-center justify-end">
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<ThemeToggler /> <ThemeToggler />
</React.Suspense> </React.Suspense>
@@ -260,18 +294,9 @@ export function PageAside(props: {
style={site?.ads ? 'mt-4' : undefined} style={site?.ads ? 'mt-4' : undefined}
/> />
</div> </div>
</aside>
); );
} }
async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) {
const { document, context } = props;
const sections = await getDocumentSections(context, document);
return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
}
function getGitSyncName(space: Space): string { function getGitSyncName(space: Space): string {
if (space.gitSync?.installationProvider === 'github') { if (space.gitSync?.installationProvider === 'github') {
return 'GitHub'; return 'GitHub';
@@ -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,13 +43,16 @@ export async function PageCover(props: {
const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => { const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => {
if (!resolved && returnNull) return; if (!resolved && returnNull) return;
const [attrs, size] = await Promise.all([ // If we don't have a size for the image, we want to calculate it so that we can use srcSet
getImageAttributes({ const size =
resolved?.file?.dimensions ??
(await context.imageResizer?.getImageSize(resolved?.href || defaultPageCover.src, {}));
const attrs = await getImageAttributes({
sizes, sizes,
source: resolved source: resolved
? { ? {
src: resolved.href, src: resolved.href,
size: resolved.file?.dimensions ?? null, size: size ?? null,
} }
: { : {
src: defaultPageCover.src, src: defaultPageCover.src,
@@ -60,14 +63,10 @@ export async function PageCover(props: {
}, },
quality: 100, quality: 100,
resize: context.imageResizer ?? false, resize: context.imageResizer ?? false,
}), });
context.imageResizer
?.getImageSize(resolved?.href || defaultPageCover.src, {})
.then((size) => size ?? undefined),
]);
return { return {
...attrs, ...attrs,
size, size: size ?? undefined,
}; };
}; };
@@ -44,6 +44,8 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
<div className="h-full w-full overflow-hidden" ref={containerRef}> <div className="h-full w-full overflow-hidden" ref={containerRef}>
<img <img
src={imgs.light.src} src={imgs.light.src}
srcSet={imgs.light.srcSet}
sizes={imgs.light.sizes}
fetchPriority="high" fetchPriority="high"
alt="Page cover" alt="Page cover"
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')} className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
@@ -55,6 +57,8 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
{imgs.dark && ( {imgs.dark && (
<img <img
src={imgs.dark.src} src={imgs.dark.src}
srcSet={imgs.dark.srcSet}
sizes={imgs.dark.sizes}
fetchPriority="low" fetchPriority="low"
alt="Page cover" alt="Page cover"
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')} className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
@@ -1,5 +1,4 @@
import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown'; import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import type { AncestorRevisionPage } from '@/lib/pages'; import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
@@ -20,13 +19,9 @@ export async function PageHeader(props: {
return null; return null;
} }
const withAIChat = isAIChatEnabled(context); const pageActions = context.customization.pageActions;
const pageActions = context.customization.pageActions ?? { const hasAncestors = ancestors.length > 0;
// TODO: After 25/07/2025, we can remove this default values as the cache will be updated
markdown: true,
externalAI: true,
};
return ( return (
<header <header
@@ -37,27 +32,23 @@ export async function PageHeader(props: {
'mb-6', 'mb-6',
'space-y-3', 'space-y-3',
'page-api-block:ml-0', 'page-api-block:ml-0',
'page-api-block:max-w-full' 'page-api-block:max-w-full',
hasAncestors ? 'page-has-ancestors' : 'page-no-ancestors'
)} )}
> >
{page.layout.tableOfContents && {page.layout.tableOfContents ? (
// Show page actions if *any* of the actions are enabled // Show page actions if *any* of the actions are enabled
(withAIChat || pageActions.markdown || pageActions.externalAI) ? (
<div
className={tcls(
'float-right mb-2 ml-4 xl:max-2xl:page-api-block:mr-58',
ancestors.length > 0 ? '-mt-2' : '-mt-3 xs:mt-2'
)}
>
<AIActionsDropdown <AIActionsDropdown
markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`} markdownPageUrl={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
withAIChat={withAIChat}
trademark={context.customization.trademark.enabled}
actions={pageActions} actions={pageActions}
className={tcls(
'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
hasAncestors ? '-my-1.5' : '-mt-3 xs:mt-2'
)}
/> />
</div>
) : null} ) : null}
{ancestors.length > 0 && (
{hasAncestors && (
<nav aria-label="Breadcrumb"> <nav aria-label="Breadcrumb">
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}> <ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}>
{ancestors.map((breadcrumb, index) => { {ancestors.map((breadcrumb, index) => {
@@ -8,7 +8,7 @@ import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { useTrackEvent } from '../Insights'; import { useTrackEvent } from '../Insights';
import { Button } from '../primitives'; import { Button, ButtonGroup } from '../primitives';
const MAX_COMMENT_LENGTH = 512; const MAX_COMMENT_LENGTH = 512;
@@ -61,8 +61,7 @@ export function PageFeedbackForm(props: {
<div className={tcls('flex flex-col gap-3 text-sm', className)}> <div className={tcls('flex flex-col gap-3 text-sm', className)}>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p>{t(languages, 'was_this_helpful')}</p> <p>{t(languages, 'was_this_helpful')}</p>
<div className="rounded-full border border-tint-subtle bg-tint-base contrast-more:border-tint-12"> <ButtonGroup className="rounded-full border border-tint bg-tint-base">
<div className="flex">
<RatingButton <RatingButton
rating={PageFeedbackRating.Good} rating={PageFeedbackRating.Good}
label={tString(languages, 'was_this_helpful_positive')} label={tString(languages, 'was_this_helpful_positive')}
@@ -84,8 +83,7 @@ export function PageFeedbackForm(props: {
active={rating === PageFeedbackRating.Bad} active={rating === PageFeedbackRating.Bad}
disabled={rating !== undefined} disabled={rating !== undefined}
/> />
</div> </ButtonGroup>
</div>
</div> </div>
{rating ? ( {rating ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -94,7 +92,7 @@ export function PageFeedbackForm(props: {
<textarea <textarea
ref={inputRef} ref={inputRef}
name="comment" name="comment"
className="max-h-40 min-h-16 grow rounded-sm straight-corners:rounded-none bg-tint-base p-2 ring-1 ring-tint ring-inset placeholder:text-sm placeholder:text-tint contrast-more:ring-tint-12 contrast-more:placeholder:text-tint-strong" className="mx-0.5 max-h-40 min-h-16 grow rounded-sm straight-corners:rounded-none bg-tint-base p-2 ring-1 ring-tint ring-inset placeholder:text-sm placeholder:text-tint contrast-more:ring-tint-12 contrast-more:placeholder:text-tint-strong"
placeholder={tString(languages, 'was_this_helpful_comment')} placeholder={tString(languages, 'was_this_helpful_comment')}
aria-label={tString(languages, 'was_this_helpful_comment')} aria-label={tString(languages, 'was_this_helpful_comment')}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setComment(e.target.value)}
@@ -138,7 +136,7 @@ function RatingButton(
active: boolean; active: boolean;
} & ButtonHTMLAttributes<HTMLButtonElement> } & ButtonHTMLAttributes<HTMLButtonElement>
) { ) {
const { rating, label, onClick, active, ...attr } = props; const { rating, label, onClick, active, disabled, ...attr } = props;
const ratingIcon = const ratingIcon =
{ {
@@ -148,23 +146,18 @@ function RatingButton(
}[rating] ?? null; }[rating] ?? null;
return ( return (
<button <Button
className={tcls( className="first:pl-2.5 last:pr-2.5"
'p-2 first:rounded-l-full first:pl-2.5 last:rounded-r-full last:pr-2.5 hover:bg-primary-hover hover:text-primary-strong',
'disabled:cursor-not-allowed disabled:hover:bg-inherit disabled:hover:text-inherit dark:disabled:hover:text-inherit',
'ring-tint contrast-more:hover:ring-1',
active
? 'bg-primary-active text-primary-strong disabled:hover:bg-primary-active disabled:hover:text-primary-strong contrast-more:ring-2 contrast-more:hover:ring-2'
: 'disabled:opacity-7 contrast-more:disabled:ring-0'
)}
type="button" type="button"
{...attr} variant="blank"
aria-label={label} label={label}
title={label}
onClick={onClick} onClick={onClick}
> active={active}
{ratingIcon} disabled={disabled}
</button> iconOnly
{...attr}
icon={ratingIcon}
/>
); );
} }
@@ -5,6 +5,7 @@ import type React from 'react';
import { TranslateContext } from '@/intl/client'; import { TranslateContext } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations'; import type { TranslationLanguage } from '@/intl/translations';
import { TooltipProvider } from '@radix-ui/react-tooltip'; import { TooltipProvider } from '@radix-ui/react-tooltip';
import { HashProvider } from '../hooks';
import { LoadingStateProvider } from '../primitives/LoadingStateProvider'; import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
export function ClientContexts(props: { export function ClientContexts(props: {
@@ -16,7 +17,9 @@ export function ClientContexts(props: {
return ( return (
<TranslateContext.Provider value={language}> <TranslateContext.Provider value={language}>
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<HashProvider>
<LoadingStateProvider>{children}</LoadingStateProvider> <LoadingStateProvider>{children}</LoadingStateProvider>
</HashProvider>
</TooltipProvider> </TooltipProvider>
</TranslateContext.Provider> </TranslateContext.Provider>
); );
@@ -15,6 +15,8 @@ export function HighlightQuery(props: {
query, query,
text, text,
highlight = [ highlight = [
'-z-1',
'relative',
'text-bold', 'text-bold',
'bg-primary', 'bg-primary',
'text-contrast-primary', 'text-contrast-primary',
@@ -33,7 +35,7 @@ export function HighlightQuery(props: {
const matches = matchString(text, query); const matches = matchString(text, query);
return ( return (
<span className={tcls('whitespace-break-spaces')}> <span className={tcls('relative z-2 whitespace-break-spaces')}>
{matches.map((entry, index) => ( {matches.map((entry, index) => (
<span key={index} className={tcls(entry.match ? highlight : null)}> <span key={index} className={tcls(entry.match ? highlight : null)}>
{entry.text} {entry.text}
@@ -1,9 +1,11 @@
'use client'; 'use client';
import { CustomizationAIMode } from '@gitbook/api'; import { CustomizationSearchStyle } from '@gitbook/api';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useAI } from '../AI';
import { AIChatButton } from '../AIChat';
import { useTrackEvent } from '../Insights'; import { useTrackEvent } from '../Insights';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { Popover } from '../primitives'; import { Popover } from '../primitives';
@@ -16,10 +18,10 @@ import { SearchScopeToggle } from './SearchScopeToggle';
import { useSearch } from './useSearch'; import { useSearch } from './useSearch';
interface SearchContainerProps { interface SearchContainerProps {
spaceId: string; siteSpaceId: string;
spaceTitle: string; spaceTitle: string;
isMultiVariants: boolean; isMultiVariants: boolean;
aiMode: CustomizationAIMode; style: CustomizationSearchStyle;
className?: string; className?: string;
} }
@@ -27,10 +29,11 @@ interface SearchContainerProps {
* Client component to render the search input and results. * Client component to render the search input and results.
*/ */
export function SearchContainer(props: SearchContainerProps) { export function SearchContainer(props: SearchContainerProps) {
const { spaceId, spaceTitle, isMultiVariants, aiMode, className } = props; const { siteSpaceId, spaceTitle, isMultiVariants, style, className } = props;
const withAIChat = aiMode === CustomizationAIMode.Assistant; const { assistants } = useAI();
const [state, setSearchState] = useSearch(withAIChat);
const [state, setSearchState] = useSearch();
const searchAsk = useSearchAskState(); const searchAsk = useSearchAskState();
const router = useRouter(); const router = useRouter();
const trackEvent = useTrackEvent(); const trackEvent = useTrackEvent();
@@ -39,34 +42,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, ? {
...prev,
open: false, open: false,
query: state.query === '' ? null : state.query, query: prev.query === '' ? null : prev.query,
});
} }
: null
);
if (to) { if (to) {
router.push(to); router.push(to);
} }
}, },
[state, setSearchState, router] [setSearchState, router]
); );
useHotkeys( useHotkeys(
@@ -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, ask: question,
open: false,
query: null, query: null,
})} open: assistant.mode === 'search',
active={active} },
leadingIcon={ () => {
recommended ? ( assistant.open(question);
<Icon icon="search" className="size-4" />
) : withAIChat ? (
<AIChatIcon className="size-4" />
) : (
<Icon icon="sparkles" className="size-4" />
)
} }
)}
active={active}
leadingIcon={recommended ? 'search' : assistant.icon}
className={recommended ? 'pr-1.5' : ''} className={recommended ? 'pr-1.5' : ''}
> >
{recommended ? ( {recommended ? (
@@ -51,7 +47,7 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
{t(language, 'search_ask', [question])} {t(language, 'search_ask', [question])}
</div> </div>
<div className="text-sm text-tint-subtle"> <div className="text-sm text-tint-subtle">
{t(language, 'search_ask_description')} {t(language, 'search_ask_description', assistant.label)}
</div> </div>
</> </>
)} )}
@@ -1,5 +1,5 @@
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons'; import { Icon, type IconName } from '@gitbook/icons';
import * as React from 'react'; import * as React from 'react';
import { Button } from '../primitives'; import { Button } from '../primitives';
import { Link, type LinkInsightsProps } from '../primitives/Link'; import { Link, type LinkInsightsProps } from '../primitives/Link';
@@ -53,7 +53,13 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
)} )}
{...rest} {...rest}
> >
<div className="size-4 shrink-0 text-tint-subtle">{leadingIcon}</div> <div className="size-4 shrink-0 text-tint-subtle">
{typeof leadingIcon === 'string' ? (
<Icon icon={leadingIcon as IconName} className="size-4" />
) : (
leadingIcon
)}
</div>
<div className="grow">{children}</div> <div className="grow">{children}</div>
{active ? ( {active ? (
<Button label={action} iconOnly variant="primary" icon="arrow-turn-down-left" /> <Button label={action} iconOnly variant="primary" icon="arrow-turn-down-left" />
@@ -4,11 +4,11 @@ import { readStreamableValue } from 'ai/rsc';
import assertNever from 'assert-never'; import assertNever from 'assert-never';
import React from 'react'; import React from 'react';
import type { Assistant } from '@/components/AI';
import { t, useLanguage } from '@/intl/client'; import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { CustomizationAIMode } from '@gitbook/api';
import { assert } from 'ts-essentials'; import { assert } from 'ts-essentials';
import { useAI } from '../AI';
import { useTrackEvent } from '../Insights'; import { useTrackEvent } from '../Insights';
import { Loading } from '../primitives'; import { Loading } from '../primitives';
import { SearchPageResultItem } from './SearchPageResultItem'; import { SearchPageResultItem } from './SearchPageResultItem';
@@ -29,7 +29,7 @@ export interface SearchResultsRef {
type ResultType = type ResultType =
| OrderedComputedResult | OrderedComputedResult
| { type: 'question'; id: string; query: string } | { type: 'question'; id: string; query: string; assistant: Assistant }
| { type: 'recommended-question'; id: string; question: string }; | { type: 'recommended-question'; id: string; question: string };
/** /**
@@ -51,12 +51,11 @@ export const SearchResults = React.forwardRef(function SearchResults(
children?: React.ReactNode; children?: React.ReactNode;
query: string; query: string;
global: boolean; global: boolean;
aiMode: CustomizationAIMode; siteSpaceId: string;
spaceId: string;
}, },
ref: React.Ref<SearchResultsRef> ref: React.Ref<SearchResultsRef>
) { ) {
const { children, query, aiMode, global, spaceId } = props; const { children, query, global, siteSpaceId } = props;
const language = useLanguage(); const language = useLanguage();
const trackEvent = useTrackEvent(); const trackEvent = useTrackEvent();
@@ -67,8 +66,8 @@ export const SearchResults = React.forwardRef(function SearchResults(
const [cursor, setCursor] = React.useState<number | null>(null); const [cursor, setCursor] = React.useState<number | null>(null);
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]); const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
const withAI = const { assistants } = useAI();
aiMode === CustomizationAIMode.Search || aiMode === CustomizationAIMode.Assistant; const withAI = assistants.length > 0;
React.useEffect(() => { React.useEffect(() => {
if (!query) { if (!query) {
@@ -77,9 +76,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
return; return;
} }
if (cachedRecommendedQuestions.has(spaceId)) { if (cachedRecommendedQuestions.has(siteSpaceId)) {
const results = cachedRecommendedQuestions.get(spaceId); const results = cachedRecommendedQuestions.get(siteSpaceId);
assert(results, `Cached recommended questions should be set for space ${spaceId}`); assert(
results,
`Cached recommended questions should be set for site-space ${siteSpaceId}`
);
setResultsState({ results, fetching: false }); setResultsState({ results, fetching: false });
return; return;
} }
@@ -98,7 +100,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
return; return;
} }
const response = await streamRecommendedQuestions(spaceId); const response = await streamRecommendedQuestions({ siteSpaceId });
for await (const entry of readStreamableValue(response.stream)) { for await (const entry of readStreamableValue(response.stream)) {
if (!entry) { if (!entry) {
continue; continue;
@@ -115,7 +117,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
id: question, id: question,
question, question,
}); });
cachedRecommendedQuestions.set(spaceId, recommendedQuestions); cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
if (!cancelled) { if (!cancelled) {
setResultsState({ results: [...recommendedQuestions], fetching: false }); setResultsState({ results: [...recommendedQuestions], fetching: false });
@@ -156,13 +158,13 @@ export const SearchResults = React.forwardRef(function SearchResults(
cancelled = true; cancelled = true;
clearTimeout(timeout); clearTimeout(timeout);
}; };
}, [query, global, withAI, trackEvent]); }, [query, global, trackEvent, withAI, siteSpaceId]);
const results: ResultType[] = React.useMemo(() => { const results: ResultType[] = React.useMemo(() => {
if (!withAI) { if (!withAI) {
return resultsState.results; return resultsState.results;
} }
return withQuestionResult(resultsState.results, query); return withAskTriggers(resultsState.results, query, assistants);
}, [resultsState.results, query, withAI]); }, [resultsState.results, query, withAI]);
React.useEffect(() => { React.useEffect(() => {
@@ -185,7 +187,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
block: 'nearest', block: 'nearest',
inline: 'nearest', inline: 'nearest',
}); });
}, [cursor, refs]); }, [cursor]);
const moveBy = React.useCallback( const moveBy = React.useCallback(
(delta: number) => { (delta: number) => {
@@ -206,7 +208,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
} }
refs.current[cursor]?.click(); refs.current[cursor]?.click();
}, [cursor, refs]); }, [cursor]);
React.useImperativeHandle( React.useImperativeHandle(
ref, ref,
@@ -278,10 +280,10 @@ export const SearchResults = React.forwardRef(function SearchResults(
ref={(ref) => { ref={(ref) => {
refs.current[index] = ref; refs.current[index] = ref;
}} }}
withAIChat={aiMode === CustomizationAIMode.Assistant}
key={item.id} key={item.id}
question={query} question={query}
active={index === cursor} active={index === cursor}
assistant={item.assistant}
/> />
); );
} }
@@ -292,9 +294,9 @@ export const SearchResults = React.forwardRef(function SearchResults(
refs.current[index] = ref; refs.current[index] = ref;
}} }}
key={item.id} key={item.id}
withAIChat={aiMode === CustomizationAIMode.Assistant}
question={item.question} question={item.question}
active={index === cursor} active={index === cursor}
assistant={assistants[0]}
recommended recommended
/> />
); );
@@ -327,12 +329,24 @@ export const SearchResults = React.forwardRef(function SearchResults(
/** /**
* Add a "Ask <question>" item at the top of the results list. * Add a "Ask <question>" item at the top of the results list.
*/ */
function withQuestionResult(results: ResultType[], query: string): ResultType[] { function withAskTriggers(
results: ResultType[],
query: string,
assistants: Assistant[]
): ResultType[] {
const without = results.filter((result) => result.type !== 'question'); const without = results.filter((result) => result.type !== 'question');
if (query.length === 0) { if (query.length === 0) {
return without; return without;
} }
return [{ type: 'question', id: 'question', query }, ...(without ?? [])]; return [
...assistants.map((assistant, index) => ({
type: 'question' as const,
id: `question-${index}`,
query,
assistant,
})),
...(without ?? []),
];
} }
@@ -22,6 +22,7 @@ import type * as React from 'react';
import { throwIfDataError } from '@/lib/data'; import { throwIfDataError } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware'; import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths'; import { joinPathWithBaseURL } from '@/lib/paths';
import { traceErrorOnly } from '@/lib/tracing';
import type { IconName } from '@gitbook/icons'; import type { IconName } from '@gitbook/icons';
import { DocumentView } from '../DocumentView'; import { DocumentView } from '../DocumentView';
@@ -68,18 +69,21 @@ export interface AskAnswerResult {
* Server action to search content in the entire site. * Server action to search content in the entire site.
*/ */
export async function searchAllSiteContent(query: string): Promise<OrderedComputedResult[]> { export async function searchAllSiteContent(query: string): Promise<OrderedComputedResult[]> {
return traceErrorOnly('Search.searchAllSiteContent', async () => {
const context = await getServerActionBaseContext(); const context = await getServerActionBaseContext();
return await searchSiteContent(context, { return await searchSiteContent(context, {
query, query,
scope: { mode: 'all' }, scope: { mode: 'all' },
}); });
});
} }
/** /**
* Server action to search content in a space. * Server action to search content in a space.
*/ */
export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> { export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> {
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
const context = await getServerActionBaseContext(); const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware(); const siteURLData = await getSiteURLDataFromMiddleware();
@@ -91,6 +95,7 @@ export async function searchSiteSpaceContent(query: string): Promise<OrderedComp
? { mode: 'current', siteSpaceId: siteURLData.siteSpace } ? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] }, : { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
}); });
});
} }
/** /**
@@ -101,6 +106,7 @@ export async function streamAskQuestion({
}: { }: {
question: string; question: string;
}) { }) {
return traceErrorOnly('Search.streamAskQuestion', async () => {
const responseStream = createStreamableValue<AskAnswerResult | undefined>(); const responseStream = createStreamableValue<AskAnswerResult | undefined>();
(async () => { (async () => {
@@ -181,17 +187,21 @@ export async function streamAskQuestion({
return { return {
stream: responseStream.value, stream: responseStream.value,
}; };
});
} }
/** /**
* Stream a list of suggested questions for the site. * Stream a list of suggested questions for the site.
* Optionally scoped to a specific space. * Optionally scoped to a specific space.
*/ */
export async function streamRecommendedQuestions(spaceId?: string) { export async function streamRecommendedQuestions(args: { siteSpaceId?: string }) {
return traceErrorOnly('Search.streamRecommendedQuestions', async () => {
const siteURLData = await getSiteURLDataFromMiddleware(); const siteURLData = await getSiteURLDataFromMiddleware();
const context = await getServerActionBaseContext(); const context = await getServerActionBaseContext();
const responseStream = createStreamableValue<SearchAIRecommendedQuestionStream | undefined>(); const responseStream = createStreamableValue<
SearchAIRecommendedQuestionStream | undefined
>();
(async () => { (async () => {
const apiClient = await context.dataFetcher.api(); const apiClient = await context.dataFetcher.api();
@@ -199,7 +209,7 @@ export async function streamRecommendedQuestions(spaceId?: string) {
siteURLData.organization, siteURLData.organization,
siteURLData.site, siteURLData.site,
{ {
spaceId, siteSpaceId: args.siteSpaceId,
} }
); );
@@ -215,6 +225,7 @@ export async function streamRecommendedQuestions(spaceId?: string) {
}); });
return { stream: responseStream.value }; return { stream: responseStream.value };
});
} }
/** /**
@@ -1,6 +1,7 @@
'use client';
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'; import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
import React from 'react'; import React from 'react';
import type { LinkProps } from '../primitives'; import type { LinkProps } from '../primitives';
export interface SearchState { export interface SearchState {
@@ -25,9 +26,17 @@ export type UpdateSearchState = (
) => Promise<URLSearchParams>; ) => Promise<URLSearchParams>;
/** /**
* Hook to access the current search query and update it. * Context to share the search state updater so all consumers use the same instance.
*/ */
export function useSearch(withAIChat = false): [SearchState | null, UpdateSearchState] { export const SearchContext = React.createContext<{
state: SearchState | null;
setState: UpdateSearchState;
} | null>(null);
export function SearchContextProvider(props: React.PropsWithChildren): React.ReactElement {
const { children } = props;
// URL-backed state
const [rawState, setRawState] = useQueryStates(keyMap, { const [rawState, setRawState] = useQueryStates(keyMap, {
history: 'replace', history: 'replace',
}); });
@@ -44,22 +53,20 @@ export function useSearch(withAIChat = false): [SearchState | null, UpdateSearch
} }
}, [rawState, setRawState]); }, [rawState, setRawState]);
// Separate local state for open (not synchronized with URL) // Local UI state for the popover open/close (not in URL)
// Default to true if there's already a query in the URL
const [open, setIsOpen] = React.useState(() => { const [open, setIsOpen] = React.useState(() => {
return rawState?.q !== null || (!withAIChat && rawState?.ask !== null); return rawState?.q !== null;
}); });
const state = React.useMemo<SearchState | null>(() => { const state = React.useMemo<SearchState | null>(() => {
if (rawState === null || (rawState.q === null && rawState.ask === null)) { if (rawState === null || (rawState.q === null && rawState.ask === null)) {
return null; return null;
} }
return { return {
query: rawState.q, query: rawState.q,
ask: rawState.ask, ask: rawState.ask,
global: !!rawState.global, global: !!rawState.global,
open: open, open,
}; };
}, [rawState, open]); }, [rawState, open]);
@@ -78,16 +85,10 @@ export function useSearch(withAIChat = false): [SearchState | null, UpdateSearch
if (update === null) { if (update === null) {
setIsOpen(false); setIsOpen(false);
return setRawState({ return setRawState({ q: null, ask: null, global: null });
q: null,
ask: null,
global: null,
});
} }
// Update the local state
setIsOpen(update.open); setIsOpen(update.open);
return setRawState({ return setRawState({
q: update.query, q: update.query,
ask: update.ask, ask: update.ask,
@@ -97,37 +98,51 @@ export function useSearch(withAIChat = false): [SearchState | null, UpdateSearch
[setRawState] [setRawState]
); );
return [state, setState]; return <SearchContext.Provider value={{ state, setState }}>{children}</SearchContext.Provider>;
}
/**
* Hook to access the current search query and update it.
*/
export function useSearch(): [SearchState | null, UpdateSearchState] {
const ctx = React.useContext(SearchContext);
if (!ctx) {
throw new Error('useSearch must be used within SearchContextProvider');
}
return [ctx.state, ctx.setState];
} }
/** /**
* Hook to create a link to a search query. * Hook to create a link to a search query.
*/ */
export function useSearchLink(): (query: Partial<SearchState>) => LinkProps { export function useSearchLink(): (
const [, setSearch] = useSearch(); params: Partial<SearchState>,
callback?: () => void
) => LinkProps {
const [, setSearchState] = useSearch();
return React.useCallback( return React.useCallback(
(query) => { (params, callback) => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
query.query ? searchParams.set('q', query.query) : searchParams.delete('q'); params.query ? searchParams.set('q', params.query) : searchParams.delete('q');
query.ask ? searchParams.set('ask', query.ask) : searchParams.delete('ask'); params.ask ? searchParams.set('ask', params.ask) : searchParams.delete('ask');
query.global ? searchParams.set('global', 'true') : searchParams.delete('global'); params.global ? searchParams.set('global', 'true') : searchParams.delete('global');
return { return {
href: `?${searchParams.toString()}`, href: `?${searchParams.toString()}`,
prefetch: false, prefetch: false,
onClick: (event) => { onClick: (event) => {
event.preventDefault(); event.preventDefault();
setSearch((prev) => ({ callback?.();
query: null, setSearchState((prev) => ({
ask: null, ...prev,
global: false, query: params.query !== undefined ? params.query : null,
open: query.open ?? prev?.open ?? false, ask: params.ask !== undefined ? params.ask : null,
...(prev ?? {}), global: params.global !== undefined ? params.global : false,
...query, open: params.open !== undefined ? params.open : false,
})); }));
}, },
}; };
}, },
[setSearch] [setSearchState]
); );
} }
@@ -135,6 +135,9 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
canonical: linker canonical: linker
.toAbsoluteURL(linker.toPathForPage({ pages: revision.pages, page })) .toAbsoluteURL(linker.toPathForPage({ pages: revision.pages, page }))
.replace(/\/+$/, ''), .replace(/\/+$/, ''),
types: {
'text/markdown': `${linker.toAbsoluteURL(linker.toPathInSpace(page.path))}.md`,
},
}, },
openGraph: { openGraph: {
images: [ images: [
@@ -1,5 +1,9 @@
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import { CustomizationAIMode, CustomizationHeaderPreset } from '@gitbook/api'; import {
CustomizationAIMode,
CustomizationHeaderPreset,
CustomizationSearchStyle,
} from '@gitbook/api';
import React from 'react'; import React from 'react';
import { Footer } from '@/components/Footer'; import { Footer } from '@/components/Footer';
@@ -10,12 +14,12 @@ import { tcls } from '@/lib/tailwind';
import type { VisitorAuthClaims } from '@/lib/adaptive'; import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env'; import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env';
import { AIContextProvider } from '../AI';
import { AIChat } from '../AIChat'; import { AIChat } from '../AIChat';
import { AIChatButton } from '../AIChat';
import { Announcement } from '../Announcement'; import { Announcement } from '../Announcement';
import { SpacesDropdown } from '../Header/SpacesDropdown'; import { SpacesDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights'; import { InsightsProvider } from '../Insights';
import { SearchContainer } from '../Search'; import { SearchContainer, SearchContextProvider } from '../Search';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections'; import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import { CurrentContentProvider } from '../hooks'; import { CurrentContentProvider } from '../hooks';
import { SpaceLayoutContextProvider } from './SpaceLayoutContext'; import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
@@ -51,19 +55,20 @@ export function SpaceLayout(props: {
const aiMode = customization.ai?.mode; const aiMode = customization.ai?.mode;
const searchAndAI = ( const search = (
<div className="flex grow items-center gap-2"> <div className="flex grow items-center gap-2">
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<SearchContainer <SearchContainer
aiMode={aiMode} style={
customization.header.preset === CustomizationHeaderPreset.None
? CustomizationSearchStyle.Subtle
: customization.styling.search
}
isMultiVariants={siteSpaces.length > 1} isMultiVariants={siteSpaces.length > 1}
spaceTitle={siteSpace.title} spaceTitle={siteSpace.title}
spaceId={siteSpace.space.id} siteSpaceId={siteSpace.id}
/> />
</React.Suspense> </React.Suspense>
{aiMode === CustomizationAIMode.Assistant ? (
<AIChatButton trademark={customization.trademark.enabled} />
) : null}
</div> </div>
); );
@@ -84,14 +89,20 @@ export function SpaceLayout(props: {
appURL={GITBOOK_APP_URL} appURL={GITBOOK_APP_URL}
apiHost={GITBOOK_API_PUBLIC_URL} apiHost={GITBOOK_API_PUBLIC_URL}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie} visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<SearchContextProvider>
<AIContextProvider
aiMode={aiMode}
trademark={customization.trademark.enabled}
> >
<Announcement context={context} /> <Announcement context={context} />
<Header withTopHeader={withTopHeader} context={context} search={searchAndAI} /> <Header
{aiMode === CustomizationAIMode.Assistant ? ( withTopHeader={withTopHeader}
<AIChat trademark={customization.trademark.enabled} /> context={context}
) : null} search={search}
/>
<div className="scroll-nojump"> <div className="scroll-nojump">
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96"> <div className="motion-safe:transition-all motion-safe:duration-300">
<div <div
className={tcls( className={tcls(
'flex', 'flex',
@@ -127,7 +138,7 @@ export function SpaceLayout(props: {
innerHeader={ innerHeader={
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC. // displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<> <>
{!withTopHeader && searchAndAI} {!withTopHeader && search}
{!withTopHeader && withSections && sections && ( {!withTopHeader && withSections && sections && (
<SiteSectionList <SiteSectionList
className={tcls('hidden', 'lg:block')} className={tcls('hidden', 'lg:block')}
@@ -152,12 +163,19 @@ export function SpaceLayout(props: {
</> </>
} }
/> />
<div className="flex min-w-0 flex-1 flex-col">{children}</div> <div className="flex min-w-0 flex-1 flex-col">
{children}
</div>
{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,15 +26,27 @@ 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) => {
let element: React.ReactNode = key;
switch (key) {
case 'mod':
element = operatingSystem === 'mac' ? '⌘' : 'Ctrl';
break;
case 'enter':
element = <Icon icon="arrow-turn-down-left" className="size-[.75em]" />;
break;
}
return (
<kbd <kbd
key={key} key={index}
className={tcls( className={tcls(
'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1', 'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1',
key === 'mod' key === 'mod'
@@ -44,9 +57,10 @@ export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue
className className
)} )}
> >
{key === 'mod' ? (operatingSystem === 'mac' ? '⌘' : 'Ctrl') : key} {element}
</kbd> </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',
+3 -1
View File
@@ -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}"',
}; };
+3 -1
View File
@@ -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}"',
}; };
+3 -1
View File
@@ -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}"',
}; };
+3 -1
View File
@@ -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,
}; };
+3 -1
View File
@@ -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}" をスキップしました',
}; };
+3 -1
View File
@@ -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',
}; };
+3 -1
View File
@@ -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}"',
};
+3 -1
View File
@@ -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}"',
}; };
+40
View File
@@ -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