Compare commits

..

1 Commits

Author SHA1 Message Date
Addison a49e21a835 Roll back to Zeno's commit 2026-01-06 13:49:41 +08:00
468 changed files with 7524 additions and 24655 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Skip AI questions when it matches the example provided to agents / crawlers.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Update button sizing
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Add "Content-Signal" to "robots.txt"
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Support variant selector in hidden section
-6
View File
@@ -1,6 +0,0 @@
---
"@gitbook/icons": minor
"gitbook": patch
---
Update icon usage to render svg symbols rather than file.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix embeddable search links
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix links to other spaces/sections in the llms.txt.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix embed tab button size regression
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"@gitbook/icons": patch
---
Switch icon masking method to fix visual cutoffs in FA7
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Pass visitor session to Ask AI.
@@ -26,7 +26,7 @@ inputs:
description: 'Commit message to associate with the deployment'
required: true
outputs:
deployment-url:
deployment-url:
description: "Deployment URL"
value: ${{ steps.upload_middleware.outputs.deployment-url }}
runs:
@@ -52,8 +52,6 @@ runs:
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
@@ -64,7 +62,6 @@ runs:
run: bun run turbo build:cloudflare
env:
GITBOOK_RUNTIME: cloudflare
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
shell: bash
- name: Upload the DO worker
@@ -110,7 +107,7 @@ runs:
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
- name: Extract middleware version worker ID
shell: bash
id: extract_middleware_version_id
+1 -18
View File
@@ -19,11 +19,8 @@ inputs:
environment:
description: 'Environment to deploy to'
required: true
headSha:
description: 'Git ref to deploy, used for the deploymentId'
required: false
outputs:
deployment-url:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy.outputs.deployment-url }}
runs:
@@ -54,25 +51,12 @@ runs:
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Inject build env vars
if: ${{ inputs.environment == 'preview' }}
shell: bash
run: |
HEAD_SHA=$(git rev-parse HEAD)
echo "resolved HEAD_SHA: $HEAD_SHA"
echo "GITBOOK_HEAD_SHA=$HEAD_SHA" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_BLOCK_SEARCH_INDEXATION=true" >> .vercel/.env.${{ inputs.environment }}.local
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
cat .vercel/.env.${{ inputs.environment }}.local
- name: Build Project Artifacts
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
@@ -80,7 +64,6 @@ runs:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
GITBOOK_RUNTIME: vercel
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
- name: Deploy Project Artifacts to Vercel
id: deploy
shell: bash
+7 -2
View File
@@ -5,6 +5,8 @@ on:
branches:
- main
- changeset-release/main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
format:
runs-on: ubuntu-latest
@@ -34,10 +36,13 @@ jobs:
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun unit
build:
build-oss:
# CI to check that the repository builds correctly on a machine without the credentials
runs-on: ubuntu-latest
name: Build
name: Build (Open Source)
timeout-minutes: 6
env:
BUN_NPM_TOKEN: ''
steps:
- name: Checkout
uses: actions/checkout@v4
+11 -43
View File
@@ -1,25 +1,15 @@
name: Preview
on:
pull_request_target:
pull_request:
push:
branches:
- main
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
approval:
# Ensure that external contributors' PRs are manually approved before deployment
name: Approve Preview Deployment
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork }}
environment:
name: preview-approval
steps:
- name: Approval gate
run: echo "Preview deployment approved."
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -28,8 +18,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy to Vercel
id: deploy
uses: ./.github/composite/deploy-vercel
@@ -40,12 +28,9 @@ jobs:
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-preview
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
headSha: ${{ github.event.pull_request.head.sha || github.sha }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -54,8 +39,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy to Cloudflare
id: deploy
uses: ./.github/composite/deploy-cloudflare
@@ -82,7 +65,7 @@ jobs:
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
if: always() && github.event_name != 'push'
if: always() && !startsWith(github.ref, 'refs/heads/main')
needs:
- deploy-v2-vercel
- deploy-v2-cloudflare
@@ -119,13 +102,10 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -138,18 +118,15 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: v2-vercel
ARGOS_BUILD_NAME: 'v2-vercel'
visual-testing-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -162,18 +139,15 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: v2-cloudflare
ARGOS_BUILD_NAME: 'v2-cloudflare'
visual-testing-customers-v2:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -186,18 +160,15 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: customers-v2-vercel
ARGOS_BUILD_NAME: 'customers-v2'
visual-testing-customers-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -210,17 +181,14 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: customers-v2-cloudflare
ARGOS_BUILD_NAME: 'customers-v2'
browserless-testing-v2-vercel:
runs-on: ubuntu-latest
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -249,4 +217,4 @@ jobs:
# run: cd ./packages/gitbook && bun e2e-browserless
# env:
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
+2
View File
@@ -3,6 +3,8 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
+2
View File
@@ -3,6 +3,8 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
+6 -3
View File
@@ -4,6 +4,9 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -26,10 +29,9 @@ jobs:
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: |
corepack enable npm
corepack install -g npm@latest
run: npm install -g npm@latest
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -46,3 +48,4 @@ jobs:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
-77
View File
@@ -1,77 +0,0 @@
# AGENTS Instructions
## Commands
```bash
bun install # Install dependencies
bun dev # Start dev server (all packages)
bun run build # Build all packages
bun run format # Format with Biome (run after every change)
bun run typecheck # Type-check all packages
bun run unit # Run unit tests
```
## Development
The dev server proxies published GitBook sites locally. After `bun dev`, access any published site at:
```
http://localhost:3000/url/<published-gitbook-url>
```
Examples:
- `http://localhost:3000/url/gitbook.com/docs`
- `http://localhost:3000/url/open-source.gitbook.io/midjourney`
## Architecture
```
packages/
gitbook/ # Main Next.js app
src/
app/ # Next.js App Router (sites/)
components/ # React components
lib/ # Server utilities, data fetching
intl/ # Internationalization (translations/)
openapi-parser/ # OpenAPI 3.0/3.1/Swagger parser
react-openapi/ # OpenAPI rendering components
react-contentkit/ # ContentKit component rendering
embed/ # Embeddable GitBook components
shared/ # Shared utilities
icons/ # Icon assets
fonts/ # Font assets
colors/ # Color tokens
expr/ # GitBook expression evaluator
cache-do/ # Cloudflare DO cache
cache-tags/ # Cache tag utilities
```
## Testing
```bash
bun run unit # Unit tests via bun test (not vitest)
bun run e2e # Playwright e2e tests (requires built app)
```
Run a specific test file:
```bash
cd packages/gitbook && bun test src/lib/cache.test.ts
```
## Changesets
After committing code changes, create a changeset for the affected package:
```md
---
"gitbook": patch
---
Provide a short description of the change.
```
Save as `.changeset/<name>.md`, then commit it separately with message: `changeset`
## Formatting
Uses Biome (not ESLint/Prettier). Always run `bun run format` before committing.
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+17 -1
View File
@@ -77,7 +77,23 @@ All pull-requests will be tested against both visual and performances testing to
## Fonts and Icons
GitBook Open uses fontawesome. For self-hosting and local development, for licensing reasons, only the icons from the free version should be used.
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
BUN_NPM_TOKEN=xxx
```
and then reinstall dependencies.
## Contributing
Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 KiB

After

Width:  |  Height:  |  Size: 622 KiB

-1
View File
@@ -23,7 +23,6 @@
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts",
"gitbook/tsconfig.json",
"**/*.css"
]
},
+1698 -1091
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
[install.scopes]
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
+8 -12
View File
@@ -6,20 +6,18 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.30.0",
"turbo": "^2.9.6",
"vercel": "50.37.3"
"@changesets/cli": "^2.29.8",
"turbo": "^2.6.3",
"vercel": "^39.4.2"
},
"packageManager": "bun@1.3.7",
"packageManager": "bun@1.3.2",
"overrides": {
"@codemirror/state": "6.4.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4",
"@radix-ui/react-slot": "1.2.4"
"esbuild": "0.24.2"
},
"private": true,
"scripts": {
@@ -43,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.180.0",
"@gitbook/api": "0.155.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -55,13 +53,11 @@
"react-dom": "^19.0.1",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1",
"vercel": "^50.26.1"
"usehooks-ts": "^3.1.1"
}
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.2.3": "patches/next@16.2.3.patch"
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
}
}
-15
View File
@@ -1,20 +1,5 @@
# @gitbook/browser-types
## 0.1.5
### Patch Changes
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
- 2e495cb: Add Global Privacy Control (GPC) support
## 0.1.4
### Patch Changes
- 7a11861: Add support for custom cookie banner registration
- Updated dependencies [6f1db32]
- @gitbook/icons@0.4.3
## 0.1.3
### Patch Changes
+1 -1
View File
@@ -9,7 +9,7 @@
}
},
"sideEffects": false,
"version": "0.1.5",
"version": "0.1.3",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:"
-24
View File
@@ -46,11 +46,6 @@ export type GitBookAssistant = {
ui?: boolean;
};
export type GitBookCookieBannerHandler = (options: {
onApprove: () => void;
onReject: () => void;
}) => void;
export type GitBookGlobal = {
/**
* Register an event listener.
@@ -77,25 +72,6 @@ export type GitBookGlobal = {
* Register a custom assistant to be available on the site.
*/
registerAssistant: (assistant: GitBookAssistant) => () => void;
/**
* Register a custom cookie banner handler.
* When registered, the built-in cookie banner will not be displayed.
*/
registerCookieBanner: (handler: GitBookCookieBannerHandler) => void;
/**
* Indicates whether cookies tracking is disabled for the current user.
* Returns `true` when tracking is disabled, `false` when tracking is enabled,
* and `undefined` when the tracking preference is unknown or not yet determined.
*/
isCookiesTrackingDisabled: () => boolean | undefined;
/**
* Indicates whether global privacy control is enabled for the current user.
* Returns `true` when global privacy control is enabled, `false` when it is disabled.
*/
isGlobalPrivacyControlEnabled: () => boolean;
};
declare global {
-37
View File
@@ -1,42 +1,5 @@
# @gitbook/embed
## 0.5.0
### Minor Changes
- 88c38fa: Add Search tab to Docs Embed, refactor search
### Patch Changes
- 8d2a95b: Docs Embed: Better support light/dark mode overrides
## 0.4.0
### Minor Changes
- b3875a1: Support `assistantName` property to override Assistant name
## 0.3.0
### Minor Changes
- 370b731: Docs Embed: Make trademark optional
## 0.2.2
### Patch Changes
- b4b471f: Fix unexpected error thrown when using the embed in standalone mode
## 0.2.1
### Patch Changes
- 96e24a1: Fix signed JWT token usage
- 0aa34cc: Fix Embed options in React
- Updated dependencies [6f1db32]
- @gitbook/icons@0.4.3
## 0.2.0
### Minor Changes
+15 -101
View File
@@ -2,9 +2,8 @@
Embed your GitBook docs in your product or website.
The Docs Embed can contain three tabs:
The Docs Embed can contain two tabs:
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
- **Search**: A search-focused surface for quickly finding pages and asking scoped questions
- **Docs**: A browser for navigating your documentation site
The embed is set up automatically based on your site's configuration. You can optionally customize and override the configuration with custom actions, tools, suggested questions, [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access), and more. See the [Configuration](#configuration) section for all available options.
@@ -41,7 +40,7 @@ GitBook('configure', {
label: 'Ask',
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'search', 'docs'],
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
@@ -50,10 +49,8 @@ GitBook('configure', {
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
assistantName: 'Support Assistant',
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */],
closeButton: true,
tools: [/* ... */]
});
```
@@ -73,7 +70,6 @@ const gitbook = createGitBook({
// Create an iframe and get its URL
const iframe = document.createElement('iframe');
iframe.src = gitbook.getFrameURL({
colorScheme: 'dark', // Optional: force the embed to render in dark mode
visitor: {
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { // Optional: custom claims for dynamic expressions
@@ -94,7 +90,7 @@ frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'search', 'docs'],
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
@@ -103,10 +99,8 @@ frame.configure({
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
assistantName: 'Support Assistant',
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */],
closeButton: true
tools: [/* ... */]
});
// Listen to events
@@ -124,14 +118,12 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
<GitBookProvider siteURL="https://docs.company.com">
<GitBookFrame
colorScheme="dark"
visitor={{
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
}}
tabs={['assistant', 'search', 'docs']}
tabs={['assistant', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
assistantName="Support Assistant"
suggestions={['What is GitBook?', 'How do I get started?']}
actions={[
{
@@ -141,7 +133,6 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
}
]}
tools={[/* ... */]}
closeButton
/>
</GitBookProvider>
```
@@ -153,7 +144,7 @@ import { useGitBook } from '@gitbook/embed/react';
function MyComponent() {
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL({ colorScheme: 'dark', visitor: { token: '...' } });
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
// ...
}
```
@@ -181,7 +172,7 @@ function MyComponent() {
### Standalone Script
- `GitBook('init', options: { siteURL: string }, frameOptions?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` - Initialize widget
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
- `GitBook('show')` - Show widget button
- `GitBook('hide')` - Hide widget button
- `GitBook('open')` - Open widget window
@@ -198,7 +189,7 @@ function MyComponent() {
**Client Factory:**
- `createGitBook(options: { siteURL: string })``GitBookClient`
- `client.getFrameURL(options?: { colorScheme?: 'light' | 'dark', visitor?: {...} })``string`
- `client.getFrameURL(options?: { visitor?: {...} })``string`
- `client.createFrame(iframe: HTMLIFrameElement)``GitBookFrameClient`
**Frame Client:**
@@ -231,61 +222,14 @@ Available in: Standalone script, NPM package, React components
Override which tabs are displayed. Defaults to your site's configuration.
- **Type**: `('assistant' | 'search' | 'docs')[]`
- **Type**: `('assistant' | 'docs')[]`
- **Options**:
- `['assistant', 'docs']` - Show both tabs
- `['assistant']` - Show only the assistant tab
- `['docs']` - Show only the docs tab
```javascript
tabs: ['assistant', 'search', 'docs']
```
### `closeButton`
Available in: Standalone script, NPM package, React components
Display a close (`x`) button in the embed sidebar.
- **Type**: `boolean`
- **Default**: `false`
```javascript
closeButton: true
```
Behavior:
- When clicked, the embed sends a `close` event to the parent page.
- In the standalone script, this event is handled automatically and the widget closes.
- In custom iframe integrations (NPM package), you must listen for the `close` event and decide how to hide/collapse your UI.
- In React, this works automatically when using the standard widget flow. If you build custom frame wiring, handle `close` the same way as the NPM package.
NPM package example:
```typescript
const frame = gitbook.createFrame(iframe);
frame.configure({
closeButton: true
});
const unsubscribe = frame.on('close', () => {
// Hide your modal/drawer/container
container.classList.add('hidden');
});
```
Notes:
- The close button is rendered in the same sidebar area as tabs/actions.
- If your UI hides that sidebar or doesn't render it, the button will not be visible.
### `trademark`
Available in: Standalone script, NPM package, React components
Display GitBook branding in the embed. Defaults to true.
- **Type**: `boolean`
- **Default**: `true`
```javascript
trademark: true
tabs: ['assistant', 'docs']
```
### `actions`
@@ -334,18 +278,6 @@ greeting: {
}
```
### `assistantName`
Available in: Standalone script, NPM package, React components
Override the assistant name displayed in the chat header and assistant entry points (for example, sidebar tabs and action labels). This name will be limited to 32 characters to prevent text overflow.
- **Type**: `string`
```javascript
assistantName: 'Support Assistant'
```
### `suggestions`
Available in: Standalone script, NPM package, React components
@@ -460,24 +392,6 @@ visitor: {
}
```
### `colorScheme`
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
Override the embed's color scheme. When omitted, the embed follows the iframe's CSS `color-scheme`, which lets it inherit the parent page or browser preference.
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
**NPM package**: Pass to `getFrameURL({ colorScheme: 'dark' })`
**React components**: Pass as the `colorScheme` prop on `<GitBookFrame>`
- **Type**: `'light' | 'dark'`
```javascript
colorScheme: 'dark'
```
### `button`
Available in: Standalone script only
+1 -3
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.5.0",
"version": "0.2.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -23,7 +23,6 @@
"react": "*"
},
"devDependencies": {
"bun-types": "catalog:",
"@types/react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
@@ -34,7 +33,6 @@
"build-lib": "tsdown",
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"clean": "rm -rf ./dist",
"unit": "bun test",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
@@ -1,51 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { createGitBook } from './createGitBook';
describe('createGitBook.getFrameURL', () => {
it('builds the embed URL when the site URL has no trailing slash', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs' });
const url = new URL(client.getFrameURL({}));
expect(url.origin).toBe('https://example.com');
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.toString()).toBe('');
});
it('handles a trailing slash and adds visitor parameters', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
const url = new URL(
client.getFrameURL({
visitor: {
token: 'signed-token',
unsignedClaims: {
role: 'editor',
count: 3,
enabled: false,
},
},
})
);
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.get('jwt_token')).toBe('signed-token');
expect(url.searchParams.get('visitor.role')).toBe('editor');
expect(url.searchParams.get('visitor.count')).toBe('3');
expect(url.searchParams.get('visitor.enabled')).toBe('false');
});
it('adds an explicit color scheme override when requested', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
const url = new URL(
client.getFrameURL({
colorScheme: 'dark',
})
);
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.get('theme')).toBe('dark');
});
});
+1 -11
View File
@@ -8,12 +8,6 @@ export type CreateGitBookOptions = {
};
export type GetFrameURLOptions = {
/**
* Override the color scheme used by the embedded docs.
* When omitted, the embed follows the iframe's CSS `color-scheme`.
*/
colorScheme?: 'light' | 'dark';
/**
* Authentication to use for the frame.
*/
@@ -48,12 +42,8 @@ export function createGitBook(options: CreateGitBookOptions) {
const url = new URL(options.siteURL);
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
if (frameOptions.colorScheme) {
url.searchParams.set('theme', frameOptions.colorScheme);
}
if (frameOptions.visitor?.token) {
url.searchParams.set('jwt_token', frameOptions.visitor.token);
url.searchParams.set('token', frameOptions.visitor.token);
}
if (frameOptions.visitor?.unsignedClaims) {
@@ -64,12 +64,11 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = {
tabs: ['assistant', 'search', 'docs'],
tabs: ['assistant', 'docs'],
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
trademark: true,
};
return {
+1 -17
View File
@@ -45,7 +45,7 @@ export type GitBookEmbeddableActionDefinition = {
*/
export type GitBookEmbeddableConfiguration = {
/** Tabs to display in the embed (if enabled on the site). */
tabs: ('assistant' | 'docs' | 'search')[];
tabs: ('assistant' | 'docs')[];
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
@@ -62,27 +62,11 @@ export type GitBookEmbeddableConfiguration = {
subtitle: string;
};
/**
* Override the assistant name displayed in the UI.
* Limited to 32 characters.
*/
assistantName?: string;
/** Suggestions of questions to be displayed in the welcome page. */
suggestions: string[];
/** Tools to be provided to the assistant. */
tools: GitBookToolDefinition[];
/**
* Display GitBook branding in the embed.
*/
trademark?: boolean;
/**
* Display a close button inside the assistant.
*/
closeButton?: boolean;
};
/**
-1
View File
@@ -1,2 +1 @@
export * from './client';
export * from './standalone';
+5 -34
View File
@@ -11,34 +11,19 @@ import { useGitBook } from './GitBookProvider';
export type GitBookFrameProps = {
className?: string;
} & GetFrameURLOptions &
Partial<GitBookEmbeddableConfiguration>;
GitBookEmbeddableConfiguration;
/**
* Render a frame with the GitBook Assistant in it.
*/
export function GitBookFrame(props: GitBookFrameProps) {
const {
className,
colorScheme,
visitor,
actions = [],
greeting,
suggestions = [],
tools = [],
tabs = ['assistant', 'search', 'docs'],
trademark = true,
closeButton = false,
assistantName,
} = props;
const { className, visitor, actions, greeting, suggestions, tools } = props;
const frameRef = useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
const frameURL = useMemo(
() => gitbook.getFrameURL({ visitor, colorScheme }),
[gitbook, visitor, colorScheme]
);
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
useEffect(() => {
if (frameRef.current) {
@@ -48,26 +33,13 @@ export function GitBookFrame(props: GitBookFrameProps) {
useEffect(() => {
gitbookFrame?.configure({
tabs,
tabs: ['assistant', 'docs'],
actions,
greeting,
suggestions,
tools,
closeButton,
trademark,
assistantName,
});
}, [
gitbookFrame,
actions,
greeting,
suggestions,
tools,
tabs,
closeButton,
trademark,
assistantName,
]);
}, [gitbookFrame, actions, greeting, suggestions, tools]);
return (
<iframe
@@ -77,7 +49,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
width="100%"
height="100%"
className={className}
style={colorScheme ? { colorScheme } : undefined}
/>
);
}
+3 -22
View File
@@ -64,8 +64,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
tabs: ['assistant', 'search', 'docs'],
trademark: true,
tabs: ['assistant', 'docs'],
};
const widgetButton = document.createElement('button');
@@ -101,19 +100,12 @@ function getIframe() {
widgetIframe?.remove();
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
if (frameOptions?.colorScheme) {
widgetIframe.style.colorScheme = frameOptions.colorScheme;
}
widgetIframe.src = client.getFrameURL({
...frameOptions,
});
widgetWindow.appendChild(widgetIframe);
_frame = client.createFrame(widgetIframe);
_frame.on('close', () => {
widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open');
});
}
return { iframe: widgetIframe, frame: _frame };
}
@@ -160,19 +152,6 @@ const GitBook = (...args: StandaloneCalls) => {
break;
case 'configure': {
const settings = args[1];
// If trademark is disabled, change the (branded) icon to the sparkle icon
if (
settings.trademark === false &&
!settings.button?.icon &&
frameConfiguration.button.icon === 'assistant'
) {
settings.button = {
label: frameConfiguration.button.label,
icon: 'sparkle',
};
}
frameConfiguration = {
...frameConfiguration,
...settings,
@@ -214,3 +193,5 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook;
precalls.forEach((call) => GitBook(...call));
GitBook('configure', {});
+1 -3
View File
@@ -15,9 +15,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
"types": []
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/expr
## 1.2.5
### Patch Changes
- 8242f18: Extend gitbook/expr std lib with some additional methods
## 1.2.4
### Patch Changes
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.2.5",
"version": "1.2.4",
"type": "module",
"exports": {
".": {
@@ -11,8 +11,8 @@
},
"sideEffects": false,
"dependencies": {
"acorn": "^8.15.0",
"acorn-loose": "^8.5.2",
"acorn": "^8.14.0",
"acorn-loose": "8.4.0",
"acorn-walk": "^8.3.4",
"assert-never": "catalog:",
"escodegen": "^2.1.0",
@@ -25,7 +25,6 @@ describe('autocomplete', () => {
description: 'An array of string',
items: SymbolString(),
});
const visitorClaimsKeyPropStringSymbol = SymbolString({ name: 'key' });
const symbols = {
visitor: SymbolObject({
name: 'visitor',
@@ -34,7 +33,7 @@ describe('autocomplete', () => {
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: visitorClaimsKeyPropStringSymbol,
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
@@ -169,12 +168,8 @@ describe('autocomplete', () => {
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.startsWith',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
'visitor.claims.key.toLowerCase',
'visitor.claims.key.toUpperCase',
'visitor.claims.key.trim',
],
},
},
@@ -232,12 +227,8 @@ describe('autocomplete', () => {
childrenRefs: [
'visitor.claims.role.length',
'visitor.claims.role.at',
'visitor.claims.role.startsWith',
'visitor.claims.role.endsWith',
'visitor.claims.role.includes',
'visitor.claims.role.toLowerCase',
'visitor.claims.role.toUpperCase',
'visitor.claims.role.trim',
],
},
},
@@ -255,47 +246,13 @@ describe('autocomplete', () => {
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.startsWith',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
'visitor.claims.key.toLowerCase',
'visitor.claims.key.toUpperCase',
'visitor.claims.key.trim',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.key.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolNumber({
name: 'length',
description:
'The length data property of a String value contains the length of the string in UTF-16 code units.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
}),
ref: 'visitor.claims.key.length',
parentRef: 'visitor.claims.key',
childrenRefs: [],
},
},
...visitorClaimsKeyPropStringSymbol.methods.map<AutocompleteSymbolSuggestion>(
(method) => ({
type: 'symbol',
symbol: {
definition: method,
ref: `visitor.claims.key.${method.name}`,
parentRef: 'visitor.claims.key',
childrenRefs: [],
},
})
),
],
},
{
expressionWithCursor: 'visitor.claims.h<cur>',
expectedSuggestions: [
@@ -54,54 +54,12 @@ describe('ExpressionRuntime', () => {
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
expectedResult: true,
},
{
scenario: 'array includes',
condition: 'reviews.includes("approved")',
inputs: { reviews: ['pending', 'approved'] },
expectedResult: true,
},
{
scenario: 'array map',
condition: '[1, 2, 3].map(n => n * x)',
inputs: { x: 2 },
expectedResult: [2, 4, 6],
},
{
scenario: 'string startsWith',
condition: 'user.role.startsWith("ad")',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string endsWith',
condition: 'user.role.endsWith("min")',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string includes',
condition: 'user.role.includes("dm")',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string toLowerCase',
condition: 'user.role.toLowerCase() === "admin"',
inputs: { user: { role: 'ADMIN' } },
expectedResult: true,
},
{
scenario: 'string toUpperCase',
condition: 'user.role.toUpperCase() === "ADMIN"',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string trim',
condition: 'user.role.trim() === "admin"',
inputs: { user: { role: ' admin ' } },
expectedResult: true,
},
])(
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
({ condition, inputs, expectedResult }) => {
@@ -255,12 +255,8 @@ describe('ExpressionRuntime', () => {
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.startsWith',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
'visitor.claims.key.toLowerCase',
'visitor.claims.key.toUpperCase',
'visitor.claims.key.trim',
],
});
@@ -305,12 +301,8 @@ describe('ExpressionRuntime', () => {
childrenRefs: [
'visitor.claims.flags.FLAG1.length',
'visitor.claims.flags.FLAG1.at',
'visitor.claims.flags.FLAG1.startsWith',
'visitor.claims.flags.FLAG1.endsWith',
'visitor.claims.flags.FLAG1.includes',
'visitor.claims.flags.FLAG1.toLowerCase',
'visitor.claims.flags.FLAG1.toUpperCase',
'visitor.claims.flags.FLAG1.trim',
],
});
@@ -326,12 +318,8 @@ describe('ExpressionRuntime', () => {
childrenRefs: [
'visitor.claims.flags.FLAG2.length',
'visitor.claims.flags.FLAG2.at',
'visitor.claims.flags.FLAG2.startsWith',
'visitor.claims.flags.FLAG2.endsWith',
'visitor.claims.flags.FLAG2.includes',
'visitor.claims.flags.FLAG2.toLowerCase',
'visitor.claims.flags.FLAG2.toUpperCase',
'visitor.claims.flags.FLAG2.trim',
],
});
-58
View File
@@ -156,31 +156,6 @@ const StandardLibrary: Partial<
members: [SymbolString(), SymbolUndefined()],
}),
}),
SymbolFunction({
name: 'startsWith',
description: `Returns true if the given characters are found at the beginning of the string, including when searchString
is an empty string. Otherwise returns false.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith',
args: [
SymbolString({
name: 'searchString',
description: `The characters to be searched for at the start of this string. Cannot be a regex.
All values that are not regexes are coerced to strings, so omitting it or passing undefined causes startsWith() to search for
the string "undefined", which is rarely what you want.`,
}),
OptionalFunctionArg(
SymbolNumber({
name: 'position',
description: `The start position at which searchString is expected to be found
(the index of searchString's first character). Defaults to 0.`,
})
),
],
returns: SymbolBoolean({
description: `true if the given characters are found at the beginning of the string, including when searchString is an empty string;
otherwise, false.`,
}),
}),
SymbolFunction({
name: 'endsWith',
description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding
@@ -230,39 +205,6 @@ const StandardLibrary: Partial<
otherwise, false.`,
}),
}),
SymbolFunction({
name: 'toLowerCase',
description:
'Returns the value of the string converted to lower case. toLowerCase() does not affect the value of the string str itself.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase',
args: [],
returns: SymbolString({
description:
'A new string representing the calling string converted to lower case.',
}),
}),
SymbolFunction({
name: 'toUpperCase',
description:
'Returns the value of the string converted to uppercase. toUpperCase() does not affect the value of the string str itself.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase',
args: [],
returns: SymbolString({
description:
'A new string representing the calling string converted to upper case.',
}),
}),
SymbolFunction({
name: 'trim',
description:
'Returns new string representing str stripped of whitespace from both its beginning and end.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trim',
args: [],
returns: SymbolString({
description:
'A new string representing str stripped of whitespace from both its beginning and end.',
}),
}),
],
},
[SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({
+1 -1
View File
@@ -36,4 +36,4 @@ screenshots/
# cloudflare
.open-next
.wrangler
worker-configuration.d.ts
worker-configuration.d.ts
-218
View File
@@ -1,223 +1,5 @@
# gitbook
## 0.25.1
### Patch Changes
- aa1fc24: Add support for sticky first column in tables
## 0.25.0
### Minor Changes
- 88c38fa: Add Search tab to Docs Embed, refactor search
- 709964f: Rework page layout
### Patch Changes
- 789c2fa: Adjust empty TOC logic and layout
- 8d2a95b: Docs Embed: Better support light/dark mode overrides
- 7fd381c: Hide empty sidebar on OpenAPI pages
- 723e579: Fix rendering multi-expandable block in a column
- 68c842b: Translations dropdown should show language spaces that best matches the current space title
- Updated dependencies [8d2a95b]
- Updated dependencies [88c38fa]
- @gitbook/embed@0.5.0
## 0.24.0
### Minor Changes
- 50653ab: Redirect sitemap.md and .well-known/sitemap.md to llms.txt.
- 50653ab: Improve AI agent detection and readability by outputting 200 for page not found.
### Patch Changes
- 126d4b7: Improve Assistant form controls
- Updated dependencies [8242f18]
- @gitbook/expr@1.2.5
- @gitbook/react-openapi@1.5.12
## 0.23.2
### Patch Changes
- Updated dependencies [ab17ff4]
- @gitbook/icons@0.4.6
- @gitbook/react-contentkit@0.7.15
## 0.23.1
### Patch Changes
- Updated dependencies [3c141f0]
- @gitbook/icons@0.4.5
- @gitbook/react-contentkit@0.7.14
## 0.23.0
### Minor Changes
- 8e5a2aa: Pan and zoom controls for mermaid diagrams
### Patch Changes
- b77c4fc: Add getPage MCP tool
- 31e77d4: Track new events for site analytics.
- f884477: Associate questions asked in the embeddable assistant to the proper channel.
- deabce9: Sort results by score to mix records and pages.
- 4909044: Add Claude Code and Codex connect actions
- da648ca: Fix regressions in Assistant UX
- 3ce7e52: Fix Variant dropdown not showing in GitBook embed
- 7f4e644: Small OpenAPI Improvements
- 4787b7e: Use localized titles/descriptions for site entities
- 7ab6351: Import site redirect patterns from @gitbook/api
- b40465e: Fix normalization of URL causing invalid redirections.
- b5d6f2e: Fix translation quality across 11 locales
- 3151864: Track events from the proper server URL.
- 16bfafe: Add option to sticky header row in tables
- 7c2823c: Fix full width header alignment
- c4a2604: Fix PDF export crash when page contains an OpenAPI block
- c56b569: Support expand-all options for OpenAPI
- Updated dependencies [7f4e644]
- Updated dependencies [b5d6f2e]
- Updated dependencies [9dd2db6]
- Updated dependencies [c56b569]
- Updated dependencies [ccd21e4]
- @gitbook/react-openapi@1.5.11
- @gitbook/openapi-parser@3.0.11
- @gitbook/icons@0.4.4
- @gitbook/react-contentkit@0.7.13
## 0.22.3
### Patch Changes
- f182680: Add backward compatibility for OpenAPI spec content URL
- bdc6cd9: Use OpenAPI publicURL as Scalar specUrl
- b3875a1: Refactor embeddable context to merge local & site properties in one unified way
- eceec6f: Move ~scalar/proxy route to site route group
- 887c3f8: Add token verification on OpenAPI proxy
- d666212: Add support for localized site section titles
- Updated dependencies [b3875a1]
- Updated dependencies [bdc6cd9]
- Updated dependencies [8ab419a]
- Updated dependencies [eceec6f]
- Updated dependencies [887c3f8]
- @gitbook/embed@0.4.0
- @gitbook/react-openapi@1.5.10
- @gitbook/openapi-parser@3.0.10
## 0.22.2
### Patch Changes
- e074283: Add server-side proxy for Scalar API client to bypass CORS
- afa868e: Show "Ask Assistant" search result immediately
- 0803db0: Track opening of search results for records and in the assistant.
- cb71511: Wrap tracking call with waitUntil
- 4a6f91a: Show social accounts in header
- 370b731: Docs Embed: Make trademark optional
- afa476c: Add server-side insight event tracking for MCP route
- Updated dependencies [e074283]
- Updated dependencies [370b731]
- @gitbook/openapi-parser@3.0.9
- @gitbook/react-openapi@1.5.9
- @gitbook/embed@0.3.0
## 0.22.1
### Patch Changes
- 9d5e397: Remove code syntax highlighting patch for Safari
- 3d8e89c: Show favicon for record search results in the assistant.
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
- 426aabe: Fix TOC header logo on bold themes
- ae30a57: Integrate Ask AI with upcoming insights by passing visitor session IDs.
- f7c251e: Update Reddit social account rendering
- e31a8f4: Hide cookie banner for AI user agents
- 2e495cb: Add Global Privacy Control (GPC) support
- Updated dependencies [1a7ef78]
- Updated dependencies [93eea0b]
- Updated dependencies [b4b471f]
- Updated dependencies [5f66860]
- Updated dependencies [2ebb18d]
- Updated dependencies [11d9b80]
- Updated dependencies [2e495cb]
- Updated dependencies [f517035]
- @gitbook/react-openapi@1.5.8
- @gitbook/embed@0.2.2
- @gitbook/browser-types@0.1.5
## 0.22.0
### Minor Changes
- e00d1c5: Support code themes customization
- f294818: Serve a markdown version of the page when the "Accept" header contains "text/markdown".
### Patch Changes
- 9d6457f: OpenAPI styling tweaks
- d738e11: Prevent form submission during IME composition
- 1e53376: Add sidesheet component, use it for TOC and AIChat
- f7f6f9d: Set <html lang> based on translated language
- 89de966: Support social links
- b53cb1d: Align styling of PageLinkItem and PageDocumentItem
- 27a34b7: Support `defaultExpanded` in Expandable block
- a5ef44a: Better conditional display of page actions
- 07c02eb: Header & TOC tweaks
- 29e2b22: Fix ordered list item index calculation
- f6cf62a: Standardise toggle chevrons across the app
- b57561d: Fix rendering of images in reusable content from an external space with the new dereferencing in the API.
- e172b25: Grow InlineActionButton to take available width on line
- 7a11861: Add support for custom cookie banner registration
- 8f32f4f: Better handle huge section groups
- 0aa34cc: Fix Embed options in React
- Updated dependencies [e00d1c5]
- Updated dependencies [96e24a1]
- Updated dependencies [4e2d863]
- Updated dependencies [d3f6c38]
- Updated dependencies [3ba9e46]
- Updated dependencies [717ad0b]
- Updated dependencies [6f1db32]
- Updated dependencies [7a11861]
- Updated dependencies [0aa34cc]
- Updated dependencies [48ca21e]
- @gitbook/react-openapi@1.5.7
- @gitbook/embed@0.2.1
- @gitbook/openapi-parser@3.0.8
- @gitbook/icons@0.4.3
- @gitbook/browser-types@0.1.4
- @gitbook/react-contentkit@0.7.12
## 0.21.3
### Patch Changes
- Updated dependencies [bf63045]
- @gitbook/react-openapi@1.5.6
## 0.21.2
### Patch Changes
- ca68236: Fix 404 when accessing rss.xml for the root page.
- 21851d3: Round page cover corners on extrawide screens
- 695e99f: Update button sizing
- 7517e98: Clean up button and input across the app
- 99d788e: Update page cover styling
- ebf3b2b: Fix an issue where content refs to spaces outside of site was resolving with share links
- 35efc9d: Fix TOCPageIcons backgrounds on gradient and bold themes
- a7f58ab: Fix embed tab button size regression
- 360b525: Switch icon masking method to fix visual cutoffs in FA7
- Updated dependencies [985c1b4]
- Updated dependencies [360b525]
- @gitbook/openapi-parser@3.0.7
- @gitbook/icons@0.4.2
- @gitbook/react-openapi@1.5.5
- @gitbook/react-contentkit@0.7.11
## 0.21.1
### Patch Changes
@@ -1,76 +0,0 @@
import { expect } from '@playwright/test';
import { type TestsCase, getCustomizationURL, runTestCases } from './util';
const testCases: TestsCase[] = [
{
name: 'Cookie Banner',
contentBaseURL: 'https://gitbook.com/docs/',
tests: [
{
name: 'should show built-in banner when no custom banner is registered',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Check that built-in banner is visible
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
},
},
{
name: 'should not show built-in banner when UserAgent is AI',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Override navigator.userAgent to simulate AI crawler (affects isAIUserAgent())
await page.addInitScript(() => {
Object.defineProperty(navigator, 'userAgent', {
get: () =>
'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot',
configurable: true,
});
});
await page.reload();
// Check that built-in banner is not visible for AI UserAgent
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
},
},
{
name: 'should not show built-in banner when custom banner is registered',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Register a custom cookie banner handler
await page.waitForFunction(() => {
return typeof window !== 'undefined' && window.GitBook !== undefined;
});
await page.evaluate(() => {
window.GitBook?.registerCookieBanner(() => {
// Custom cookie banner handler - no-op for testing to avoid reload
});
});
// Check that built-in banner is not visible
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
},
},
],
},
];
runTestCases(testCases);
+29 -42
View File
@@ -1,18 +1,5 @@
import { type TestsCase, runTestCases, waitForCookiesDialog } from './util';
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
const CLOSED_ONETRUST_COOKIE_BANNER = [
{
name: '__gitbook_cookie_granted',
value: 'no',
},
{
name: 'OptanonAlertBoxClosed',
value: new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(),
},
];
/** A list of test cases to run on the customers' docs sites. */
const testCases: TestsCase[] = [
{
@@ -23,18 +10,18 @@ const testCases: TestsCase[] = [
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
],
},
{
name: 'Nexthink',
contentBaseURL: 'https://docs.nexthink.com',
tests: [
{
name: 'Home',
url: '/',
screenshot: { waitForTOCScrolling: false },
run: waitForCookiesDialog,
},
],
},
// {
// name: 'Nexthink',
// contentBaseURL: 'https://docs.nexthink.com',
// tests: [
// {
// name: 'Home',
// url: '/',
// screenshot: { waitForTOCScrolling: false },
// run: waitForCookiesDialog,
// },
// ],
// },
{
name: 'asiksupport-stg.dto.kemkes.go.id',
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
@@ -50,6 +37,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://faq.deltaemulator.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.dify.ai',
contentBaseURL: 'https://docs.dify.ai',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'seeddao.gitbook.io',
contentBaseURL: 'https://seeddao.gitbook.io',
@@ -110,12 +102,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.midas.app',
tests: [{ name: 'Home', url: '/' }],
},
// Disabling for now, one of the image is making the tests timeout
// {
// name: 'docs.keeper.io',
// contentBaseURL: 'https://docs.keeper.io',
// tests: [{ name: 'Home', url: '/en', run: waitForCookiesDialog }],
// },
{
name: 'docs.keeper.io',
contentBaseURL: 'https://docs.keeper.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'adiblar.gitbook.io',
contentBaseURL: 'https://adiblar.gitbook.io',
@@ -223,12 +214,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.soniclabs.com',
tests: [{ name: 'Home', url: '/' }],
},
// This one redirects to binance now
// {
// name: 'docs.meshchain.ai',
// contentBaseURL: 'https://docs.meshchain.ai',
// tests: [{ name: 'Home', url: '/' }],
// },
{
name: 'docs.meshchain.ai',
contentBaseURL: 'https://docs.meshchain.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.thousandeyes.com',
contentBaseURL: 'https://docs.thousandeyes.com',
@@ -247,12 +237,9 @@ const testCases: TestsCase[] = [
{
name: 'run-ai-docs.nvidia.com',
contentBaseURL: 'https://run-ai-docs.nvidia.com',
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
tests: [
{
name: 'Home',
url: '/',
cookies: CLOSED_ONETRUST_COOKIE_BANNER,
},
{ name: 'Home', url: '/' },
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
],
},
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -29,7 +29,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - e2e - all pages', {
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -67,7 +67,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - gb-docs - all pages', {
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -109,7 +109,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - e2e - single-page', {
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -151,7 +151,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - gb-docs - single-page', {
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
+15 -59
View File
@@ -2,11 +2,9 @@ import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationAIMode,
CustomizationBackground,
CustomizationCodeTheme,
CustomizationCorners,
CustomizationDefaultFont,
CustomizationDefaultMonospaceFont,
CustomizationDefaultThemeMode,
CustomizationDepth,
type CustomizationHeaderItem,
CustomizationHeaderPreset,
@@ -17,6 +15,7 @@ import {
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationTheme,
CustomizationThemeMode,
type CustomizationThemedColor,
type SiteCustomizationSettings,
SiteExternalLinksTarget,
@@ -75,10 +74,6 @@ export type TestsCase = {
skip?: boolean;
tests: Array<Test>;
contentBaseURL?: string;
/**
* Whether screenshots in this test case should capture the full scrollable page by default.
*/
fullPage?: boolean;
};
export const allLocales: CustomizationLocale[] = [
@@ -88,9 +83,9 @@ export const allLocales: CustomizationLocale[] = [
CustomizationLocale.Zh,
];
export const allThemeModes: CustomizationDefaultThemeMode[] = [
CustomizationDefaultThemeMode.Light,
CustomizationDefaultThemeMode.Dark,
export const allThemeModes: CustomizationThemeMode[] = [
CustomizationThemeMode.Light,
CustomizationThemeMode.Dark,
];
export const allTintColors: Array<{
@@ -146,11 +141,6 @@ export const headerLinks: CustomizationHeaderItem[] = [
},
];
type IconURLState = { state: 'pending'; uri: null } | { state: 'loaded'; uri: string };
type IconStateWindow = Window & {
__ICONS_STATES__?: Record<string, IconURLState>;
};
export async function waitForCookiesDialog(page: Page) {
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible({
@@ -164,19 +154,9 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404);
}
export async function setTimeToMorning(page: Page) {
const now = new Date();
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
await page.clock.install({ time: now });
}
export async function waitForCoverImages(page: Page, options?: { darkMode?: boolean }) {
const selector = options?.darkMode
? 'img[alt="Page cover"].dark\\:inline'
: 'img[alt="Page cover"]:not(.dark\\:inline)';
export async function waitForCoverImages(page: Page) {
// Wait for cover images to exist (not the shimmer placeholder)
await expect(page.locator(selector)).toBeVisible({
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
timeout: 10_000,
});
}
@@ -249,9 +229,9 @@ export function runTestCases(testCases: TestsCase[]) {
.intercom-lightweight-app {
display: none !important;
}
`,
`,
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? testCase.fullPage ?? false,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
if (screenshotOptions?.waitForTOCScrolling !== false) {
@@ -318,16 +298,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
background: CustomizationBackground.Plain,
icons: CustomizationIconsStyle.Regular,
links: CustomizationLinksStyle.Default,
codeTheme: {
default: {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
},
openapi: {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
},
},
sidebar: {
background: CustomizationSidebarBackgroundStyle.Default,
list: CustomizationSidebarListStyle.Default,
@@ -349,7 +319,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
groups: [],
},
themes: {
default: CustomizationDefaultThemeMode.System,
default: CustomizationThemeMode.Light,
toggeable: true,
},
pdf: {
@@ -385,7 +355,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
url: 'https://www.gitbook.com/privacy',
},
socialPreview: {},
socialAccounts: [],
};
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
@@ -401,9 +370,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/
export async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
const iconWindow = window as IconStateWindow;
const urlStates: Record<string, IconURLState> = iconWindow.__ICONS_STATES__ || {};
iconWindow.__ICONS_STATES__ = urlStates;
const urlStates: Record<
string,
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
> = (window as any).__ICONS_STATES__ || {};
(window as any).__ICONS_STATES__ = urlStates;
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
const response = await fetch(url);
@@ -436,20 +407,6 @@ export async function waitForIcons(page: Page) {
return true;
}
const svgSymbol = icon.querySelector('[data-testid="symbol-use"]');
if (svgSymbol) {
if (icon.dataset.gbIconSymbolState === 'loaded') {
return true;
}
const href = svgSymbol.getAttribute('href') ?? svgSymbol.getAttribute('xlink:href');
if (!href?.startsWith('#')) {
return false;
}
return document.getElementById(href.slice(1)) instanceof SVGElement;
}
const state = icon.getAttribute('data-argos-state');
if (state === 'pending') {
@@ -498,8 +455,7 @@ export async function waitForIcons(page: Page) {
*/
async function waitForTOCScrolling(page: Page) {
const viewport = await page.viewportSize();
if (viewport && viewport.width >= 1024 && !page.url().includes('~gitbook/embed/demo')) {
// The embed demo is an iframe, which means the viewport is only a fraction of the main document. So there is no open TOC to scroll to.
if (viewport && viewport.width >= 1024) {
const toc = page.getByTestId('table-of-contents');
await expect(toc).toBeVisible();
await page.evaluate(() => {
+1 -24
View File
@@ -1,27 +1,9 @@
// @ts-check
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
let deploymentId =
process.env.GITBOOK_RUNTIME === 'cloudflare'
? undefined
: process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
const { VERCEL_TARGET_ENV } = process.env;
// Because preview, staging and prod shares the same SHA, the deployment will fail if we don't prefix it with the environment name.
if (VERCEL_TARGET_ENV === 'preview') {
deploymentId = `t-${deploymentId}`;
} else if (VERCEL_TARGET_ENV === 'staging') {
deploymentId = `s-${deploymentId}`;
} else if (VERCEL_TARGET_ENV === 'production') {
deploymentId = `p-${deploymentId}`;
}
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
@@ -35,18 +17,14 @@ const nextConfig = {
// Since content is fully static, we don't want to fetch on hover again
optimisticClientCache: false,
// Disable splitting the RSC in like 5 chunks
prefetchInlining: true,
},
env: {
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
// GitBook envs
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
@@ -59,7 +37,6 @@ const nextConfig = {
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION,
// Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
@@ -2,15 +2,12 @@
"main": "default.js",
"name": "gitbook-open-v2-server",
"keep_names": false,
"compatibility_date": "2026-04-02",
"compatibility_date": "2025-04-14",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": false
},
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
@@ -92,8 +89,7 @@
},
"staging": {
"vars": {
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"NEXT_PRIVATE_DEBUG_CACHE": "true"
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"r2_buckets": [
{
@@ -137,8 +133,7 @@
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
// This means that we'll have up to 100 durable objects instance running at the same time
"MAX_REVALIDATE_CONCURRENCY": "100",
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"NEXT_PRIVATE_DEBUG_CACHE": "true"
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"r2_buckets": [
{
@@ -8,9 +8,6 @@
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": false
},
"env": {
"preview": {
"vars": {
@@ -1,16 +1,13 @@
{
"main": "middleware.js",
"name": "gitbook-open-v2",
"compatibility_date": "2026-04-02",
"compatibility_date": "2025-04-14",
"keep_names": false,
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": false
},
"assets": {
"directory": "../../.open-next/assets",
"binding": "ASSETS"
@@ -29,7 +29,6 @@ export class GitbookIncrementalCache implements IncrementalCache {
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
const cacheKey = this.getR2Key(key, cacheType);
console.log(`[GitbookIncrementalCache] Getting cache for key: ${cacheKey}`, key, cacheType);
const r2 = getCloudflareContext().env[BINDING_NAME];
if (!r2) throw new Error('No R2 bucket');
@@ -1,15 +1,12 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
// export default withRegionalCache(new GitbookIncrementalCache(), {
// mode: 'long-lived',
// // Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// // TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
// bypassTagCacheOnCacheHit: false,
// //TODO: remove, reducing cache ttl of regional cache to help debugging
// defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
// // We don't want to update the cache entry on every cache hit
// shouldLazilyUpdateOnCacheHit: false,
// });
//TODO: reenable regional cache once we know what's going on
export default new GitbookIncrementalCache();
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
bypassTagCacheOnCacheHit: false,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -6,13 +6,12 @@ import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-ca
const originalTagCache = doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
// We can set a long TTL for the regional cache, as we invalidate it on update
regionalCacheTtlSec: 24 * 60 * 60, // 1 day
regionalCacheTtlSec: 60 * 5 /* 5 minutes */,
// Because we invalidate the Cache API on update, we can safely set this to true
regionalCacheDangerouslyPersistMissingTags: true,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 2,
numberOfHardReplicas: 1,
regionalReplication: {
defaultRegion: 'enam',
},
+19 -32
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.25.1",
"version": "0.21.1",
"private": true,
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
@@ -17,22 +17,19 @@
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "3.10.1",
"@opennextjs/cloudflare": "1.19.1",
"@panzoom/panzoom": "^4.6.1",
"@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.14.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@tusbar/cache-control": "^1.0.2",
"@vercel/agent-readability": "^0.4.0",
"ai": "^4.2.2",
"assert-never": "catalog:",
"bidc": "catalog:",
@@ -40,23 +37,20 @@
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"feed": "^5.1.0",
"flexsearch": "^0.8.212",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
"leven": "^4.1.0",
"mcp-handler": "^1.0.2",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1",
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "^11.14.0",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"next": "^16.2.3",
"next": "15.4.10",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -64,10 +58,9 @@
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"quick-lru": "^7.0.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react": "catalog:",
"react-dom": "catalog:",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
@@ -75,7 +68,7 @@
"remark-rehype": "^11.1.1",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^3.21.0",
"shiki": "^3.2.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
@@ -85,12 +78,11 @@
"usehooks-ts": "catalog:",
"warn-once": "^0.1.1",
"zod": "^3",
"zod-to-json-schema": "^3.25.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@argos-ci/playwright": "^6.4.2",
"@playwright/test": "^1.58.2",
"@argos-ci/playwright": "^5.0.9",
"@playwright/test": "^1.54.2",
"@scalar/api-client-react": "catalog:",
"@tailwindcss/postcss": "^4.1.11",
"@types/js-cookie": "^3.0.6",
@@ -100,10 +92,9 @@
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/rison": "^0.0.9",
"@types/negotiator": "^0.6.4",
"bun-types": "catalog:",
"deepmerge": "^4.3.1",
"env-cmd": "^10.1.0",
@@ -113,22 +104,22 @@
"tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "50.37.3",
"wrangler": "^4.79.0",
"vercel": "^39.3.0",
"wrangler": "^4.43.0",
"rss-parser": "^3.13.0"
},
"scripts": {
"generate": "./scripts/generate.sh",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
"build": "next build --webpack",
"build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack",
"dev": "env-cmd --silent -f ../../.env.local next",
"build": "next build",
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
"build:cloudflare": "opennextjs-cloudflare build",
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"e2e-browserless": "bun test ./tests/",
@@ -140,9 +131,5 @@
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
}
}
@@ -23,8 +23,7 @@ export default async function SiteDynamicLayout({
return (
<CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable"
bodyClassName="site-background"
className="site-background"
forcedTheme={forcedTheme}
context={context}
>
@@ -1,30 +0,0 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
/**
* Redirect to the upstream auth provider login URL of site, or to the site root when not configured.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
const noLoginFallbackURL = context.linker.toAbsoluteURL(context.linker.toPathInSite(''));
if (!context.site.urls.login) {
return NextResponse.redirect(noLoginFallbackURL);
}
try {
const loginURL = new URL(context.site.urls.login);
const location = request.nextUrl.searchParams.get('location');
if (location) {
loginURL.searchParams.set('location', location);
}
return NextResponse.redirect(loginURL);
} catch (_error) {
return NextResponse.redirect(noLoginFallbackURL);
}
}
@@ -1,36 +0,0 @@
import {
type RouteLayoutParams,
getDynamicSiteContext,
getSiteURLDataFromParams,
} from '@/app/utils';
import { getVisitorAuthBasePath } from '@/lib/data';
import { getVisitorAuthCookieName } from '@/lib/visitors';
import { cookies } from 'next/headers';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
/**
* Clear the site-scoped auth session cookies and redirect to the site root.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const resolvedParams = await params;
const [{ context }, siteURLData, cookieStore] = await Promise.all([
getDynamicSiteContext(resolvedParams),
getSiteURLDataFromParams(resolvedParams),
cookies(),
]);
cookieStore.delete(
getVisitorAuthCookieName(
getVisitorAuthBasePath(new URL(request.nextUrl.toString()), siteURLData)
)
);
// TODO: Redirect to the site root for now. Once the API supports it,
// optionally redirect to a logoutURL (e.g when needing to logout from upstream auth too)
// when defined in visitor auth settings.
return NextResponse.redirect(context.linker.toAbsoluteURL(context.linker.toPathInSite('')));
}
@@ -5,7 +5,6 @@ import {
generateEmbeddableViewport,
} from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { getThemeFromMiddleware } from '@/lib/middleware';
import { shouldTrackEvents } from '@/lib/tracking';
import { headers } from 'next/headers';
@@ -19,14 +18,12 @@ export default async function RootLayout({
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents(await headers());
const forcedTheme = await getThemeFromMiddleware();
return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
forcedTheme={forcedTheme}
>
{children}
</EmbeddableRootLayout>
@@ -1,16 +0,0 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableSearchPage } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export const dynamic = 'force-static';
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
return <EmbeddableSearchPage context={context} />;
}
@@ -1,12 +0,0 @@
import type { RouteLayoutParams } from '@/app/utils';
import type { NextRequest } from 'next/server';
import { handleMcpRequest } from '../handler';
async function handler(
rawRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
return handleMcpRequest(rawRequest, await params, '~gitbook/mcp/auth');
}
export { handler as GET, handler as POST };
@@ -1,227 +0,0 @@
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { getExposableError, throwIfDataError } from '@/lib/data';
import { getMarkdownForPageInSpace } from '@/lib/markdownPage';
import { resolvePagePath } from '@/lib/pages';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy, findSiteSpaceByUrl } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
export async function handleMcpRequest(
rawRequest: NextRequest,
params: RouteLayoutParams,
endpoint: '~gitbook/mcp' | '~gitbook/mcp/auth'
) {
const { context } = await getDynamicSiteContext(params);
const { dataFetcher, linker, site } = context;
if (!context.customization.pageActions.mcp) {
return new Response('Not Found', { status: 404 });
}
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint)));
requestURL.search = rawRequest.nextUrl.search;
const request = new Request(requestURL, rawRequest);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: context.site.id,
events: [
{
type: 'mcp_request',
location: {
displayContext: SiteInsightsDisplayContext.Server,
},
},
],
request,
})
);
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
'searchDocumentation',
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
{
query: z.string(),
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request,
})
);
return {
content: results.flatMap((result) => {
if (result.type === 'record') {
return {
type: 'text',
text: [
`Title: ${result.title}`,
`Link: ${result.url}`,
result.description ? `Content: ${result.description}` : '',
]
.filter(Boolean)
.join('\n'),
};
}
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === result.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return result.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
const body = pageResult.sections
?.map((section) => section.body)
.join('\n');
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
}
);
const siteUrl = context.siteSpace.urls.published;
server.tool(
'getPage',
`Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`,
{
url: z
.string()
.describe('The URL of the page to fetch')
.transform((value, ctx) => {
if (URL.canParse(value)) {
return value;
}
if (URL.canParse(`https://${value}`)) {
return `https://${value}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`,
});
return z.NEVER;
}),
},
async ({ url }) => {
try {
const match = findSiteSpaceByUrl(context.structure, url);
if (!match) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
isError: true,
};
}
const revision = await throwIfDataError(
dataFetcher.getRevision({
spaceId: match.siteSpace.space.id,
revisionId: match.siteSpace.space.revision,
})
);
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
if (!resolved) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
isError: true,
};
}
const markdown = await getMarkdownForPageInSpace(
context,
match.siteSpace,
resolved.page
);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'page_view',
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
page: resolved.page.id,
space: match.siteSpace.space.id,
revision: match.siteSpace.space.revision,
},
},
],
request,
})
);
return { content: [{ type: 'text', text: markdown }] };
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
}
);
},
{},
{
streamableHttpEndpoint: context.linker.toPathInSite(endpoint),
maxDuration: 60,
verboseLogs: true,
disableSse: true,
}
);
return mcpHandler(request);
}
@@ -1,12 +0,0 @@
import type { RouteLayoutParams } from '@/app/utils';
import type { NextRequest } from 'next/server';
import { handleMcpRequest } from './handler';
async function handler(
rawRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
return handleMcpRequest(rawRequest, await params, '~gitbook/mcp');
}
export { handler as GET, handler as POST };
@@ -1,204 +0,0 @@
import type {
ComputedPageResult,
ComputedSectionResult,
OrderedComputedResult,
SearchSiteContentRequest,
} from '@/components/Search/search-types';
import { throwIfDataError } from '@/lib/data';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy, getLocalizedTitle } from '@/lib/sites';
import type {
SearchPageResult,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
SiteSpace,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { type NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
const [context, siteURLData] = await Promise.all([
getServerActionBaseContext({ isEmbeddable: asEmbeddable }),
getSiteURLDataFromMiddleware(),
]);
if (query.length <= 1) {
return NextResponse.json([]);
}
const [searchResults, { structure }] = await Promise.all([
throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
query,
scope,
})
),
throwIfDataError(
context.dataFetcher.getPublishedContentSite({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
siteShareKey: siteURLData.shareKey,
})
),
]);
const results = searchResults
.flatMap((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
id: resultItem.id,
title: resultItem.title,
description: resultItem.description,
href: resultItem.url,
score: resultItem.score,
};
return [{ score: resultItem.score, items: [result] }];
}
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
return resultItem.pages.map((pageItem) => ({
score: pageItem.score,
items: transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: context.linker,
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
}),
}));
})
.sort((a, b) => b.score - a.score)
.flatMap((group) => group.items);
return NextResponse.json(results);
}
function transformSitePageResult(args: {
asEmbeddable: boolean;
linker: Awaited<ReturnType<typeof getServerActionBaseContext>>['linker'];
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
siteSpace?: SiteSpace;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup | null;
}): OrderedComputedResult[] {
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
args;
const currentLanguage = siteSpace?.space.language;
const spaceURL = siteSpace?.urls.published;
const breadcrumbs: NonNullable<ComputedPageResult['breadcrumbs']> = [];
if (siteSectionGroup) {
breadcrumbs.push({
icon: siteSectionGroup.icon as IconName,
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
});
}
if (siteSection) {
breadcrumbs.push({
icon: siteSection.icon as IconName,
label: getLocalizedTitle(siteSection, currentLanguage),
});
}
if (
(siteSection?.siteSpaces?.filter(
(space) =>
siteSection.siteSpaces?.filter(
(candidate) => candidate.space.language === space.space.language
).length > 1
).length ?? 0) > 1 &&
siteSpace
) {
breadcrumbs.push({
label: getLocalizedTitle(siteSpace, currentLanguage),
});
}
breadcrumbs.push(
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
}))
);
const pageHref = !spaceURL
? linker.toPathInSpace(pageItem.path)
: asEmbeddable
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pageItem.path)
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path));
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
href: pageHref,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: pageItem.score,
breadcrumbs,
};
const pageSections =
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => {
let sectionHref = linker.toPathInSpace(pageItem.path);
if (spaceURL) {
if (asEmbeddable) {
sectionHref = toEmbeddableLinkForPublishedContent(
linker,
spaceURL,
section.path
);
} else {
sectionHref = linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, section.path)
);
}
}
return {
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: sectionHref,
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: section.score,
};
}) ?? [];
// Find the best-scoring section to use as a body preview on the page result.
const bestSection = pageSections.reduce<ComputedSectionResult | undefined>(
(best, section) => (!best || section.score > best.score ? section : best),
undefined
);
if (bestSection) {
page.bestSection = {
href: bestSection.href,
title: bestSection.title,
body: bestSection.body,
score: bestSection.score,
};
}
return [page];
}
@@ -1,32 +0,0 @@
import { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest } from '@/routes/openapi-proxy';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function POST(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function PUT(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function DELETE(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function PATCH(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function HEAD(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function OPTIONS() {
return handleOpenAPIProxyOptions();
}
@@ -19,11 +19,7 @@ export default async function SiteStaticLayout({
const withTracking = shouldTrackEvents();
return (
<CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable"
bodyClassName="site-background"
context={context}
>
<CustomizationRootLayout className="site-background" context={context}>
<SiteLayout
context={context}
withTracking={withTracking}
@@ -10,6 +10,5 @@ export async function GET(
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context);
return serveLLMsTxt(context, { withMarkdownPages: true });
}
@@ -18,14 +18,6 @@ export async function GET(
<html>
<head>
<meta name="color-scheme" content="light dark">
<style type="text/css">
.control {
display: none;
}
.custom-trigger .control {
display: inline-flex;
}
</style>
</head>
<body>
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -113,29 +105,17 @@ export async function GET(
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>
<button type="button" class="control" onclick="window.GitBook('open')">Open</button>
<button type="button" class="control" onclick="window.GitBook('close')">Close</button>
</body>
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
<script>
const useCustomTrigger = new URLSearchParams(window.location.search).get('trigger') === 'custom';
window.GitBook('configure', {
suggestions: [
'Help me get started',
'What can I ask you?',
'Show me tips and tricks',
],
tabs: ['assistant', 'search', 'docs'],
closeButton: useCustomTrigger
});
if (useCustomTrigger) {
document.body.classList.add('custom-trigger');
window.GitBook('hide');
} else {
window.GitBook('open');
}
window.GitBook('configure', {
suggestions: [
'Help me get started',
'What can I ask you?',
'Show me tips and tricks',
],
});
window.GitBook('open');
</script>
</html>
`,
@@ -24,22 +24,10 @@ export async function GET(
(function () {
const w = window;
const gb = w.GitBook;
function getScriptSearchParams() {
const script = document.currentScript;
if (!script) return new URLSearchParams();
const url = new URL(script.src);
return url.searchParams;
}
const searchParams = getScriptSearchParams()
const token = searchParams.get('jwt_token');
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
const initFrameOptions = token ? { visitor: { token } } : undefined;
if (typeof gb === "function") {
gb('init', initOptions, initFrameOptions);
gb('init', initOptions);
} else {
var d = document;
@@ -52,7 +40,7 @@ export async function GET(
};
w.GitBook = g;
g('init', initOptions, initFrameOptions);
g('init', initOptions);
const load = function () {
const style = document.createElement('link');
@@ -1,16 +0,0 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableSearchPage } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export const dynamic = 'force-static';
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
return <EmbeddableSearchPage context={context} />;
}
@@ -1,16 +0,0 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & { question: string }> }
) {
const { question: encodedQuestion } = await params;
const { context } = await getStaticSiteContext(await params);
const question = decodeURIComponent(encodedQuestion);
return serveAskMarkdown(context, question);
}
@@ -0,0 +1,92 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy } from '@/lib/sites';
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
async function handler(
nextRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
const { dataFetcher, linker, site } = context;
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
'searchDocumentation',
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
{
query: z.string(),
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
return {
content: results.flatMap((spaceResult) => {
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceResult.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return spaceResult.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
const body = pageResult.sections
?.map((section) => section.body)
.join('\n');
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
}
);
},
{},
{
basePath: context.linker.toPathInSite('~gitbook/'),
streamableHttpEndpoint: '/mcp',
maxDuration: 60,
verboseLogs: true,
disableSse: true,
}
);
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
);
requestURL.search = nextRequest.nextUrl.search;
const request = new Request(requestURL, nextRequest);
return mcpHandler(request);
}
export { handler as GET, handler as POST };
@@ -1,183 +0,0 @@
import {
type RevisionPage,
type RevisionPageDocument,
type RevisionPageGroup,
SiteVisibility,
} from '@gitbook/api';
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { isPageIndexable } from '@/lib/seo';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
getLocalizedTitle,
listAllSiteSpaces,
} from '@/lib/sites';
interface Breadcrumb {
label: string;
icon?: string;
emoji?: string;
}
interface RawIndexPage {
id: string;
title: string;
pathname: string;
siteSpaceId: string;
lang?: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: Breadcrumb[];
}
type AncestorPage = RevisionPageDocument | RevisionPageGroup;
interface IndexPageEntry {
page: RevisionPageDocument;
ancestors: AncestorPage[];
}
/**
* Walk the page tree and return all indexable document pages together with
* their ancestor chain (groups + parent documents), enabling breadcrumb generation.
*/
function getIndexablePagesWithAncestors(
rootPages: RevisionPage[],
ancestors: AncestorPage[] = []
): IndexPageEntry[] {
const results: IndexPageEntry[] = [];
for (const page of rootPages) {
if (page.type === 'link' || page.type === 'computed') continue;
if (page.hidden || !isPageIndexable([], page)) continue;
if (page.type === 'document') {
results.push({ page, ancestors });
// Recurse into children with this document as an ancestor
if (page.pages?.length) {
results.push(
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
...ancestors,
page,
])
);
}
} else if (page.type === 'group') {
// Groups themselves are not documents — push them only as ancestors
if (page.pages?.length) {
results.push(
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
...ancestors,
page,
])
);
}
}
}
return results;
}
export const revalidate = 86400; // 1 day in seconds
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
const { dataFetcher, linker, structure } = context;
const visibleSpaces = listAllSiteSpaces(structure).filter((ss) => !ss.hidden);
const revisions = await Promise.all(
visibleSpaces.map((ss) =>
throwIfDataError(
dataFetcher.getRevision({
spaceId: ss.space.id,
revisionId: ss.space.revision,
})
)
)
);
const seen = new Set<string>();
const pages: RawIndexPage[] = [];
for (let i = 0; i < visibleSpaces.length; i++) {
const siteSpace = visibleSpaces[i]!;
const revision = revisions[i]!;
const forkedLinker = linker.withOtherSiteSpace({
spaceBasePath: getFallbackSiteSpacePath(context, siteSpace),
});
const lang = siteSpace.space.language ?? undefined;
const sectionInfo = findSiteSpaceBy(structure, (ss) => ss.id === siteSpace.id);
const { siteSection, siteSectionGroup } = sectionInfo ?? {};
for (const { page, ancestors } of getIndexablePagesWithAncestors(revision.pages)) {
const cacheKey = `${siteSpace.id}:${page.id}`;
if (seen.has(cacheKey)) continue;
seen.add(cacheKey);
const breadcrumbs: Breadcrumb[] = [
siteSectionGroup
? {
label: getLocalizedTitle(siteSectionGroup, lang),
icon: siteSectionGroup.icon ?? undefined,
}
: undefined,
siteSection
? {
label: getLocalizedTitle(siteSection, lang),
icon: siteSection.icon ?? undefined,
}
: undefined,
...ancestors.map((a) => ({
label: a.title,
icon: a.icon ?? undefined,
emoji: a.emoji ?? undefined,
})),
].filter((c) => c !== undefined);
pages.push({
id: page.id,
title: page.title,
pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }),
siteSpaceId: siteSpace.id,
lang,
icon: page.icon ?? undefined,
emoji: page.emoji ?? undefined,
description: page.description ?? undefined,
breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
});
}
}
// We only cache the search index on the client if the site is public or unlisted, to avoid leaking information about private sites.
// For private sites, we set `Cache-Control: no-store` to prevent caching at all.
const shouldCacheOnClient =
context.site.visibility === SiteVisibility.Public ||
context.site.visibility === SiteVisibility.Unlisted;
return new Response(
JSON.stringify({
// We include a version number in the response to allow future changes to the format without breaking clients that might have cached the old format.
version: 1,
pages,
}),
{
headers: {
'Content-Type': 'application/json',
// Cache for 5 minutes on the client, 1 day on the CDN, and allow serving stale content while revalidating for 1 day
'Cache-Control': shouldCacheOnClient
? 'public, max-age=300, s-maxage=86400, stale-while-revalidate=86400'
: 'no-store',
},
}
);
}
+1 -6
View File
@@ -83,11 +83,6 @@ export function getPagePathFromParams(params: RouteParams) {
// If decoding the param fails, return a 404 instead of crashing
try {
const decoded = decodeURIComponent(params.pagePath);
// For the root page, we encode '/' to avoid an empty param being passed.
if (decoded === '/') {
return '';
}
return decoded;
} catch (error) {
console.error(
@@ -119,7 +114,7 @@ function getModeFromParams(mode: string): RouteParamMode {
/**
* Get the decoded site data from the params.
*/
export function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
try {
const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded);
-2
View File
@@ -11,7 +11,6 @@ import {
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_SECRET,
GITBOOK_URL,
GITBOOK_USER_AGENT,
@@ -26,7 +25,6 @@ export async function GET(_req: NextRequest) {
GITBOOK_APP_URL,
GITBOOK_API_URL,
GITBOOK_API_PUBLIC_URL,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_ASSETS_URL,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
@@ -1,28 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server';
import { getIconSymbol } from '@/lib/icons/symbols';
import { getIconSymbolId } from '@gitbook/icons';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ style: string; icon: string }> }
) {
const { style, icon } = await params;
const symbol = await getIconSymbol(style, icon, getIconSymbolId(style, icon));
if (!symbol) {
return NextResponse.json(
{
error: 'Symbol not found',
},
{ status: 404 }
);
}
return new NextResponse(symbol.document, {
headers: {
'content-type': 'image/svg+xml; charset=utf-8',
'cache-control': 'public, max-age=31536000, immutable',
},
});
}
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
body.tags.forEach((tag) => {
logger.log(`Revalidating tag: ${tag}`);
revalidateTag(tag, { expire: 0 }); // Force revalidation without waiting for the next scheduled revalidation
revalidateTag(tag);
});
return NextResponse.json({
@@ -21,15 +21,16 @@ export async function getSpacePDFContext(
const apiToken = await getAPITokenFromMiddleware();
const basePath = getPDFRoutePath(params);
const linker = createLinker({
spaceBasePath: basePath,
siteBasePath: basePath,
});
const dataFetcher = createDataFetcher({
apiToken: apiToken,
});
const baseContext: GitBookBaseContext = {
linker: createLinker({
spaceBasePath: basePath,
siteBasePath: basePath,
}),
linker,
dataFetcher,
};
@@ -1,58 +0,0 @@
'use client';
import { Button } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { z } from 'zod';
import { AIToolContainer } from './common';
import { type GetAIControlProps, createAIControl } from './helpers';
export const ConfirmControlOutputSchema = z.object({
result: z.enum(['confirmed', 'cancelled']),
});
export const ConfirmControlDef = createAIControl({
name: 'confirm',
exposeAsTool: false,
description:
'Display a confirmation prompt to the user (Confirm / Cancel) to approve or abort a pending action. Use this when an operation is irreversible, sensitive, or should only proceed with explicit user consent. Returns either a `confirmed` or `cancelled` result based on the users click.',
inputSchema: z.object({
icon: z
.string()
.optional()
.describe('Icon to be shown in the confirm button (any Fontawesome icon name).'),
label: z.string().describe('Label to be shown in the confirm button.'),
}),
outputSchema: ConfirmControlOutputSchema,
render: (props) => {
return <ConfirmControl {...props} />;
},
});
function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
const { label, icon, onSubmit } = props;
const language = useLanguage();
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<Button
data-testid="ai-chat-tool-confirm-cancel"
onClick={() => {
onSubmit({ result: 'cancelled' });
}}
truncate={false}
variant="blank"
icon="xmark"
label={tString(language, 'cancel')}
/>
<Button
data-testid="ai-chat-tool-confirm-accept"
onClick={() => {
onSubmit({ result: 'confirmed' });
}}
variant="primary"
truncate={false}
icon={icon}
label={label}
/>
</AIToolContainer>
);
}
@@ -1,229 +0,0 @@
'use client';
import { Button, Input } from '@/components/primitives';
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon, IconStyle } from '@gitbook/icons';
import * as React from 'react';
import { z } from 'zod';
import { AIToolContainer } from './common';
import { type GetAIControlProps, createAIControl } from './helpers';
const OTHER_OPTION_ID = '$other';
export const MultiChoiceControlOutputSchema = z.object({
ids: z.array(z.string()).describe('The identifiers of the options selected by the user.'),
labels: z.array(z.string()).describe('The labels of the options selected by the user.'),
input: z
.string()
.optional()
.describe(
'The custom text entered by the user when "Other" is enabled and selected among the choices.'
),
});
export const MultiChoiceControlDef = createAIControl({
name: 'multi-choice',
exposeAsTool: true,
description:
'Use this control whenever you need the user to choose one or more options from a predefined list. Never add an "Other" option to the `options` array, use the `allowOther` property instead.',
inputSchema: z.object({
prompt: z
.string()
.describe(
'Provide the question or instruction that tells the user what choices they need to make.'
),
options: z
.array(
z
.object({
id: z
.string()
.describe(
'Provide a unique, stable identifier for this option. This is the value returned to the agent when the user selects it.'
),
label: z
.string()
.describe('Provide the short label the user sees for this option.'),
description: z
.string()
.optional()
.describe(
'Optionally provide supporting details to help the user understand this option.'
),
})
.describe('Define one selectable option the user can pick.')
)
.describe(
'Provide the list of options the user can choose from. The user may select one or more.'
),
allowOther: z
.boolean()
.optional()
.describe(
'Set to true to let the user select an "Other" option and enter a custom text response.'
),
}),
outputSchema: MultiChoiceControlOutputSchema,
render: (props) => {
return <MultiChoiceControl {...props} />;
},
});
function MultiChoiceControl(props: GetAIControlProps<typeof MultiChoiceControlDef>) {
const { prompt, options, allowOther, onSubmit } = props;
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [otherInput, setOtherInput] = React.useState('');
const orderedOptionIds = [
...options.map((option) => option.id),
...(allowOther ? [OTHER_OPTION_ID] : []),
];
const language = useLanguage();
const toggleOption = (id: string) => {
setSelectedIds((current) =>
current.includes(id)
? current.filter((currentId) => currentId !== id)
: orderedOptionIds.filter(
(optionId) => optionId === id || current.includes(optionId)
)
);
};
const hasOtherSelected = selectedIds.includes(OTHER_OPTION_ID);
const canSubmit = selectedIds.length > 0 && (!hasOtherSelected || otherInput.trim().length > 0);
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<ScrollContainer orientation="vertical" contentClassName="flex flex-col gap-2">
<p className="px-2 pt-1 font-semibold text-sm">{prompt}</p>
{options.map((option) => {
const isSelected = selectedIds.includes(option.id);
return (
<button
key={option.id}
type="button"
data-testid={`ai-chat-tool-multi-choice-option-${option.id}`}
onClick={() => {
toggleOption(option.id);
}}
className={tcls(
'text-left transition-colors',
'circular-corners:rounded-3xl rounded-corners:rounded-xl px-2 py-1 text-left transition-colors',
isSelected
? 'bg-primary text-tint-strong contrast-more:bg-primary-active'
: 'hover:bg-tint contrast-more:hover:bg-tint-hover'
)}
>
<div className="flex items-center gap-2">
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border bg-tint-base transition-colors',
isSelected
? 'border-primary-original bg-primary-original text-contrast-primary-original'
: 'border-tint-subtle'
)}
>
{isSelected ? (
<Icon
icon="check"
iconStyle={IconStyle.Solid}
className="size-3"
/>
) : null}
</span>
<span className="min-w-0">
<p className="font-medium text-sm">{option.label}</p>
{option.description ? (
<p className="mt-0.5 text-tint-subtle text-xs">
{option.description}
</p>
) : null}
</span>
</div>
</button>
);
})}
{allowOther ? (
<button
type="button"
data-testid="ai-chat-tool-multi-choice-option-other"
tabIndex={-1}
onClick={() => {
toggleOption(OTHER_OPTION_ID);
}}
>
<Input
label={tString(language, 'form_other_prompt')}
value={otherInput}
onValueChange={setOtherInput}
data-testid="ai-chat-tool-multi-choice-other-input"
placeholder={tString(
language,
hasOtherSelected ? 'form_other_prompt' : 'form_other_field'
)}
className={tcls(
'grow gap-2 border-0 px-2 ring-inset **:placeholder:text-tint',
hasOtherSelected
? 'bg-primary text-tint-strong hover:bg-primary contrast-more:bg-primary-active'
: 'hover:not-focus-within:bg-tint contrast-more:hover:bg-tint-hover'
)}
sizing="small"
onClick={(event) => {
event.stopPropagation();
if (!hasOtherSelected) {
toggleOption(OTHER_OPTION_ID);
}
}}
leading={
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border bg-tint-base transition-colors',
hasOtherSelected
? 'border-primary-original bg-primary-original text-contrast-primary-original'
: 'border-tint-subtle'
)}
>
{hasOtherSelected ? (
<Icon
icon="check"
iconStyle={IconStyle.Solid}
className="size-3"
/>
) : null}
</span>
}
/>
</button>
) : null}
</ScrollContainer>
<Button
data-testid="ai-chat-tool-multi-choice-submit"
variant="primary"
label={tString(language, 'submit')}
disabled={!canSubmit}
onClick={() => {
if (!canSubmit) {
return;
}
onSubmit({
ids: selectedIds,
labels: selectedIds.map((id) =>
id === OTHER_OPTION_ID
? 'Other'
: options.find((option) => option.id === id)?.label || ''
),
input: hasOtherSelected ? otherInput.trim() : undefined,
});
}}
/>
</AIToolContainer>
);
}
@@ -1,213 +0,0 @@
'use client';
import { Button, Input } from '@/components/primitives';
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import * as React from 'react';
import { z } from 'zod';
import { AIToolContainer } from './common';
import { type GetAIControlProps, createAIControl } from './helpers';
const OTHER_OPTION_ID = '$other';
export const SingleChoiceControlOutputSchema = z.object({
id: z.string().describe('The identifier of the option selected by the user.'),
label: z.string().describe('The label of the option selected by the user.'),
input: z
.string()
.optional()
.describe('The custom text entered by the user when "Other" is enabled and selected.'),
});
export const SingleChoiceControlDef = createAIControl({
name: 'single-choice',
exposeAsTool: true,
description:
'Use this control whenever you need the user to choose exactly one option from a predefined list. Important: NEVER write an "Other" choice yourself to the `options` array, pass the `allowOther` property as `true` to add it automatically instead.',
inputSchema: z.object({
prompt: z
.string()
.describe(
'Provide the question or instruction that tells the user what single choice they need to make.'
),
options: z
.array(
z
.object({
id: z
.string()
.describe(
'Provide a unique, stable identifier for this option. This is the value returned to the agent when the user selects it.'
),
label: z
.string()
.describe('Provide the short label the user sees for this option.'),
description: z
.string()
.optional()
.describe(
'Optionally provide supporting details to help the user understand this option. Keep it concise and do not repeat the label.'
),
})
.describe('Define one selectable option the user can pick.')
)
.describe(
'Provide the list of options the user can choose from. The user must select exactly one.'
),
allowOther: z
.boolean()
.optional()
.describe(
'Set to true to let the user select an "Other" option and enter a custom text response.'
),
}),
outputSchema: SingleChoiceControlOutputSchema,
render: (props) => {
return <SingleChoiceControl {...props} />;
},
});
function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControlDef>) {
const { prompt, options, allowOther, onSubmit } = props;
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [otherInput, setOtherInput] = React.useState('');
const language = useLanguage();
const canSubmit =
selectedId !== null &&
(selectedId !== OTHER_OPTION_ID || (allowOther && otherInput.trim().length > 0));
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<p className="px-2 pt-1 font-semibold text-sm">{prompt}</p>
<ScrollContainer orientation="vertical" contentClassName="flex flex-col gap-2">
{options.map((option) => {
const isSelected = selectedId === option.id;
return (
<button
key={option.id}
type="button"
data-testid={`ai-chat-tool-single-choice-option-${option.id}`}
onClick={() => {
setSelectedId(option.id);
}}
className={tcls(
'text-left transition-colors',
'circular-corners:rounded-3xl rounded-corners:rounded-xl px-2 py-1 text-left transition-colors',
isSelected
? 'bg-primary text-tint-strong contrast-more:bg-primary-active'
: 'hover:bg-tint contrast-more:hover:bg-tint-hover'
)}
>
<div className="flex items-center gap-2">
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border bg-tint-base transition-colors',
isSelected
? 'border-primary-original'
: 'border-tint-subtle'
)}
>
<span
className={tcls(
'size-2.5 rounded-full transition-colors',
isSelected ? 'bg-primary-original' : 'bg-transparent'
)}
/>
</span>
<span className="min-w-0">
<p className="font-medium text-sm">{option.label}</p>
{option.description ? (
<p className="mt-0.5 text-tint-subtle text-xs">
{option.description}
</p>
) : null}
</span>
</div>
</button>
);
})}
{allowOther ? (
<button
type="button"
data-testid="ai-chat-tool-single-choice-option-other"
tabIndex={-1} // The input is already focusable, so prevent focus on the wrapper button
onClick={() => {
setSelectedId(OTHER_OPTION_ID);
}}
>
<Input
label={tString(language, 'form_other_prompt')}
value={otherInput}
onValueChange={setOtherInput}
data-testid="ai-chat-tool-single-choice-other-input"
placeholder={tString(
language,
selectedId === OTHER_OPTION_ID
? 'form_other_prompt'
: 'form_other_field'
)}
className={tcls(
'grow gap-2 border-0 px-2 ring-inset **:placeholder:text-tint',
selectedId === OTHER_OPTION_ID
? 'bg-primary text-tint-strong hover:bg-primary contrast-more:bg-primary-active'
: 'hover:not-focus-within:bg-tint contrast-more:hover:bg-tint-hover'
)}
sizing="small"
leading={
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border bg-tint-base transition-colors',
selectedId === OTHER_OPTION_ID
? 'border-primary-original'
: 'border-tint-subtle'
)}
>
<span
className={tcls(
'size-2.5 rounded-full transition-colors',
selectedId === OTHER_OPTION_ID
? 'bg-primary-original'
: 'bg-transparent'
)}
/>
</span>
}
/>
</button>
) : null}
</ScrollContainer>
<Button
data-testid="ai-chat-tool-single-choice-submit"
variant="primary"
label={tString(language, 'submit')}
disabled={!canSubmit}
onClick={() => {
if (!canSubmit || !selectedId) {
return;
}
if (selectedId === OTHER_OPTION_ID) {
onSubmit({
id: OTHER_OPTION_ID,
label: 'Other',
input: otherInput.trim(),
});
return;
}
onSubmit({
id: selectedId,
label: options.find((option) => option.id === selectedId)?.label || '',
});
}}
/>
</AIToolContainer>
);
}
@@ -1,15 +0,0 @@
import { tcls } from '@/lib/tailwind';
import type { ComponentPropsWithRef } from 'react';
export function AIToolContainer(props: ComponentPropsWithRef<'div'>) {
return (
<div
{...props}
className={tcls(
'min-h-0 shrink grow-0 animate-blur-in circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base p-2',
props.className
)}
style={{ animationDelay: '0.3s', ...props.style }}
/>
);
}
@@ -1,83 +0,0 @@
import { tString } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations/types';
import type {
AIStreamResponseToolCallPending,
AIToolCallResult,
AIToolDefinition,
} from '@gitbook/api';
import type { ZodType, z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
type AIUIToolContext = Pick<AIStreamResponseToolCallPending, 'toolCall' | 'toolCallId'>;
type AIControlDefinition<
Name extends string = any,
Input extends Record<string, unknown> = any,
Output extends Record<string, unknown> = any,
> = AIToolDefinition & {
createControl: (args: {
context: AIUIToolContext;
input: Input;
language: TranslationLanguage;
send: (result: Pick<AIToolCallResult, 'output' | 'summary'>) => Promise<void>;
}) => AIControl<Name, Input, Output>;
exposeAsTool: boolean;
};
type AIControlProps<Input = Record<string, unknown>, Output = Record<string, unknown>> = Input & {
onSubmit: (output: Output) => Promise<void>;
};
type AIControl<Name = string, Input = Record<string, unknown>, Output = Record<string, unknown>> = {
props: AIControlProps<Input, Output>;
name: Name;
context: AIUIToolContext;
render: () => React.ReactNode;
};
type AIControlFromDef<T extends AIControlDefinition> = ReturnType<T['createControl']>;
export type GetAIControlProps<T extends AIControlDefinition> = AIControlFromDef<T>['props'];
export function createAIControl<
Name extends string,
InputSchema extends ZodType<Record<string, unknown>>,
OutputSchema extends ZodType<Record<string, unknown>>,
>(def: {
name: Name;
description: string;
inputSchema: InputSchema;
outputSchema: OutputSchema;
render: (props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>>) => React.ReactNode;
/**
* Indicates if the control should be exposed as a tool or not.
*/
exposeAsTool: boolean;
}): AIControlDefinition<Name, z.infer<InputSchema>, z.infer<OutputSchema>> {
return {
name: `ui--${def.name}`,
description: def.description,
inputSchema: zodToJsonSchema(def.inputSchema as any) as AIToolDefinition['inputSchema'],
createControl: ({ context, input, language, send }) => {
const props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>> = {
...input,
onSubmit: async (output) => {
await send({
output,
summary: {
icon: 'comment-check',
text: tString(language, 'ai_control_submitted_answer'),
},
});
},
};
return {
props,
name: def.name,
context,
render: () => def.render(props),
};
},
exposeAsTool: def.exposeAsTool,
};
}
@@ -1,12 +0,0 @@
import { ConfirmControlDef } from './ConfirmControl';
import { MultiChoiceControlDef } from './MultiChoiceControl';
import { SingleChoiceControlDef } from './SingleChoiceControl';
const CONTROLS = [SingleChoiceControlDef, MultiChoiceControlDef, ConfirmControlDef];
export type AnyAIControlTool = (typeof CONTROLS)[number];
export type AnyAIControl = ReturnType<AnyAIControlTool['createControl']>;
export function getControlTools(): AnyAIControlTool[] {
return CONTROLS.filter((control) => control.exposeAsTool);
}
@@ -1,7 +1,6 @@
import { CollapsibleContent } from '@/components/primitives';
import type { GitBookSiteContext } from '@/lib/context';
import { tcls } from '@/lib/tailwind';
import { type AIMessage, AIMessageStepPhase } from '@gitbook/api';
import type { AIMessage } from '@gitbook/api';
import { DocumentView } from '../../DocumentView';
import { AIToolCallsSummary } from './AIToolCallsSummary';
import type { RenderAIMessageOptions } from './types';
@@ -18,41 +17,31 @@ export function AIMessageView(
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
return message.steps.length > 0 ? (
<div className="flex flex-col space-y-2">
<div className="flex flex-col gap-2">
{message.steps.map((step, index) => {
const hasContent = Boolean(step.content && step.content.nodes.length > 0);
const Tag =
step.phase === AIMessageStepPhase.Commentary ? CollapsibleContent : 'div';
return (
<Tag
<div
key={index}
className={tcls(
'flex flex-col space-y-2 border-tint-subtle',
hasContent ? 'has-content' : ''
'flex flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : ''
)}
>
{step.content ? (
<DocumentView
document={step.content}
context={{
mode: 'default',
contentContext: context,
wrapBlocksInSuspense: false,
withLinkPreviews,
}}
style={tcls(
'ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow empty:hidden',
step.phase === AIMessageStepPhase.Commentary
? 'text-tint group-data-[disabled]/collapsible:text-inherit'
: ''
)}
/>
) : null}
<DocumentView
document={step.content}
context={{
mode: 'default',
contentContext: context,
wrapBlocksInSuspense: false,
withLinkPreviews,
}}
style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow"
/>
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
) : null}
</Tag>
</div>
);
})}
</div>
@@ -1,6 +1,5 @@
import { HighlightQuery } from '@/components/Search/HighlightQuery';
import { Link, StyledLink } from '@/components/primitives';
import { Favicon } from '@/components/utils';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import type { GitBookSiteContext } from '@/lib/context';
@@ -16,7 +15,6 @@ import type {
ContentRef,
} from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import assertNever from 'assert-never';
import type * as React from 'react';
/**
@@ -41,10 +39,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
const { toolCall, context } = props;
return (
<div
data-testid="ai-chat-tool-summary"
className="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle"
>
<div className="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle">
<Icon
icon={getIconForToolCall(toolCall)}
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
@@ -140,37 +135,25 @@ async function DescriptionForSearchToolCall(props: {
// Resolve all hrefs for search results in parallel
const searchResultsWithHrefs = await Promise.all(
toolCall.results.map(async (result) => {
switch (result.type) {
case 'page': {
const resolved = await resolveContentRef(
result.anchor
? {
kind: 'anchor',
page: result.pageId,
space: result.spaceId,
anchor: result.anchor,
}
: {
kind: 'page',
page: result.pageId,
space: result.spaceId,
},
context
);
return {
...result,
href: resolved?.href || '#',
};
}
case 'record':
return {
...result,
href: result.url ?? '#',
};
default:
assertNever(result);
}
const resolved = await resolveContentRef(
result.anchor
? {
kind: 'anchor',
page: result.pageId,
space: result.spaceId,
anchor: result.anchor,
}
: {
kind: 'page',
page: result.pageId,
space: result.spaceId,
},
context
);
return {
...result,
href: resolved?.href || '#',
};
})
);
@@ -206,80 +189,45 @@ async function DescriptionForSearchToolCall(props: {
{hasResults ? (
<div className="hide-scrollbar mt-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => {
const resultKey = (() => {
switch (result.type) {
case 'page':
return `${result.spaceId}/${result.pageId}`;
case 'record':
return result.recordId;
default:
assertNever(result);
}
})();
const iconClassName = 'mt-1 size-3 shrink-0 text-tint-subtle';
const icon = <Icon icon="memo" className={iconClassName} />;
return (
<li
key={`${resultKey}-${index}`}
className="animate-fade-in-slow"
style={{
animationDelay: `${index * 25}ms`,
}}
{searchResultsWithHrefs.map((result, index) => (
<li
key={`${result.pageId}-${index}`}
className="animate-fade-in-slow"
style={{
animationDelay: `${index * 25}ms`,
}}
>
<Link
href={result.href}
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
>
<Link
href={result.href}
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
insights={
result.type === 'record'
? {
type: 'search_open_result',
query: toolCall.query,
result: {
recordId: result.recordId,
},
}
: {
type: 'search_open_result',
query: toolCall.query,
result: {
spaceId: result.spaceId,
pageId: result.pageId,
},
}
}
>
{result.type === 'record' ? (
<Favicon url={result.href} className={iconClassName} />
) : (
icon
)}
<div className="flex flex-col gap-1 text-tint">
<h3 className="line-clamp-2 font-medium text-sm text-tint">
<Icon
icon="memo"
className="mt-1 size-3 shrink-0 text-tint-subtle"
/>
<div className="flex flex-col gap-1 text-tint">
<h3 className="line-clamp-2 font-medium text-sm text-tint">
<HighlightQuery
query={toolCall.query}
text={result.title}
/>
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<HighlightQuery
query={toolCall.query}
text={result.title}
text={result.description}
/>
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<HighlightQuery
query={toolCall.query}
text={result.description}
/>
</p>
)}
</div>
<Icon
icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
);
})}
</p>
)}
</div>
<Icon
icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
))}
</ol>
</div>
) : null}
@@ -39,7 +39,9 @@ export async function streamRenderAIMessage(
if (message.steps[stepIndex]) {
message.steps = [...message.steps];
// @ts-expect-error
message.steps[stepIndex] = { ...message.steps[stepIndex] };
// @ts-expect-error
callback(message.steps[stepIndex]);
} else {
message.steps = [
@@ -65,16 +67,6 @@ export async function streamRenderAIMessage(
event: AIStreamResponse;
}>(rawStream, async (event) => {
switch (event.type) {
/**
* A new step started (phase update).
*/
case 'response_step_start': {
updateProcessingMessageStep(event.stepIndex, (step) => {
step.phase = event.phase;
});
break;
}
/**
* The agent is processing a tool call in a new message.
*/
@@ -1,4 +1,5 @@
'use server';
import { getEmbeddableLinker } from '@/lib/embeddable';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
@@ -8,8 +9,6 @@ import {
AIModel,
type AIToolCallResult,
type AIToolDefinition,
SiteCoreChannelType,
type SiteInsightsSession,
} from '@gitbook/api';
import { streamRenderAIMessage } from './api';
import type { RenderAIMessageOptions } from './types';
@@ -23,7 +22,6 @@ export async function* streamAIChatResponse({
previousResponseId,
toolCall,
tools,
session,
options,
}: {
message?: string;
@@ -31,13 +29,13 @@ export async function* streamAIChatResponse({
previousResponseId?: string;
toolCall?: AIToolCallResult;
tools?: AIToolDefinition[];
session: SiteInsightsSession;
options?: RenderAIMessageOptions;
}) {
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
const context = await getServerActionBaseContext({
isEmbeddable: options?.asEmbeddable,
});
let context = await getServerActionBaseContext();
if (options?.asEmbeddable) {
context = { ...context, linker: getEmbeddableLinker(context.linker) };
}
const siteURLData = await getSiteURLDataFromMiddleware();
@@ -59,12 +57,6 @@ export async function* streamAIChatResponse({
previousResponseId,
toolCall,
tools,
session,
channel: {
type: options?.asEmbeddable
? SiteCoreChannelType.Embed
: SiteCoreChannelType.Site,
},
}
);
@@ -1,8 +0,0 @@
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { integrationsAssistantTools } from '../Integrations';
import { type AnyAIControlTool, getControlTools } from './controls';
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
const integrationTools = integrationsAssistantTools.getState().tools;
return [...getControlTools(), ...integrationTools];
}
+7 -8
View File
@@ -10,14 +10,13 @@ import type { GitBookAssistant } from '@gitbook/browser-types';
import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSetSearchState } from '../Search/useSearch';
import { useSearch } from '../Search/useSearch';
// Unify assistants configuration context with the assistants hook in one place
export type AIConfig = {
aiMode: CustomizationAIMode;
suggestions?: string[];
trademark: boolean;
assistantName?: string;
greeting?: {
title: string;
subtitle: string;
@@ -55,10 +54,10 @@ export type Assistant = Omit<GitBookAssistant, 'icon'> & {
const AIContext = React.createContext<AIConfig | null>(null);
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
const { aiMode, trademark, suggestions, greeting, assistantName, children } = props;
const { aiMode, trademark, suggestions, greeting, children } = props;
const value = React.useMemo(
() => ({ aiMode, trademark, suggestions, greeting, assistantName }),
[aiMode, trademark, suggestions, greeting, assistantName]
() => ({ aiMode, trademark, suggestions, greeting }),
[aiMode, trademark, suggestions, greeting]
);
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
}
@@ -85,19 +84,19 @@ export function useAI(): AIContext {
const chat = useAIChatState();
const chatController = useAIChatController();
const language = useLanguage();
const setSearchState = useSetSearchState();
const [, setSearchState] = useSearch();
const assistants: Assistant[] = [];
if (config.aiMode === CustomizationAIMode.Assistant) {
assistants.push({
id: 'gitbook-assistant',
label: config.assistantName ?? getAIChatName(language, config.trademark),
label: getAIChatName(language, config.trademark),
icon: (
<AIChatIcon
state={chat.loading ? 'thinking' : 'default'}
trademark={config.trademark}
className="size-text-lg"
className="size-4"
/>
),
open: (query?: string) => {
+74 -201
View File
@@ -6,43 +6,37 @@ import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
AIMessageRole,
AIMessageStepPhase,
type AIStreamResponse,
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type { IconName } from '@gitbook/icons';
import * as React from 'react';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { useSetSearchState } from '../Search';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { useTrackEvent } from '../Insights';
import { integrationsAssistantTools } from '../Integrations';
import { useSearch } from '../Search';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
export type AIChatMessage = {
role: AIMessageRole;
content: React.ReactNode;
query?: string;
activity?: AIChatMessageActivity;
};
export type AIChatMessageActivity = {
currentPhase?: AIMessageStepPhase;
toolCount: number;
hasCommentary: boolean;
hasFinalAnswer: boolean;
};
export type AIChatPendingTool = {
icon?: IconName;
label: string;
export type AIChatStatus =
| 'default'
| 'thinking'
| 'exploring'
| 'working'
| 'done'
| 'error'
| 'confirm';
/**
* Confirm the tool call by calling this function.
*/
confirm: () => Promise<void>;
/**
* Tool call result to cancel it.
*/
cancelToolCall: AIToolCallResult;
};
export type AIChatState = {
/**
@@ -76,9 +70,9 @@ export type AIChatState = {
followUpSuggestions: string[];
/**
* Control to be displayed to the user.
* Tools that are pending confirmation to be executed.
*/
control: AnyAIControl | null;
pendingTools: AIChatPendingTool[];
/**
* If true, the session is in progress.
@@ -132,7 +126,7 @@ const globalState = zustand.create<AIChatState>(() => {
messages: [],
query: null,
followUpSuggestions: [],
control: null,
pendingTools: [],
loading: false,
error: false,
initialQuery: null,
@@ -169,7 +163,7 @@ export function AIChatProvider(props: {
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const setSearchState = useSetSearchState();
const [, setSearchState] = useSearch();
const language = useLanguage();
// Event listeners storage
@@ -218,7 +212,7 @@ export function AIChatProvider(props: {
return {
...state,
followUpSuggestions: [],
control: null,
pendingTools: [],
loading: true,
error: false,
messages: [
@@ -226,7 +220,6 @@ export function AIChatProvider(props: {
{
role: AIMessageRole.Assistant,
content: null, // Placeholder for streaming response
activity: getDefaultAIChatMessageActivity(),
},
],
};
@@ -234,16 +227,16 @@ export function AIChatProvider(props: {
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const tools = getTools();
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
const integrationTools = integrationsAssistantTools.getState().tools;
const toolDef = integrationTools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef || !('execute' in toolDef)) {
if (!toolDef) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
try {
const result = await toolDef.execute(event.toolCall.input);
await streamResponse({
streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
@@ -252,7 +245,7 @@ export function AIChatProvider(props: {
},
});
} catch (error) {
await streamResponse({
streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
@@ -270,17 +263,15 @@ export function AIChatProvider(props: {
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const tools = getTools();
const integrationTools = integrationsAssistantTools.getState().tools;
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
messageContext: messageContextRef.current,
previousResponseId: globalState.getState().responseId ?? undefined,
session: await getInsightsSession(),
tools: tools.map((tool) => ({
tools: integrationTools.map((tool) => ({
name: tool.name,
description: tool.description,
// Issue with the schema generated by Zod and Next.js serialization.
inputSchema: tool.inputSchema,
})),
options: {
@@ -324,86 +315,46 @@ export function AIChatProvider(props: {
break;
}
case 'response_tool_call_pending': {
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
const toolDef = integrationTools.find(
(tool) => tool.name === event.toolCall.tool
);
if (!toolDef) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
if ('createControl' in toolDef) {
globalState.setState((state) => ({
...state,
control: toolDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: event.toolCall.input as any,
language,
send: async (result) => {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
},
}),
}));
break;
}
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
const confirmation = toolDef.confirmation;
if (confirmation) {
globalState.setState((state) => ({
...state,
control: ConfirmControlDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: {
label: confirmation.label,
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);
},
},
language,
send: async (result) => {
const output = ConfirmControlOutputSchema.parse(
result.output
);
switch (output.result) {
case 'cancelled': {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: { cancelled: true },
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
});
break;
}
case 'confirmed':
await executeToolCall(event);
break;
default:
assertNever(output.result);
}
},
}),
],
}));
break;
} else {
toolToExecute = event;
}
toolToExecute = event;
break;
}
}
@@ -416,11 +367,6 @@ export function AIChatProvider(props: {
{
role: AIMessageRole.Assistant,
content: data.content,
activity: updateAIChatMessageActivity(
state.messages[state.messages.length - 1]?.activity ??
getDefaultAIChatMessageActivity(),
event
),
},
],
}));
@@ -429,13 +375,13 @@ export function AIChatProvider(props: {
// Execute the tool call if it doesn't require confirmation
if (toolToExecute) {
await executeToolCall(toolToExecute);
} else {
globalState.setState((state) => ({
...state,
loading: false,
error: false,
}));
}
globalState.setState((state) => ({
...state,
loading: false,
error: false,
}));
} catch (error) {
console.error('Error streaming AI response', error);
globalState.setState((state) => ({
@@ -457,11 +403,7 @@ export function AIChatProvider(props: {
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, control } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
const { query, messages, pendingTools } = globalState.getState();
// For first message, update the ask parameter in URL
if (messages.length === 0) {
@@ -506,9 +448,15 @@ export function AIChatProvider(props: {
};
});
streamResponse({ message: 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, language]
[setSearchState, trackEvent, streamResponse]
);
// Clear the conversation and reset ask parameter
@@ -519,7 +467,7 @@ export function AIChatProvider(props: {
messages: [],
query: null,
followUpSuggestions: [],
control: null,
pendingTools: [],
responseId: null,
error: false,
initialQuery: null,
@@ -581,78 +529,3 @@ export function useAIChatController(): AIChatController {
}
return controller;
}
export function getAIChatStatus(chat: AIChatState): AIChatStatus {
if (chat.error) {
return 'error';
}
if (chat.control) {
return 'confirm';
}
if (chat.loading) {
const latestMessage = getLatestAssistantMessage(chat.messages);
const phase = latestMessage?.activity?.currentPhase;
switch (phase) {
case AIMessageStepPhase.Commentary:
return 'exploring';
case AIMessageStepPhase.FinalAnswer:
return 'working';
default:
return 'thinking';
}
}
if (chat.messages.length > 0) {
return 'done';
}
return 'default';
}
function getLatestAssistantMessage(messages: AIChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message?.role === AIMessageRole.Assistant) {
return message;
}
}
return null;
}
function updateAIChatMessageActivity(
activity: AIChatMessageActivity,
event: AIStreamResponse
): AIChatMessageActivity {
switch (event.type) {
case 'response_step_start': {
return {
...activity,
currentPhase: event.phase,
hasCommentary:
activity.hasCommentary || event.phase === AIMessageStepPhase.Commentary,
hasFinalAnswer:
activity.hasFinalAnswer || event.phase === AIMessageStepPhase.FinalAnswer,
};
}
case 'response_tool_call': {
return {
...activity,
toolCount: activity.toolCount + 1,
};
}
default:
return activity;
}
}
function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
return {
currentPhase: undefined,
toolCount: 0,
hasCommentary: false,
hasFinalAnswer: false,
};
}
@@ -9,7 +9,6 @@ import { useHotkeys } from 'react-hotkeys-hook';
import {
type AIChatController,
type AIChatState,
getAIChatStatus,
useAI,
useAIChatController,
useAIChatState,
@@ -28,8 +27,6 @@ import { useTrackEvent } from '../Insights';
import { useNow } from '../hooks';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { AIChatControl } from './AIChatControl';
import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
@@ -72,28 +69,22 @@ export function AIChat() {
}, [chat.opened, trackEvent]);
return (
<SideSheet
side="right"
open={chat.opened}
onOpenChange={(open) => {
if (open) {
chatController.open();
} else {
chatController.close();
}
}}
withOverlay={true}
<div
data-testid="ai-chat"
className={tcls(
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
'ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl scroll-mt-36 px-4 py-4 transition-[width,opacity,margin,display] transition-discrete duration-300 sm:px-6 lg:fixed lg:w-80 lg:p-0 xl:w-96',
chat.opened
? 'lg:starting:ml-0 lg:starting:w-0 lg:starting:opacity-0'
: 'hidden lg:ml-0 lg:w-0! lg:opacity-0'
)}
>
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrameHeader className="not-embed:px-4">
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96">
<EmbeddableFrameMain>
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{config.assistantName ?? getAIChatName(language, config.trademark)}
{getAIChatName(language, config.trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
@@ -108,17 +99,16 @@ export function AIChat() {
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody className="not-embed:px-0">
<EmbeddableFrameBody>
<AIChatBody
chatController={chatController}
chat={chat}
suggestions={config.suggestions}
trademark={config.trademark}
/>
</EmbeddableFrameBody>
</EmbeddableFrameMain>
</EmbeddableFrame>
</SideSheet>
</div>
);
}
@@ -131,13 +121,24 @@ export function AIChatDynamicIcon(props: {
}) {
const { trademark, className } = props;
const chat = useAIChatState();
const status = getAIChatStatus(chat);
return (
<AIChatIcon
className={tcls('size-5 text-tint', className)}
trademark={trademark}
state={status}
state={
chat.error
? 'error'
: chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'working'
: 'thinking'
: chat.messages.length > 0
? chat.pendingTools.length > 0
? 'confirm'
: 'done'
: 'default'
}
/>
);
}
@@ -150,45 +151,35 @@ export function AIChatSubtitle(props: {
}) {
const { chat } = props;
const language = useLanguage();
const status = getAIChatStatus(chat);
const subtitleKey =
status === 'thinking'
? 'ai_chat_thinking'
: status === 'exploring'
? 'ai_chat_exploring'
: status === 'working'
? 'ai_chat_working'
: status === 'confirm'
? 'ai_chat_waiting'
: null;
return (
<EmbeddableFrameSubtitle
className={tcls('relative', subtitleKey ? 'h-3 opacity-11' : 'h-0 opacity-0')}
className={tcls('relative', chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0')}
>
{(
[
['thinking', 'ai_chat_thinking'],
['exploring', 'ai_chat_exploring'],
['working', 'ai_chat_working'],
['confirm', 'ai_chat_waiting'],
] as const
).map(([candidateStatus, key]) => (
<span
key={candidateStatus}
className={tcls(
'absolute left-0',
status === candidateStatus
? 'animate-blur-in-display-slow'
: 'animate-blur-out-display-slow'
)}
style={{
animationDelay: status === candidateStatus ? '.3s' : undefined,
}}
>
{subtitleKey ? t(language, key) : null}
</span>
))}
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-in-slow'
: 'hidden'
: 'animate-blur-out-slow'
)}
>
{t(language, 'ai_chat_working')}
</span>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-out-slow'
: 'animate-blur-in-slow'
: 'hidden'
)}
>
{t(language, 'ai_chat_thinking')}
</span>
</EmbeddableFrameSubtitle>
);
}
@@ -201,13 +192,13 @@ export function AIChatBody(props: {
chat: AIChatState;
welcomeMessage?: string;
suggestions?: string[];
trademark?: boolean;
greeting?: {
title: string;
subtitle: string;
};
}) {
const { chatController, chat, suggestions, greeting, trademark } = props;
const { chatController, chat, suggestions, greeting } = props;
const { trademark } = useAI().config;
const language = useLanguage();
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
@@ -225,11 +216,11 @@ export function AIChatBody(props: {
return (
<>
<ScrollContainer
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
contentClassName="py-4 gutter-stable flex flex-col gap-4 not-embed:px-4 [scroll-behavior:smooth]"
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
contentClassName="p-4 gutter-stable flex flex-col gap-4"
orientation="vertical"
trailing={{ fade: false, button: true }}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
fadeEdges={['leading']}
active={`message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
>
{isEmpty ? (
<div className="flex grow flex-col">
@@ -248,14 +239,13 @@ export function AIChatBody(props: {
<h5
className="animate-blur-in-slow font-bold text-lg text-tint-strong leading-tight [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.5s' }}
data-testid="ai-chat-greeting-title"
data-testid="ai-chat-time-greeting"
>
{greeting?.title || timeGreeting}
</h5>
<p
className="animate-blur-in-slow text-tint leading-tight [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.6s' }}
data-testid="ai-chat-greeting-subtitle"
>
{greeting?.subtitle ||
t(language, 'ai_chat_assistant_description')}
@@ -274,11 +264,10 @@ export function AIChatBody(props: {
)}
</ScrollContainer>
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 not-embed:px-4 pb-4">
<div className="flex flex-col gap-2 px-4 pb-4">
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
{chat.control ? <AIChatControl control={chat.control} /> : null}
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
@@ -2,12 +2,9 @@
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import type { Assistant } from '../AI';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
/**
* Button to open/close the AI chat.
*/
@@ -18,15 +15,15 @@ export function AIChatButton(props: {
}) {
const { assistant, showLabel = true, withShortcut = true } = props;
const language = useLanguage();
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
return (
<Button
icon={assistant.icon}
data-testid="ai-chat-button"
iconOnly={!showLabel || isMobile}
iconOnly={!showLabel}
size="medium"
variant="header"
className="@max-2xl:px-2.5 @max-2xl:[&_.button-content]:hidden [&_.button-leading-icon]:my-0.5"
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)}
@@ -1,8 +0,0 @@
'use client';
import type { AnyAIControl } from '../AI/controls';
export function AIChatControl(props: { control: AnyAIControl }) {
const { control } = props;
return control.render();
}

Some files were not shown because too many files have changed in this diff Show More