mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-25 20:03:16 +00:00
Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ae09b1ec5 | |||
| f71ab8426c | |||
| a3d1558f99 | |||
| ca53869e2d | |||
| 33598e665b | |||
| 9045ba5248 | |||
| 6cd9f2aaaa | |||
| 6d4a1653a2 | |||
| 9ce2759e1b | |||
| 5663064b46 | |||
| 6246b106af | |||
| 9ad85cb30c | |||
| 25bcff6eff | |||
| f232b8fff5 | |||
| e7f9bc2565 | |||
| 7061fc2223 | |||
| 21053f56fb | |||
| 2746ffc000 | |||
| 568ee3a660 | |||
| 420bf4a6f6 | |||
| 72cffdd14b | |||
| 0c9e3952b7 | |||
| 07be0e224c | |||
| c765394914 | |||
| ab96ee806b | |||
| 7cc6090e03 | |||
| 4b78672135 | |||
| 10289e4881 | |||
| 8b6a6df1d6 | |||
| 83df84ac28 | |||
| 5e5075a137 | |||
| 90c1f68e19 | |||
| 0e10124626 | |||
| 01e1c2ae6a | |||
| 65558cfbb1 | |||
| 10d894ce19 | |||
| d1a2958212 | |||
| 894b6adc4c | |||
| b3b819f8e5 | |||
| 2b98af0cc0 | |||
| 54a56ed772 | |||
| 071627d4f0 | |||
| 7595706b92 | |||
| be080248b1 | |||
| d3d51507e5 | |||
| a17ab65a6d | |||
| e3aca9e4f6 | |||
| 045f603849 | |||
| b2854500a1 | |||
| ead88591c2 | |||
| aa1fc246e2 | |||
| e792cffeb3 | |||
| 119e3bc369 | |||
| 823e7d5c4b | |||
| f5f659e7fc | |||
| 723e579958 | |||
| f27901c453 | |||
| 789c2fab60 | |||
| 3fa727c80b | |||
| 88c38fa505 | |||
| bda9fe2d76 | |||
| 2bdade3b57 | |||
| 8d2a95b168 | |||
| 68c842bd4c | |||
| ecb4292551 | |||
| 7fd381c0b1 | |||
| 709964fe3e | |||
| afc7a38bef | |||
| bbcfd72a6d | |||
| 8242f18b8a | |||
| 4f7fba08df | |||
| f17e292ba4 | |||
| 6e1074ec4d | |||
| c5e5857623 | |||
| 11df1fd395 | |||
| 569d4046be | |||
| 50653abd08 | |||
| 84082225af | |||
| 7d882cef5f | |||
| 126d4b78b6 | |||
| e8224bad6c | |||
| 6063437513 | |||
| ab17ff47b0 | |||
| d97b404c7a | |||
| 3c141f0231 | |||
| e3d391e01b | |||
| 62cfec874a | |||
| ccd21e4943 | |||
| 696d9d0d01 | |||
| b77c4fc5b9 | |||
| 7e1ea48080 | |||
| b5d6f2e908 | |||
| 832b44f0ac | |||
| 4909044831 | |||
| 4ed84c209a | |||
| ca01888fe2 | |||
| 6855c05781 | |||
| ab867eb69c | |||
| deabce98ae | |||
| f884477a65 | |||
| c56b569a05 | |||
| 1ba412ff6c | |||
| 7c2823cd26 | |||
| 16bfafe32b | |||
| da648cabd8 | |||
| a36e437580 | |||
| d5dd5e7ba1 | |||
| c4a2604b4d | |||
| 7f4e644733 | |||
| 5d3f78b112 | |||
| 60bcf43304 | |||
| b40465e7b7 | |||
| 3151864f81 | |||
| 80803c92f2 | |||
| 31e77d4e17 | |||
| 8e5a2aa6e6 | |||
| fe6f6420e3 | |||
| aff6285eb9 | |||
| 5f132bc979 | |||
| 4297de3571 | |||
| 6455f2e3ff | |||
| dee52fa80a | |||
| 4ac29817a2 | |||
| f09ca60930 | |||
| a7c0f7251b | |||
| efc6117665 | |||
| 6c613d0df3 | |||
| 45d32b5084 | |||
| 0f62a629b4 | |||
| 7ab63516bd | |||
| 9dd2db6678 | |||
| 4787b7e910 | |||
| be02e33ada | |||
| 3ce7e529f7 | |||
| 0f162a45a7 | |||
| 02ba0ef601 | |||
| bdc6cd9aba | |||
| b3875a19d7 | |||
| 884d900e38 | |||
| f68755695e | |||
| 9ffde72ced | |||
| 9e3629b12b | |||
| 48f81c80f0 | |||
| 0baa8e5022 | |||
| 59b9663ce3 | |||
| 9be66e3e8a | |||
| 9a0f35c26d | |||
| f1826807a7 | |||
| 887c3f8eac | |||
| d666212674 | |||
| 8cb9131d83 | |||
| 98b36616cb | |||
| eceec6f641 | |||
| 9f4d93502e | |||
| 8ab419a47e | |||
| 56ce5dfd74 | |||
| 370b731972 | |||
| b7fb9e57ca | |||
| 5b9f5fa991 | |||
| bf1f11794e | |||
| 8621c7f571 | |||
| 4a6f91a19d | |||
| 38a1c85fee | |||
| cb71511cda | |||
| afa476c51f | |||
| 1e9ed753a1 | |||
| 22da7961d1 | |||
| e074283bbf | |||
| afa868e0e1 | |||
| c5bdde4401 | |||
| 5efe13e622 | |||
| 41ef3435bc | |||
| 157d5fb9e7 | |||
| ec28a7b686 | |||
| b3e9ff9839 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Skip AI questions when it matches the example provided to agents / crawlers.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Track opening of search results for records and in the assistant.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add "Content-Signal" to "robots.txt"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support variant selector in hidden section
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/icons": minor
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Update icon usage to render svg symbols rather than file.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix embeddable search links
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix links to other spaces/sections in the llms.txt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"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:
|
||||
@@ -53,6 +53,7 @@ runs:
|
||||
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
|
||||
@@ -63,6 +64,7 @@ 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
|
||||
@@ -108,7 +110,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
|
||||
|
||||
@@ -19,8 +19,11 @@ 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:
|
||||
@@ -52,12 +55,24 @@ runs:
|
||||
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
|
||||
@@ -65,6 +80,7 @@ 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
|
||||
|
||||
@@ -40,6 +40,7 @@ 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
|
||||
|
||||
@@ -26,9 +26,10 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
run: |
|
||||
corepack enable npm
|
||||
corepack install -g npm@latest
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
@@ -45,4 +46,3 @@ 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 }}
|
||||
|
||||
|
||||
@@ -1,3 +1,77 @@
|
||||
# AGENTS Instructions
|
||||
|
||||
- After making code changes, run `bun format`.
|
||||
## 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.
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/gitbook/worker-configuration.d.ts",
|
||||
"gitbook/tsconfig.json",
|
||||
"**/*.css"
|
||||
]
|
||||
},
|
||||
|
||||
+11
-7
@@ -6,9 +6,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"turbo": "^2.8.10",
|
||||
"vercel": "^50.23.2"
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.9.6",
|
||||
"vercel": "50.37.3"
|
||||
},
|
||||
"packageManager": "bun@1.3.7",
|
||||
"overrides": {
|
||||
@@ -17,7 +17,9 @@
|
||||
"@types/react-dom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.24.2"
|
||||
"esbuild": "0.27.3",
|
||||
"axios": "1.8.4",
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -41,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.164.0",
|
||||
"@gitbook/api": "0.180.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -53,11 +55,13 @@
|
||||
"react-dom": "^19.0.1",
|
||||
"tsdown": "^0.15.6",
|
||||
"typescript": "^5.5.3",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vercel": "^50.26.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"
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
|
||||
"next@16.2.3": "patches/next@16.2.3.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @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
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
Embed your GitBook docs in your product or website.
|
||||
|
||||
The Docs Embed can contain two tabs:
|
||||
The Docs Embed can contain three 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.
|
||||
@@ -40,7 +41,7 @@ GitBook('configure', {
|
||||
label: 'Ask',
|
||||
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
|
||||
},
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
actions: [
|
||||
{
|
||||
icon: 'circle-question',
|
||||
@@ -49,6 +50,7 @@ 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,
|
||||
@@ -71,6 +73,7 @@ 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
|
||||
@@ -91,7 +94,7 @@ frame.clearChat();
|
||||
|
||||
// Configure the embed (see Configuration section for all options)
|
||||
frame.configure({
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
actions: [
|
||||
{
|
||||
icon: 'circle-question',
|
||||
@@ -100,6 +103,7 @@ 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
|
||||
@@ -120,12 +124,14 @@ 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', 'docs']}
|
||||
tabs={['assistant', 'search', 'docs']}
|
||||
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
|
||||
assistantName="Support Assistant"
|
||||
suggestions={['What is GitBook?', 'How do I get started?']}
|
||||
actions={[
|
||||
{
|
||||
@@ -147,7 +153,7 @@ import { useGitBook } from '@gitbook/embed/react';
|
||||
|
||||
function MyComponent() {
|
||||
const gitbook = useGitBook();
|
||||
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
|
||||
const frameURL = gitbook.getFrameURL({ colorScheme: 'dark', visitor: { token: '...' } });
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -175,7 +181,7 @@ function MyComponent() {
|
||||
|
||||
### Standalone Script
|
||||
|
||||
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
|
||||
- `GitBook('init', options: { siteURL: string }, frameOptions?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` - Initialize widget
|
||||
- `GitBook('show')` - Show widget button
|
||||
- `GitBook('hide')` - Hide widget button
|
||||
- `GitBook('open')` - Open widget window
|
||||
@@ -192,7 +198,7 @@ function MyComponent() {
|
||||
|
||||
**Client Factory:**
|
||||
- `createGitBook(options: { siteURL: string })` → `GitBookClient`
|
||||
- `client.getFrameURL(options?: { visitor?: {...} })` → `string`
|
||||
- `client.getFrameURL(options?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` → `string`
|
||||
- `client.createFrame(iframe: HTMLIFrameElement)` → `GitBookFrameClient`
|
||||
|
||||
**Frame Client:**
|
||||
@@ -225,10 +231,10 @@ Available in: Standalone script, NPM package, React components
|
||||
|
||||
Override which tabs are displayed. Defaults to your site's configuration.
|
||||
|
||||
- **Type**: `('assistant' | 'docs')[]`
|
||||
- **Type**: `('assistant' | 'search' | 'docs')[]`
|
||||
|
||||
```javascript
|
||||
tabs: ['assistant', 'docs']
|
||||
tabs: ['assistant', 'search', 'docs']
|
||||
```
|
||||
|
||||
### `closeButton`
|
||||
@@ -269,6 +275,19 @@ 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
|
||||
```
|
||||
|
||||
### `actions`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
@@ -315,6 +334,18 @@ 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
|
||||
@@ -429,6 +460,24 @@ 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
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.2.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
|
||||
@@ -35,4 +35,17 @@ describe('createGitBook.getFrameURL', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,12 @@ 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.
|
||||
*/
|
||||
@@ -42,6 +48,10 @@ 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);
|
||||
}
|
||||
|
||||
@@ -64,11 +64,12 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
const configuration: GitBookEmbeddableConfiguration = {
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
actions: [],
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
trademark: true,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -45,7 +45,7 @@ export type GitBookEmbeddableActionDefinition = {
|
||||
*/
|
||||
export type GitBookEmbeddableConfiguration = {
|
||||
/** Tabs to display in the embed (if enabled on the site). */
|
||||
tabs: ('assistant' | 'docs')[];
|
||||
tabs: ('assistant' | 'docs' | 'search')[];
|
||||
|
||||
/** Additional buttons to be displayed in the header of the GitBook embed. */
|
||||
actions: GitBookEmbeddableActionDefinition[];
|
||||
@@ -62,12 +62,23 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -19,20 +19,26 @@ export type GitBookFrameProps = {
|
||||
export function GitBookFrame(props: GitBookFrameProps) {
|
||||
const {
|
||||
className,
|
||||
colorScheme,
|
||||
visitor,
|
||||
actions = [],
|
||||
greeting,
|
||||
suggestions = [],
|
||||
tools = [],
|
||||
tabs = ['assistant', 'docs'],
|
||||
tabs = ['assistant', 'search', 'docs'],
|
||||
trademark = true,
|
||||
closeButton = false,
|
||||
assistantName,
|
||||
} = props;
|
||||
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const gitbook = useGitBook();
|
||||
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
|
||||
|
||||
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
||||
const frameURL = useMemo(
|
||||
() => gitbook.getFrameURL({ visitor, colorScheme }),
|
||||
[gitbook, visitor, colorScheme]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (frameRef.current) {
|
||||
@@ -48,8 +54,20 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
suggestions,
|
||||
tools,
|
||||
closeButton,
|
||||
trademark,
|
||||
assistantName,
|
||||
});
|
||||
}, [gitbookFrame, actions, greeting, suggestions, tools, tabs, closeButton]);
|
||||
}, [
|
||||
gitbookFrame,
|
||||
actions,
|
||||
greeting,
|
||||
suggestions,
|
||||
tools,
|
||||
tabs,
|
||||
closeButton,
|
||||
trademark,
|
||||
assistantName,
|
||||
]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
@@ -59,6 +77,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
width="100%"
|
||||
height="100%"
|
||||
className={className}
|
||||
style={colorScheme ? { colorScheme } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
trademark: true,
|
||||
};
|
||||
|
||||
const widgetButton = document.createElement('button');
|
||||
@@ -100,6 +101,9 @@ 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,
|
||||
});
|
||||
@@ -156,6 +160,19 @@ 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,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/expr
|
||||
|
||||
## 1.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8242f18: Extend gitbook/expr std lib with some additional methods
|
||||
|
||||
## 1.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@gitbook/expr",
|
||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('autocomplete', () => {
|
||||
description: 'An array of string',
|
||||
items: SymbolString(),
|
||||
});
|
||||
const visitorClaimsKeyPropStringSymbol = SymbolString({ name: 'key' });
|
||||
const symbols = {
|
||||
visitor: SymbolObject({
|
||||
name: 'visitor',
|
||||
@@ -33,7 +34,7 @@ describe('autocomplete', () => {
|
||||
name: 'claims',
|
||||
description: 'The claims contained in the visitor JWT token',
|
||||
properties: {
|
||||
key: SymbolString({ name: 'key' }),
|
||||
key: visitorClaimsKeyPropStringSymbol,
|
||||
flags: SymbolObject({
|
||||
name: 'flags',
|
||||
properties: {
|
||||
@@ -168,8 +169,12 @@ 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',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -227,8 +232,12 @@ 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',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -246,13 +255,47 @@ 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,12 +54,54 @@ 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,8 +255,12 @@ 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',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -301,8 +305,12 @@ 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',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -318,8 +326,12 @@ 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',
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -156,6 +156,31 @@ 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
|
||||
@@ -205,6 +230,39 @@ 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) => ({
|
||||
|
||||
@@ -36,4 +36,4 @@ screenshots/
|
||||
# cloudflare
|
||||
.open-next
|
||||
.wrangler
|
||||
worker-configuration.d.ts
|
||||
worker-configuration.d.ts
|
||||
|
||||
@@ -1,5 +1,130 @@
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
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[] = [
|
||||
{
|
||||
@@ -97,11 +110,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.midas.app',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.keeper.io',
|
||||
contentBaseURL: 'https://docs.keeper.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
// 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: 'adiblar.gitbook.io',
|
||||
contentBaseURL: 'https://adiblar.gitbook.io',
|
||||
@@ -209,11 +223,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.soniclabs.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.meshchain.ai',
|
||||
contentBaseURL: 'https://docs.meshchain.ai',
|
||||
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.thousandeyes.com',
|
||||
contentBaseURL: 'https://docs.thousandeyes.com',
|
||||
@@ -233,7 +248,11 @@ const testCases: TestsCase[] = [
|
||||
name: 'run-ai-docs.nvidia.com',
|
||||
contentBaseURL: 'https://run-ai-docs.nvidia.com',
|
||||
tests: [
|
||||
{ name: 'Home', url: '/' },
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/',
|
||||
cookies: CLOSED_ONETRUST_COOKIE_BANNER,
|
||||
},
|
||||
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
CustomizationBackground,
|
||||
CustomizationCorners,
|
||||
CustomizationDefaultMonospaceFont,
|
||||
CustomizationDefaultThemeMode,
|
||||
CustomizationDepth,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationThemeMode,
|
||||
SiteSocialAccountPlatform,
|
||||
} from '@gitbook/api';
|
||||
import type { GitBookStandalone } from '@gitbook/embed';
|
||||
@@ -16,7 +16,7 @@ import jwt from 'jsonwebtoken';
|
||||
|
||||
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
|
||||
|
||||
import { getSiteAPIToken } from '../tests/utils';
|
||||
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
type Test,
|
||||
type TestsCase,
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. To make screenshot testing of the GitBook Assistant visually consistent, look up the title of the first page you find and respond with only EXACTLY its title. To find the page title, invoke the search tool with the query "GitBook". Before invoking the search tool, respond with the exact text: "I'm going to look up 'GitBook' and then respond with only the page title.". Do not execute any other tools or output any other text.`;
|
||||
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. Search for "Lorem ipsum", then return the first sentence of the first page you find.`;
|
||||
|
||||
const overrideAIInitialState = () => {
|
||||
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
|
||||
@@ -69,7 +69,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
@@ -83,8 +82,6 @@ const searchTestCases: Test[] = [
|
||||
});
|
||||
const pageResults = await page.getByTestId('search-page-result').all();
|
||||
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
||||
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
|
||||
await expect(pageSectionResults.length).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
|
||||
},
|
||||
},
|
||||
@@ -95,7 +92,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.keyboard.press('ControlOrMeta+K');
|
||||
@@ -149,7 +145,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||||
@@ -162,7 +157,7 @@ const searchTestCases: Test[] = [
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
await expect(recommendedQuestions.length).toBeGreaterThanOrEqual(1); // Expect at least 1 question
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.fill(AI_PROMPT);
|
||||
@@ -186,7 +181,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.keyboard.press('ControlOrMeta+I');
|
||||
@@ -203,7 +197,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.getByTestId('ai-chat-button').click();
|
||||
@@ -220,7 +213,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
})}&ask=`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
@@ -238,7 +230,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
})}&ask=${encodeURIComponent(AI_PROMPT)}`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
@@ -327,7 +318,6 @@ const testCases: TestsCase[] = [
|
||||
await expect(navigationLink).toBeVisible();
|
||||
},
|
||||
},
|
||||
...searchTestCases,
|
||||
{
|
||||
name: 'Not found',
|
||||
url: 'content-not-found',
|
||||
@@ -623,7 +613,6 @@ const testCases: TestsCase[] = [
|
||||
url: '',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
...searchTestCases,
|
||||
{
|
||||
name: 'Not found',
|
||||
url: 'content-not-found',
|
||||
@@ -709,10 +698,30 @@ const testCases: TestsCase[] = [
|
||||
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Show error when missing token',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
// Intentionally not setting the token to test error handling when the token is missing
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('limit', '10');
|
||||
|
||||
return `~space/${data.space}/~gitbook/pdf?${searchParams.toString()}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page, response) => {
|
||||
expect(response).not.toBeNull();
|
||||
expect(response?.status()).toBe(400);
|
||||
await expect(page.getByText('Missing API token')).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site Preview',
|
||||
name: 'Site Previews',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
tests: [
|
||||
{
|
||||
@@ -725,7 +734,7 @@ const testCases: TestsCase[] = [
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
@@ -740,7 +749,7 @@ const testCases: TestsCase[] = [
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
@@ -750,10 +759,74 @@ const testCases: TestsCase[] = [
|
||||
const sectionTabLinks = sectionTabs.getByRole('link');
|
||||
for (const link of await sectionTabLinks.all()) {
|
||||
const href = await link.getAttribute('href');
|
||||
expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/);
|
||||
expect(href?.includes('/preview/site_p4Xo4')).toBeTruthy();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With customization cookie',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||
// Trademark exists by default
|
||||
expect(await page.getByTestId('gb-trademark').count()).toBeGreaterThanOrEqual(
|
||||
1
|
||||
); // We output 2 trademark buttons that are quite different, and can only determine which one is appropriate based on screen size / CSS. So our check needs to be more lenient than "exactly 1".
|
||||
|
||||
// Go to another page with the customization query to disable the trademark
|
||||
const pageBlocks = new URL(page.url());
|
||||
pageBlocks.pathname = `${pageBlocks.pathname.replace(/\/$/, '')}/blocks`;
|
||||
pageBlocks.search = getCustomizationURL({
|
||||
trademark: {
|
||||
enabled: false,
|
||||
},
|
||||
}).slice(1);
|
||||
await page.goto(pageBlocks.toString());
|
||||
// No trademark because customization is disabled
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Blocks' })
|
||||
).toBeVisible();
|
||||
|
||||
const pageBlocksCode = new URL(page.url());
|
||||
pageBlocksCode.pathname = `${pageBlocksCode.pathname.replace(/\/$/, '')}/code`;
|
||||
pageBlocksCode.search = '';
|
||||
await page.goto(pageBlocksCode.toString());
|
||||
// The trademark should not be visible because the cookie is still set,
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Code' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Redirect to app for authentication when missing token',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken('https://gitbook.com/docs');
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
// Intentionally not setting the token to test redirection for authentication
|
||||
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await page.waitForURL(
|
||||
(url) =>
|
||||
url.host === 'app.gitbook.com' && url.pathname.includes('/preview/auth')
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -793,9 +866,15 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Search & AI',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: searchTestCases,
|
||||
},
|
||||
{
|
||||
name: 'Content tests',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
fullPage: true,
|
||||
tests: [
|
||||
{
|
||||
name: 'Text',
|
||||
@@ -991,6 +1070,11 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/columns',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Mermaid',
|
||||
url: 'blocks/mermaid',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1014,7 +1098,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'With cover for dark mode',
|
||||
url: `page-options/page-with-dark-cover${getCustomizationURL({
|
||||
themes: {
|
||||
default: CustomizationThemeMode.Dark,
|
||||
default: CustomizationDefaultThemeMode.Dark,
|
||||
toggeable: false,
|
||||
},
|
||||
})}`,
|
||||
@@ -1301,7 +1385,7 @@ const testCases: TestsCase[] = [
|
||||
).toBeVisible();
|
||||
const url = page.url();
|
||||
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
|
||||
expect(url.endsWith('/shared/')).toBeTruthy(); // correct page
|
||||
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
@@ -1321,7 +1405,7 @@ const testCases: TestsCase[] = [
|
||||
).toBeVisible();
|
||||
const url = page.url();
|
||||
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
|
||||
expect(url.endsWith('/shared/')).toBeTruthy(); // correct page
|
||||
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
@@ -1332,13 +1416,21 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Redirect to SSO page',
|
||||
name: 'Basic redirect',
|
||||
url: 'a/redirect/to/sso',
|
||||
run: async (page) => {
|
||||
await expect(page.locator('h1')).toHaveText('SSO');
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
{
|
||||
name: 'Complex wildcard with special characters',
|
||||
url: 'foo/bar/baz/123456789-welcome-to-gitbook-%22%20target=%22_blank',
|
||||
run: async (page) => {
|
||||
await expect(page.locator('h1')).toHaveText('SEO');
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2064,7 +2156,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Docs Embed - Basic',
|
||||
contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Standalone UX',
|
||||
@@ -2111,7 +2203,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Docs Embed - Assistant + Docs',
|
||||
contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
tests: [
|
||||
{
|
||||
@@ -2139,7 +2231,7 @@ const testCases: TestsCase[] = [
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('navigateToPage', '/getting-started/quickstart');
|
||||
GitBook('navigateToPage', '/text-page');
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
@@ -2148,7 +2240,7 @@ const testCases: TestsCase[] = [
|
||||
});
|
||||
await expect(iframe.owner()).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('getting-started/quickstart')
|
||||
expect.stringContaining('text-page')
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -2209,7 +2301,7 @@ const testCases: TestsCase[] = [
|
||||
onClick: () => {
|
||||
const GitBook =
|
||||
window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('navigateToPage', '/getting-started/quickstart');
|
||||
GitBook('navigateToPage', '/text-page');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2250,7 +2342,7 @@ const testCases: TestsCase[] = [
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible();
|
||||
await expect(iframe.owner()).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('getting-started/quickstart')
|
||||
expect.stringContaining('text-page')
|
||||
);
|
||||
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Open external link');
|
||||
@@ -2315,7 +2407,7 @@ const testCases: TestsCase[] = [
|
||||
'I want to contact support. Call the tool directly without a preamble. Do not respond with anything else.'
|
||||
);
|
||||
const toolConfirmation = iframe
|
||||
.getByTestId('ai-chat-tool-confirmation')
|
||||
.getByTestId('ai-chat-tool-confirm-accept')
|
||||
.first();
|
||||
await expect(toolConfirmation).toBeVisible({
|
||||
timeout: 30000,
|
||||
@@ -2334,6 +2426,12 @@ const testCases: TestsCase[] = [
|
||||
name: 'Docs only',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('configure', {
|
||||
tabs: ['docs'],
|
||||
});
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||
@@ -2345,6 +2443,12 @@ const testCases: TestsCase[] = [
|
||||
name: 'Table of contents',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('configure', {
|
||||
tabs: ['docs'],
|
||||
});
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||
@@ -2360,6 +2464,12 @@ const testCases: TestsCase[] = [
|
||||
name: 'Open in new tab',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('configure', {
|
||||
tabs: ['docs'],
|
||||
});
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CustomizationCorners,
|
||||
CustomizationDefaultFont,
|
||||
CustomizationDefaultMonospaceFont,
|
||||
CustomizationDefaultThemeMode,
|
||||
CustomizationDepth,
|
||||
type CustomizationHeaderItem,
|
||||
CustomizationHeaderPreset,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
CustomizationSidebarBackgroundStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationTheme,
|
||||
CustomizationThemeMode,
|
||||
type CustomizationThemedColor,
|
||||
type SiteCustomizationSettings,
|
||||
SiteExternalLinksTarget,
|
||||
@@ -75,6 +75,10 @@ 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[] = [
|
||||
@@ -84,9 +88,9 @@ export const allLocales: CustomizationLocale[] = [
|
||||
CustomizationLocale.Zh,
|
||||
];
|
||||
|
||||
export const allThemeModes: CustomizationThemeMode[] = [
|
||||
CustomizationThemeMode.Light,
|
||||
CustomizationThemeMode.Dark,
|
||||
export const allThemeModes: CustomizationDefaultThemeMode[] = [
|
||||
CustomizationDefaultThemeMode.Light,
|
||||
CustomizationDefaultThemeMode.Dark,
|
||||
];
|
||||
|
||||
export const allTintColors: Array<{
|
||||
@@ -142,6 +146,11 @@ 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({
|
||||
@@ -240,9 +249,9 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
`,
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
fullPage: testEntry.fullPage ?? testCase.fullPage ?? false,
|
||||
beforeScreenshot: async ({ runStabilization }) => {
|
||||
await runStabilization();
|
||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||
@@ -340,7 +349,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
groups: [],
|
||||
},
|
||||
themes: {
|
||||
default: CustomizationThemeMode.Light,
|
||||
default: CustomizationDefaultThemeMode.System,
|
||||
toggeable: true,
|
||||
},
|
||||
pdf: {
|
||||
@@ -392,11 +401,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
*/
|
||||
export async function waitForIcons(page: Page) {
|
||||
await page.waitForFunction(() => {
|
||||
const urlStates: Record<
|
||||
string,
|
||||
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
||||
> = (window as any).__ICONS_STATES__ || {};
|
||||
(window as any).__ICONS_STATES__ = urlStates;
|
||||
const iconWindow = window as IconStateWindow;
|
||||
const urlStates: Record<string, IconURLState> = iconWindow.__ICONS_STATES__ || {};
|
||||
iconWindow.__ICONS_STATES__ = urlStates;
|
||||
|
||||
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url);
|
||||
@@ -429,6 +436,20 @@ 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') {
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
// @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: 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
|
||||
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,
|
||||
@@ -18,15 +35,18 @@ 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.GITHUB_SHA ?? '').slice(0, 7),
|
||||
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? 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,
|
||||
@@ -39,6 +59,7 @@ 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,12 +2,15 @@
|
||||
"main": "default.js",
|
||||
"name": "gitbook-open-v2-server",
|
||||
"keep_names": false,
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_date": "2026-04-02",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
},
|
||||
@@ -89,7 +92,8 @@
|
||||
},
|
||||
"staging": {
|
||||
"vars": {
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
@@ -133,7 +137,8 @@
|
||||
// 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"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
},
|
||||
"env": {
|
||||
"preview": {
|
||||
"vars": {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"main": "middleware.js",
|
||||
"name": "gitbook-open-v2",
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_date": "2026-04-02",
|
||||
"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,6 +29,7 @@ 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,12 +1,15 @@
|
||||
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,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
// 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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.22.1",
|
||||
"version": "0.25.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -19,17 +19,20 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "^3.8.5",
|
||||
"@opennextjs/cloudflare": "^1.14.4",
|
||||
"@opennextjs/aws": "3.10.1",
|
||||
"@opennextjs/cloudflare": "1.19.1",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@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",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"@vercel/agent-readability": "^0.4.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "catalog:",
|
||||
"bidc": "catalog:",
|
||||
@@ -37,22 +40,23 @@
|
||||
"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.12.3",
|
||||
"mermaid": "^11.14.0",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "15.4.11",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -60,9 +64,10 @@
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.3",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"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",
|
||||
@@ -80,6 +85,7 @@
|
||||
"usehooks-ts": "catalog:",
|
||||
"warn-once": "^0.1.1",
|
||||
"zod": "^3",
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -94,8 +100,8 @@
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/rison": "^0.0.9",
|
||||
"@types/negotiator": "^0.6.4",
|
||||
"bun-types": "catalog:",
|
||||
@@ -107,18 +113,18 @@
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "catalog:",
|
||||
"vercel": "^50.15.1",
|
||||
"wrangler": "^4.43.0",
|
||||
"vercel": "50.37.3",
|
||||
"wrangler": "^4.79.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",
|
||||
"build": "next build",
|
||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
|
||||
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
|
||||
"build": "next build --webpack",
|
||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack",
|
||||
"start": "GITBOOK_URL=http://localhost:3000 next start",
|
||||
"build:cloudflare": "opennextjs-cloudflare build",
|
||||
"build:cloudflare": "GITBOOK_RUNTIME=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",
|
||||
@@ -134,5 +140,9 @@
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
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('')));
|
||||
}
|
||||
+3
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
@@ -18,12 +19,14 @@ 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>
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
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} />;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
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 };
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
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);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
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 };
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
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];
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
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();
|
||||
}
|
||||
@@ -10,5 +10,6 @@ export async function GET(
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveLLMsTxt(context, { withMarkdownPages: true });
|
||||
|
||||
return serveLLMsTxt(context);
|
||||
}
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ export async function GET(
|
||||
'What can I ask you?',
|
||||
'Show me tips and tricks',
|
||||
],
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
closeButton: useCustomTrigger
|
||||
});
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
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} />;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
}
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
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((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'),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
{},
|
||||
{
|
||||
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 };
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ function getModeFromParams(mode: string): RouteParamMode {
|
||||
/**
|
||||
* Get the decoded site data from the params.
|
||||
*/
|
||||
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
||||
export function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
||||
try {
|
||||
const decoded = decodeURIComponent(params.siteData);
|
||||
return rison.decode(decoded);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
revalidateTag(tag, { expire: 0 }); // Force revalidation without waiting for the next scheduled revalidation
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -21,16 +21,15 @@ 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,
|
||||
linker: createLinker({
|
||||
spaceBasePath: basePath,
|
||||
siteBasePath: basePath,
|
||||
}),
|
||||
dataFetcher,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'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 user’s 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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,6 +1,7 @@
|
||||
import { CollapsibleContent } from '@/components/primitives';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { AIMessage } from '@gitbook/api';
|
||||
import { type AIMessage, AIMessageStepPhase } from '@gitbook/api';
|
||||
import { DocumentView } from '../../DocumentView';
|
||||
import { AIToolCallsSummary } from './AIToolCallsSummary';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
@@ -17,31 +18,41 @@ export function AIMessageView(
|
||||
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
|
||||
|
||||
return message.steps.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col space-y-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 (
|
||||
<div
|
||||
<Tag
|
||||
key={index}
|
||||
className={tcls(
|
||||
'flex flex-col gap-2',
|
||||
step.content.nodes.length > 0 ? 'has-content' : ''
|
||||
'flex flex-col space-y-2 border-tint-subtle',
|
||||
hasContent ? 'has-content' : ''
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
{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}
|
||||
|
||||
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
||||
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
|
||||
) : null}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -39,9 +39,7 @@ 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 = [
|
||||
@@ -67,6 +65,16 @@ 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,5 +1,4 @@
|
||||
'use server';
|
||||
import { getEmbeddableLinker } from '@/lib/embeddable';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
AIModel,
|
||||
type AIToolCallResult,
|
||||
type AIToolDefinition,
|
||||
SiteCoreChannelType,
|
||||
type SiteInsightsSession,
|
||||
} from '@gitbook/api';
|
||||
import { streamRenderAIMessage } from './api';
|
||||
@@ -35,10 +35,9 @@ export async function* streamAIChatResponse({
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||
let context = await getServerActionBaseContext();
|
||||
if (options?.asEmbeddable) {
|
||||
context = { ...context, linker: getEmbeddableLinker(context.linker) };
|
||||
}
|
||||
const context = await getServerActionBaseContext({
|
||||
isEmbeddable: options?.asEmbeddable,
|
||||
});
|
||||
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
@@ -61,6 +60,11 @@ export async function* streamAIChatResponse({
|
||||
toolCall,
|
||||
tools,
|
||||
session,
|
||||
channel: {
|
||||
type: options?.asEmbeddable
|
||||
? SiteCoreChannelType.Embed
|
||||
: SiteCoreChannelType.Site,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
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];
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export type AIConfig = {
|
||||
aiMode: CustomizationAIMode;
|
||||
suggestions?: string[];
|
||||
trademark: boolean;
|
||||
assistantName?: string;
|
||||
greeting?: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -54,10 +55,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, children } = props;
|
||||
const { aiMode, trademark, suggestions, greeting, assistantName, children } = props;
|
||||
const value = React.useMemo(
|
||||
() => ({ aiMode, trademark, suggestions, greeting }),
|
||||
[aiMode, trademark, suggestions, greeting]
|
||||
() => ({ aiMode, trademark, suggestions, greeting, assistantName }),
|
||||
[aiMode, trademark, suggestions, greeting, assistantName]
|
||||
);
|
||||
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||
}
|
||||
@@ -91,7 +92,7 @@ export function useAI(): AIContext {
|
||||
if (config.aiMode === CustomizationAIMode.Assistant) {
|
||||
assistants.push({
|
||||
id: 'gitbook-assistant',
|
||||
label: getAIChatName(language, config.trademark),
|
||||
label: config.assistantName ?? getAIChatName(language, config.trademark),
|
||||
icon: (
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
|
||||
@@ -6,39 +6,44 @@ import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
AIMessageRole,
|
||||
AIMessageStepPhase,
|
||||
type AIStreamResponse,
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
import { getVisitor, useTrackEvent } from '../Insights';
|
||||
import { getSession } from '../Insights/sessions';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import type { AnyAIControl } from './controls';
|
||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
||||
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 AIChatPendingTool = {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Confirm the tool call by calling this function.
|
||||
*/
|
||||
confirm: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Tool call result to cancel it.
|
||||
*/
|
||||
cancelToolCall: AIToolCallResult;
|
||||
export type AIChatMessageActivity = {
|
||||
currentPhase?: AIMessageStepPhase;
|
||||
toolCount: number;
|
||||
hasCommentary: boolean;
|
||||
hasFinalAnswer: boolean;
|
||||
};
|
||||
|
||||
export type AIChatStatus =
|
||||
| 'default'
|
||||
| 'thinking'
|
||||
| 'exploring'
|
||||
| 'working'
|
||||
| 'done'
|
||||
| 'error'
|
||||
| 'confirm';
|
||||
|
||||
export type AIChatState = {
|
||||
/**
|
||||
* If true, the chat is open.
|
||||
@@ -71,9 +76,9 @@ export type AIChatState = {
|
||||
followUpSuggestions: string[];
|
||||
|
||||
/**
|
||||
* Tools that are pending confirmation to be executed.
|
||||
* Control to be displayed to the user.
|
||||
*/
|
||||
pendingTools: AIChatPendingTool[];
|
||||
control: AnyAIControl | null;
|
||||
|
||||
/**
|
||||
* If true, the session is in progress.
|
||||
@@ -127,7 +132,7 @@ const globalState = zustand.create<AIChatState>(() => {
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
control: null,
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
@@ -213,7 +218,7 @@ export function AIChatProvider(props: {
|
||||
return {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
control: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
@@ -221,6 +226,7 @@ export function AIChatProvider(props: {
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null, // Placeholder for streaming response
|
||||
activity: getDefaultAIChatMessageActivity(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -228,16 +234,16 @@ export function AIChatProvider(props: {
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const toolDef = integrationTools.find((tool) => tool.name === event.toolCall.tool);
|
||||
const tools = getTools();
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef) {
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toolDef.execute(event.toolCall.input);
|
||||
streamResponse({
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
@@ -246,7 +252,7 @@ export function AIChatProvider(props: {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
streamResponse({
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
@@ -264,19 +270,17 @@ export function AIChatProvider(props: {
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const tools = getTools();
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
session: {
|
||||
sessionId: getSession().id,
|
||||
visitorId: (await getVisitor()).deviceId,
|
||||
},
|
||||
tools: integrationTools.map((tool) => ({
|
||||
session: await getInsightsSession(),
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// Issue with the schema generated by Zod and Next.js serialization.
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
options: {
|
||||
@@ -320,46 +324,86 @@ export function AIChatProvider(props: {
|
||||
break;
|
||||
}
|
||||
case 'response_tool_call_pending': {
|
||||
const toolDef = integrationTools.find(
|
||||
(tool) => tool.name === event.toolCall.tool
|
||||
);
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
const confirmation = toolDef.confirmation;
|
||||
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;
|
||||
if (confirmation) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
pendingTools: [
|
||||
...state.pendingTools,
|
||||
{
|
||||
icon: confirmation.icon,
|
||||
label: confirmation.label,
|
||||
cancelToolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
cancelled: 'User did not confirm the tool call',
|
||||
},
|
||||
summary: {
|
||||
icon: 'forward',
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
confirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
confirm: async () => {
|
||||
await executeToolCall(event);
|
||||
},
|
||||
control: ConfirmControlDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
],
|
||||
input: {
|
||||
label: confirmation.label,
|
||||
icon: confirmation.icon,
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
}));
|
||||
} else {
|
||||
toolToExecute = event;
|
||||
break;
|
||||
}
|
||||
|
||||
toolToExecute = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -372,6 +416,11 @@ export function AIChatProvider(props: {
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: data.content,
|
||||
activity: updateAIChatMessageActivity(
|
||||
state.messages[state.messages.length - 1]?.activity ??
|
||||
getDefaultAIChatMessageActivity(),
|
||||
event
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
@@ -380,13 +429,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) => ({
|
||||
@@ -408,7 +457,11 @@ export function AIChatProvider(props: {
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, pendingTools } = globalState.getState();
|
||||
const { query, messages, control } = globalState.getState();
|
||||
|
||||
if (control) {
|
||||
throw new Error("We can't post a message when a control is active");
|
||||
}
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
@@ -453,15 +506,9 @@ export function AIChatProvider(props: {
|
||||
};
|
||||
});
|
||||
|
||||
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 } : {}),
|
||||
});
|
||||
streamResponse({ message: input.message });
|
||||
},
|
||||
[setSearchState, trackEvent, streamResponse]
|
||||
[setSearchState, trackEvent, streamResponse, language]
|
||||
);
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
@@ -472,7 +519,7 @@ export function AIChatProvider(props: {
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
control: null,
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
@@ -534,3 +581,78 @@ 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,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
type AIChatController,
|
||||
type AIChatState,
|
||||
getAIChatStatus,
|
||||
useAI,
|
||||
useAIChatController,
|
||||
useAIChatState,
|
||||
@@ -28,6 +29,7 @@ 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';
|
||||
@@ -91,7 +93,7 @@ export function AIChat() {
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>
|
||||
{getAIChatName(language, config.trademark)}
|
||||
{config.assistantName ?? getAIChatName(language, config.trademark)}
|
||||
</EmbeddableFrameTitle>
|
||||
<AIChatSubtitle chat={chat} />
|
||||
</EmbeddableFrameHeaderMain>
|
||||
@@ -106,11 +108,12 @@ export function AIChat() {
|
||||
/>
|
||||
</EmbeddableFrameButtons>
|
||||
</EmbeddableFrameHeader>
|
||||
<EmbeddableFrameBody className="not-embed:px-4">
|
||||
<EmbeddableFrameBody className="not-embed:px-0">
|
||||
<AIChatBody
|
||||
chatController={chatController}
|
||||
chat={chat}
|
||||
suggestions={config.suggestions}
|
||||
trademark={config.trademark}
|
||||
/>
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrameMain>
|
||||
@@ -128,24 +131,13 @@ 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={
|
||||
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'
|
||||
}
|
||||
state={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -158,35 +150,45 @@ 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', chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0')}
|
||||
className={tcls('relative', subtitleKey ? 'h-3 opacity-11' : 'h-0 opacity-0')}
|
||||
>
|
||||
<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>
|
||||
{(
|
||||
[
|
||||
['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>
|
||||
))}
|
||||
</EmbeddableFrameSubtitle>
|
||||
);
|
||||
}
|
||||
@@ -199,13 +201,13 @@ export function AIChatBody(props: {
|
||||
chat: AIChatState;
|
||||
welcomeMessage?: string;
|
||||
suggestions?: string[];
|
||||
trademark?: boolean;
|
||||
greeting?: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
}) {
|
||||
const { chatController, chat, suggestions, greeting } = props;
|
||||
const { trademark } = useAI().config;
|
||||
const { chatController, chat, suggestions, greeting, trademark } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
|
||||
@@ -223,8 +225,8 @@ export function AIChatBody(props: {
|
||||
return (
|
||||
<>
|
||||
<ScrollContainer
|
||||
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
|
||||
contentClassName="py-4 gutter-stable flex flex-col gap-4"
|
||||
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]"
|
||||
orientation="vertical"
|
||||
trailing={{ fade: false, button: true }}
|
||||
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
|
||||
@@ -272,10 +274,11 @@ export function AIChatBody(props: {
|
||||
)}
|
||||
</ScrollContainer>
|
||||
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 not-embed: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}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
const MOBILE_BREAKPOINT = 672; // 42rem, equal to Tailwind's @max-2xl container breakpoint
|
||||
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
|
||||
|
||||
/**
|
||||
* Button to open/close the AI chat.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import type { AnyAIControl } from '../AI/controls';
|
||||
|
||||
export function AIChatControl(props: { control: AnyAIControl }) {
|
||||
const { control } = props;
|
||||
return control.render();
|
||||
}
|
||||
@@ -4,7 +4,15 @@ import type React from 'react';
|
||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
size?: number;
|
||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
|
||||
state?:
|
||||
| 'default'
|
||||
| 'intro'
|
||||
| 'thinking'
|
||||
| 'exploring'
|
||||
| 'working'
|
||||
| 'done'
|
||||
| 'error'
|
||||
| 'confirm';
|
||||
trademark?: boolean;
|
||||
}
|
||||
|
||||
@@ -25,6 +33,7 @@ export function AIChatIcon({
|
||||
animation: {
|
||||
intro: 'spin 2s forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
exploring: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
done: '',
|
||||
confirm: '',
|
||||
@@ -43,7 +52,7 @@ export function AIChatIcon({
|
||||
viewBox="0 0 16 16"
|
||||
preserveAspectRatio="xMaxYMid meet"
|
||||
className={className}
|
||||
aria-busy={state === 'thinking'}
|
||||
aria-busy={['thinking', 'exploring', 'working'].includes(state)}
|
||||
overflow="visible"
|
||||
{...props}
|
||||
fill="none"
|
||||
@@ -63,6 +72,7 @@ export function AIChatIcon({
|
||||
animation: {
|
||||
intro: 'fadeIn .5s .5s backwards, spin 2s .5s forwards cubic-bezier(0.43,1.54,0.64,1)',
|
||||
thinking: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
exploring: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
working: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
done: '',
|
||||
confirm: '',
|
||||
@@ -124,6 +134,7 @@ export function AIChatIcon({
|
||||
animation: {
|
||||
intro: '',
|
||||
thinking: '',
|
||||
exploring: '',
|
||||
working: '',
|
||||
done: '',
|
||||
confirm: 'bounceSmall 1s infinite both',
|
||||
@@ -146,6 +157,7 @@ export function AIChatIcon({
|
||||
animation: {
|
||||
intro: 'fadeIn 2s forwards',
|
||||
thinking: '',
|
||||
exploring: '',
|
||||
working: '',
|
||||
done: '',
|
||||
confirm: '',
|
||||
@@ -186,6 +198,7 @@ export function AIChatIcon({
|
||||
animation: {
|
||||
intro: 'pathEnter 1.5s both ease-out',
|
||||
thinking: 'pathLoading 2s infinite both',
|
||||
exploring: 'pathLoading 2s infinite both',
|
||||
working: 'pathLoading 2s infinite both',
|
||||
done: 'pathEnter 1s forwards ease',
|
||||
confirm: '',
|
||||
@@ -201,7 +214,15 @@ export function AIChatIcon({
|
||||
|
||||
interface AISearchIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
|
||||
state?:
|
||||
| 'default'
|
||||
| 'intro'
|
||||
| 'thinking'
|
||||
| 'exploring'
|
||||
| 'working'
|
||||
| 'done'
|
||||
| 'error'
|
||||
| 'confirm';
|
||||
}
|
||||
|
||||
export function AISearchIcon({ className = 'size-4', state = 'default' }: AISearchIconProps) {
|
||||
@@ -213,6 +234,7 @@ export function AISearchIcon({ className = 'size-4', state = 'default' }: AISear
|
||||
intro: 'fadeIn 1s both, orbit 1s cubic-bezier(0.16,1,0.3,1)',
|
||||
thinking:
|
||||
'fadeIn 1s both, orbit 1s orbit 2s 1s infinite forwards linear ease-out,',
|
||||
exploring: '',
|
||||
working: '',
|
||||
done: '',
|
||||
confirm: '',
|
||||
@@ -234,6 +256,7 @@ export function AISearchIcon({ className = 'size-4', state = 'default' }: AISear
|
||||
animation: {
|
||||
intro: 'spin 2s .5s forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
exploring: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
done: '',
|
||||
confirm: '',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@@ -58,7 +59,10 @@ export function AIChatInput(props: {
|
||||
size: 'small',
|
||||
label: tString(language, 'send'),
|
||||
}}
|
||||
className="animate-blur-in-slow bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base"
|
||||
className={tcls(
|
||||
chat.control ? 'animate-blur-out-display' : 'animate-blur-in-slow',
|
||||
'bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base'
|
||||
)}
|
||||
rows={1}
|
||||
maxLength={2048}
|
||||
keyboardShortcut={
|
||||
@@ -69,7 +73,7 @@ export function AIChatInput(props: {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || loading}
|
||||
disabled={disabled || loading || chat.control !== null}
|
||||
aria-busy={loading}
|
||||
ref={inputRef}
|
||||
trailing={
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import type { AIChatController, AIChatState } from '../AI';
|
||||
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||
import {
|
||||
type AIChatController,
|
||||
type AIChatMessage,
|
||||
type AIChatState,
|
||||
getAIChatStatus,
|
||||
} from '../AI';
|
||||
import { ToggleChevron } from '../primitives';
|
||||
import { Button } from '../primitives/Button';
|
||||
import { Collapsible, CollapsibleTrigger } from '../primitives/Collapsible';
|
||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
|
||||
@@ -12,10 +19,11 @@ export function AIChatMessages(props: {
|
||||
chatController: AIChatController;
|
||||
}) {
|
||||
const { chat, chatController } = props;
|
||||
const status = getAIChatStatus(chat);
|
||||
const showLoadingShim = chat.loading && status !== 'working' && status !== 'done';
|
||||
|
||||
// Group messages: user messages start a new group, all following messages until next user message belong to that group
|
||||
type Message = (typeof chat.messages)[0];
|
||||
type MessageGroup = { message: Message; originalIndex: number };
|
||||
type MessageGroup = { message: AIChatMessage; originalIndex: number };
|
||||
const messageGroups: Array<Array<MessageGroup>> = [];
|
||||
let currentGroup: Array<MessageGroup> = [];
|
||||
|
||||
@@ -37,6 +45,8 @@ export function AIChatMessages(props: {
|
||||
messageGroups.push(currentGroup);
|
||||
}
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
return messageGroups.map((group, groupIndex) => {
|
||||
const isLastGroup = group === messageGroups[messageGroups.length - 1];
|
||||
return (
|
||||
@@ -53,8 +63,16 @@ export function AIChatMessages(props: {
|
||||
>
|
||||
{group.map(({ message, originalIndex }) => {
|
||||
const isLastMessage = originalIndex === chat.messages.length - 1;
|
||||
const toolCount = message.activity?.toolCount ?? 0;
|
||||
const hasCommentary =
|
||||
(message.activity?.hasCommentary ?? false) || toolCount > 0;
|
||||
const hasFinalAnswer =
|
||||
(message.activity?.hasFinalAnswer ?? false) || !isLastMessage;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Collapsible
|
||||
open={!hasFinalAnswer}
|
||||
disabled={!hasFinalAnswer}
|
||||
key={originalIndex}
|
||||
data-testid={
|
||||
message.role === AIMessageRole.User
|
||||
@@ -63,26 +81,60 @@ export function AIChatMessages(props: {
|
||||
}
|
||||
id={`message-${originalIndex}`}
|
||||
className={tcls(
|
||||
'flex flex-col gap-6',
|
||||
'flex flex-col gap-2',
|
||||
'break-words',
|
||||
'group/message',
|
||||
'animate-blur-in-slow',
|
||||
isLastMessage ? 'basis-full' : '',
|
||||
message.role === AIMessageRole.User
|
||||
? 'max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
: 'origin-top-left text-tint-strong'
|
||||
? 'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
: 'origin-top-left text-tint-strong',
|
||||
isLastMessage && message.role === AIMessageRole.Assistant
|
||||
? 'grow'
|
||||
: ''
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
|
||||
}}
|
||||
>
|
||||
{message.role === AIMessageRole.Assistant &&
|
||||
hasCommentary &&
|
||||
hasFinalAnswer ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="small"
|
||||
label={tString(language, 'ai_chat_view_activity')}
|
||||
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{toolCount > 0
|
||||
? t(
|
||||
language,
|
||||
'ai_chat_explored_with',
|
||||
tString(
|
||||
language,
|
||||
toolCount === 1
|
||||
? 'tool_count'
|
||||
: 'tool_count_plural',
|
||||
toolCount.toString()
|
||||
)
|
||||
)
|
||||
: t(language, 'ai_chat_explored')}
|
||||
<ToggleChevron orientation="right-to-down" />
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
|
||||
{message.content}
|
||||
|
||||
{isLastMessage && message.role === AIMessageRole.Assistant ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex w-full shrink-0 flex-col gap-2 starting:opacity-0 transition-all transition-discrete duration-500',
|
||||
chat.loading ? '' : 'hidden opacity-0'
|
||||
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
|
||||
showLoadingShim
|
||||
? 'max-h-48 opacity-11'
|
||||
: 'pointer-events-none max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<HoldMessage className={message.content ? 'hidden' : ''} />
|
||||
@@ -96,21 +148,20 @@ export function AIChatMessages(props: {
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.pendingTools.length === 0 ? (
|
||||
!chat.control ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
className="-ml-1.5 -mt-4 mb-2"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function AIChatSuggestedQuestions(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex max-w-full max-w-full flex-col items-start gap-2 self-start"
|
||||
className="flex max-w-full flex-col items-start gap-2 self-start"
|
||||
data-testid="ai-chat-suggested-questions"
|
||||
>
|
||||
{suggestions.map((question, index) => (
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import type { AIChatState } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
/**
|
||||
* Display buttons to confirm tool calls.
|
||||
*/
|
||||
export function AIChatToolConfirmations(props: {
|
||||
chat: AIChatState;
|
||||
}) {
|
||||
const { chat } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
useHotkeys(
|
||||
'mod+enter',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
chat.pendingTools[0]?.confirm();
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[chat.pendingTools]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap justify-end gap-2 empty:hidden">
|
||||
{chat.pendingTools.map((tool, index) => (
|
||||
<div
|
||||
className="flex w-full animate-present-slow flex-col gap-1"
|
||||
key={index}
|
||||
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
|
||||
>
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirmation"
|
||||
onClick={() => {
|
||||
tool.confirm();
|
||||
}}
|
||||
tabIndex={index}
|
||||
label={tool.label}
|
||||
className="w-full justify-center"
|
||||
size={index === 0 ? 'medium' : 'small'}
|
||||
variant={index === 0 ? 'primary' : 'secondary'}
|
||||
icon={tool.icon}
|
||||
/>
|
||||
{index === 0 && (
|
||||
<div
|
||||
className="flex pointer-none:hidden w-full animate-fade-in-slow items-center justify-end gap-2 text-tint text-xs"
|
||||
style={{ animationDelay: '1000ms' }}
|
||||
>
|
||||
{t(
|
||||
language,
|
||||
'press_to_confirm',
|
||||
<KeyboardShortcut
|
||||
keys={['mod', 'enter']}
|
||||
className="mx-0 text-tint"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function AIChatFollowupSuggestions(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="mt-auto flex grow flex-col">
|
||||
<div
|
||||
className="sticky bottom-0 mt-auto flex max-w-full flex-col items-start gap-2"
|
||||
data-testid="ai-chat-followup-suggestions"
|
||||
|
||||
@@ -30,10 +30,10 @@ export function ToolbarVisibilityHint(props: ToolbarVisibilityHintProps) {
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
}}
|
||||
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3 py-0.5"
|
||||
className="-translate-x-1/2 pointer-events-none absolute bottom-[calc(100%+2px)] left-1/2 flex items-center gap-1.5 rounded-lg border border-[#eaeaea] border-solid bg-white py-1 pr-1 pl-2"
|
||||
>
|
||||
<span className="whitespace-nowrap text-[11px] text-neutral-9">
|
||||
Only visible to your GitBook organization
|
||||
This toolbar appears only when signed in to GitBook.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function Announcement(props: {
|
||||
return (
|
||||
<AnnouncementBanner
|
||||
announcement={customization.announcement}
|
||||
locale={context.locale}
|
||||
contentRef={resolvedContentRef}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { setLocalStorageItem } from '@/lib/browser';
|
||||
import type { ResolvedContentRef } from '@/lib/references';
|
||||
import { getLocalizedMessage, getLocalizedTitle } from '@/lib/sites';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||
import {
|
||||
type CustomizationAnnouncement,
|
||||
SiteInsightsLinkPosition,
|
||||
type TranslationLanguage,
|
||||
} from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { Button, Link } from '../primitives';
|
||||
@@ -16,11 +21,14 @@ import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
|
||||
*/
|
||||
export function AnnouncementBanner(props: {
|
||||
announcement: CustomizationAnnouncement;
|
||||
locale: TranslationLanguage | undefined;
|
||||
contentRef: ResolvedContentRef | null;
|
||||
}) {
|
||||
const { announcement, contentRef } = props;
|
||||
const { announcement, locale, contentRef } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const message = getLocalizedMessage(announcement, locale);
|
||||
const linkTitle = announcement.link ? getLocalizedTitle(announcement.link, locale) : undefined;
|
||||
|
||||
const hasLink = announcement.link && contentRef?.href;
|
||||
const closeable = announcement.style !== 'danger';
|
||||
@@ -61,7 +69,7 @@ export function AnnouncementBanner(props: {
|
||||
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
|
||||
/>
|
||||
<div>
|
||||
{announcement.message}
|
||||
{message}
|
||||
{hasLink ? (
|
||||
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
|
||||
{contentRef?.icon ? (
|
||||
@@ -69,9 +77,7 @@ export function AnnouncementBanner(props: {
|
||||
{contentRef?.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{announcement.link?.title && (
|
||||
<span className="mr-1">{announcement.link?.title}</span>
|
||||
)}
|
||||
{linkTitle ? <span className="mr-1">{linkTitle}</span> : null}
|
||||
<Icon
|
||||
icon={
|
||||
announcement.link?.to.kind === 'url'
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
SkeletonHeading,
|
||||
SkeletonImage,
|
||||
SkeletonParagraph,
|
||||
SkeletonSmall,
|
||||
SkeletonUpdate,
|
||||
} from '@/components/primitives';
|
||||
import type { ClassValue } from '@/lib/tailwind';
|
||||
@@ -142,7 +141,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'file':
|
||||
return <SkeletonHeading id={id} style={style} />;
|
||||
case 'paragraph':
|
||||
return <SkeletonSmall id={id} style={style} />;
|
||||
return <SkeletonParagraph id={id} className={style} />;
|
||||
case 'list-ordered':
|
||||
case 'list-unordered':
|
||||
case 'list-tasks':
|
||||
@@ -153,7 +152,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'tabs':
|
||||
case 'stepper-step':
|
||||
case 'if':
|
||||
return <SkeletonParagraph id={id} style={style} />;
|
||||
return <SkeletonParagraph id={id} className={style} />;
|
||||
case 'expandable':
|
||||
case 'table':
|
||||
case 'swagger':
|
||||
@@ -173,7 +172,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'drawing':
|
||||
return <SkeletonImage id={id} style={style} />;
|
||||
case 'updates':
|
||||
return <SkeletonUpdate id={id} style={style} />;
|
||||
return <SkeletonUpdate id={id} className={style} />;
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DocumentBlock, JSONDocument } from '@gitbook/api';
|
||||
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { CONTENT_STYLE, CONTENT_STYLE_REDUCED } from '../layout';
|
||||
import { Block } from './Block';
|
||||
import type { DocumentContextProps } from './DocumentView';
|
||||
import { isBlockOffscreen } from './utils';
|
||||
@@ -63,7 +64,7 @@ const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [
|
||||
'columns',
|
||||
'code',
|
||||
'content-ref',
|
||||
'hint',
|
||||
'divider',
|
||||
];
|
||||
|
||||
const LIST_BLOCKS: DocumentBlock['type'][] = ['list-ordered', 'list-tasks', 'list-unordered'];
|
||||
@@ -89,12 +90,10 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
||||
key={node.key || `${node.type}-${index}`}
|
||||
block={node}
|
||||
style={[
|
||||
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
|
||||
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
||||
? 'max-w-screen-xl'
|
||||
: 'max-w-3xl',
|
||||
'decoration-primary/6',
|
||||
FULL_WIDTH_BLOCKS.includes(node.type) ? CONTENT_STYLE : CONTENT_STYLE_REDUCED,
|
||||
|
||||
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
|
||||
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
|
||||
blockStyle,
|
||||
]}
|
||||
isEstimatedOffscreen={isOffscreen}
|
||||
|
||||
@@ -193,10 +193,10 @@ function CodeBlockExpandable(props: {
|
||||
<Button
|
||||
icon={<ToggleChevron open={isExpanded} />}
|
||||
size="xsmall"
|
||||
variant="blank"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
|
||||
className="pointer-events-auto z-1 my-2 bg-tint! text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={controls}
|
||||
>
|
||||
|
||||
@@ -41,14 +41,14 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
ref={ref}
|
||||
aria-busy={ariaBusy}
|
||||
className={tcls(
|
||||
'group/codeblock shiki grid shrink grid-flow-col overflow-hidden',
|
||||
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
|
||||
style
|
||||
)}
|
||||
/* Sets the code theme's mode (light or dark) for the site's theme mode (light or dark).
|
||||
* Used to style UI elements (scrollbars, form controls) correctly and apply the right default to "plain" code blocks. */
|
||||
data-color-scheme={`${theme.themes.light.type} ${theme.themes.dark.type}`}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
{title ? (
|
||||
<div
|
||||
className="relative top-px z-20 inline-flex items-center justify-center circular-corners:rounded-t-xl rounded-corners:rounded-t-lg straight-corners:rounded-t-xs border border-tint-subtle border-b-0 bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base"
|
||||
@@ -63,43 +63,45 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<CopyCodeButton
|
||||
codeId={codeId}
|
||||
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
|
||||
/>
|
||||
<pre
|
||||
className={tcls(
|
||||
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base p-2 text-tint-strong [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base',
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
|
||||
title && 'rounded-ss-none!'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
}}
|
||||
>
|
||||
<code
|
||||
id={codeId}
|
||||
<div className="relative">
|
||||
<CopyCodeButton
|
||||
codeId={codeId}
|
||||
style="absolute top-2 right-2 z-2 self-start justify-self-end font-sans leading-none opacity-0 backdrop-blur-md group-hover/codeblock:opacity-11"
|
||||
/>
|
||||
<pre
|
||||
className={tcls(
|
||||
'inline-grid max-h-full min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
|
||||
withWrap && 'whitespace-pre-wrap',
|
||||
'[[aria-expanded=false]_&]:mask-b-from-50%'
|
||||
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
|
||||
title && 'rounded-ss-none!'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
}}
|
||||
>
|
||||
{theme.lines.map((line, index) => (
|
||||
<CodeHighlightLine
|
||||
bg={bg}
|
||||
fg={fg}
|
||||
key={index}
|
||||
line={line}
|
||||
isLast={index === theme.lines.length - 1}
|
||||
withLineNumbers={withLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
<code
|
||||
id={codeId}
|
||||
className={tcls(
|
||||
'table max-h-full w-fit min-w-full [counter-reset:line] print:max-h-none print:whitespace-pre-wrap',
|
||||
withWrap && 'whitespace-pre-wrap',
|
||||
'[[aria-expanded=false]_&]:mask-b-from-50%'
|
||||
)}
|
||||
>
|
||||
{theme.lines.map((line, index) => (
|
||||
<CodeHighlightLine
|
||||
bg={bg}
|
||||
fg={fg}
|
||||
key={index}
|
||||
line={line}
|
||||
isLast={index === theme.lines.length - 1}
|
||||
withLineNumbers={withLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,63 +3,117 @@
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
|
||||
import { Loading } from '@/components/primitives/Loading';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import Panzoom from '@panzoom/panzoom';
|
||||
import type { RenderResult } from 'mermaid';
|
||||
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
|
||||
import { getPlainCodeBlock } from './highlight';
|
||||
|
||||
/**
|
||||
* Used to render a Mermaid diagram from a CodeBlock.
|
||||
*/
|
||||
export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style } = props;
|
||||
const { block, mode, style } = props;
|
||||
const source = getPlainCodeBlock(block);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const darkMode = resolvedTheme === 'dark';
|
||||
const id = useSafeId();
|
||||
const hasBeenInViewport = useHasBeenInViewport(rootRef, { rootMargin: '800px' });
|
||||
const shouldRender = mode === 'print' || hasBeenInViewport;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = diagramRef.current;
|
||||
if (!container) {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!container || !wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let cleanupPanZoom: (() => void) | undefined;
|
||||
setError(false);
|
||||
setIsLoading(true);
|
||||
setPanZoom(null);
|
||||
container.innerHTML = '';
|
||||
|
||||
renderMermaidDiagram({
|
||||
container,
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
const cancelScheduledRender = scheduleMermaidWork(() => {
|
||||
enqueueMermaidRender(async () => {
|
||||
if (cancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderMermaidDiagram({
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
.then((result) => {
|
||||
if (!result || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = result.svg;
|
||||
if (container.querySelector('svg')) {
|
||||
result.bindFunctions?.(container);
|
||||
}
|
||||
|
||||
cleanupPanZoom = initPanzoom({
|
||||
container,
|
||||
wrapper,
|
||||
onInit: setPanZoom,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelScheduledRender();
|
||||
cleanupPanZoom?.();
|
||||
setPanZoom(null);
|
||||
};
|
||||
}, [source, id, darkMode]);
|
||||
}, [source, id, darkMode, shouldRender]);
|
||||
|
||||
if (error) {
|
||||
return <ClientCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tcls('relative', style)} contentEditable={false}>
|
||||
<div className={isLoading ? 'invisible absolute inset-x-0' : undefined}>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={tcls('group/mermaid relative', style)}
|
||||
contentEditable={false}
|
||||
>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={
|
||||
isLoading
|
||||
? 'invisible absolute inset-x-0 overflow-hidden'
|
||||
: 'cursor-grab overflow-hidden active:cursor-grabbing'
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={diagramRef}
|
||||
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
|
||||
@@ -70,23 +124,18 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
<Loading className="h-8 w-8" />
|
||||
</div>
|
||||
) : null}
|
||||
{!isLoading && panZoom ? <MermaidPanZoomControls panZoom={panZoom} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderMermaidDiagram(args: {
|
||||
container: HTMLElement;
|
||||
source: string;
|
||||
id: string;
|
||||
darkMode: boolean;
|
||||
}) {
|
||||
const { container, source, id, darkMode } = args;
|
||||
const [{ default: mermaid }, { default: zenuml }] = await Promise.all([
|
||||
import('mermaid'),
|
||||
import('@mermaid-js/mermaid-zenuml'),
|
||||
]);
|
||||
|
||||
await mermaid.registerExternalDiagrams([zenuml]);
|
||||
}): Promise<RenderResult> {
|
||||
const { source, id, darkMode } = args;
|
||||
const { mermaid } = await loadMermaid();
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
@@ -95,9 +144,141 @@ async function renderMermaidDiagram(args: {
|
||||
theme: darkMode ? 'dark' : undefined,
|
||||
});
|
||||
|
||||
const { svg, bindFunctions } = await mermaid.render(`mermaid-diagram-${id}`, source, container);
|
||||
container.innerHTML = svg;
|
||||
bindFunctions?.(container);
|
||||
const renderContainer = createMermaidRenderContainer();
|
||||
|
||||
try {
|
||||
return await mermaid.render(`mermaid-diagram-${id}`, source, renderContainer);
|
||||
} finally {
|
||||
renderContainer.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mermaid measures labels while rendering, so the temporary render target must be
|
||||
* connected to the document. Keep it fixed and contained so those layout reads do
|
||||
* not walk the visible document flow.
|
||||
*/
|
||||
function createMermaidRenderContainer() {
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(container.style, {
|
||||
contain: 'strict',
|
||||
height: '100vh',
|
||||
isolation: 'isolate',
|
||||
left: '0',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
visibility: 'hidden',
|
||||
width: '100vw',
|
||||
zIndex: '-1',
|
||||
});
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
let mermaidLoadPromise: Promise<{
|
||||
mermaid: typeof import('mermaid')['default'];
|
||||
}> | null = null;
|
||||
|
||||
async function loadMermaid() {
|
||||
if (!mermaidLoadPromise) {
|
||||
mermaidLoadPromise = Promise.all([import('mermaid'), import('@mermaid-js/mermaid-zenuml')])
|
||||
.then(async ([{ default: mermaid }, { default: zenuml }]) => {
|
||||
await mermaid.registerExternalDiagrams([zenuml]);
|
||||
return { mermaid };
|
||||
})
|
||||
.catch((error) => {
|
||||
mermaidLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return mermaidLoadPromise;
|
||||
}
|
||||
|
||||
let mermaidRenderQueue = Promise.resolve();
|
||||
|
||||
function enqueueMermaidRender<T>(task: () => Promise<T>) {
|
||||
const result = mermaidRenderQueue.then(task, task);
|
||||
mermaidRenderQueue = result.catch(() => {}).then(waitForNextFrame);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function waitForNextFrame() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleMermaidWork(callback: () => void) {
|
||||
const win = window as WindowWithIdleCallback;
|
||||
let cancelled = false;
|
||||
|
||||
if (win.requestIdleCallback && win.cancelIdleCallback) {
|
||||
const handle = win.requestIdleCallback(
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
{ timeout: 1500 }
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
win.cancelIdleCallback?.(handle);
|
||||
};
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}
|
||||
|
||||
type WindowWithIdleCallback = Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize panzoom on the diagram container.
|
||||
*/
|
||||
function initPanzoom(args: {
|
||||
container: HTMLElement;
|
||||
wrapper: HTMLElement;
|
||||
onInit: (instance: ReturnType<typeof Panzoom> | null) => void;
|
||||
}): () => void {
|
||||
const { container, wrapper, onInit } = args;
|
||||
|
||||
const instance = Panzoom(container, {
|
||||
maxScale: 5,
|
||||
minScale: 0.5,
|
||||
cursor: 'grab',
|
||||
panOnlyWhenZoomed: true,
|
||||
});
|
||||
|
||||
onInit(instance);
|
||||
|
||||
wrapper.addEventListener('wheel', instance.zoomWithWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
wrapper.removeEventListener('wheel', instance.zoomWithWheel);
|
||||
instance.destroy();
|
||||
onInit(null);
|
||||
};
|
||||
}
|
||||
|
||||
function useSafeId() {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import type { PanzoomObject } from '@panzoom/panzoom';
|
||||
|
||||
import { Button } from '@/components/primitives';
|
||||
|
||||
const PAN_STEP = 50;
|
||||
|
||||
/**
|
||||
* Navigation and zoom controls for mermaid diagrams, positioned as an overlay.
|
||||
*/
|
||||
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
|
||||
const { panZoom } = props;
|
||||
const btnProps = {
|
||||
variant: 'secondary' as const,
|
||||
size: 'xsmall' as const,
|
||||
iconOnly: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5 opacity-0 transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none">
|
||||
{/* Row 1: empty, pan up, zoom in */}
|
||||
<div />
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-up"
|
||||
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button {...btnProps} icon="plus" onClick={() => panZoom.zoomIn()} />
|
||||
{/* Row 2: pan left, reset, pan right */}
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-left"
|
||||
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
<Button {...btnProps} icon="refresh" onClick={() => panZoom.reset()} />
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-right"
|
||||
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
{/* Row 3: empty, pan down, zoom out */}
|
||||
<div />
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-down"
|
||||
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button {...btnProps} icon="minus" onClick={() => panZoom.zoomOut()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,5 +7,5 @@ import type { BlockProps } from './Block';
|
||||
export function Divider(props: BlockProps<DocumentBlockDivider>) {
|
||||
const { style } = props;
|
||||
|
||||
return <hr className={tcls(style, 'page-width-wide:max-w-full border-tint-subtle')} />;
|
||||
return <hr className={tcls(style, 'border-tint-subtle')} />;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle
|
||||
style={[
|
||||
'mx-auto w-full decoration-primary/6',
|
||||
block.data && 'fullWidth' in block.data && block.data.fullWidth
|
||||
? 'max-w-screen-xl'
|
||||
? 'max-w-6xl'
|
||||
: 'max-w-3xl',
|
||||
blockStyle,
|
||||
]}
|
||||
|
||||
@@ -56,12 +56,13 @@ export function Details(props: {
|
||||
'border-tint-subtle',
|
||||
//all
|
||||
'mt-0!',
|
||||
'mb-0',
|
||||
//select first child
|
||||
'first:mt-5!',
|
||||
'first:rounded-t-lg',
|
||||
//select first in group
|
||||
'[:not(&)_+&]:mt-5!',
|
||||
'[:not(&)_+&]:rounded-t-lg',
|
||||
'[:not(&)_+_&]:mt-5!',
|
||||
'[:not(&)_+_&]:rounded-t-lg',
|
||||
//select last in group
|
||||
'[&:not(:has(+_&))]:mb-5',
|
||||
'[&:not(:has(+_&))]:rounded-b-lg',
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
|
||||
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
|
||||
import * as api from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import type React from 'react';
|
||||
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
|
||||
import { Button, type ButtonProps } from '../primitives';
|
||||
import type { InlineProps } from './Inline';
|
||||
import { InlineActionButton } from './InlineActionButton';
|
||||
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
|
||||
|
||||
export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
const { inline } = props;
|
||||
const { inline, context } = props;
|
||||
|
||||
const buttonProps: ButtonProps = {
|
||||
label: inline.data.label,
|
||||
@@ -17,7 +20,8 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
};
|
||||
|
||||
const ButtonImplementation = () => {
|
||||
if ('action' in inline.data && 'query' in inline.data.action) {
|
||||
// In print/PDF mode, skip interactive action buttons (AI/search providers are not mounted).
|
||||
if (context.mode !== 'print' && 'action' in inline.data && 'query' in inline.data.action) {
|
||||
return (
|
||||
<InlineActionButton
|
||||
action={inline.data.action.action}
|
||||
@@ -57,21 +61,27 @@ export async function InlineLinkButton(
|
||||
const href =
|
||||
resolved?.href ??
|
||||
(inline.data.ref ? resolveContentRefFallback(inline.data.ref)?.href : undefined);
|
||||
const sharedProps: React.ComponentProps<typeof Button> = {
|
||||
...buttonProps,
|
||||
insights: {
|
||||
type: 'link_click' as const,
|
||||
link: {
|
||||
target: inline.data.ref,
|
||||
position: api.SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
},
|
||||
href,
|
||||
disabled: href === undefined,
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: inline.data.ref,
|
||||
position: api.SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
href={href}
|
||||
disabled={href === undefined}
|
||||
/>
|
||||
);
|
||||
const button =
|
||||
href &&
|
||||
context.contentContext &&
|
||||
isSiteAuthLoginHref(context.contentContext.linker, href) ? (
|
||||
<SiteAuthLoginButton {...sharedProps} />
|
||||
) : (
|
||||
<Button {...sharedProps} />
|
||||
);
|
||||
|
||||
if (inline.data.ref && !resolved) {
|
||||
return <NotFoundRefHoverCard context={context}>{button}</NotFoundRefHoverCard>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { DocumentInlineIcon } from '@gitbook/api';
|
||||
|
||||
import { textColorToStyle } from '@/lib/colors';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import type { InlineProps } from './Inline';
|
||||
import { textColorToStyle } from './utils/colors';
|
||||
|
||||
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
|
||||
const { inline } = props;
|
||||
|
||||
@@ -26,7 +26,7 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, specUrl, error } = await resolveOpenAPIOperationBlock({
|
||||
const { data, specUrl, publicURL, error } = await resolveOpenAPIOperationBlock({
|
||||
block,
|
||||
context: context.contentContext,
|
||||
});
|
||||
@@ -46,7 +46,15 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
|
||||
return (
|
||||
<BaseOpenAPIOperation
|
||||
data={data}
|
||||
context={getOpenAPIContext({ props, specUrl, context: context.contentContext })}
|
||||
context={getOpenAPIContext({
|
||||
props,
|
||||
specUrl: publicURL,
|
||||
context: context.contentContext,
|
||||
expandAllResponses:
|
||||
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
|
||||
expandAllModelSections:
|
||||
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
|
||||
})}
|
||||
className="openapi-block"
|
||||
/>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user